diff --git a/README.md b/README.md index 8202d02d1..34b08b45d 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,22 @@ await workos.get('/organizations', { maxRetries: 0 }); Set `maxRetries: 0` to disable automatic retries entirely. +### Access token issuer validation + +Session helpers (`authenticateWithSessionCookie`, `loadSealedSession(...).authenticate()`) +verify the access token signature against the WorkOS JWKS. To also enforce the +token's `iss` claim, pass the expected issuer when creating the client: + +```ts +const workos = new WorkOS('sk_1234', { + clientId: 'client_...', + issuer: 'https://api.workos.com/user_management/client_...', +}); +``` + +`issuer` also accepts an array when tokens from more than one issuer should be +accepted. When `issuer` is not set, the `iss` claim is not validated. + ## Public Client Mode (Browser/Mobile/CLI) For apps that can't securely store secrets, initialize with just a client ID: diff --git a/src/common/interfaces/workos-options.interface.ts b/src/common/interfaces/workos-options.interface.ts index a28927119..deaffc74b 100644 --- a/src/common/interfaces/workos-options.interface.ts +++ b/src/common/interfaces/workos-options.interface.ts @@ -9,6 +9,12 @@ export interface WorkOSOptions { appInfo?: AppInfo; fetchFn?: typeof fetch; clientId?: string; + /** + * Expected `iss` claim of WorkOS access tokens, enforced when verifying + * session access tokens. Accepts a single issuer or a list of allowed + * issuers. When not set, the issuer claim is not validated. + */ + issuer?: string | string[]; timeout?: number; // Timeout in milliseconds /** * Maximum number of automatic retries for transient failures (network diff --git a/src/user-management/session.spec.ts b/src/user-management/session.spec.ts index 05fea30fa..ec852bde9 100644 --- a/src/user-management/session.spec.ts +++ b/src/user-management/session.spec.ts @@ -164,6 +164,122 @@ describe('Session', () => { accessToken, }); }); + + describe('issuer validation', () => { + const cookiePassword = 'alongcookiesecretmadefortestingsessions'; + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJpbXBlcnNvbmF0b3IiOnsiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInJlYXNvbiI6InRlc3QifSwic2lkIjoic2Vzc2lvbl8xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwicm9sZSI6Im1lbWJlciIsInJvbGVzIjpbIm1lbWJlciIsImFkbWluIl0sInBlcm1pc3Npb25zIjpbInBvc3RzOmNyZWF0ZSIsInBvc3RzOmRlbGV0ZSJdLCJlbnRpdGxlbWVudHMiOlsiYXVkaXQtbG9ncyJdLCJmZWF0dXJlX2ZsYWdzIjpbImRhcmstbW9kZSIsImJldGEtZmVhdHVyZXMiXSwidXNlciI6eyJvYmplY3QiOiJ1c2VyIiwiaWQiOiJ1c2VyXzAxSDVKUURWN1I3QVRFWVpERUcwVzVQUllTIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIn19.TNUzJYn6lzLWFFsiWiKEgIshyUs-bKJQf1VxwNr1cGI'; + + let sessionData: string; + + beforeAll(async () => { + sessionData = await sealData( + { + accessToken, + refreshToken: 'def456', + user: { + object: 'user', + id: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', + email: 'test@example.com', + }, + }, + { password: cookiePassword }, + ); + }); + + beforeEach(() => { + jest + .mocked(jose.jwtVerify) + .mockReset() + .mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey); + }); + + it('does not validate the issuer claim by default', async () => { + const session = workos.userManagement.loadSealedSession({ + sessionData, + cookiePassword, + }); + + await session.authenticate(); + + expect(jose.jwtVerify).toHaveBeenCalledTimes(1); + expect(jose.jwtVerify).toHaveBeenCalledWith( + accessToken, + expect.any(Function), + undefined, + ); + }); + + it('validates the issuer claim when issuer is configured', async () => { + const workosWithIssuer = new WorkOS( + 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + { + clientId: 'client_123', + issuer: 'https://auth.example.com', + }, + ); + const session = workosWithIssuer.userManagement.loadSealedSession({ + sessionData, + cookiePassword, + }); + + await session.authenticate(); + + expect(jose.jwtVerify).toHaveBeenCalledTimes(1); + expect(jose.jwtVerify).toHaveBeenCalledWith( + accessToken, + expect.any(Function), + { issuer: 'https://auth.example.com' }, + ); + }); + + it('validates the issuer claim when a list of issuers is configured', async () => { + const workosWithIssuer = new WorkOS( + 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + { + clientId: 'client_123', + issuer: ['https://auth.example.com', 'https://api.workos.com'], + }, + ); + const session = workosWithIssuer.userManagement.loadSealedSession({ + sessionData, + cookiePassword, + }); + + await session.authenticate(); + + expect(jose.jwtVerify).toHaveBeenCalledTimes(1); + expect(jose.jwtVerify).toHaveBeenCalledWith( + accessToken, + expect.any(Function), + { issuer: ['https://auth.example.com', 'https://api.workos.com'] }, + ); + }); + + it('returns invalid_jwt when the issuer claim does not match', async () => { + const error = new Error('unexpected "iss" claim value'); + (error as Error & { code: string }).code = + 'ERR_JWT_CLAIM_VALIDATION_FAILED'; + jest.mocked(jose.jwtVerify).mockRejectedValue(error); + + const workosWithIssuer = new WorkOS( + 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + { + clientId: 'client_123', + issuer: 'https://auth.example.com', + }, + ); + const session = workosWithIssuer.userManagement.loadSealedSession({ + sessionData, + cookiePassword, + }); + + await expect(session.authenticate()).resolves.toEqual({ + authenticated: false, + reason: 'invalid_jwt', + }); + }); + }); }); describe('refresh', () => { diff --git a/src/user-management/session.ts b/src/user-management/session.ts index 3f257f329..58320c670 100644 --- a/src/user-management/session.ts +++ b/src/user-management/session.ts @@ -240,8 +240,9 @@ export class CookieSession { ); } + const { issuer } = this.userManagement; try { - await jwtVerify(accessToken, jwks); + 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 diff --git a/src/user-management/user-management.spec.ts b/src/user-management/user-management.spec.ts index 726954064..7980c6170 100644 --- a/src/user-management/user-management.spec.ts +++ b/src/user-management/user-management.spec.ts @@ -1725,6 +1725,148 @@ describe('UserManagement', () => { accessToken, }); }); + + describe('issuer validation', () => { + const cookiePassword = 'alongcookiesecretmadefortestingsessions'; + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJpbXBlcnNvbmF0b3IiOnsiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInJlYXNvbiI6InRlc3QifSwic2lkIjoic2Vzc2lvbl8xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwicm9sZSI6Im1lbWJlciIsInBlcm1pc3Npb25zIjpbInBvc3RzOmNyZWF0ZSIsInBvc3RzOmRlbGV0ZSJdLCJlbnRpdGxlbWVudHMiOlsiYXVkaXQtbG9ncyJdLCJmZWF0dXJlX2ZsYWdzIjpbImRhcmstbW9kZSIsImJldGEtZmVhdHVyZXMiXSwidXNlciI6eyJvYmplY3QiOiJ1c2VyIiwiaWQiOiJ1c2VyXzAxSDVKUURWN1I3QVRFWVpERUcwVzVQUllTIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIn19.YVNjR8S2xGn2jAoLuEcBQNJ1_xY3OzjRE1-BK0zjfQE'; + let sessionData: string; + + beforeAll(async () => { + sessionData = await sealData( + { + accessToken, + refreshToken: 'def456', + user: { + object: 'user', + id: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', + email: 'test@example.com', + }, + }, + { password: cookiePassword }, + ); + }); + + beforeEach(() => { + jest + .mocked(jose.jwtVerify) + .mockReset() + .mockResolvedValue({} as jose.JWTVerifyResult & jose.ResolvedKey); + }); + + it('does not validate the issuer claim by default', async () => { + await workos.userManagement.authenticateWithSessionCookie({ + sessionData, + cookiePassword, + }); + + expect(jose.jwtVerify).toHaveBeenCalledTimes(1); + expect(jose.jwtVerify).toHaveBeenCalledWith( + accessToken, + expect.anything(), + undefined, + ); + }); + + it('validates the issuer claim when issuer is configured', async () => { + const workosWithIssuer = new WorkOS( + 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + { + clientId: 'client_123', + issuer: 'https://auth.example.com', + }, + ); + + await workosWithIssuer.userManagement.authenticateWithSessionCookie({ + sessionData, + cookiePassword, + }); + + expect(jose.jwtVerify).toHaveBeenCalledTimes(1); + expect(jose.jwtVerify).toHaveBeenCalledWith( + accessToken, + expect.anything(), + { issuer: 'https://auth.example.com' }, + ); + }); + + it('validates the issuer claim when a list of issuers is configured', async () => { + const workosWithIssuer = new WorkOS( + 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + { + clientId: 'client_123', + issuer: ['https://auth.example.com', 'https://api.workos.com'], + }, + ); + + await workosWithIssuer.userManagement.authenticateWithSessionCookie({ + sessionData, + cookiePassword, + }); + + expect(jose.jwtVerify).toHaveBeenCalledTimes(1); + expect(jose.jwtVerify).toHaveBeenCalledWith( + accessToken, + expect.anything(), + { issuer: ['https://auth.example.com', 'https://api.workos.com'] }, + ); + }); + + it('validates the issuer claim when clientId comes from WORKOS_CLIENT_ID', async () => { + const OLD_ENV = process.env; + process.env = { ...OLD_ENV, WORKOS_CLIENT_ID: 'client_from_env' }; + + try { + const workosWithIssuer = new WorkOS( + 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + { issuer: 'https://auth.example.com' }, + ); + + expect(workosWithIssuer.userManagement.clientId).toBe( + 'client_from_env', + ); + + await workosWithIssuer.userManagement.authenticateWithSessionCookie({ + sessionData, + cookiePassword, + }); + + expect(jose.jwtVerify).toHaveBeenCalledTimes(1); + expect(jose.jwtVerify).toHaveBeenCalledWith( + accessToken, + expect.anything(), + { issuer: 'https://auth.example.com' }, + ); + } finally { + process.env = OLD_ENV; + } + }); + + it('returns invalid_jwt when the issuer claim does not match', async () => { + const error = new Error('unexpected "iss" claim value'); + (error as Error & { code: string }).code = + 'ERR_JWT_CLAIM_VALIDATION_FAILED'; + jest.mocked(jose.jwtVerify).mockRejectedValue(error); + + const workosWithIssuer = new WorkOS( + 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + { + clientId: 'client_123', + issuer: 'https://auth.example.com', + }, + ); + + await expect( + workosWithIssuer.userManagement.authenticateWithSessionCookie({ + sessionData, + cookiePassword, + }), + ).resolves.toEqual({ + authenticated: false, + reason: 'invalid_jwt', + }); + }); + }); }); describe('getSessionFromCookie', () => { diff --git a/src/user-management/user-management.ts b/src/user-management/user-management.ts index 6bd954b4c..37fbc0a37 100644 --- a/src/user-management/user-management.ts +++ b/src/user-management/user-management.ts @@ -200,11 +200,11 @@ export class UserManagement { private _jwks: ReturnType | undefined; public clientId: string | undefined; + public issuer: string | string[] | undefined; constructor(private readonly workos: WorkOS) { - const { clientId } = workos.options; - - this.clientId = clientId; + this.clientId = workos.clientId; + this.issuer = workos.options.issuer; } /** @@ -751,7 +751,11 @@ export class UserManagement { } try { - await jwtVerify(accessToken, jwks); + await jwtVerify( + accessToken, + jwks, + this.issuer ? { issuer: this.issuer } : undefined, + ); return true; } catch (e) { // Only treat as invalid JWT if it's an actual JWT/JWS error from jose