From 0ce5d527824c0f1a3521552c0069254917b79102 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Sat, 5 Sep 2026 19:28:12 +0000 Subject: [PATCH 1/3] Add optional issuer config for access token validation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 21 ++++---- src/core/AuthKitCore.issuer.spec.ts | 81 +++++++++++++++++++++++++++++ src/core/AuthKitCore.ts | 8 ++- src/core/config/types.ts | 7 +++ 4 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 src/core/AuthKitCore.issuer.spec.ts diff --git a/README.md b/README.md index f368dd2..0d1a6ec 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 (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..ed3369e --- /dev/null +++ b/src/core/AuthKitCore.issuer.spec.ts @@ -0,0 +1,81 @@ +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('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/types.ts b/src/core/config/types.ts index ec2ce98..679e02a 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 + * Equivalent to the WORKOS_ISSUER environment variable + * When not set, the issuer claim is not validated + */ + issuer?: string; + /** * The maximum age of the session cookie in seconds * Equivalent to the WORKOS_COOKIE_MAX_AGE environment variable From 339662018a10b00d61023c7712400b7b3c3bd235 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Sat, 5 Sep 2026 19:48:55 +0000 Subject: [PATCH 2/3] Accept a list of issuers via issuer config and WORKOS_ISSUER Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 22 +++++++++---------- src/core/AuthKitCore.issuer.spec.ts | 16 ++++++++++++++ src/core/config/ConfigurationProvider.spec.ts | 19 ++++++++++++++++ src/core/config/ConfigurationProvider.ts | 9 ++++++++ src/core/config/types.ts | 6 ++--- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 0d1a6ec..47fe331 100644 --- a/README.md +++ b/README.md @@ -164,17 +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` | -| `WORKOS_ISSUER` | `issuer` | Expected `iss` claim of access tokens (not validated when unset) | +| 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 index ed3369e..0582497 100644 --- a/src/core/AuthKitCore.issuer.spec.ts +++ b/src/core/AuthKitCore.issuer.spec.ts @@ -67,6 +67,22 @@ describe('AuthKitCore.verifyToken() issuer validation', () => { ); }); + 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'), diff --git a/src/core/config/ConfigurationProvider.spec.ts b/src/core/config/ConfigurationProvider.spec.ts index a6b6480..37c6fd6 100644 --- a/src/core/config/ConfigurationProvider.spec.ts +++ b/src/core/config/ConfigurationProvider.spec.ts @@ -78,6 +78,25 @@ 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'); + }); }); describe('getEnvironmentVariableName()', () => { diff --git a/src/core/config/ConfigurationProvider.ts b/src/core/config/ConfigurationProvider.ts index 104d673..4a29c2e 100644 --- a/src/core/config/ConfigurationProvider.ts +++ b/src/core/config/ConfigurationProvider.ts @@ -124,6 +124,15 @@ export class ConfigurationProvider { return (isNaN(num) ? undefined : num) as AuthKitConfig[K]; } + // Handle comma-separated issuer lists + if (key === 'issuer') { + 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]; } diff --git a/src/core/config/types.ts b/src/core/config/types.ts index 679e02a..d204863 100644 --- a/src/core/config/types.ts +++ b/src/core/config/types.ts @@ -46,11 +46,11 @@ export interface AuthKitConfig { apiPort?: number; /** - * The expected `iss` claim of WorkOS access tokens - * Equivalent to the WORKOS_ISSUER environment variable + * 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; + issuer?: string | string[]; /** * The maximum age of the session cookie in seconds From e7654ba21fa4ac0c2b634cfbfe069474f8abc128 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Sat, 5 Sep 2026 19:52:48 +0000 Subject: [PATCH 3/3] Resolve source-only optional keys in getConfig and split issuer lists only from env Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/core/config/ConfigurationProvider.spec.ts | 28 +++++++++++++++++++ src/core/config/ConfigurationProvider.ts | 20 +++++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/core/config/ConfigurationProvider.spec.ts b/src/core/config/ConfigurationProvider.spec.ts index 37c6fd6..d5a8066 100644 --- a/src/core/config/ConfigurationProvider.spec.ts +++ b/src/core/config/ConfigurationProvider.spec.ts @@ -97,6 +97,12 @@ describe('ConfigurationProvider', () => { 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()', () => { @@ -137,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 4a29c2e..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,8 +137,8 @@ export class ConfigurationProvider { return (isNaN(num) ? undefined : num) as AuthKitConfig[K]; } - // Handle comma-separated issuer lists - if (key === 'issuer') { + // Handle comma-separated issuer lists (environment values only) + if (key === 'issuer' && fromEnvironment) { const issuers = value .split(',') .map(issuer => issuer.trim()) @@ -193,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