Skip to content

Commit 3b408bf

Browse files
committed
[v8] Replace iron-session with iron-webcrypto v2 (#1416)
## Summary - Replaces `iron-session` dependency with direct `iron-webcrypto` v2.0.0 - Creates lightweight `seal.ts` wrapper providing iron-session compatible API - Reduces dependency footprint while maintaining backwards compatibility ## Changes - Add `src/common/crypto/seal.ts` with `sealData` and `unsealData` functions - Update `package.json` to use `iron-webcrypto` ^2.0.0 - Update `jest.config.cjs` to transform ESM-only `uint8array-extras` dependency - Update imports in session and user-management modules
1 parent 56c05ec commit 3b408bf

File tree

9 files changed

+226
-42
lines changed

9 files changed

+226
-42
lines changed

jest.config.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ module.exports = {
1313
'^.+\\.m?js$': '<rootDir>/jest-transform-esm.cjs',
1414
},
1515
transformIgnorePatterns: [
16-
'node_modules/(?!(iron-session|uncrypto|cookie-es|@noble|@scure|jose)/)',
16+
'node_modules/(?!(iron-webcrypto|uint8array-extras|@noble|@scure|jose)/)',
1717
],
1818
};

package-lock.json

Lines changed: 19 additions & 34 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@
4141
"typecheck": "tsc --noEmit"
4242
},
4343
"dependencies": {
44-
"iron-session": "^8.0.4",
44+
"iron-webcrypto": "^2.0.0",
4545
"jose": "~6.1.0"
4646
},
4747
"devDependencies": {
48+
"@arethetypeswrong/cli": "^0.18.2",
4849
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
4950
"@babel/preset-env": "^7.26.9",
5051
"@babel/preset-typescript": "^7.27.0",
@@ -71,8 +72,7 @@
7172
"tsdown": "^0.17.0",
7273
"tsx": "^4.20.6",
7374
"typescript": "5.9.3",
74-
"typescript-eslint": "^8.46.0",
75-
"@arethetypeswrong/cli": "^0.18.2"
75+
"typescript-eslint": "^8.46.0"
7676
},
7777
"exports": {
7878
".": {

src/common/crypto/seal.spec.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { sealData, unsealData } from './seal';
2+
3+
describe('seal', () => {
4+
const password = 'test-password-at-least-32-characters-long';
5+
6+
describe('sealData', () => {
7+
it('seals data and appends version delimiter', async () => {
8+
const data = { foo: 'bar' };
9+
const sealed = await sealData(data, { password });
10+
11+
expect(sealed).toContain('~2');
12+
expect(sealed.startsWith('Fe26.2*')).toBe(true);
13+
});
14+
});
15+
16+
describe('unsealData', () => {
17+
it('round-trips seal/unseal', async () => {
18+
const data = { userId: '123', email: '[email protected]' };
19+
const sealed = await sealData(data, { password });
20+
const unsealed = await unsealData<typeof data>(sealed, { password });
21+
22+
expect(unsealed).toEqual(data);
23+
});
24+
25+
it('round-trips complex nested data', async () => {
26+
const data = {
27+
user: { id: '123', name: 'Test' },
28+
roles: ['admin', 'user'],
29+
metadata: { nested: { deep: true } },
30+
};
31+
const sealed = await sealData(data, { password });
32+
const unsealed = await unsealData<typeof data>(sealed, { password });
33+
34+
expect(unsealed).toEqual(data);
35+
});
36+
37+
it('returns empty object for bad password', async () => {
38+
const data = { foo: 'bar' };
39+
const sealed = await sealData(data, { password });
40+
const unsealed = await unsealData(sealed, {
41+
password: 'wrong-password-at-least-32-characters',
42+
});
43+
44+
expect(unsealed).toEqual({});
45+
});
46+
47+
it('returns empty object for tampered seal', async () => {
48+
const data = { foo: 'bar' };
49+
const sealed = await sealData(data, { password });
50+
// Tamper with the sealed data
51+
const tampered = sealed.replace('Fe26.2', 'Fe26.2tampered');
52+
const unsealed = await unsealData(tampered, { password });
53+
54+
expect(unsealed).toEqual({});
55+
});
56+
57+
it('handles legacy tokens with persistent field (version 1)', async () => {
58+
// Manually construct a v1-style token that would have persistent wrapper
59+
const data = { persistent: { userId: '123' } };
60+
const sealed = await sealData(data, { password });
61+
// Replace ~2 with ~1 to simulate v1 token
62+
const v1Sealed = sealed.replace('~2', '~1');
63+
64+
const unsealed = await unsealData<{ userId: string }>(v1Sealed, {
65+
password,
66+
});
67+
expect(unsealed).toEqual({ userId: '123' });
68+
});
69+
70+
it('handles tokens without version delimiter', async () => {
71+
const data = { foo: 'bar' };
72+
const sealed = await sealData(data, { password });
73+
// Remove version delimiter to simulate pre-versioned token
74+
const noVersion = sealed.split('~')[0];
75+
76+
const unsealed = await unsealData<typeof data>(noVersion, { password });
77+
expect(unsealed).toEqual(data);
78+
});
79+
});
80+
81+
describe('iron-session compatibility', () => {
82+
// This test verifies that tokens sealed with this implementation
83+
// produce the same format as iron-session (Fe26.2 prefix with ~2 suffix)
84+
it('produces iron-session compatible format', async () => {
85+
const data = { userId: '123' };
86+
const sealed = await sealData(data, { password });
87+
88+
// Verify format: Fe26.2*...*~2
89+
const parts = sealed.split('~');
90+
expect(parts).toHaveLength(2);
91+
expect(parts[1]).toBe('2');
92+
93+
const ironPart = parts[0];
94+
expect(ironPart.startsWith('Fe26.2*')).toBe(true);
95+
96+
// Iron format has 8 asterisk-delimited components
97+
const components = ironPart.split('*');
98+
expect(components).toHaveLength(8);
99+
expect(components[0]).toBe('Fe26.2');
100+
});
101+
102+
// Verify that tokens can be round-tripped and maintain the expected format
103+
// This ensures compatibility with iron-session which uses the same Fe26.2 format
104+
it('can unseal self-generated tokens (simulates iron-session compatibility)', async () => {
105+
const data = { userId: '123', email: '[email protected]' };
106+
const sealed = await sealData(data, { password });
107+
108+
// Verify the format matches iron-session's expected output
109+
expect(sealed).toMatch(
110+
/^Fe26\.2\*1\*[^*]+\*[^*]+\*[^*]+\*\*[^*]+\*[^*]+~2$/,
111+
);
112+
113+
const unsealed = await unsealData<typeof data>(sealed, { password });
114+
expect(unsealed).toEqual(data);
115+
});
116+
});
117+
});

src/common/crypto/seal.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {
2+
seal as ironSeal,
3+
unseal as ironUnseal,
4+
defaults,
5+
} from 'iron-webcrypto';
6+
7+
const VERSION_DELIMITER = '~';
8+
const CURRENT_MAJOR_VERSION = 2;
9+
10+
interface SealOptions {
11+
password: string;
12+
}
13+
14+
interface UnsealOptions {
15+
password: string;
16+
}
17+
18+
function parseSeal(seal: string): {
19+
sealWithoutVersion: string;
20+
tokenVersion: number | null;
21+
} {
22+
const [sealWithoutVersion = '', tokenVersionAsString] =
23+
seal.split(VERSION_DELIMITER);
24+
const tokenVersion =
25+
tokenVersionAsString == null ? null : parseInt(tokenVersionAsString, 10);
26+
return { sealWithoutVersion, tokenVersion };
27+
}
28+
29+
export async function sealData(
30+
data: unknown,
31+
{ password }: SealOptions,
32+
): Promise<string> {
33+
const passwordObj = {
34+
id: '1',
35+
secret: password,
36+
};
37+
38+
const seal = await ironSeal(data, passwordObj, {
39+
...defaults,
40+
ttl: 0,
41+
encode: JSON.stringify,
42+
});
43+
44+
return `${seal}${VERSION_DELIMITER}${CURRENT_MAJOR_VERSION}`;
45+
}
46+
47+
export async function unsealData<T = unknown>(
48+
encryptedData: string,
49+
{ password }: UnsealOptions,
50+
): Promise<T> {
51+
const { sealWithoutVersion, tokenVersion } = parseSeal(encryptedData);
52+
53+
const passwordMap = { 1: password };
54+
55+
let data: unknown;
56+
try {
57+
data =
58+
(await ironUnseal(sealWithoutVersion, passwordMap, {
59+
...defaults,
60+
ttl: 0,
61+
})) ?? {};
62+
} catch (error) {
63+
if (
64+
error instanceof Error &&
65+
/^(Expired seal|Bad hmac value|Cannot find password|Incorrect number of sealed components|Wrong mac prefix)/.test(
66+
error.message,
67+
)
68+
) {
69+
return {} as T;
70+
}
71+
throw error;
72+
}
73+
74+
if (tokenVersion === 2) {
75+
return data as T;
76+
} else if (tokenVersion !== null) {
77+
const record = data as Record<string, unknown>;
78+
return (record.persistent ?? data) as T;
79+
}
80+
81+
return data as T;
82+
}

src/user-management/session.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { WorkOS } from '../workos';
22
import { CookieSession } from './session';
33
import * as jose from 'jose';
4-
import { sealData } from 'iron-session';
4+
import { sealData } from '../common/crypto/seal';
55
import userFixture from './fixtures/user.json';
66
import fetch from 'jest-fetch-mock';
77
import { fetchOnce } from '../common/utils/test-utils';

src/user-management/session.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
SessionCookieData,
1111
} from './interfaces';
1212
import { UserManagement } from './user-management';
13-
import { unsealData } from 'iron-session';
13+
import { unsealData } from '../common/crypto/seal';
1414
import { getJose } from '../utils/jose';
1515

1616
type RefreshOptions = {

src/user-management/user-management.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import passwordResetFixture from './fixtures/password_reset.json';
2121
import userFixture from './fixtures/user.json';
2222
import identityFixture from './fixtures/identity.json';
2323
import * as jose from 'jose';
24-
import { sealData } from 'iron-session';
24+
import { sealData } from '../common/crypto/seal';
2525

2626
jest.mock('jose', () => ({
2727
...jest.requireActual('jose'),

src/user-management/user-management.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { sealData, unsealData } from 'iron-session';
1+
import { sealData, unsealData } from '../common/crypto/seal';
22
import * as clientUserManagement from '../client/user-management';
33
import { PaginationOptions } from '../common/interfaces/pagination-options.interface';
44
import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';

0 commit comments

Comments
 (0)