From 9626b764eb84287a1e29783a768f33190d28411d Mon Sep 17 00:00:00 2001 From: Wang Sijie Date: Mon, 8 Aug 2022 16:11:23 +0800 Subject: [PATCH] feat(express): with logto (#378) --- packages/express/src/index.test.ts | 17 ++++++++-- packages/express/src/index.ts | 14 +++++++- packages/express/src/types.ts | 4 +++ packages/next-sample/pages/index.tsx | 4 +-- packages/next-sample/pages/profile-ssr.tsx | 4 +-- packages/next/src/index.test.ts | 28 +++------------- packages/next/src/index.ts | 39 ++-------------------- packages/next/src/types.ts | 14 +------- packages/node/src/index.test.ts | 35 +++++++++++++++++++ packages/node/src/index.ts | 34 +++++++++++++++++++ packages/node/src/types.ts | 13 ++++++++ 11 files changed, 126 insertions(+), 80 deletions(-) create mode 100644 packages/node/src/types.ts diff --git a/packages/express/src/index.test.ts b/packages/express/src/index.test.ts index fd82c32f..44a03da3 100644 --- a/packages/express/src/index.test.ts +++ b/packages/express/src/index.test.ts @@ -20,7 +20,7 @@ const getIdTokenClaims = jest.fn(() => ({ sub: 'user_id', })); const signOut = jest.fn(); -const getAccessToken = jest.fn(async () => true); +const getContext = jest.fn(async () => ({ isAuthenticated: true })); jest.mock('./storage', () => jest.fn(() => ({ @@ -41,7 +41,7 @@ jest.mock('@logto/node', () => signIn(); }, handleSignInCallback, - getAccessToken, + getContext, getIdTokenClaims, signOut: () => { navigate(configs.baseUrl); @@ -100,4 +100,17 @@ describe('Express', () => { expect(signOut).toHaveBeenCalled(); }); }); + + describe('withLogto', () => { + it('should assign `user` to `request`', async () => { + const client = new LogtoClient(configs); + await testMiddleware({ + middleware: client.withLogto(), + test: async ({ request }) => { + expect(request.user).toBeDefined(); + }, + }); + expect(getContext).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index 9ae0ee98..9de2e35c 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -5,7 +5,9 @@ import { Request, Response, NextFunction, Router } from 'express'; import { LogtoExpressError } from './errors'; import ExpressStorage from './storage'; -import { LogtoExpressConfig } from './types'; +import { LogtoExpressConfig, WithLogtoConfig } from './types'; + +export type { LogtoContext } from '@logto/node'; export type Middleware = ( request: Request, @@ -66,6 +68,16 @@ export default class LogtoClient { return router; }; + withLogto = + (config: WithLogtoConfig = {}): Middleware => + async (request: IncomingMessage, response: Response, next: NextFunction) => { + const client = this.createNodeClient(request, response); + const user = await client.getContext(config.getAccessToken); + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + Object.defineProperty(request, 'user', { enumerable: true, get: () => user }); + next(); + }; + private createNodeClient(request: IncomingMessage, response: Response) { this.checkSession(request); const storage = new ExpressStorage(request); diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts index a23f4536..7d6a5247 100644 --- a/packages/express/src/types.ts +++ b/packages/express/src/types.ts @@ -9,3 +9,7 @@ declare module 'http' { export type LogtoExpressConfig = LogtoConfig & { baseUrl: string; }; + +export type WithLogtoConfig = { + getAccessToken?: boolean; +}; diff --git a/packages/next-sample/pages/index.tsx b/packages/next-sample/pages/index.tsx index a2e415a3..958c3ed2 100644 --- a/packages/next-sample/pages/index.tsx +++ b/packages/next-sample/pages/index.tsx @@ -1,10 +1,10 @@ -import { LogtoUser } from '@logto/next'; +import { LogtoContext } from '@logto/next'; import Link from 'next/link'; import { useMemo } from 'react'; import useSWR from 'swr'; const Home = () => { - const { data } = useSWR('/api/logto/user'); + const { data } = useSWR('/api/logto/user'); const { data: protectedResource } = useSWR<{ data: string }>('/api/protected-resource'); const userInfo = useMemo(() => { diff --git a/packages/next-sample/pages/profile-ssr.tsx b/packages/next-sample/pages/profile-ssr.tsx index aa8a1eba..97d541fa 100644 --- a/packages/next-sample/pages/profile-ssr.tsx +++ b/packages/next-sample/pages/profile-ssr.tsx @@ -1,10 +1,10 @@ -import { LogtoUser } from '@logto/next'; +import { LogtoContext } from '@logto/next'; import { useMemo } from 'react'; import { logtoClient } from '../libraries/logto'; type Props = { - user: LogtoUser; + user: LogtoContext; }; const ProfileSsr = ({ user }: Props) => { diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index ead8cb10..c2e82133 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -25,7 +25,7 @@ const getIdTokenClaims = jest.fn(() => ({ sub: 'user_id', })); const signOut = jest.fn(); -const getAccessToken = jest.fn(async () => true); +const getContext = jest.fn(async () => true); const mockResponse = (_: unknown, response: NextApiResponse) => { response.status(200).end(); @@ -53,7 +53,7 @@ jest.mock('@logto/node', () => signIn(); }, handleSignInCallback, - getAccessToken, + getContext, getIdTokenClaims, signOut: () => { navigate(configs.baseUrl); @@ -107,26 +107,7 @@ describe('Next', () => { }); describe('withLogtoApiRoute', () => { - it('should set isAuthenticated to false when "getAccessToken" is enabled and is unable to getAccessToken', async () => { - getAccessToken.mockRejectedValueOnce(new Error('Unauthorized')); - const client = new LogtoClient(configs); - await testApiHandler({ - handler: client.withLogtoApiRoute( - (request, response) => { - expect(request.user).toBeDefined(); - response.json(request.user); - }, - { getAccessToken: true } - ), - test: async ({ fetch }) => { - const response = await fetch({ method: 'GET', redirect: 'manual' }); - await expect(response.json()).resolves.toEqual({ isAuthenticated: false }); - }, - }); - expect(getAccessToken).toHaveBeenCalled(); - }); - - it('should assign `user` to `request` and not call getAccessToken by default', async () => { + it('should assign `user` to `request`', async () => { const client = new LogtoClient(configs); await testApiHandler({ handler: client.withLogtoApiRoute((request, response) => { @@ -137,8 +118,7 @@ describe('Next', () => { await fetch({ method: 'GET', redirect: 'manual' }); }, }); - expect(getIdTokenClaims).toHaveBeenCalled(); - expect(getAccessToken).not.toHaveBeenCalled(); + expect(getContext).toHaveBeenCalled(); }); }); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index d1c52f3b..f9476f51 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -5,9 +5,9 @@ import { withIronSessionApiRoute, withIronSessionSsr } from 'iron-session/next'; import { GetServerSidePropsContext, GetServerSidePropsResult, NextApiHandler } from 'next'; import NextStorage from './storage'; -import { LogtoNextConfig, LogtoUser, WithLogtoConfig } from './types'; +import { LogtoNextConfig, WithLogtoConfig } from './types'; -export type { LogtoUser } from './types'; +export type { LogtoContext } from '@logto/node'; export default class LogtoClient { private navigateUrl?: string; @@ -134,40 +134,7 @@ export default class LogtoClient { private async getLogtoUserFromRequest(request: IncomingMessage, getAccessToken?: boolean) { const nodeClient = this.createNodeClient(request); - const { isAuthenticated } = nodeClient; - if (!isAuthenticated) { - const user: LogtoUser = { - isAuthenticated, - }; - - return user; - } - - if (!getAccessToken) { - const user: LogtoUser = { - isAuthenticated, - claims: nodeClient.getIdTokenClaims(), - }; - - return user; - } - - try { - const accessToken = await nodeClient.getAccessToken(); - await this.storage?.save(); - - const user: LogtoUser = { - isAuthenticated, - claims: nodeClient.getIdTokenClaims(), - accessToken, - }; - - return user; - } catch { - return { - isAuthenticated: false, - }; - } + return nodeClient.getContext(getAccessToken); } } diff --git a/packages/next/src/types.ts b/packages/next/src/types.ts index f70a7b5a..fa55ed1a 100644 --- a/packages/next/src/types.ts +++ b/packages/next/src/types.ts @@ -1,4 +1,4 @@ -import { IdTokenClaims, LogtoConfig } from '@logto/node'; +import { LogtoConfig } from '@logto/node'; import { IronSession } from 'iron-session'; import { NextApiRequest } from 'next'; @@ -13,24 +13,12 @@ declare module 'iron-session' { } } -declare module 'http' { - interface IncomingMessage { - user: LogtoUser; - } -} - export type LogtoNextConfig = LogtoConfig & { cookieSecret: string; cookieSecure: boolean; baseUrl: string; }; -export type LogtoUser = { - isAuthenticated: boolean; - claims?: IdTokenClaims; - accessToken?: string; -}; - /** * @getAccessToken: if set to true, will try to get an access token and attach to req.user, * if unable to grant an access token, will set req.user.isAuthenticated to false, diff --git a/packages/node/src/index.test.ts b/packages/node/src/index.test.ts index 97343eba..49aaa4d9 100644 --- a/packages/node/src/index.test.ts +++ b/packages/node/src/index.test.ts @@ -10,10 +10,45 @@ const storage = { removeItem: jest.fn(), }; +const getAccessToken = jest.fn(async () => true); +const getIdTokenClaims = jest.fn(() => ({ sub: 'sub' })); +jest.mock('@logto/client', () => ({ + __esModule: true, + default: jest.fn(() => ({ + getAccessToken, + getIdTokenClaims, + isAuthenticated: true, + })), + createRequester: jest.fn(), +})); + describe('LogtoClient', () => { describe('constructor', () => { it('constructor should not throw', () => { expect(() => new LogtoClient({ endpoint, appId }, { navigate, storage })).not.toThrow(); }); }); + + describe('getContext', () => { + beforeEach(() => { + getAccessToken.mockClear(); + }); + + it('should set isAuthenticated to false when "getAccessToken" is enabled and is unable to getAccessToken', async () => { + getAccessToken.mockRejectedValueOnce(new Error('Unauthorized')); + const client = new LogtoClient({ endpoint, appId }, { navigate, storage }); + await expect(client.getContext(true)).resolves.toEqual({ isAuthenticated: false }); + expect(getAccessToken).toHaveBeenCalled(); + }); + + it('should return context and not call getAccessToken by default', async () => { + const client = new LogtoClient({ endpoint, appId }, { navigate, storage }); + await expect(client.getContext()).resolves.toEqual({ + isAuthenticated: true, + claims: { sub: 'sub' }, + }); + expect(getIdTokenClaims).toHaveBeenCalled(); + expect(getAccessToken).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index aaeb1332..52e6d204 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -1,8 +1,11 @@ import BaseClient, { LogtoConfig, createRequester, ClientAdapter } from '@logto/client'; import fetch from 'node-fetch'; +import { LogtoContext } from './types'; import { generateCodeChallenge, generateCodeVerifier, generateState } from './utils/generators'; +export type { LogtoContext } from './types'; + export type { IdTokenClaims, LogtoErrorCode, @@ -41,4 +44,35 @@ export default class LogtoClient extends BaseClient { generateState, }); } + + getContext = async (getAccessToken = false): Promise => { + const { isAuthenticated } = this; + + if (!isAuthenticated) { + return { + isAuthenticated, + }; + } + + if (!getAccessToken) { + return { + isAuthenticated, + claims: this.getIdTokenClaims(), + }; + } + + try { + const accessToken = await this.getAccessToken(); + + return { + isAuthenticated, + claims: this.getIdTokenClaims(), + accessToken, + }; + } catch { + return { + isAuthenticated: false, + }; + } + }; } diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts new file mode 100644 index 00000000..0a5c8a47 --- /dev/null +++ b/packages/node/src/types.ts @@ -0,0 +1,13 @@ +import { IdTokenClaims } from '@logto/client'; + +declare module 'http' { + interface IncomingMessage { + user: LogtoContext; + } +} + +export type LogtoContext = { + isAuthenticated: boolean; + claims?: IdTokenClaims; + accessToken?: string; +};