Skip to content

Commit 0a318dc

Browse files
authored
[v8] Add dynamic import wrapper for jose to support Node.js 20.15-20.18 (#1371)
The jose library is ESM-only and cannot be loaded via require() in Node.js versions before 20.19.0. This adds a dynamic import wrapper that works across all Node.js 20+ versions using import() which is supported in both ESM and CJS. Breaking changes: - UserManagement.jwks getter changed to async UserManagement.getJWKS() method - CookieSession.jwks property removed (uses UserManagement.getJWKS() instead) The wrapper enables: - Lazy loading of jose (only when JWT methods are called) - Support for all Node.js 20.x versions - Smaller bundle size (no jose bundling needed) - Clean migration path when Node 20 reaches EOL (April 2026) Also updates: - Minimum Node version to 20.15.0 (conservative choice within 20.x) - tsup config: removes redundant external arrays (not needed with bundle: false) ## Description ## Documentation Does this require changes to the WorkOS Docs? E.g. the [API Reference](https://workos.com/docs/reference) or code snippets need updates. ``` [ ] Yes ``` If yes, link a related docs PR and add a docs maintainer as a reviewer. Their approval is required.
1 parent b7b67fb commit 0a318dc

File tree

6 files changed

+52
-26
lines changed

6 files changed

+52
-26
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
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
@@ -9,7 +9,7 @@
99
"workos"
1010
],
1111
"engines": {
12-
"node": ">=20"
12+
"node": ">=20.15.0"
1313
},
1414
"type": "module",
1515
"main": "./lib/cjs/index.cjs",

