From b59255b4213a3fae20f7be651e80e5a774d488e3 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 6 May 2026 12:14:56 -0700 Subject: [PATCH 1/5] feat: add bidirectional session encryption adapter to drop cookie sealing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces SessionEncryptionAdapter that wraps iron-webcrypto and auto-detects format on read (Fe26.* prefix → iron, else → JSON). Write mode is controlled by a `sealed` flag (default: false). The factory default now writes plain JSON cookies while transparently reading legacy iron-sealed cookies, enabling zero-downtime migration. Passing `sealed: true` re-enables encryption for rollback. --- src/core/encryption/sessionEncryption.spec.ts | 186 ++++++++++++++++++ src/core/encryption/sessionEncryption.ts | 40 ++++ src/index.ts | 3 +- src/service/factory.ts | 6 +- 4 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 src/core/encryption/sessionEncryption.spec.ts create mode 100644 src/core/encryption/sessionEncryption.ts diff --git a/src/core/encryption/sessionEncryption.spec.ts b/src/core/encryption/sessionEncryption.spec.ts new file mode 100644 index 0000000..1e19ba2 --- /dev/null +++ b/src/core/encryption/sessionEncryption.spec.ts @@ -0,0 +1,186 @@ +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', 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); + }); + + 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('sealed mode', () => { + const adapter = new SessionEncryptionAdapter(iron, true); + + it('writes iron-sealed data', async () => { + const result = await adapter.sealData(testData, { + password: testPassword, + }); + expect(result).toMatch(/^Fe26\.2\*/); + expect(result).toMatch(/~2$/); + }); + + it('reads iron-sealed data', async () => { + const sealed = await adapter.sealData(testData, { + password: testPassword, + }); + const result = await adapter.unsealData(sealed, { + password: testPassword, + }); + expect(result).toEqual(testData); + }); + + it('reads plain JSON (migrating back from unsealed)', async () => { + const json = JSON.stringify(testData); + const result = await adapter.unsealData(json, { + password: testPassword, + }); + expect(result).toEqual(testData); + }); + }); + + describe('bidirectional migration', () => { + const unsealedAdapter = new SessionEncryptionAdapter(iron, false); + const sealedAdapter = new SessionEncryptionAdapter(iron, true); + + 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('TTL passthrough', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('sealed mode respects TTL on unseal', async () => { + const adapter = new SessionEncryptionAdapter(iron, true); + 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('unsealed mode ignores TTL (cookie maxAge handles expiry)', async () => { + const adapter = new SessionEncryptionAdapter(iron, false); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const encoded = await adapter.sealData(testData, { + password: testPassword, + ttl: 1, + }); + + vi.setSystemTime(new Date('2027-01-01T00:00:00.000Z')); + const decoded = await adapter.unsealData(encoded, { + password: testPassword, + ttl: 1, + }); + expect(decoded).toEqual(testData); + }); + }); + + describe('error handling', () => { + const adapter = new SessionEncryptionAdapter(iron); + + it('throws on malformed JSON', 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(); + }); + }); +}); diff --git a/src/core/encryption/sessionEncryption.ts b/src/core/encryption/sessionEncryption.ts new file mode 100644 index 0000000..dd29bac --- /dev/null +++ b/src/core/encryption/sessionEncryption.ts @@ -0,0 +1,40 @@ +import type { SessionEncryption } from '../session/types.js'; + +const IRON_SEAL_PREFIX = 'Fe26.'; + +/** + * 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. + */ +export class SessionEncryptionAdapter implements SessionEncryption { + readonly ironEncryption: SessionEncryption; + readonly sealed: boolean; + + constructor(ironEncryption: SessionEncryption, sealed: boolean = false) { + this.ironEncryption = ironEncryption; + this.sealed = sealed; + } + + async sealData( + data: unknown, + options: { password: string; ttl?: number | undefined }, + ): Promise { + if (this.sealed) { + 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..758b81a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,9 +46,10 @@ export { } from './core/pkce/cookieName.js'; // ============================================ -// Encryption Fallback +// Encryption // ============================================ export { default as sessionEncryption } from './core/encryption/ironWebcryptoEncryption.js'; +export { SessionEncryptionAdapter } 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 From 8d34bf2d9f76f405ff93a2cb37e38cc90b91f2d6 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 6 May 2026 12:35:03 -0700 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20PKCE?= =?UTF-8?q?=20always=20sealed,=20session=20validation,=20options=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Always seal when TTL > 0 so PKCE codeVerifier never appears in plaintext in the OAuth state URL parameter - Default mode is 'sealed' for safe rolling deploys (readers first, flip to 'unsealed' once all nodes run the adapter) - Replace positional boolean with options object ({ mode: 'sealed' | 'unsealed' }) - Add runtime session shape validation in decryptSession() — rejects arrays, primitives, null, and objects missing required fields - Add tests: empty/whitespace input, fake Fe26. prefix, invalid session shapes, PKCE TTL guard, prefix collision safety --- src/core/AuthKitCore.spec.ts | 39 ++++ src/core/AuthKitCore.ts | 20 +++ src/core/encryption/sessionEncryption.spec.ts | 169 +++++++++++++----- src/core/encryption/sessionEncryption.ts | 31 +++- src/index.ts | 5 +- 5 files changed, 216 insertions(+), 48 deletions(-) diff --git a/src/core/AuthKitCore.spec.ts b/src/core/AuthKitCore.spec.ts index 0d15c7e..8867f05 100644 --- a/src/core/AuthKitCore.spec.ts +++ b/src/core/AuthKitCore.spec.ts @@ -205,6 +205,45 @@ 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/encryption/sessionEncryption.spec.ts b/src/core/encryption/sessionEncryption.spec.ts index 1e19ba2..5720025 100644 --- a/src/core/encryption/sessionEncryption.spec.ts +++ b/src/core/encryption/sessionEncryption.spec.ts @@ -11,58 +11,71 @@ const testData = { describe('SessionEncryptionAdapter', () => { const iron = new IronEncryption(); - describe('unsealed mode (default)', () => { + describe('sealed mode (default)', () => { const adapter = new SessionEncryptionAdapter(iron); - it('writes plain JSON', async () => { + it('writes iron-sealed data by default', async () => { const result = await adapter.sealData(testData, { password: testPassword, }); - expect(JSON.parse(result)).toEqual(testData); + expect(result).toMatch(/^Fe26\.2\*/); }); - it('reads plain JSON', async () => { - const json = JSON.stringify(testData); - const result = await adapter.unsealData(json, { + it('reads iron-sealed data', async () => { + const sealed = await adapter.sealData(testData, { + password: testPassword, + }); + const result = await adapter.unsealData(sealed, { 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, { + it('reads plain JSON (forward-compat with unsealed mode)', async () => { + const json = JSON.stringify(testData); + const result = await adapter.unsealData(json, { password: testPassword, }); expect(result).toEqual(testData); }); + }); - it('round-trips through unsealed format', async () => { - const encoded = await adapter.sealData(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, }); - const decoded = await adapter.unsealData(encoded, { + expect(JSON.parse(result)).toEqual(testData); + }); + + it('writes plain JSON when ttl omitted', async () => { + const result = await adapter.sealData(testData, { password: testPassword, }); - expect(decoded).toEqual(testData); + expect(JSON.parse(result)).toEqual(testData); }); - }); - describe('sealed mode', () => { - const adapter = new SessionEncryptionAdapter(iron, true); - - it('writes iron-sealed data', async () => { + it('always seals when ttl > 0 (PKCE protection)', async () => { const result = await adapter.sealData(testData, { password: testPassword, + ttl: 600, }); expect(result).toMatch(/^Fe26\.2\*/); - expect(result).toMatch(/~2$/); }); - it('reads iron-sealed data', async () => { - const sealed = await adapter.sealData(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, { @@ -71,18 +84,24 @@ describe('SessionEncryptionAdapter', () => { expect(result).toEqual(testData); }); - it('reads plain JSON (migrating back from unsealed)', async () => { - const json = JSON.stringify(testData); - const result = await adapter.unsealData(json, { + it('round-trips through unsealed format', async () => { + const encoded = await adapter.sealData(testData, { password: testPassword, }); - expect(result).toEqual(testData); + const decoded = await adapter.unsealData(encoded, { + password: testPassword, + }); + expect(decoded).toEqual(testData); }); }); describe('bidirectional migration', () => { - const unsealedAdapter = new SessionEncryptionAdapter(iron, false); - const sealedAdapter = new SessionEncryptionAdapter(iron, true); + 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, { @@ -119,7 +138,48 @@ describe('SessionEncryptionAdapter', () => { }); }); - describe('TTL passthrough', () => { + 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(); }); @@ -129,7 +189,7 @@ describe('SessionEncryptionAdapter', () => { }); it('sealed mode respects TTL on unseal', async () => { - const adapter = new SessionEncryptionAdapter(iron, true); + const adapter = new SessionEncryptionAdapter(iron, { mode: 'sealed' }); vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); const sealed = await adapter.sealData(testData, { @@ -143,28 +203,38 @@ describe('SessionEncryptionAdapter', () => { ).rejects.toThrow(); }); - it('unsealed mode ignores TTL (cookie maxAge handles expiry)', async () => { - const adapter = new SessionEncryptionAdapter(iron, false); + 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 encoded = await adapter.sealData(testData, { + const sealed = await adapter.sealData(testData, { password: testPassword, ttl: 1, }); - vi.setSystemTime(new Date('2027-01-01T00:00:00.000Z')); - const decoded = await adapter.unsealData(encoded, { - password: testPassword, - ttl: 1, - }); - expect(decoded).toEqual(testData); + 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 malformed JSON', async () => { + 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, @@ -182,5 +252,22 @@ describe('SessionEncryptionAdapter', () => { }), ).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 index dd29bac..6667fea 100644 --- a/src/core/encryption/sessionEncryption.ts +++ b/src/core/encryption/sessionEncryption.ts @@ -2,27 +2,46 @@ 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. + * 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 { - readonly ironEncryption: SessionEncryption; - readonly sealed: boolean; + private readonly ironEncryption: SessionEncryption; + private readonly mode: 'sealed' | 'unsealed'; - constructor(ironEncryption: SessionEncryption, sealed: boolean = false) { + constructor( + ironEncryption: SessionEncryption, + options: SessionEncryptionAdapterOptions = {}, + ) { this.ironEncryption = ironEncryption; - this.sealed = sealed; + this.mode = options.mode ?? 'sealed'; } async sealData( data: unknown, options: { password: string; ttl?: number | undefined }, ): Promise { - if (this.sealed) { + if (this.mode === 'sealed' || (options.ttl != null && options.ttl > 0)) { return this.ironEncryption.sealData(data, options); } return JSON.stringify(data); diff --git a/src/index.ts b/src/index.ts index 758b81a..96de383 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,7 +49,10 @@ export { // Encryption // ============================================ export { default as sessionEncryption } from './core/encryption/ironWebcryptoEncryption.js'; -export { SessionEncryptionAdapter } from './core/encryption/sessionEncryption.js'; +export { + SessionEncryptionAdapter, + type SessionEncryptionAdapterOptions, +} from './core/encryption/sessionEncryption.js'; // ============================================ // Configuration From 6899e61eb3064c46efad02eb706badb40bc0e555 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 6 May 2026 13:50:56 -0700 Subject: [PATCH 3/5] fix: default to unsealed mode Session cookies are written as plain JSON by default. PKCE state remains iron-sealed (TTL > 0 guard). Consumers can pass { mode: 'sealed' } to re-enable encryption. --- src/core/encryption/sessionEncryption.spec.ts | 22 +++++++++---------- src/core/encryption/sessionEncryption.ts | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/core/encryption/sessionEncryption.spec.ts b/src/core/encryption/sessionEncryption.spec.ts index 5720025..4338b1a 100644 --- a/src/core/encryption/sessionEncryption.spec.ts +++ b/src/core/encryption/sessionEncryption.spec.ts @@ -11,29 +11,29 @@ const testData = { describe('SessionEncryptionAdapter', () => { const iron = new IronEncryption(); - describe('sealed mode (default)', () => { + describe('unsealed mode (default)', () => { const adapter = new SessionEncryptionAdapter(iron); - it('writes iron-sealed data by default', async () => { + it('writes plain JSON by default', async () => { const result = await adapter.sealData(testData, { password: testPassword, }); - expect(result).toMatch(/^Fe26\.2\*/); + expect(JSON.parse(result)).toEqual(testData); }); - it('reads iron-sealed data', async () => { - const sealed = await adapter.sealData(testData, { - password: testPassword, - }); - const result = await adapter.unsealData(sealed, { + it('reads plain JSON', async () => { + const json = JSON.stringify(testData); + const result = await adapter.unsealData(json, { password: testPassword, }); expect(result).toEqual(testData); }); - it('reads plain JSON (forward-compat with unsealed mode)', async () => { - const json = JSON.stringify(testData); - const result = await adapter.unsealData(json, { + 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); diff --git a/src/core/encryption/sessionEncryption.ts b/src/core/encryption/sessionEncryption.ts index 6667fea..3d5015e 100644 --- a/src/core/encryption/sessionEncryption.ts +++ b/src/core/encryption/sessionEncryption.ts @@ -34,7 +34,7 @@ export class SessionEncryptionAdapter implements SessionEncryption { options: SessionEncryptionAdapterOptions = {}, ) { this.ironEncryption = ironEncryption; - this.mode = options.mode ?? 'sealed'; + this.mode = options.mode ?? 'unsealed'; } async sealData( From a63912ec2b4d6e641fa93a193b9b77d44c58a6b3 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 6 May 2026 13:55:08 -0700 Subject: [PATCH 4/5] chore: formatting --- src/core/AuthKitCore.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/core/AuthKitCore.spec.ts b/src/core/AuthKitCore.spec.ts index 8867f05..e6ddaa4 100644 --- a/src/core/AuthKitCore.spec.ts +++ b/src/core/AuthKitCore.spec.ts @@ -214,10 +214,7 @@ describe('AuthKitCore', () => { ['empty object', {}], ['missing user', { accessToken: 'at', refreshToken: 'rt' }], ['null user', { accessToken: 'at', refreshToken: 'rt', user: null }], - [ - 'missing refreshToken', - { accessToken: 'at', user: { id: 'user_123' } }, - ], + ['missing refreshToken', { accessToken: 'at', user: { id: 'user_123' } }], ])( 'throws SessionEncryptionError for invalid shape: %s', async (_label, badValue) => { From 3ea706e98e604da1a0a4053f90cf8a909a68a8e7 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 7 May 2026 15:58:48 -0700 Subject: [PATCH 5/5] feat: make cookiePassword optional, derive from apiKey + clientId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORKOS_COOKIE_PASSWORD is no longer required. When not provided, a sealing password is derived from the already-required apiKey and clientId. This eliminates an extra env var for most setups. Explicitly setting WORKOS_COOKIE_PASSWORD still works and takes precedence — useful for cookie continuity across API key rotations. --- src/core/config.spec.ts | 17 +++++-- src/core/config/ConfigurationProvider.spec.ts | 48 ++++++++++++++++++- src/core/config/ConfigurationProvider.ts | 33 +++++++++---- src/core/config/types.ts | 12 +++-- 4 files changed, 92 insertions(+), 18 deletions(-) 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;