Skip to content

Commit 9d421ef

Browse files
authored
feat(clerk-js,shared): Backport Session Minter SDK changes to core-2 (#8643)
1 parent 071b2de commit 9d421ef

17 files changed

Lines changed: 522 additions & 31 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Skip `expired_token` retry flow when Session Minter is enabled. When `sessionMinter` is on, the token is sent in the POST body, so the retry-with-expired-token fallback is unnecessary. The retry flow is preserved for non-Session Minter mode.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Send `force_origin=true` body param on `/tokens` requests when `skipCache` is true, so FAPI Proxy routes to origin instead of Session Minter.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Add monotonic token replacement based on `oiat` to prevent edge-minted tokens with stale claims from overwriting fresher DB-minted tokens in multi-tab scenarios.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/shared': patch
3+
---
4+
5+
Add `oiat` (original_issued_at) field to `JwtHeader` type for Session Minter monotonic token freshness checks.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/shared': patch
4+
---
5+
6+
Send previous session token on `/tokens` requests to support Session Minter edge token minting.

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{ "path": "./dist/clerk.js", "maxSize": "931KB" },
44
{ "path": "./dist/clerk.browser.js", "maxSize": "87KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "129KB" },
6-
{ "path": "./dist/clerk.headless*.js", "maxSize": "67KB" },
6+
{ "path": "./dist/clerk.headless*.js", "maxSize": "68KB" },
77
{ "path": "./dist/ui-common*.js", "maxSize": "123KB" },
88
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "126KB" },
99
{ "path": "./dist/vendors*.js", "maxSize": "50KB" },

packages/clerk-js/src/core/__tests__/tokenCache.test.ts

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,23 @@ function createJwtWithTtl(iatSeconds: number, ttlSeconds: number): string {
3131
return `${headerB64}.${payloadB64}.${signature}`;
3232
}
3333

34+
/**
35+
* Helper to create a JWT with custom iat AND oiat header for monotonic-freshness tests
36+
*/
37+
function createJwtWithOiat(iatSeconds: number, oiatSeconds: number, ttlSeconds = 60): string {
38+
const header = { alg: 'HS256', typ: 'JWT', oiat: oiatSeconds };
39+
const payload = { sid: 'session_123', exp: iatSeconds + ttlSeconds, iat: iatSeconds };
40+
const b64 = (o: object) => btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
41+
return `${b64(header)}.${b64(payload)}.test-signature`;
42+
}
43+
3444
describe('SessionTokenCache', () => {
3545
let mockBroadcastChannel: {
3646
addEventListener: ReturnType<typeof vi.fn>;
3747
close: ReturnType<typeof vi.fn>;
3848
postMessage: ReturnType<typeof vi.fn>;
3949
};
40-
let broadcastListener: (e: MessageEvent<SessionTokenEvent>) => void;
50+
let broadcastListener: (e: MessageEvent<SessionTokenEvent>) => void | Promise<void>;
4151
let originalBroadcastChannel: any;
4252

4353
beforeEach(() => {
@@ -96,7 +106,7 @@ describe('SessionTokenCache', () => {
96106
},
97107
} as MessageEvent<SessionTokenEvent>;
98108

99-
broadcastListener(event);
109+
void broadcastListener(event);
100110

101111
expect(SessionTokenCache.size()).toBe(0);
102112
});
@@ -113,7 +123,7 @@ describe('SessionTokenCache', () => {
113123
},
114124
} as MessageEvent<SessionTokenEvent>;
115125

116-
broadcastListener(event);
126+
void broadcastListener(event);
117127

118128
expect(SessionTokenCache.size()).toBe(1);
119129
});
@@ -130,7 +140,7 @@ describe('SessionTokenCache', () => {
130140
},
131141
} as MessageEvent<SessionTokenEvent>;
132142

133-
broadcastListener(event);
143+
void broadcastListener(event);
134144

135145
expect(SessionTokenCache.size()).toBe(1);
136146
});
@@ -148,7 +158,7 @@ describe('SessionTokenCache', () => {
148158
} as MessageEvent<SessionTokenEvent>;
149159

150160
expect(() => {
151-
broadcastListener(event);
161+
void broadcastListener(event);
152162
}).not.toThrow();
153163

154164
expect(SessionTokenCache.size()).toBe(0);
@@ -168,7 +178,7 @@ describe('SessionTokenCache', () => {
168178
},
169179
} as MessageEvent<SessionTokenEvent>;
170180

171-
broadcastListener(event);
181+
void broadcastListener(event);
172182

173183
expect(SessionTokenCache.size()).toBe(0);
174184
});
@@ -188,31 +198,33 @@ describe('SessionTokenCache', () => {
188198
},
189199
} as MessageEvent<SessionTokenEvent>;
190200

