Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions src/core/encryption/sessionEncryption.spec.ts
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();
});
});
});
40 changes: 40 additions & 0 deletions src/core/encryption/sessionEncryption.ts
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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

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);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment on lines +40 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Plain-JSON session cookies expose refreshToken to server-side log infrastructure

The PR's safety argument (httpOnly, secure, sameSite=lax) only prevents client-side JavaScript access. It does not protect against server-side log capture: CDN edge logs, WAF/SIEM systems, reverse proxies, and load balancers routinely log raw Cookie request headers at DEBUG or AUDIT level. Once a request hits those systems, the Cookie: wos-session={"accessToken":"eyJ...","refreshToken":"rt_...","user":{...}} header is written in plain text.

refreshToken is long-lived — an attacker who extracts it from a log entry can impersonate the user until the token is revoked. Consider at minimum documenting that callers must sanitize Cookie headers from any logging infrastructure, or offer a way to enable sealed mode by default for security-sensitive deployments.

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;
}
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/service/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The new default creates a fresh SessionEncryptionAdapter on every encryptionFactory() call, whereas the old default returned the shared ironWebcryptoEncryption singleton. Because SessionEncryptionAdapter is stateless this is functionally equivalent today, but it diverges from the existing pattern and would mask state bugs if the class ever acquired state. Wrapping the construction in once (already imported) matches how the rest of the factory avoids repeated construction.

Suggested change
encryptionFactory = () =>
new SessionEncryptionAdapter(ironWebcryptoEncryption),
encryptionFactory = once(() =>
new SessionEncryptionAdapter(ironWebcryptoEncryption)),

Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
} = options;

// Lazily create the real AuthService with resolved config
Expand Down
Loading