diff --git a/src/user-management/session.spec.ts b/src/user-management/session.spec.ts index 8e2ea5ced..502e9b319 100644 --- a/src/user-management/session.spec.ts +++ b/src/user-management/session.spec.ts @@ -20,6 +20,27 @@ jest.mock('jose', () => ({ jwtVerify: jest.fn(), })); +const verifiedUserAccessToken = { + payload: { + iss: 'https://api.workos.com/user_management/client_123', + sub: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', + sid: 'session_123', + exp: 1735684500, + iat: 1735683600, + auth_time: 1735683600, + }, +} as unknown as jose.JWTVerifyResult & jose.ResolvedKey; + +function verifiedAccessToken(accessToken: string) { + return { + ...verifiedUserAccessToken, + payload: { + ...verifiedUserAccessToken.payload, + ...jose.decodeJwt(accessToken), + }, + }; +} + describe('Session', () => { let workos: WorkOS; @@ -117,16 +138,56 @@ describe('Session', () => { }); }); - it('returns a successful response if the sessionData is valid', async () => { - jest - .mocked(jose.jwtVerify) - .mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey); + it.each([ + [ + 'an agent access token', + { sub: 'agent_01H5JQDV7R7ATEYZDEG0W5PRYS', sub_profile: 'ai_agent' }, + ], + ['a widget session token', { sub_type: 'authkit' }], + ])( + 'returns a failed response if the accessToken is %s', + async (_family, familyClaims) => { + jest.mocked(jose.jwtVerify).mockResolvedValue({ + ...verifiedUserAccessToken, + payload: { ...verifiedUserAccessToken.payload, ...familyClaims }, + }); + + const cookiePassword = 'alongcookiesecretmadefortestingsessions'; + const sessionData = await sealData( + { + accessToken: 'abc123', + refreshToken: 'def456', + user: { + object: 'user', + id: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', + email: 'test@example.com', + }, + }, + { password: cookiePassword }, + ); + + const session = workos.userManagement.loadSealedSession({ + sessionData, + cookiePassword, + }); + + await expect(session.authenticate()).resolves.toEqual({ + authenticated: false, + reason: 'invalid_jwt', + }); + }, + ); + it('returns a successful response if the sessionData is valid', async () => { const cookiePassword = 'alongcookiesecretmadefortestingsessions'; const accessToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJpbXBlcnNvbmF0b3IiOnsiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInJlYXNvbiI6InRlc3QifSwic2lkIjoic2Vzc2lvbl8xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwicm9sZSI6Im1lbWJlciIsInJvbGVzIjpbIm1lbWJlciIsImFkbWluIl0sInBlcm1pc3Npb25zIjpbInBvc3RzOmNyZWF0ZSIsInBvc3RzOmRlbGV0ZSJdLCJlbnRpdGxlbWVudHMiOlsiYXVkaXQtbG9ncyJdLCJmZWF0dXJlX2ZsYWdzIjpbImRhcmstbW9kZSIsImJldGEtZmVhdHVyZXMiXSwidXNlciI6eyJvYmplY3QiOiJ1c2VyIiwiaWQiOiJ1c2VyXzAxSDVKUURWN1I3QVRFWVpERUcwVzVQUllTIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIn19.TNUzJYn6lzLWFFsiWiKEgIshyUs-bKJQf1VxwNr1cGI'; + jest + .mocked(jose.jwtVerify) + .mockResolvedValue(verifiedAccessToken(accessToken)); + const sessionData = await sealData( { accessToken, @@ -202,7 +263,7 @@ describe('Session', () => { jest .mocked(jose.jwtVerify) .mockReset() - .mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey); + .mockResolvedValue(verifiedUserAccessToken); }); it('does not validate the issuer claim by default', async () => { @@ -672,9 +733,7 @@ describe('Session', () => { refreshToken, }); - jest - .mocked(jose.jwtVerify) - .mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey); + jest.mocked(jose.jwtVerify).mockResolvedValue(verifiedUserAccessToken); const cookiePassword = 'alongcookiesecretmadefortestingsessions'; @@ -761,9 +820,7 @@ describe('Session', () => { describe('getLogoutUrl', () => { it('returns a logout URL for the user', async () => { - jest - .mocked(jose.jwtVerify) - .mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey); + jest.mocked(jose.jwtVerify).mockResolvedValue(verifiedUserAccessToken); const cookiePassword = 'alongcookiesecretmadefortestingsessions'; @@ -806,9 +863,7 @@ describe('Session', () => { describe('when a returnTo URL is provided', () => { it('returns a logout URL for the user', async () => { - jest - .mocked(jose.jwtVerify) - .mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey); + jest.mocked(jose.jwtVerify).mockResolvedValue(verifiedUserAccessToken); const cookiePassword = 'alongcookiesecretmadefortestingsessions'; diff --git a/src/user-management/session.ts b/src/user-management/session.ts index e51a20e82..e27627c2c 100644 --- a/src/user-management/session.ts +++ b/src/user-management/session.ts @@ -78,15 +78,17 @@ export class CookieSession { }; } - if (!(await this.isValidJwt(session.accessToken))) { + const claims = await this.userManagement.verifyAccessToken( + session.accessToken, + ); + + if (!claims) { return { authenticated: false, reason: AuthenticateWithSessionCookieFailureReason.INVALID_JWT, }; } - const { decodeJwt } = await getJose(); - const { sid: sessionId, org_id: organizationId, @@ -95,7 +97,7 @@ export class CookieSession { permissions, entitlements, feature_flags: featureFlags, - } = decodeJwt(session.accessToken); + } = claims; return { authenticated: true, @@ -242,34 +244,6 @@ export class CookieSession { returnTo, }); } - - private async isValidJwt(accessToken: string): Promise { - const { jwtVerify } = await getJose(); - const jwks = await this.userManagement.getJWKS(); - if (!jwks) { - throw new Error( - 'Missing client ID. Did you provide it when initializing WorkOS?', - ); - } - - const { issuer } = this.userManagement; - try { - await jwtVerify(accessToken, jwks, issuer ? { issuer } : undefined); - return true; - } catch (e) { - // Only treat as invalid JWT if it's an actual JWT/JWS error from jose - // Network errors, crypto failures, etc. should propagate - if ( - e instanceof Error && - 'code' in e && - typeof e.code === 'string' && - (e.code.startsWith('ERR_JWT_') || e.code.startsWith('ERR_JWS_')) - ) { - return false; - } - throw e; - } - } } /** diff --git a/src/user-management/user-management.spec.ts b/src/user-management/user-management.spec.ts index 0bf8868b7..b207f6a0e 100644 --- a/src/user-management/user-management.spec.ts +++ b/src/user-management/user-management.spec.ts @@ -36,6 +36,27 @@ jest.mock('jose', () => ({ createRemoteJWKSet: jest.fn(), })); +const verifiedUserAccessToken = { + payload: { + iss: 'https://api.workos.com/user_management/client_123', + sub: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', + sid: 'session_123', + exp: 1735684500, + iat: 1735683600, + auth_time: 1735683600, + }, +} as unknown as jose.JWTVerifyResult & jose.ResolvedKey; + +function verifiedAccessToken(accessToken: string) { + return { + ...verifiedUserAccessToken, + payload: { + ...verifiedUserAccessToken.payload, + ...jose.decodeJwt(accessToken), + }, + }; +} + const userId = 'user_01H5JQDV7R7ATEYZDEG0W5PRYS'; const organizationMembershipId = 'om_01H5JQDV7R7ATEYZDEG0W5PRYS'; const emailVerificationId = 'email_verification_01H5JQDV7R7ATEYZDEG0W5PRYS'; @@ -1614,6 +1635,43 @@ describe('UserManagement', () => { ).resolves.toEqual({ authenticated: false, reason: 'invalid_jwt' }); }); + it.each([ + [ + 'an agent access token', + { sub: 'agent_01H5JQDV7R7ATEYZDEG0W5PRYS', sub_profile: 'ai_agent' }, + ], + ['a widget session token', { sub_type: 'authkit' }], + ])( + 'returns authenticated = false when the JWT is %s', + async (_family, familyClaims) => { + jest.mocked(jose.jwtVerify).mockResolvedValue({ + ...verifiedUserAccessToken, + payload: { ...verifiedUserAccessToken.payload, ...familyClaims }, + }); + + const cookiePassword = 'alongcookiesecretmadefortestingsessions'; + const sessionData = await sealData( + { + accessToken: 'abc123', + refreshToken: 'def456', + user: { + object: 'user', + id: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', + email: 'test@example.com', + }, + }, + { password: cookiePassword }, + ); + + await expect( + workos.userManagement.authenticateWithSessionCookie({ + sessionData, + cookiePassword, + }), + ).resolves.toEqual({ authenticated: false, reason: 'invalid_jwt' }); + }, + ); + it('rethrows non-JWT errors (e.g., network failures)', async () => { jest.mocked(jose.jwtVerify).mockImplementationOnce(() => { // Simulate a network error (no jose error code) @@ -1643,14 +1701,14 @@ describe('UserManagement', () => { }); it('returns the JWT claims when provided a valid JWT', async () => { - jest - .mocked(jose.jwtVerify) - .mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey); - const cookiePassword = 'alongcookiesecretmadefortestingsessions'; const accessToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJpbXBlcnNvbmF0b3IiOnsiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInJlYXNvbiI6InRlc3QifSwic2lkIjoic2Vzc2lvbl8xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwicm9sZSI6Im1lbWJlciIsInBlcm1pc3Npb25zIjpbInBvc3RzOmNyZWF0ZSIsInBvc3RzOmRlbGV0ZSJdLCJlbnRpdGxlbWVudHMiOlsiYXVkaXQtbG9ncyJdLCJmZWF0dXJlX2ZsYWdzIjpbImRhcmstbW9kZSIsImJldGEtZmVhdHVyZXMiXSwidXNlciI6eyJvYmplY3QiOiJ1c2VyIiwiaWQiOiJ1c2VyXzAxSDVKUURWN1I3QVRFWVpERUcwVzVQUllTIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIn19.YVNjR8S2xGn2jAoLuEcBQNJ1_xY3OzjRE1-BK0zjfQE'; + jest + .mocked(jose.jwtVerify) + .mockResolvedValue(verifiedAccessToken(accessToken)); + const sessionData = await sealData( { accessToken, @@ -1685,14 +1743,14 @@ describe('UserManagement', () => { }); it('returns the JWT claims when provided a valid JWT with multiple roles', async () => { - jest - .mocked(jose.jwtVerify) - .mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey); - const cookiePassword = 'alongcookiesecretmadefortestingsessions'; const accessToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJpbXBlcnNvbmF0b3IiOnsiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInJlYXNvbiI6InRlc3QifSwic2lkIjoic2Vzc2lvbl8xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwicm9sZSI6ImFkbWluIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwicGVybWlzc2lvbnMiOlsicG9zdHM6Y3JlYXRlIiwicG9zdHM6ZGVsZXRlIl0sImVudGl0bGVtZW50cyI6WyJhdWRpdC1sb2dzIl0sImZlYXR1cmVfZmxhZ3MiOlsiZGFyay1tb2RlIiwiYmV0YS1mZWF0dXJlcyJdLCJ1c2VyIjp7Im9iamVjdCI6InVzZXIiLCJpZCI6InVzZXJfMDFINUpRRFY3UjdBVEVZWkRFRzBXNVBSWVMiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifX0.hsMptIB7PmbF5pxxtgTtCdUyOAhA11ZIAP-JY5zU5fE'; + jest + .mocked(jose.jwtVerify) + .mockResolvedValue(verifiedAccessToken(accessToken)); + const sessionData = await sealData( { accessToken, @@ -1752,7 +1810,7 @@ describe('UserManagement', () => { jest .mocked(jose.jwtVerify) .mockReset() - .mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey); + .mockResolvedValue(verifiedUserAccessToken); }); it('does not validate the issuer claim by default', async () => { diff --git a/src/user-management/user-management.ts b/src/user-management/user-management.ts index e0578e25e..cdf618b47 100644 --- a/src/user-management/user-management.ts +++ b/src/user-management/user-management.ts @@ -689,14 +689,6 @@ export class UserManagement { throw new Error('Cookie password is required'); } - const jwks = await this.getJWKS(); - - if (!jwks) { - throw new Error('Must provide clientId to initialize JWKS'); - } - - const { decodeJwt } = await getJose(); - if (!sessionData) { return { authenticated: false, @@ -719,7 +711,9 @@ export class UserManagement { }; } - if (!(await this.isValidJwt(session.accessToken))) { + const claims = await this.verifyAccessToken(session.accessToken); + + if (!claims) { return { authenticated: false, reason: AuthenticateWithSessionCookieFailureReason.INVALID_JWT, @@ -734,7 +728,7 @@ export class UserManagement { permissions, entitlements, feature_flags: featureFlags, - } = decodeJwt(session.accessToken); + } = claims; return { authenticated: true, @@ -867,10 +861,6 @@ export class UserManagement { ); } - private async isValidJwt(accessToken: string): Promise { - return (await this.verifyJwt(accessToken)) !== null; - } - private async prepareAuthenticationResponse({ authenticationResponse, session,