src/user-management/session.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
21
import { OauthException } from '../common/exceptions/oauth.exception';
32
import {
43
AccessToken,
@@ -12,14 +11,14 @@ import {
1211
} from './interfaces';
1312
import { UserManagement } from './user-management';
1413
import { unsealData } from 'iron-session';
14+
import { getJose } from '../utils/jose';
1515

1616
type RefreshOptions = {
1717
cookiePassword?: string;
1818
organizationId?: string;
1919
};
2020

2121
export class CookieSession {
22-
private jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
2322
private userManagement: UserManagement;
2423
private cookiePassword: string;
2524
private sessionData: string;
@@ -36,8 +35,6 @@ export class CookieSession {
3635
this.userManagement = userManagement;
3736
this.cookiePassword = cookiePassword;
3837
this.sessionData = sessionData;
39-
40-
this.jwks = this.userManagement.jwks;
4138
}
4239

4340
/**
@@ -86,6 +83,8 @@ export class CookieSession {
8683
};
8784
}
8885

86+
const { decodeJwt } = await getJose();
87+
8988
const {
9089
sid: sessionId,
9190
org_id: organizationId,
@@ -120,6 +119,7 @@ export class CookieSession {
120119
* @returns An object indicating whether the refresh was successful or not. If successful, it will include the new sealed session data.
121120
*/
122121
async refresh(options: RefreshOptions = {}): Promise<RefreshSessionResponse> {
122+
const { decodeJwt } = await getJose();
123123
const session = await unsealData<SessionCookieData>(this.sessionData, {
124124
password: this.cookiePassword,
125125
});
@@ -224,14 +224,16 @@ export class CookieSession {
224224
}
225225

226226
private async isValidJwt(accessToken: string): Promise<boolean> {
227-
if (!this.jwks) {
227+
const { jwtVerify } = await getJose();
228+
const jwks = await this.userManagement.getJWKS();
229+
if (!jwks) {
228230
throw new Error(
229231
'Missing client ID. Did you provide it when initializing WorkOS?',
230232
);
231233
}
232234

233235
try {
234-
await jwtVerify(accessToken, this.jwks);
236+
await jwtVerify(accessToken, jwks);
235237
return true;
236238
} catch (e) {
237239
return false;

src/user-management/user-management.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { sealData, unsealData } from 'iron-session';
2-
import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
32
import * as clientUserManagement from '../client/user-management';
43
import { PaginationOptions } from '../common/interfaces/pagination-options.interface';
54
import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';
@@ -152,9 +151,12 @@ import { deserializeOrganizationMembership } from './serializers/organization-me
152151
import { serializeSendInvitationOptions } from './serializers/send-invitation-options.serializer';
153152
import { serializeUpdateOrganizationMembershipOptions } from './serializers/update-organization-membership-options.serializer';
154153
import { CookieSession } from './session';
154+
import { getJose } from '../utils/jose';
155155

156156
export class UserManagement {
157-
private _jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
157+
private _jwks:
158+
| ReturnType<typeof import('jose').createRemoteJWKSet>
159+
| undefined;
158160
public clientId: string | undefined;
159161

160162
constructor(private readonly workos: WorkOS) {
@@ -163,7 +165,10 @@ export class UserManagement {
163165
this.clientId = clientId;
164166
}
165167

166-
get jwks(): ReturnType<typeof createRemoteJWKSet> | undefined {
168+
async getJWKS(): Promise<
169+
ReturnType<typeof import('jose').createRemoteJWKSet> | undefined
170+
> {
171+
const { createRemoteJWKSet } = await getJose();
167172
if (!this.clientId) {
168173
return;
169174
}
@@ -421,10 +426,14 @@ export class UserManagement {
421426
throw new Error('Cookie password is required');
422427
}
423428

424-
if (!this.jwks) {
429+
const jwks = await this.getJWKS();
430+
431+
if (!jwks) {
425432
throw new Error('Must provide clientId to initialize JWKS');
426433
}
427434

435+
const { decodeJwt } = await getJose();
436+
428437
if (!sessionData) {
429438
return {
430439
authenticated: false,
@@ -477,12 +486,14 @@ export class UserManagement {
477486
}
478487

479488
private async isValidJwt(accessToken: string): Promise<boolean> {
480-
if (!this.jwks) {
489+
const jwks = await this.getJWKS();
490+
const { jwtVerify } = await getJose();
491+
if (!jwks) {
481492
throw new Error('Must provide clientId to initialize JWKS');
482493
}
483494

484495
try {
485-
await jwtVerify(accessToken, this.jwks);
496+
await jwtVerify(accessToken, jwks);
486497
return true;
487498
} catch (e) {
488499
return false;
@@ -520,6 +531,8 @@ export class UserManagement {
520531
throw new Error('Cookie password is required');
521532
}
522533

534+
const { decodeJwt } = await getJose();
535+
523536
const { org_id: organizationIdFromAccessToken } = decodeJwt<AccessToken>(
524537
authenticationResponse.accessToken,
525538
);

src/utils/jose.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
let _josePromise: Promise<typeof import('jose')> | undefined;
2+
3+
/**
4+
* Dynamically imports the jose library using import() to support Node.js 20.0-20.18.
5+
*
6+
* The jose library is ESM-only and cannot be loaded via require() in Node.js versions
7+
* before 20.19.0. This wrapper uses dynamic import() which works in both ESM and CJS
8+
* across all Node.js 20+ versions.
9+
*
10+
* This workaround can be removed when Node.js 20 reaches end-of-life (April 2026),
11+
* at which point we can bump to Node.js 22+ and use direct imports.
12+
*
13+
* @returns Promise that resolves to the jose module
14+
*/
15+
export function getJose() {
16+
return (_josePromise ??= import('jose'));
17+
}

tsup.config.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,21 @@ export default defineConfig([
1818
target: 'es2022',
1919
sourcemap: true,
2020
clean: true,
21-
dts: {
21+
dts: {
2222
resolve: true,
2323
compilerOptions: {
2424
lib: ['dom', 'es2022'],
2525
types: ['node'],
2626
},
2727
},
2828
bundle: false,
29-
external: ['iron-session', 'jose', 'leb', 'pluralize'],
3029
outExtension() {
3130
return { js: '.js' };
3231
},
3332
esbuildOptions(options) {
3433
options.keepNames = true;
3534
},
36-
esbuildPlugins: [
37-
fixImportsPlugin()
38-
]
35+
esbuildPlugins: [fixImportsPlugin()],
3936
},
4037
// CJS build
4138
{
@@ -53,23 +50,20 @@ export default defineConfig([
5350
target: 'es2022',
5451
sourcemap: true,
5552
clean: false, // Don't clean, keep ESM files
56-
dts: {
53+
dts: {
5754
resolve: true,
5855
compilerOptions: {
5956
lib: ['dom', 'es2022'],
6057
types: ['node'],
6158
},
6259
},
6360
bundle: false,
64-
external: ['iron-session', 'jose', 'leb', 'pluralize'],
6561
outExtension() {
6662
return { js: '.cjs', dts: '.d.cts' };
6763
},
6864
esbuildOptions(options) {
6965
options.keepNames = true;
7066
},
71-
esbuildPlugins: [
72-
fixImportsPlugin()
73-
]
74-
}
75-
]);
67+
esbuildPlugins: [fixImportsPlugin()],
68+
},
69+
]);

0 commit comments

Comments
 (0)