Skip to content

Commit 5c3d84a

Browse files
committed
replace iron-session with iron-webcrypto
1 parent 011bcb0 commit 5c3d84a

File tree

8 files changed

+123
-37
lines changed

8 files changed

+123
-37
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|@noble|@scure|jose)/)',
1717
],
1818
};

package-lock.json

Lines changed: 1 addition & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
"typecheck": "tsc --noEmit"
4242
},
4343
"dependencies": {
44-
"iron-session": "^8.0.4",
44+
"iron-webcrypto": "^1.2.1",
4545
"jose": "~6.1.0"
4646
},
4747
"devDependencies": {

src/common/crypto/seal.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {
2+
seal as ironSeal,
3+
unseal as ironUnseal,
4+
defaults,
5+
} from 'iron-webcrypto';
6+
7+
// Extract the precise Crypto type that iron-webcrypto expects
8+
type IronCrypto = Parameters<typeof ironSeal>[0];
9+
10+
// Cast globalThis.crypto to iron-webcrypto's expected type
11+
// Runtime-safe: DOM Crypto is fully compatible with iron-webcrypto's _Crypto
12+
const ironCrypto: IronCrypto = globalThis.crypto as unknown as IronCrypto;
13+
14+
const VERSION_DELIMITER = '~';
15+
const CURRENT_MAJOR_VERSION = 2;
16+
17+
interface SealOptions {
18+
password: string;
19+
ttl?: number;
20+
}
21+
22+
interface UnsealOptions {
23+
password: string;
24+
ttl?: number;
25+
}
26+
27+
/**
28+
* Parse an iron-session seal to extract the version
29+
*/
30+
function parseSeal(seal: string): {
31+
sealWithoutVersion: string;
32+
tokenVersion: number | null;
33+
} {
34+
const [sealWithoutVersion = '', tokenVersionAsString] =
35+
seal.split(VERSION_DELIMITER);
36+
const tokenVersion =
37+
tokenVersionAsString == null ? null : parseInt(tokenVersionAsString, 10);
38+
return { sealWithoutVersion, tokenVersion };
39+
}
40+
41+
/**
42+
* Seal (encrypt) data in a format compatible with iron-session.
43+
*
44+
* @param data - The data to seal
45+
* @param options - Sealing options
46+
* @param options.password - Password for encryption (must be at least 32 characters)
47+
* @param options.ttl - Time to live in seconds (default: 0 = no expiration)
48+
* @returns The sealed string
49+
*/
50+
export async function sealData(
51+
data: unknown,
52+
{ password, ttl = 0 }: SealOptions,
53+
): Promise<string> {
54+
const passwordObj = {
55+
id: '1',
56+
secret: password,
57+
};
58+
59+
const seal = await ironSeal(ironCrypto, data, passwordObj, {
60+
...defaults,
61+
ttl: ttl * 1000, // Convert seconds to milliseconds
62+
});
63+
64+
// Add the version delimiter exactly like iron-session does
65+
return `${seal}${VERSION_DELIMITER}${CURRENT_MAJOR_VERSION}`;
66+
}
67+
68+
/**
69+
* Unseal (decrypt) data that was sealed with iron-session or sealData.
70+
*
71+
* @param encryptedData - The sealed string to decrypt
72+
* @param options - Unsealing options
73+
* @param options.password - Password for decryption
74+
* @param options.ttl - Time to live in seconds (default: 0 = no expiration check)
75+
* @returns The unsealed data, or empty object if unsealing fails
76+
*/
77+
export async function unsealData<T = unknown>(
78+
encryptedData: string,
79+
{ password, ttl = 0 }: UnsealOptions,
80+
): Promise<T> {
81+
const { sealWithoutVersion, tokenVersion } = parseSeal(encryptedData);
82+
83+
// Format password as a map like iron-session expects
84+
const passwordMap = { 1: password };
85+
86+
let data: unknown;
87+
try {
88+
data =
89+
(await ironUnseal(ironCrypto, sealWithoutVersion, passwordMap, {
90+
...defaults,
91+
ttl: ttl * 1000,
92+
})) ?? {};
93+
} catch (error) {
94+
// Match iron-session's behavior: return empty object for known errors
95+
if (
96+
error instanceof Error &&
97+
/^(Expired seal|Bad hmac value|Cannot find password|Incorrect number of sealed components)/.test(
98+
error.message,
99+
)
100+
) {
101+
return {} as T;
102+
}
103+
throw error;
104+
}
105+
106+
// Handle token version for backwards compatibility
107+
if (tokenVersion === 2) {
108+
return data as T;
109+
} else if (tokenVersion !== null) {
110+
// For older token versions, extract the persistent property
111+
const record = data as Record<string, unknown>;
112+
return (record.persistent ?? data) as T;
113+
}
114+
115+
return data as T;
116+
}

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)