diff --git a/README.md b/README.md index f368dd2..47fe331 100644 --- a/README.md +++ b/README.md @@ -164,16 +164,17 @@ auth.claims.sid; // string ## Configuration Options -| Environment Variable | Config Key | Description | -| ------------------------- | ---------------- | ------------------------------------ | -| `WORKOS_CLIENT_ID` | `clientId` | WorkOS client ID | -| `WORKOS_API_KEY` | `apiKey` | WorkOS API key | -| `WORKOS_REDIRECT_URI` | `redirectUri` | OAuth callback URL | -| `WORKOS_COOKIE_PASSWORD` | `cookiePassword` | 32+ char encryption key | -| `WORKOS_COOKIE_NAME` | `cookieName` | Cookie name (default: `wos-session`) | -| `WORKOS_COOKIE_MAX_AGE` | `cookieMaxAge` | Cookie lifetime in seconds | -| `WORKOS_COOKIE_DOMAIN` | `cookieDomain` | Cookie domain | -| `WORKOS_COOKIE_SAME_SITE` | `cookieSameSite` | `lax`, `strict`, or `none` | +| Environment Variable | Config Key | Description | +| ------------------------- | ---------------- | --------------------------------------------------------------------------------------------------- | +| `WORKOS_CLIENT_ID` | `clientId` | WorkOS client ID | +| `WORKOS_API_KEY` | `apiKey` | WorkOS API key | +| `WORKOS_REDIRECT_URI` | `redirectUri` | OAuth callback URL | +| `WORKOS_COOKIE_PASSWORD` | `cookiePassword` | 32+ char encryption key | +| `WORKOS_COOKIE_NAME` | `cookieName` | Cookie name (default: `wos-session`) | +| `WORKOS_COOKIE_MAX_AGE` | `cookieMaxAge` | Cookie lifetime in seconds | +| `WORKOS_COOKIE_DOMAIN` | `cookieDomain` | Cookie domain | +| `WORKOS_COOKIE_SAME_SITE` | `cookieSameSite` | `lax`, `strict`, or `none` | +| `WORKOS_ISSUER` | `issuer` | Expected `iss` claim of access tokens, comma-separated to accept several (not validated when unset) | Environment variables override programmatic config. diff --git a/src/core/AuthKitCore.issuer.spec.ts b/src/core/AuthKitCore.issuer.spec.ts new file mode 100644 index 0000000..0582497 --- /dev/null +++ b/src/core/AuthKitCore.issuer.spec.ts @@ -0,0 +1,97 @@ +import { jwtVerify } from 'jose'; +import { AuthKitCore } from './AuthKitCore.js'; + +vi.mock('jose', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + jwtVerify: vi.fn().mockResolvedValue({ payload: {}, protectedHeader: {} }), + }; +}); + +const mockClient = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + }, +}; + +const mockEncryption = { + sealData: async () => 'encrypted-session-data', + unsealData: async () => ({}), +}; + +const baseConfig = { + cookiePassword: 'test-password-that-is-32-chars-long!!', + clientId: 'test-client-id', +}; + +function createCore(config: Record) { + return new AuthKitCore( + config as any, + mockClient as any, + mockEncryption as any, + ); +} + +describe('AuthKitCore.verifyToken() issuer validation', () => { + beforeEach(() => { + vi.mocked(jwtVerify).mockClear(); + }); + + it('does not validate the issuer claim by default', async () => { + const core = createCore(baseConfig); + + await expect(core.verifyToken('some.jwt.token')).resolves.toBe(true); + + expect(jwtVerify).toHaveBeenCalledTimes(1); + expect(jwtVerify).toHaveBeenCalledWith( + 'some.jwt.token', + expect.any(Function), + undefined, + ); + }); + + it('validates the issuer claim when issuer is configured', async () => { + const core = createCore({ + ...baseConfig, + issuer: 'https://auth.example.com', + }); + + await expect(core.verifyToken('some.jwt.token')).resolves.toBe(true); + + expect(jwtVerify).toHaveBeenCalledTimes(1); + expect(jwtVerify).toHaveBeenCalledWith( + 'some.jwt.token', + expect.any(Function), + { issuer: 'https://auth.example.com' }, + ); + }); + + it('passes a list of issuers through to jwtVerify', async () => { + const issuer = [ + 'https://auth.example.com', + 'https://api.workos.com/user_management/test-client-id', + ]; + const core = createCore({ ...baseConfig, issuer }); + + await expect(core.verifyToken('some.jwt.token')).resolves.toBe(true); + + expect(jwtVerify).toHaveBeenCalledWith( + 'some.jwt.token', + expect.any(Function), + { issuer }, + ); + }); + + it('returns false when jwtVerify rejects the issuer', async () => { + vi.mocked(jwtVerify).mockRejectedValueOnce( + new Error('unexpected "iss" claim value'), + ); + const core = createCore({ + ...baseConfig, + issuer: 'https://auth.example.com', + }); + + await expect(core.verifyToken('some.jwt.token')).resolves.toBe(false); + }); +}); diff --git a/src/core/AuthKitCore.ts b/src/core/AuthKitCore.ts index f537b7a..080fbbb 100644 --- a/src/core/AuthKitCore.ts +++ b/src/core/AuthKitCore.ts @@ -70,13 +70,19 @@ export class AuthKitCore { /** * Verify a JWT access token against WorkOS JWKS. + * The `iss` claim is only validated when `config.issuer` is set. * * @param token - The JWT access token to verify * @returns true if valid, false otherwise */ async verifyToken(token: string): Promise { + const issuer = this.config.issuer; try { - await jwtVerify(token, this.getPublicKey()); + await jwtVerify( + token, + this.getPublicKey(), + issuer ? { issuer } : undefined, + ); return true; } catch { return false; diff --git a/src/core/config/ConfigurationProvider.spec.ts b/src/core/config/ConfigurationProvider.spec.ts index a6b6480..d5a8066 100644 --- a/src/core/config/ConfigurationProvider.spec.ts +++ b/src/core/config/ConfigurationProvider.spec.ts @@ -78,6 +78,31 @@ describe('ConfigurationProvider', () => { expect(provider.getValue('apiPort')).toBeUndefined(); }); + + it('parses a comma-separated issuer into a list', () => { + const source = vi + .fn() + .mockReturnValue('https://a.example.com, https://b.example.com,'); + provider.configure(source); + + expect(provider.getValue('issuer')).toEqual([ + 'https://a.example.com', + 'https://b.example.com', + ]); + }); + + it('keeps a single issuer as a string', () => { + const source = vi.fn().mockReturnValue('https://a.example.com'); + provider.configure(source); + + expect(provider.getValue('issuer')).toBe('https://a.example.com'); + }); + + it('does not split a programmatic issuer string', () => { + provider.configure({ issuer: 'https://a.example.com/path,a' }); + + expect(provider.getValue('issuer')).toBe('https://a.example.com/path,a'); + }); }); describe('getEnvironmentVariableName()', () => { @@ -118,6 +143,28 @@ describe('ConfigurationProvider', () => { const config = provider.getConfig(); expect(config.cookieName).toBe('test-cookie'); }); + + it('includes optional keys that are only set in the value source', () => { + const validPassword = 'a'.repeat(32); + provider.configure( + { + clientId: 'test-client', + apiKey: 'test-api-key', + redirectUri: 'http://localhost:3000/callback', + cookiePassword: validPassword, + }, + key => + key === 'WORKOS_ISSUER' + ? 'https://a.example.com,https://b.example.com' + : undefined, + ); + + const config = provider.getConfig(); + expect(config.issuer).toEqual([ + 'https://a.example.com', + 'https://b.example.com', + ]); + }); }); describe('validate()', () => { diff --git a/src/core/config/ConfigurationProvider.ts b/src/core/config/ConfigurationProvider.ts index 104d673..bf52d21 100644 --- a/src/core/config/ConfigurationProvider.ts +++ b/src/core/config/ConfigurationProvider.ts @@ -40,6 +40,14 @@ export class ConfigurationProvider { 'cookiePassword', ]; + // Optional keys with no default; only resolvable from the value source + private readonly optionalKeys: (keyof AuthKitConfig)[] = [ + 'apiPort', + 'issuer', + 'cookieSameSite', + 'cookieDomain', + ]; + /** * Convert a camelCase string to an uppercase, underscore-separated environment variable name. * @param str The string to convert @@ -79,7 +87,11 @@ export class ConfigurationProvider { const rawValue = envValue ?? this.config[key]; if (rawValue != null) { - return this.convertValueType(key, rawValue) as AuthKitConfig[K]; + return this.convertValueType( + key, + rawValue, + envValue != null, + ) as AuthKitConfig[K]; } if (this.requiredKeys.includes(key)) { @@ -108,6 +120,7 @@ export class ConfigurationProvider { private convertValueType( key: K, value: unknown, + fromEnvironment = false, ): AuthKitConfig[K] | undefined { if (typeof value !== 'string') { return value as AuthKitConfig[K]; @@ -124,6 +137,15 @@ export class ConfigurationProvider { return (isNaN(num) ? undefined : num) as AuthKitConfig[K]; } + // Handle comma-separated issuer lists (environment values only) + if (key === 'issuer' && fromEnvironment) { + const issuers = value + .split(',') + .map(issuer => issuer.trim()) + .filter(Boolean); + return (issuers.length <= 1 ? issuers[0] : issuers) as AuthKitConfig[K]; + } + return value as AuthKitConfig[K]; } @@ -184,6 +206,7 @@ export class ConfigurationProvider { const allKeys = new Set([ ...(Object.keys(this.config) as (keyof AuthKitConfig)[]), ...this.requiredKeys, + ...this.optionalKeys, ]); // Merge each key, with environment variables taking precedence diff --git a/src/core/config/types.ts b/src/core/config/types.ts index ec2ce98..d204863 100644 --- a/src/core/config/types.ts +++ b/src/core/config/types.ts @@ -45,6 +45,13 @@ export interface AuthKitConfig { */ apiPort?: number; + /** + * The expected `iss` claim of WorkOS access tokens, or a list of accepted issuers + * Equivalent to the WORKOS_ISSUER environment variable (comma-separated for a list) + * When not set, the issuer claim is not validated + */ + issuer?: string | string[]; + /** * The maximum age of the session cookie in seconds * Equivalent to the WORKOS_COOKIE_MAX_AGE environment variable