Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
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
36 changes: 36 additions & 0 deletions src/core/AuthKitCore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down
20 changes: 20 additions & 0 deletions src/core/AuthKitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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<string, unknown>).accessToken === 'string' &&
typeof (value as Record<string, unknown>).refreshToken === 'string' &&
typeof (value as Record<string, unknown>).user === 'object' &&
(value as Record<string, unknown>).user !== null
);
}
273 changes: 273 additions & 0 deletions src/core/encryption/sessionEncryption.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading