diff --git a/src/core/AuthKitCore.spec.ts b/src/core/AuthKitCore.spec.ts index 0d15c7e..e6ddaa4 100644 --- a/src/core/AuthKitCore.spec.ts +++ b/src/core/AuthKitCore.spec.ts @@ -205,6 +205,42 @@ describe('AuthKitCore', () => { SessionEncryptionError, ); }); + + it.each([ + ['string', 'hello'], + ['number', 42], + ['null', null], + ['array', [1, 2, 3]], + ['empty object', {}], + ['missing user', { accessToken: 'at', refreshToken: 'rt' }], + ['null user', { accessToken: 'at', refreshToken: 'rt', user: null }], + ['missing refreshToken', { accessToken: 'at', user: { id: 'user_123' } }], + ])( + 'throws SessionEncryptionError for invalid shape: %s', + async (_label, badValue) => { + const badEncryption = { + sealData: async () => 'encrypted', + unsealData: async () => badValue, + }; + const badCore = new AuthKitCore( + mockConfig as any, + mockClient as any, + badEncryption as any, + ); + + await expect(badCore.decryptSession('data')).rejects.toThrow( + SessionEncryptionError, + ); + }, + ); + + it('accepts valid session shape', async () => { + const result = await core.decryptSession('encrypted-data'); + + expect(result.accessToken).toBe('test-access-token'); + expect(result.refreshToken).toBe('test-refresh-token'); + expect(result.user).toEqual(mockUser); + }); }); describe('refreshTokens()', () => { diff --git a/src/core/AuthKitCore.ts b/src/core/AuthKitCore.ts index 6477c3d..16db70b 100644 --- a/src/core/AuthKitCore.ts +++ b/src/core/AuthKitCore.ts @@ -147,8 +147,16 @@ export class AuthKitCore { encryptedSession, { password: this.config.cookiePassword }, ); + + if (!isSessionLike(session)) { + throw new Error('Decoded value is not a valid Session object'); + } + return session; } catch (error) { + if (error instanceof SessionEncryptionError) { + throw error; + } throw new SessionEncryptionError('Failed to decrypt session', error); } } @@ -303,3 +311,15 @@ export class AuthKitCore { }; } } + +function isSessionLike(value: unknown): value is Session { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as Record).accessToken === 'string' && + typeof (value as Record).refreshToken === 'string' && + typeof (value as Record).user === 'object' && + (value as Record).user !== null + ); +} diff --git a/src/core/config.spec.ts b/src/core/config.spec.ts index 0f212ce..9165d18 100644 --- a/src/core/config.spec.ts +++ b/src/core/config.spec.ts @@ -108,16 +108,26 @@ describe('config', () => { const config = getFullConfig(); expect(config).toMatchObject({ clientId: 'test-client' }); }); + + it('derives cookiePassword when not provided', () => { + configure({ + clientId: 'test-client', + apiKey: 'test-api-key', + redirectUri: 'http://localhost:3000/callback', + }); + + const config = getFullConfig(); + expect(config.cookiePassword).toBeDefined(); + expect(config.cookiePassword).toContain('test-api-key'); + }); }); describe('validateConfig()', () => { it('passes with all required config', () => { - const validPassword = 'a'.repeat(32); configure({ clientId: 'test-client', apiKey: 'test-api-key', redirectUri: 'http://localhost:3000/callback', - cookiePassword: validPassword, }); expect(() => validateConfig()).not.toThrow(); @@ -133,9 +143,6 @@ describe('config', () => { expect(() => validateConfig()).toThrow(/WORKOS_CLIENT_ID is required/); expect(() => validateConfig()).toThrow(/WORKOS_API_KEY is required/); expect(() => validateConfig()).toThrow(/WORKOS_REDIRECT_URI is required/); - expect(() => validateConfig()).toThrow( - /WORKOS_COOKIE_PASSWORD is required/, - ); }); it('throws for short cookie password', () => { diff --git a/src/core/config/ConfigurationProvider.spec.ts b/src/core/config/ConfigurationProvider.spec.ts index a6b6480..aac5260 100644 --- a/src/core/config/ConfigurationProvider.spec.ts +++ b/src/core/config/ConfigurationProvider.spec.ts @@ -118,6 +118,42 @@ describe('ConfigurationProvider', () => { const config = provider.getConfig(); expect(config.cookieName).toBe('test-cookie'); }); + + it('uses explicit cookiePassword when provided', () => { + const validPassword = 'a'.repeat(32); + provider.configure({ + clientId: 'test-client', + apiKey: 'test-api-key', + redirectUri: 'http://localhost:3000/callback', + cookiePassword: validPassword, + }); + + const config = provider.getConfig(); + expect(config.cookiePassword).toBe(validPassword); + }); + + it('derives cookiePassword from apiKey and clientId when not provided', () => { + provider.configure({ + clientId: 'test-client', + apiKey: 'test-api-key', + redirectUri: 'http://localhost:3000/callback', + }); + + const config = provider.getConfig(); + expect(config.cookiePassword).toBe('wos-ck:test-api-key:test-client'); + }); + + it('derives a deterministic password', () => { + provider.configure({ + clientId: 'test-client', + apiKey: 'test-api-key', + redirectUri: 'http://localhost:3000/callback', + }); + + const config1 = provider.getConfig(); + const config2 = provider.getConfig(); + expect(config1.cookiePassword).toBe(config2.cookiePassword); + }); }); describe('validate()', () => { @@ -135,7 +171,7 @@ describe('ConfigurationProvider', () => { it('throws with all missing required fields at once', () => { expect(() => provider.validate()).toThrow( - /AuthKit configuration error\. Missing or invalid environment variables:\n\n • WORKOS_CLIENT_ID is required\n • WORKOS_API_KEY is required\n • WORKOS_REDIRECT_URI is required\n • WORKOS_COOKIE_PASSWORD is required/, + /AuthKit configuration error\. Missing or invalid environment variables:\n\n • WORKOS_CLIENT_ID is required\n • WORKOS_API_KEY is required\n • WORKOS_REDIRECT_URI is required/, ); }); @@ -185,6 +221,16 @@ describe('ConfigurationProvider', () => { ); }); + it('passes without cookiePassword when apiKey and clientId are set', () => { + provider.configure({ + clientId: 'test-client', + apiKey: 'test-api-key', + redirectUri: 'http://localhost:3000/callback', + }); + + expect(() => provider.validate()).not.toThrow(); + }); + it('prefers environment values over config in validation', () => { const source = vi.fn((key: string) => { if (key === 'WORKOS_COOKIE_PASSWORD') return 'a'.repeat(32); diff --git a/src/core/config/ConfigurationProvider.ts b/src/core/config/ConfigurationProvider.ts index 104d673..1122a11 100644 --- a/src/core/config/ConfigurationProvider.ts +++ b/src/core/config/ConfigurationProvider.ts @@ -37,7 +37,6 @@ export class ConfigurationProvider { 'clientId', 'apiKey', 'redirectUri', - 'cookiePassword', ]; /** @@ -155,14 +154,19 @@ export class ConfigurationProvider { if (!value) { errors.push(`${envKey} is required`); - } else if (key === 'cookiePassword') { - // Special validation for cookiePassword length - const password = String(value); - if (password.length < 32) { - errors.push( - `${envKey} must be at least 32 characters (currently ${password.length})`, - ); - } + } + } + + // Validate cookiePassword length when explicitly provided + const passwordEnvKey = this.getEnvironmentVariableName('cookiePassword'); + const explicitPassword = + this.getEnvironmentValue(passwordEnvKey) ?? this.config.cookiePassword; + if (explicitPassword != null) { + const password = String(explicitPassword); + if (password.length < 32) { + errors.push( + `${passwordEnvKey} must be at least 32 characters (currently ${password.length})`, + ); } } @@ -202,6 +206,17 @@ export class ConfigurationProvider { } } + if (!fullConfig.cookiePassword) { + fullConfig.cookiePassword = deriveCookiePassword( + fullConfig.apiKey, + fullConfig.clientId, + ); + } + return fullConfig; } } + +function deriveCookiePassword(apiKey: string, clientId: string): string { + return `wos-ck:${apiKey}:${clientId}`; +} diff --git a/src/core/config/types.ts b/src/core/config/types.ts index ec2ce98..9374c60 100644 --- a/src/core/config/types.ts +++ b/src/core/config/types.ts @@ -21,9 +21,15 @@ export interface AuthKitConfig { redirectUri: string; /** - * The password used to encrypt the session cookie - * Equivalent to the WORKOS_COOKIE_PASSWORD environment variable - * Must be at least 32 characters long + * The password used to seal PKCE verifier cookies and (in sealed mode) + * session cookies. + * + * Optional. If not provided, a password is derived from `apiKey` and + * `clientId`. Set this explicitly if you need cookie continuity across + * API key rotations. + * + * Equivalent to the WORKOS_COOKIE_PASSWORD environment variable. + * Must be at least 32 characters long when explicitly provided. */ cookiePassword: string; diff --git a/src/core/encryption/sessionEncryption.spec.ts b/src/core/encryption/sessionEncryption.spec.ts new file mode 100644 index 0000000..4338b1a --- /dev/null +++ b/src/core/encryption/sessionEncryption.spec.ts @@ -0,0 +1,273 @@ +import { SessionEncryption as IronEncryption } from './ironWebcryptoEncryption.js'; +import { SessionEncryptionAdapter } from './sessionEncryption.js'; + +const testPassword = 'this-is-a-test-password-that-is-32-characters-long!'; +const testData = { + accessToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test', + refreshToken: 'refresh_abc123', + user: { id: 'user_01', email: 'test@example.com' }, +}; + +describe('SessionEncryptionAdapter', () => { + const iron = new IronEncryption(); + + describe('unsealed mode (default)', () => { + const adapter = new SessionEncryptionAdapter(iron); + + it('writes plain JSON by default', async () => { + const result = await adapter.sealData(testData, { + password: testPassword, + }); + expect(JSON.parse(result)).toEqual(testData); + }); + + it('reads plain JSON', async () => { + const json = JSON.stringify(testData); + const result = await adapter.unsealData(json, { + password: testPassword, + }); + expect(result).toEqual(testData); + }); + + it('reads legacy iron-sealed data', async () => { + const sealed = await iron.sealData(testData, { + password: testPassword, + }); + const result = await adapter.unsealData(sealed, { + password: testPassword, + }); + expect(result).toEqual(testData); + }); + }); + + describe('unsealed mode', () => { + const adapter = new SessionEncryptionAdapter(iron, { mode: 'unsealed' }); + + it('writes plain JSON for session cookies (ttl=0)', async () => { + const result = await adapter.sealData(testData, { + password: testPassword, + ttl: 0, + }); + expect(JSON.parse(result)).toEqual(testData); + }); + + it('writes plain JSON when ttl omitted', async () => { + const result = await adapter.sealData(testData, { + password: testPassword, + }); + expect(JSON.parse(result)).toEqual(testData); + }); + + it('always seals when ttl > 0 (PKCE protection)', async () => { + const result = await adapter.sealData(testData, { + password: testPassword, + ttl: 600, + }); + expect(result).toMatch(/^Fe26\.2\*/); + }); + + it('reads plain JSON', async () => { + const json = JSON.stringify(testData); + const result = await adapter.unsealData(json, { + password: testPassword, + }); + expect(result).toEqual(testData); + }); + + it('reads legacy iron-sealed data', async () => { + const sealed = await iron.sealData(testData, { + password: testPassword, + }); + const result = await adapter.unsealData(sealed, { + password: testPassword, + }); + expect(result).toEqual(testData); + }); + + it('round-trips through unsealed format', async () => { + const encoded = await adapter.sealData(testData, { + password: testPassword, + }); + const decoded = await adapter.unsealData(encoded, { + password: testPassword, + }); + expect(decoded).toEqual(testData); + }); + }); + + describe('bidirectional migration', () => { + const unsealedAdapter = new SessionEncryptionAdapter(iron, { + mode: 'unsealed', + }); + const sealedAdapter = new SessionEncryptionAdapter(iron, { + mode: 'sealed', + }); + + it('sealed adapter reads what unsealed adapter writes', async () => { + const encoded = await unsealedAdapter.sealData(testData, { + password: testPassword, + }); + const decoded = await sealedAdapter.unsealData(encoded, { + password: testPassword, + }); + expect(decoded).toEqual(testData); + }); + + it('unsealed adapter reads what sealed adapter writes', async () => { + const encoded = await sealedAdapter.sealData(testData, { + password: testPassword, + }); + const decoded = await unsealedAdapter.unsealData(encoded, { + password: testPassword, + }); + expect(decoded).toEqual(testData); + }); + + it('handles unicode in session data', async () => { + const unicodeData = { + ...testData, + user: { id: 'user_01', name: '日本語テスト 🔐' }, + }; + const encoded = await unsealedAdapter.sealData(unicodeData, { + password: testPassword, + }); + const decoded = await unsealedAdapter.unsealData(encoded, { + password: testPassword, + }); + expect(decoded).toEqual(unicodeData); + }); + }); + + describe('PKCE TTL guard', () => { + const adapter = new SessionEncryptionAdapter(iron, { mode: 'unsealed' }); + + it('seals when ttl is positive', async () => { + const result = await adapter.sealData(testData, { + password: testPassword, + ttl: 1, + }); + expect(result).toMatch(/^Fe26\.2\*/); + }); + + it('does not seal when ttl is 0', async () => { + const result = await adapter.sealData(testData, { + password: testPassword, + ttl: 0, + }); + expect(result).not.toMatch(/^Fe26\./); + expect(JSON.parse(result)).toEqual(testData); + }); + + it('does not seal when ttl is undefined', async () => { + const result = await adapter.sealData(testData, { + password: testPassword, + }); + expect(result).not.toMatch(/^Fe26\./); + expect(JSON.parse(result)).toEqual(testData); + }); + + it('round-trips PKCE sealed data in unsealed mode', async () => { + const sealed = await adapter.sealData(testData, { + password: testPassword, + ttl: 600, + }); + const decoded = await adapter.unsealData(sealed, { + password: testPassword, + ttl: 600, + }); + expect(decoded).toEqual(testData); + }); + }); + + describe('TTL enforcement', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('sealed mode respects TTL on unseal', async () => { + const adapter = new SessionEncryptionAdapter(iron, { mode: 'sealed' }); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const sealed = await adapter.sealData(testData, { + password: testPassword, + ttl: 1, + }); + + vi.setSystemTime(new Date('2026-01-01T00:02:00.000Z')); + await expect( + adapter.unsealData(sealed, { password: testPassword, ttl: 1 }), + ).rejects.toThrow(); + }); + + it('PKCE ttl > 0 is iron-sealed even in unsealed mode, so TTL is enforced', async () => { + const adapter = new SessionEncryptionAdapter(iron, { mode: 'unsealed' }); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const sealed = await adapter.sealData(testData, { + password: testPassword, + ttl: 1, + }); + + vi.setSystemTime(new Date('2026-01-01T00:02:00.000Z')); + await expect( + adapter.unsealData(sealed, { password: testPassword, ttl: 1 }), + ).rejects.toThrow(); + }); + }); + + describe('error handling', () => { + const adapter = new SessionEncryptionAdapter(iron); + + it('throws on empty string', async () => { + await expect( + adapter.unsealData('', { password: testPassword }), + ).rejects.toThrow(); + }); + + it('throws on whitespace-only string', async () => { + await expect( + adapter.unsealData(' ', { password: testPassword }), + ).rejects.toThrow(); + }); + + it('throws on malformed JSON that does not start with Fe26.', async () => { + await expect( + adapter.unsealData('not-json-and-not-iron', { + password: testPassword, + }), + ).rejects.toThrow(); + }); + + it('throws on iron seal with wrong password', async () => { + const sealed = await iron.sealData(testData, { + password: testPassword, + }); + await expect( + adapter.unsealData(sealed, { + password: 'wrong-password-that-is-32-chars!!', + }), + ).rejects.toThrow(); + }); + + it('throws on fake Fe26. prefix (invalid iron token)', async () => { + await expect( + adapter.unsealData('Fe26.2*garbage*data', { + password: testPassword, + }), + ).rejects.toThrow(); + }); + }); + + describe('prefix collision safety', () => { + it('JSON.stringify of objects never starts with Fe26.', () => { + expect(JSON.stringify(testData).startsWith('Fe26.')).toBe(false); + expect(JSON.stringify({}).startsWith('Fe26.')).toBe(false); + expect(JSON.stringify([]).startsWith('Fe26.')).toBe(false); + expect(JSON.stringify('Fe26.fake').startsWith('Fe26.')).toBe(false); + }); + }); +}); diff --git a/src/core/encryption/sessionEncryption.ts b/src/core/encryption/sessionEncryption.ts new file mode 100644 index 0000000..3d5015e --- /dev/null +++ b/src/core/encryption/sessionEncryption.ts @@ -0,0 +1,59 @@ +import type { SessionEncryption } from '../session/types.js'; + +const IRON_SEAL_PREFIX = 'Fe26.'; + +export interface SessionEncryptionAdapterOptions { + /** + * `'sealed'` — always encrypt via iron-webcrypto (current behavior). + * `'unsealed'` — write plain JSON for session cookies. PKCE state + * (TTL > 0) is always sealed regardless of this setting because + * the sealed blob appears in the OAuth `state` URL parameter. + * + * Default: `'sealed'` — deploy readers first, flip to `'unsealed'` + * once all nodes run the adapter. + */ + mode?: 'sealed' | 'unsealed'; +} + +/** + * Bidirectional session encryption adapter. + * + * Reads both sealed (iron-webcrypto) and unsealed (plain JSON) formats. + * Writes in whichever mode is configured, enabling zero-downtime migration + * in either direction. PKCE state is always sealed regardless of mode + * because the sealed blob appears in the OAuth `state` URL parameter — + * an unsealed blob would expose the PKCE `codeVerifier` in browser + * history, server logs, and proxy logs. + */ +export class SessionEncryptionAdapter implements SessionEncryption { + private readonly ironEncryption: SessionEncryption; + private readonly mode: 'sealed' | 'unsealed'; + + constructor( + ironEncryption: SessionEncryption, + options: SessionEncryptionAdapterOptions = {}, + ) { + this.ironEncryption = ironEncryption; + this.mode = options.mode ?? 'unsealed'; + } + + async sealData( + data: unknown, + options: { password: string; ttl?: number | undefined }, + ): Promise { + if (this.mode === 'sealed' || (options.ttl != null && options.ttl > 0)) { + return this.ironEncryption.sealData(data, options); + } + return JSON.stringify(data); + } + + async unsealData( + encryptedData: string, + options: { password: string; ttl?: number | undefined }, + ): Promise { + if (encryptedData.startsWith(IRON_SEAL_PREFIX)) { + return this.ironEncryption.unsealData(encryptedData, options); + } + return JSON.parse(encryptedData) as T; + } +} diff --git a/src/index.ts b/src/index.ts index f0fa4e8..96de383 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,9 +46,13 @@ export { } from './core/pkce/cookieName.js'; // ============================================ -// Encryption Fallback +// Encryption // ============================================ export { default as sessionEncryption } from './core/encryption/ironWebcryptoEncryption.js'; +export { + SessionEncryptionAdapter, + type SessionEncryptionAdapterOptions, +} from './core/encryption/sessionEncryption.js'; // ============================================ // Configuration diff --git a/src/service/factory.ts b/src/service/factory.ts index 90f3399..f411f5c 100644 --- a/src/service/factory.ts +++ b/src/service/factory.ts @@ -3,7 +3,8 @@ import { once } from '../utils.js'; import { getFullConfig } from '../core/config.js'; import type { AuthKitConfig } from '../core/config/types.js'; import { getWorkOS } from '../core/client/workos.js'; -import sessionEncryption from '../core/encryption/ironWebcryptoEncryption.js'; +import ironWebcryptoEncryption from '../core/encryption/ironWebcryptoEncryption.js'; +import { SessionEncryptionAdapter } from '../core/encryption/sessionEncryption.js'; import type { SessionEncryption, SessionStorage, @@ -47,7 +48,8 @@ export function createAuthService(options: { const { sessionStorageFactory, clientFactory = () => getWorkOS(), - encryptionFactory = () => sessionEncryption, + encryptionFactory = () => + new SessionEncryptionAdapter(ironWebcryptoEncryption), } = options; // Lazily create the real AuthService with resolved config