diff --git a/src/core/AuthKitCore.issuer.spec.ts b/src/core/AuthKitCore.issuer.spec.ts index 0582497..7a07677 100644 --- a/src/core/AuthKitCore.issuer.spec.ts +++ b/src/core/AuthKitCore.issuer.spec.ts @@ -1,11 +1,19 @@ import { jwtVerify } from 'jose'; import { AuthKitCore } from './AuthKitCore.js'; +const userAccessTokenPayload = vi.hoisted(() => ({ + sid: 'session_123', + auth_time: 1700000000, +})); + vi.mock('jose', async importOriginal => { const actual = await importOriginal(); return { ...actual, - jwtVerify: vi.fn().mockResolvedValue({ payload: {}, protectedHeader: {} }), + jwtVerify: vi.fn().mockResolvedValue({ + payload: userAccessTokenPayload, + protectedHeader: {}, + }), }; }); @@ -95,3 +103,24 @@ describe('AuthKitCore.verifyToken() issuer validation', () => { await expect(core.verifyToken('some.jwt.token')).resolves.toBe(false); }); }); + +describe('AuthKitCore.verifyToken() token claims', () => { + it.each([ + ['sub_profile user', { sub_profile: 'user' }, true], + ['agent sub_profile', { sub_profile: 'ai_agent' }, false], + ['widget sub_type', { sub_type: 'authkit' }, false], + ['missing auth_time', { auth_time: undefined }, false], + ['string auth_time', { auth_time: '1700000000' }, false], + ['missing sid', { sid: undefined }, false], + ['numeric sid', { sid: 123 }, false], + ])('%s', async (_name, claims, expected) => { + vi.mocked(jwtVerify).mockResolvedValueOnce({ + payload: { ...userAccessTokenPayload, ...claims }, + protectedHeader: { alg: 'RS256' }, + } as any); + + await expect( + createCore(baseConfig).verifyToken('some.jwt.token'), + ).resolves.toBe(expected); + }); +}); diff --git a/src/core/AuthKitCore.ts b/src/core/AuthKitCore.ts index db6f09d..a4cdba0 100644 --- a/src/core/AuthKitCore.ts +++ b/src/core/AuthKitCore.ts @@ -4,7 +4,12 @@ import { type User, type WorkOS, } from '@workos-inc/node'; -import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; +import { + createRemoteJWKSet, + decodeJwt, + jwtVerify, + type JWTPayload, +} from 'jose'; import { constantTimeEqual, once } from '../utils.js'; import type { AuthKitConfig } from './config/types.js'; import { @@ -81,12 +86,12 @@ export class AuthKitCore { async verifyToken(token: string): Promise { const issuer = this.config.issuer; try { - await jwtVerify( + const { payload } = await jwtVerify( token, this.getPublicKey(), issuer ? { issuer } : undefined, ); - return true; + return isUserAccessTokenPayload(payload); } catch { return false; } @@ -379,3 +384,14 @@ export class AuthKitCore { }; } } + +// Other token types signed by the same environment key (agent and widget +// tokens) must not authenticate a session cookie. +function isUserAccessTokenPayload(payload: JWTPayload): boolean { + return ( + typeof payload.sid === 'string' && + typeof payload.auth_time === 'number' && + (payload.sub_profile === undefined || payload.sub_profile === 'user') && + payload.sub_type === undefined + ); +} diff --git a/src/service/publicClient.spec.ts b/src/service/publicClient.spec.ts index 2229c94..ab3efd4 100644 --- a/src/service/publicClient.spec.ts +++ b/src/service/publicClient.spec.ts @@ -41,7 +41,7 @@ function cookiePair(setCookie: string | string[] | undefined) { const signingKey = generateKeyPair('RS256'); async function signAccessToken(expiresAt: number) { const { privateKey } = await signingKey; - return new SignJWT({ sid: 'session_123' }) + return new SignJWT({ sid: 'session_123', auth_time: expiresAt - 300 }) .setProtectedHeader({ alg: 'RS256', kid: 'key_1' }) .setIssuedAt(expiresAt - 300) .setExpirationTime(expiresAt) @@ -246,6 +246,20 @@ describe.each([ refresh_token: 'refresh_1', }); expectClientCredentials(authenticateCalls[0]!); + + // The refreshed session authenticates without another refresh. + const { headers: refreshedHeaders } = await service.saveSession( + undefined, + refreshedSessionData!, + ); + const next = await service.withAuth( + new Request('http://localhost:3000/', { + headers: { cookie: cookiePair(refreshedHeaders?.['Set-Cookie']) }, + }), + ); + expect(next.auth.user?.id).toBe('user_123'); + expect(next.refreshedSessionData).toBeUndefined(); + expect(authenticateCalls).toHaveLength(1); }); it.runIf(!apiKey)(