Skip to content

Commit 1e2e237

Browse files
authored
feat(clerk-js): Monotonic token replacement based on oiat (#8097)
1 parent a6916b1 commit 1e2e237

5 files changed

Lines changed: 237 additions & 13 deletions

File tree

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.

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

Lines changed: 62 additions & 8 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(() => {
@@ -193,26 +203,28 @@ describe('SessionTokenCache', () => {
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 resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' });
210225
expect(resultAfterNewer).toBeDefined();
211226
const newerCreatedAt = resultAfterNewer?.entry.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,55 @@ describe('SessionTokenCache', () => {
224236
},
225237
} as MessageEvent<SessionTokenEvent>;
226238

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

229241
const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' });
230242
expect(resultAfterOlder).toBeDefined();
231243
expect(resultAfterOlder?.entry.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 resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' });
267+
expect(resultAfterOlder).toBeDefined();
268+
expect(resultAfterOlder?.entry.createdAt).toBe(1666648190);
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+
283+
const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' });
284+
expect(resultAfterNewer).toBeDefined();
285+
expect(resultAfterNewer?.entry.createdAt).toBe(1666648250);
286+
});
287+
234288
it('successfully updates cache with valid token', () => {
235289
const event: MessageEvent<SessionTokenEvent> = {
236290
data: {
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/tokenCache.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { TokenId } from '@/utils/tokenId';
55

66
import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller';
77
import { Token } from './resources/internal';
8+
import { pickFreshestJwt } from './tokenFreshness';
89

910
/**
1011
* Identifies a cached token entry by tokenId and optional audience.
@@ -288,11 +289,10 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
288289
const result = get({ tokenId: data.tokenId });
289290
if (result) {
290291
const existingToken = await result.entry.tokenResolver;
291-
const existingIat = existingToken.jwt?.claims?.iat;
292-
if (existingIat && existingIat >= iat) {
292+
if (pickFreshestJwt(existingToken, token) === existingToken) {
293293
debugLogger.debug(
294-
'Ignoring older token broadcast',
295-
{ existingIat, incomingIat: iat, tabId, tokenId: data.tokenId, traceId: data.traceId },
294+
'Ignoring staler token broadcast',
295+
{ tokenId: data.tokenId, traceId: data.traceId },
296296
'tokenCache',
297297
);
298298
return;
@@ -379,7 +379,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
379379
entry.tokenResolver
380380
.then(newToken => {
381381
// If this entry was overwritten by a newer set() call while our promise
382-
// was pending, bail out to avoid installing orphaned timers.
382+
// was pending, bail out to avoid installing orphaned timers. Monotonic
383+
// replacement is enforced at the read sites (cookie + broadcast + Session)
384+
// where the user-visible state lives.
383385
if (cache.get(key) !== value) {
384386
return;
385387
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { JWT, TokenResource } from '@clerk/shared/types';
2+
3+
function asJwt(input: TokenResource | JWT): JWT | undefined {
4+
return 'getRawString' in input ? input.jwt : input;
5+
}
6+
7+
/**
8+
* Picks the freshest of two tokens. Returns whichever argument has the more
9+
* recent claim freshness. On a full tie (same oiat AND same iat) it returns
10+
* `incoming`, since two tokens with identical timestamps can still differ in
11+
* other claims (azp, org_id, etc.) during a token-format rollout, so the
12+
* guard should only suppress when existing is strictly fresher.
13+
*
14+
* All origin-minted tokens carry the `oiat` JWT header (origin-issued-at;
15+
* timestamp when claims were last assembled from the DB). A token without
16+
* `oiat` is from a pre-feature codebase and is by definition staler than any
17+
* token that has one.
18+
*
19+
* Used by the cross-tab broadcast handler in tokenCache to drop stale
20+
* edge-minted tokens that would otherwise clobber a fresher cached entry.
21+
*
22+
* @internal
23+
*/
24+
export function pickFreshestJwt<T extends TokenResource | JWT>(existing: T, incoming: T): T {
25+
const existingOiat = asJwt(existing)?.header?.oiat;
26+
const incomingOiat = asJwt(incoming)?.header?.oiat;
27+
28+
if (existingOiat == null && incomingOiat == null) {
29+
return incoming;
30+
}
31+
if (incomingOiat == null) {
32+
return existing;
33+
}
34+
if (existingOiat == null) {
35+
return incoming;
36+
}
37+
38+
if (existingOiat > incomingOiat) {
39+
return existing;
40+
}
41+
if (existingOiat < incomingOiat) {
42+
return incoming;
43+
}
44+
45+
// Equal oiat: tie-break by iat (more recent mint wins). On a full tie,
46+
// return incoming: two tokens with identical oiat+iat may still differ
47+
// in other claims (azp, org_id, etc.) added in a token-format rollout,
48+
// so we only suppress when existing is strictly fresher.
49+
const existingIat = asJwt(existing)?.claims?.iat ?? 0;
50+
const incomingIat = asJwt(incoming)?.claims?.iat ?? 0;
51+
return existingIat > incomingIat ? existing : incoming;
52+
}

0 commit comments

Comments
 (0)