From 86e3cd16e795e754c1335fef74f781d394f8afe7 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Wed, 4 Nov 2026 21:12:46 +0000 Subject: [PATCH 1/2] fix(user-management): Reject non-user access tokens in session cookies CookieSession.authenticate and authenticateWithSessionCookie now validate the cookie's access token with verifyAccessToken, so they apply the same access token claim checks (hasRequiredAccessTokenClaims) as direct verification instead of only checking signature, expiry and issuer. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/user-management/session.spec.ts | 69 +++++++++++++++++---- src/user-management/session.ts | 26 +------- src/user-management/user-management.spec.ts | 58 ++++++++++++++--- src/user-management/user-management.ts | 2 +- 4 files changed, 109 insertions(+), 46 deletions(-) diff --git a/src/user-management/session.spec.ts b/src/user-management/session.spec.ts index 8e2ea5ced..0f947dda4 100644 --- a/src/user-management/session.spec.ts +++ b/src/user-management/session.spec.ts @@ -20,6 +20,17 @@ 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; + describe('Session', () => { let workos: WorkOS; @@ -117,10 +128,48 @@ describe('Session', () => { }); }); + 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 () => { - jest - .mocked(jose.jwtVerify) - .mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey); + jest.mocked(jose.jwtVerify).mockResolvedValue(verifiedUserAccessToken); const cookiePassword = 'alongcookiesecretmadefortestingsessions'; @@ -202,7 +251,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 +721,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 +808,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 +851,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..a56431892 100644 --- a/src/user-management/session.ts +++ b/src/user-management/session.ts @@ -244,31 +244,7 @@ export class CookieSession { } 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; - } + return (await this.userManagement.verifyAccessToken(accessToken)) !== null; } } diff --git a/src/user-management/user-management.spec.ts b/src/user-management/user-management.spec.ts index 0bf8868b7..7ab1de5d3 100644 --- a/src/user-management/user-management.spec.ts +++ b/src/user-management/user-management.spec.ts @@ -36,6 +36,17 @@ 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; + const userId = 'user_01H5JQDV7R7ATEYZDEG0W5PRYS'; const organizationMembershipId = 'om_01H5JQDV7R7ATEYZDEG0W5PRYS'; const emailVerificationId = 'email_verification_01H5JQDV7R7ATEYZDEG0W5PRYS'; @@ -1614,6 +1625,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,9 +1691,7 @@ describe('UserManagement', () => { }); it('returns the JWT claims when provided a valid JWT', async () => { - jest - .mocked(jose.jwtVerify) - .mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey); + jest.mocked(jose.jwtVerify).mockResolvedValue(verifiedUserAccessToken); const cookiePassword = 'alongcookiesecretmadefortestingsessions'; const accessToken = @@ -1685,9 +1731,7 @@ 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); + jest.mocked(jose.jwtVerify).mockResolvedValue(verifiedUserAccessToken); const cookiePassword = 'alongcookiesecretmadefortestingsessions'; const accessToken = @@ -1752,7 +1796,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..0e9eeb9db 100644 --- a/src/user-management/user-management.ts +++ b/src/user-management/user-management.ts @@ -868,7 +868,7 @@ export class UserManagement { } private async isValidJwt(accessToken: string): Promise { - return (await this.verifyJwt(accessToken)) !== null; + return (await this.verifyAccessToken(accessToken)) !== null; } private async prepareAuthenticationResponse({ From 5b0a7b23cc16cf73ea7733ccf2e1a0e7d4a09e98 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Wed, 4 Nov 2026 22:50:20 +0000 Subject: [PATCH 2/2] refactor(user-management): Read session cookie claims from the verified payload Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/user-management/session.spec.ts | 16 +++++++++++++-- src/user-management/session.ts | 14 ++++++------- src/user-management/user-management.spec.ts | 22 +++++++++++++++++---- src/user-management/user-management.ts | 18 ++++------------- 4 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/user-management/session.spec.ts b/src/user-management/session.spec.ts index 0f947dda4..502e9b319 100644 --- a/src/user-management/session.spec.ts +++ b/src/user-management/session.spec.ts @@ -31,6 +31,16 @@ const verifiedUserAccessToken = { }, } as unknown as jose.JWTVerifyResult & jose.ResolvedKey; +function verifiedAccessToken(accessToken: string) { + return { + ...verifiedUserAccessToken, + payload: { + ...verifiedUserAccessToken.payload, + ...jose.decodeJwt(accessToken), + }, + }; +} + describe('Session', () => { let workos: WorkOS; @@ -169,13 +179,15 @@ describe('Session', () => { ); it('returns a successful response if the sessionData is valid', async () => { - jest.mocked(jose.jwtVerify).mockResolvedValue(verifiedUserAccessToken); - const cookiePassword = 'alongcookiesecretmadefortestingsessions'; const accessToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJpbXBlcnNvbmF0b3IiOnsiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInJlYXNvbiI6InRlc3QifSwic2lkIjoic2Vzc2lvbl8xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwicm9sZSI6Im1lbWJlciIsInJvbGVzIjpbIm1lbWJlciIsImFkbWluIl0sInBlcm1pc3Npb25zIjpbInBvc3RzOmNyZWF0ZSIsInBvc3RzOmRlbGV0ZSJdLCJlbnRpdGxlbWVudHMiOlsiYXVkaXQtbG9ncyJdLCJmZWF0dXJlX2ZsYWdzIjpbImRhcmstbW9kZSIsImJldGEtZmVhdHVyZXMiXSwidXNlciI6eyJvYmplY3QiOiJ1c2VyIiwiaWQiOiJ1c2VyXzAxSDVKUURWN1I3QVRFWVpERUcwVzVQUllTIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIn19.TNUzJYn6lzLWFFsiWiKEgIshyUs-bKJQf1VxwNr1cGI'; + jest + .mocked(jose.jwtVerify) + .mockResolvedValue(verifiedAccessToken(accessToken)); + const sessionData = await sealData( { accessToken, diff --git a/src/user-management/session.ts b/src/user-management/session.ts index a56431892..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,10 +244,6 @@ export class CookieSession { returnTo, }); } - - private async isValidJwt(accessToken: string): Promise { - return (await this.userManagement.verifyAccessToken(accessToken)) !== null; - } } /** diff --git a/src/user-management/user-management.spec.ts b/src/user-management/user-management.spec.ts index 7ab1de5d3..b207f6a0e 100644 --- a/src/user-management/user-management.spec.ts +++ b/src/user-management/user-management.spec.ts @@ -47,6 +47,16 @@ const verifiedUserAccessToken = { }, } 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'; @@ -1691,12 +1701,14 @@ describe('UserManagement', () => { }); it('returns the JWT claims when provided a valid JWT', async () => { - jest.mocked(jose.jwtVerify).mockResolvedValue(verifiedUserAccessToken); - const cookiePassword = 'alongcookiesecretmadefortestingsessions'; const accessToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJpbXBlcnNvbmF0b3IiOnsiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInJlYXNvbiI6InRlc3QifSwic2lkIjoic2Vzc2lvbl8xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwicm9sZSI6Im1lbWJlciIsInBlcm1pc3Npb25zIjpbInBvc3RzOmNyZWF0ZSIsInBvc3RzOmRlbGV0ZSJdLCJlbnRpdGxlbWVudHMiOlsiYXVkaXQtbG9ncyJdLCJmZWF0dXJlX2ZsYWdzIjpbImRhcmstbW9kZSIsImJldGEtZmVhdHVyZXMiXSwidXNlciI6eyJvYmplY3QiOiJ1c2VyIiwiaWQiOiJ1c2VyXzAxSDVKUURWN1I3QVRFWVpERUcwVzVQUllTIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIn19.YVNjR8S2xGn2jAoLuEcBQNJ1_xY3OzjRE1-BK0zjfQE'; + jest + .mocked(jose.jwtVerify) + .mockResolvedValue(verifiedAccessToken(accessToken)); + const sessionData = await sealData( { accessToken, @@ -1731,12 +1743,14 @@ describe('UserManagement', () => { }); it('returns the JWT claims when provided a valid JWT with multiple roles', async () => { - jest.mocked(jose.jwtVerify).mockResolvedValue(verifiedUserAccessToken); - const cookiePassword = 'alongcookiesecretmadefortestingsessions'; const accessToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJpbXBlcnNvbmF0b3IiOnsiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInJlYXNvbiI6InRlc3QifSwic2lkIjoic2Vzc2lvbl8xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwicm9sZSI6ImFkbWluIiwicm9sZXMiOlsiYWRtaW4iLCJtZW1iZXIiXSwicGVybWlzc2lvbnMiOlsicG9zdHM6Y3JlYXRlIiwicG9zdHM6ZGVsZXRlIl0sImVudGl0bGVtZW50cyI6WyJhdWRpdC1sb2dzIl0sImZlYXR1cmVfZmxhZ3MiOlsiZGFyay1tb2RlIiwiYmV0YS1mZWF0dXJlcyJdLCJ1c2VyIjp7Im9iamVjdCI6InVzZXIiLCJpZCI6InVzZXJfMDFINUpRRFY3UjdBVEVZWkRFRzBXNVBSWVMiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifX0.hsMptIB7PmbF5pxxtgTtCdUyOAhA11ZIAP-JY5zU5fE'; + jest + .mocked(jose.jwtVerify) + .mockResolvedValue(verifiedAccessToken(accessToken)); + const sessionData = await sealData( { accessToken, diff --git a/src/user-management/user-management.ts b/src/user-management/user-management.ts index 0e9eeb9db..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.verifyAccessToken(accessToken)) !== null; - } - private async prepareAuthenticationResponse({ authenticationResponse, session,