diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 3f8abe8dc..0ff9cc4bf 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -24,6 +24,7 @@ const getIdTokenClaims = jest.fn(() => ({ sub: 'user_id', })); const signOut = jest.fn(); +const getAccessToken = jest.fn(async () => true); jest.mock('./storage', () => jest.fn(() => ({ @@ -47,7 +48,7 @@ jest.mock('@logto/node', () => signIn(); }, handleSignInCallback, - isAuthenticated: true, + getAccessToken, getIdTokenClaims, signOut: () => { navigate(configs.baseUrl); @@ -100,6 +101,22 @@ describe('Next', () => { }); describe('withLogtoApiRoute', () => { + it('should set isAuthenticated to false when 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); + }), + 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`', async () => { const client = new LogtoClient(configs); await testApiHandler({ @@ -111,6 +128,7 @@ describe('Next', () => { await fetch({ method: 'GET', redirect: 'manual' }); }, }); + expect(getAccessToken).toHaveBeenCalled(); expect(getIdTokenClaims).toHaveBeenCalled(); }); }); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 846545946..50c1bf923 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -7,6 +7,17 @@ import { LogtoNextConfig, LogtoUser } from './types'; export type { LogtoUser } from './types'; +// Refresh token can be revoked, so it is authenticated only when we have a unexpired access token +const checkIsAuthenticatedByAccessToken = async (client: NodeClient): Promise => { + try { + await client.getAccessToken(); + + return true; + } catch { + return false; + } +}; + export default class LogtoClient { private navigateUrl?: string; private storage?: NextStorage; @@ -55,7 +66,8 @@ export default class LogtoClient { withLogtoApiRoute = (handler: NextApiHandler): NextApiHandler => this.withIronSession(async (request, response) => { const nodeClient = this.createNodeClient(request); - const { isAuthenticated } = nodeClient; + const isAuthenticated = await checkIsAuthenticatedByAccessToken(nodeClient); + await this.storage?.save(); const user: LogtoUser = { isAuthenticated, @@ -91,6 +103,7 @@ export default class LogtoClient { password: this.config.cookieSecret, cookieOptions: { secure: this.config.cookieSecure, + maxAge: 14 * 24 * 60 * 60, }, }); } diff --git a/packages/next/src/storage.ts b/packages/next/src/storage.ts index b06dd9b6e..9235ba213 100644 --- a/packages/next/src/storage.ts +++ b/packages/next/src/storage.ts @@ -3,10 +3,12 @@ import { Storage, StorageKey } from '@logto/node'; import { NextRequestWithIronSession } from './types'; export default class NextStorage implements Storage { + private sessionChanged = false; constructor(private readonly request: NextRequestWithIronSession) {} async setItem(key: StorageKey, value: string) { this.request.session[key] = value; + this.sessionChanged = true; } getItem(key: StorageKey) { @@ -21,9 +23,15 @@ export default class NextStorage implements Storage { removeItem(key: StorageKey) { this.request.session[key] = undefined; + this.sessionChanged = true; } async save() { + if (!this.sessionChanged) { + return; + } + await this.request.session.save(); + this.sessionChanged = false; } }