191-
broadcastListener(event);
201+
void broadcastListener(event);
192202

193203
expect(SessionTokenCache.size()).toBe(0);
194204
});
195205

196-
it('enforces monotonicity: does not overwrite newer token with older one', () => {
206+
it('enforces monotonicity: does not overwrite newer token with older one', async () => {
207+
// Both tokens carry oiat (the production case post-rollout). Older oiat
208+
// broadcast must not clobber the newer one already in cache.
209+
const newerJwt = createJwtWithOiat(1666648250, 1666648250);
210+
const olderJwt = createJwtWithOiat(1666648190, 1666648190);
211+
197212
const newerEvent: MessageEvent<SessionTokenEvent> = {
198213
data: {
199214
organizationId: null,
200215
sessionId: 'session_123',
201216
template: undefined,
202217
tokenId: 'session_123',
203-
tokenRaw: mockJwt,
218+
tokenRaw: newerJwt,
204219
traceId: 'test_trace_7',
205220
},
206221
} as MessageEvent<SessionTokenEvent>;
207222

208-
broadcastListener(newerEvent);
223+
await broadcastListener(newerEvent);
209224
const cachedEntryAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' });
210225
expect(cachedEntryAfterNewer).toBeDefined();
211226
const newerCreatedAt = cachedEntryAfterNewer?.createdAt;
212227

213-
// mockJwt has iat: 1666648250, so create an older one with iat: 1666648190 (60 seconds earlier)
214-
const olderJwt =
215-
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2NDg4NTAsImlhdCI6MTY2NjY0ODE5MH0.Z1BC47lImYvaAtluJlY-kBo0qOoAk42Xb-gNrB2SxJg';
216228
const olderEvent: MessageEvent<SessionTokenEvent> = {
217229
data: {
218230
organizationId: null,
@@ -224,13 +236,54 @@ describe('SessionTokenCache', () => {
224236
},
225237
} as MessageEvent<SessionTokenEvent>;
226238

227-
broadcastListener(olderEvent);
239+
await broadcastListener(olderEvent);
228240

229241
const cachedEntryAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' });
230242
expect(cachedEntryAfterOlder).toBeDefined();
231243
expect(cachedEntryAfterOlder?.createdAt).toBe(newerCreatedAt);
232244
});
233245

246+
it('enforces monotonicity: replaces older cached token when a fresher-oiat broadcast arrives', async () => {
247+
// Inverse of the previous test: a fresher-oiat broadcast must overwrite
248+
// an older-oiat token already in cache. Use ttl=120 so both tokens stay
249+
// valid against the test clock (nowSec=1666648260) - cache.get drops
250+
// entries past their expiry.
251+
const olderJwt = createJwtWithOiat(1666648190, 1666648190, 120);
252+
const newerJwt = createJwtWithOiat(1666648250, 1666648250, 120);
253+
254+
const olderEvent: MessageEvent<SessionTokenEvent> = {
255+
data: {
256+
organizationId: null,
257+
sessionId: 'session_123',
258+
template: undefined,
259+
tokenId: 'session_123',
260+
tokenRaw: olderJwt,
261+
traceId: 'test_trace_older_first',
262+
},
263+
} as MessageEvent<SessionTokenEvent>;
264+
265+
await broadcastListener(olderEvent);
266+
const cachedEntryAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' });
267+
expect(cachedEntryAfterOlder).toBeDefined();
268+
const olderCreatedAt = cachedEntryAfterOlder?.createdAt;
269+
270+
const newerEvent: MessageEvent<SessionTokenEvent> = {
271+
data: {
272+
organizationId: null,
273+
sessionId: 'session_123',
274+
template: undefined,
275+
tokenId: 'session_123',
276+
tokenRaw: newerJwt,
277+
traceId: 'test_trace_newer_second',
278+
},
279+
} as MessageEvent<SessionTokenEvent>;
280+
281+
await broadcastListener(newerEvent);
282+
const cachedEntryAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' });
283+
expect(cachedEntryAfterNewer).toBeDefined();
284+
expect(cachedEntryAfterNewer?.createdAt).not.toBe(olderCreatedAt);
285+
});
286+
234287
it('successfully updates cache with valid token', () => {
235288
const event: MessageEvent<SessionTokenEvent> = {
236289
data: {
@@ -243,7 +296,7 @@ describe('SessionTokenCache', () => {
243296
},
244297
} as MessageEvent<SessionTokenEvent>;
245298

246-
broadcastListener(event);
299+
void broadcastListener(event);
247300

248301
const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' });
249302
expect(cachedEntry).toBeDefined();
@@ -265,7 +318,7 @@ describe('SessionTokenCache', () => {
265318
},
266319
} as MessageEvent<SessionTokenEvent>;
267320

268-
broadcastListener(event);
321+
void broadcastListener(event);
269322

270323
// Flush microtasks to let the tokenResolver promise settle without advancing timers
271324
await Promise.resolve();
@@ -757,7 +810,7 @@ describe('SessionTokenCache', () => {
757810
} as MessageEvent<SessionTokenEvent>;
758811

759812
expect(() => {
760-
broadcastListener(event);
813+
void broadcastListener(event);
761814
}).not.toThrow();
762815
});
763816
});
@@ -801,7 +854,7 @@ describe('SessionTokenCache', () => {
801854
},
802855
} as MessageEvent<SessionTokenEvent>;
803856

804-
broadcastListener(broadcastEvent);
857+
void broadcastListener(broadcastEvent);
805858

806859
await vi.waitFor(() => {
807860
expect(SessionTokenCache.get({ tokenId: session2Id })).toBeDefined();
@@ -862,7 +915,7 @@ describe('SessionTokenCache', () => {
862915
},
863916
} as MessageEvent<SessionTokenEvent>;
864917

865-
broadcastListener(broadcastEvent);
918+
void broadcastListener(broadcastEvent);
866919

867920
await vi.waitFor(async () => {
868921
const updatedCached = SessionTokenCache.get({ tokenId: sessionId });
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { JWT, TokenResource } from '@clerk/shared/types';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { pickFreshestJwt } from '../tokenFreshness';
5+
6+
function makeToken(opts: { oiat?: number; iat?: number } = {}): TokenResource {
7+
return {
8+
jwt: {
9+
header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) },
10+
claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) },
11+
},
12+
getRawString: () => 'mock-jwt',
13+
} as unknown as TokenResource;
14+
}
15+
16+
function makeJwt(opts: { oiat?: number; iat?: number } = {}): JWT {
17+
return {
18+
header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) },
19+
claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) },
20+
} as unknown as JWT;
21+
}
22+
23+
describe('pickFreshestJwt', () => {
24+
describe('both have oiat (the only reachable path post-rollout)', () => {
25+
it('picks existing when existing oiat > incoming oiat', () => {
26+
const existing = makeToken({ oiat: 100 });
27+
const incoming = makeToken({ oiat: 90 });
28+
expect(pickFreshestJwt(existing, incoming)).toBe(existing);
29+
});
30+
31+
it('picks incoming when existing oiat < incoming oiat', () => {
32+
const existing = makeToken({ oiat: 90 });
33+
const incoming = makeToken({ oiat: 100 });
34+
expect(pickFreshestJwt(existing, incoming)).toBe(incoming);
35+
});
36+
37+
it('picks existing when oiat equal and existing iat > incoming iat', () => {
38+
const existing = makeToken({ oiat: 100, iat: 200 });
39+
const incoming = makeToken({ oiat: 100, iat: 150 });
40+
expect(pickFreshestJwt(existing, incoming)).toBe(existing);
41+
});
42+
43+
it('picks incoming when oiat equal and existing iat < incoming iat', () => {
44+
const existing = makeToken({ oiat: 100, iat: 150 });
45+
const incoming = makeToken({ oiat: 100, iat: 200 });
46+
expect(pickFreshestJwt(existing, incoming)).toBe(incoming);
47+
});
48+
49+
it('picks incoming when oiat equal and iat equal (other claims may differ)', () => {
50+
// Two tokens with identical oiat+iat may still differ in other claims
51+
// (azp, org_id, etc.) during a token-format rollout. Only suppress when
52+
// existing is strictly fresher; on full ties, let incoming through.
53+
const existing = makeToken({ oiat: 100, iat: 150 });
54+
const incoming = makeToken({ oiat: 100, iat: 150 });
55+
expect(pickFreshestJwt(existing, incoming)).toBe(incoming);
56+
});
57+
58+
it('picks existing when oiat equal and incoming iat missing (treated as 0)', () => {
59+
const existing = makeToken({ oiat: 100, iat: 150 });
60+
const incoming = makeToken({ oiat: 100 });
61+
expect(pickFreshestJwt(existing, incoming)).toBe(existing);
62+
});
63+
});
64+
65+
describe('legacy (missing oiat) safety net', () => {
66+
it('picks existing when incoming is legacy (no oiat) and existing has oiat', () => {
67+
const existing = makeToken({ oiat: 100 });
68+
const incoming = makeToken({ iat: 9999 });
69+
expect(pickFreshestJwt(existing, incoming)).toBe(existing);
70+
});
71+
72+
it('picks incoming when existing is legacy and incoming has oiat', () => {
73+
const existing = makeToken({ iat: 9999 });
74+
const incoming = makeToken({ oiat: 100 });
75+
expect(pickFreshestJwt(existing, incoming)).toBe(incoming);
76+
});
77+
78+
it('picks incoming when both sides are legacy (cannot rank, safe default)', () => {
79+
const existing = makeToken({ iat: 200 });
80+
const incoming = makeToken({ iat: 100 });
81+
expect(pickFreshestJwt(existing, incoming)).toBe(incoming);
82+
});
83+
});
84+
85+
describe('same object reference', () => {
86+
// When the cache hands back the same object that is already stored as
87+
// lastActiveToken, callers use `pickFreshestJwt(a, b) === a` to detect
88+
// "existing won, suppress redundant emit". This test documents that
89+
// intentional behavior.
90+
it('returns the same reference when both args are the same object', () => {
91+
const token = makeToken({ oiat: 100, iat: 150 });
92+
expect(pickFreshestJwt(token, token)).toBe(token);
93+
});
94+
});
95+
96+
describe('JWT input (cookie path)', () => {
97+
it('accepts raw decoded JWT for both arguments', () => {
98+
const a = makeJwt({ oiat: 100 });
99+
const b = makeJwt({ oiat: 200 });
100+
expect(pickFreshestJwt(a, b)).toBe(b);
101+
expect(pickFreshestJwt(b, a)).toBe(b);
102+
});
103+
104+
it('tie-breaks by iat on equal oiat for raw JWT inputs', () => {
105+
const a = makeJwt({ oiat: 100, iat: 150 });
106+
const b = makeJwt({ oiat: 100, iat: 200 });
107+
expect(pickFreshestJwt(a, b)).toBe(b);
108+
expect(pickFreshestJwt(b, a)).toBe(b);
109+
});
110+
});
111+
});

packages/clerk-js/src/core/resources/AuthConfig.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
88
reverification: boolean = false;
99
singleSessionMode: boolean = false;
1010
preferredChannels: Record<string, PhoneCodeChannel> | null = null;
11+
sessionMinter: boolean = false;
1112

1213
public constructor(data: Partial<AuthConfigJSON> | null = null) {
1314
super();
@@ -23,6 +24,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
2324
this.reverification = this.withDefault(data.reverification, this.reverification);
2425
this.singleSessionMode = this.withDefault(data.single_session_mode, this.singleSessionMode);
2526
this.preferredChannels = this.withDefault(data.preferred_channels, this.preferredChannels);
27+
this.sessionMinter = this.withDefault(data.session_minter, this.sessionMinter);
2628
return this;
2729
}
2830

@@ -33,6 +35,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
3335
object: 'auth_config',
3436
reverification: this.reverification,
3537
single_session_mode: this.singleSessionMode,
38+
session_minter: this.sessionMinter,
3639
};
3740
}
3841
}

0 commit comments

Comments
 (0)