-
Notifications
You must be signed in to change notification settings - Fork 1
feat: drop cookie sealing with bidirectional migration #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
b59255b
8d34bf2
6899e61
a63912e
3ea706e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string> { | ||
| if (this.sealed) { | ||
| return this.ironEncryption.sealData(data, options); | ||
| } | ||
| return JSON.stringify(data); | ||
| } | ||
|
greptile-apps[bot] marked this conversation as resolved.
Comment on lines
+40
to
+48
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The PR's safety argument (
Rule Used: Do not log sensitive fields like access_token, ref... (source) |
||
|
|
||
| async unsealData<T = unknown>( | ||
| encryptedData: string, | ||
| options: { password: string; ttl?: number | undefined }, | ||
| ): Promise<T> { | ||
| if (encryptedData.startsWith(IRON_SEAL_PREFIX)) { | ||
| return this.ironEncryption.unsealData<T>(encryptedData, options); | ||
| } | ||
| return JSON.parse(encryptedData) as T; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<TRequest, TResponse>(options: { | |||||||||
| const { | ||||||||||
| sessionStorageFactory, | ||||||||||
| clientFactory = () => getWorkOS(), | ||||||||||
| encryptionFactory = () => sessionEncryption, | ||||||||||
| encryptionFactory = () => | ||||||||||
| new SessionEncryptionAdapter(ironWebcryptoEncryption), | ||||||||||
|
Comment on lines
+51
to
+52
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
devin-ai-integration[bot] marked this conversation as resolved.
|
||||||||||
| } = options; | ||||||||||
|
|
||||||||||
| // Lazily create the real AuthService with resolved config | ||||||||||
|
|
||||||||||
Uh oh!
There was an error while loading. Please reload this page.