Skip to content

Commit 65b4a93

Browse files
committed
feat(clerk-js): Monotonic token replacement based on oiat
Prevent multi-tab race conditions where an edge-minted token with stale claims overwrites a fresher DB-minted token. Uses `oiat ?? iat` as the claim freshness metric. A token with oiat (JWT header) uses oiat as its claim freshness. A token without oiat is origin-minted (coupled FF), so iat represents claim freshness. Four guard points: 1. tokenCache handleBroadcastMessage - replaces old iat comparison 2. tokenCache setInternal - async compare-and-swap at resolve time 3. Session #dispatchTokenEvents - before token:update emit 4. AuthCookieService updateSessionCookie - cookie chokepoint with session scoping (different sessions always allowed through) Guard 4 catches the sleeping tab edge case where in-memory guards pass (stale baseline) but the cookie has a fresher value from another tab.
1 parent 9ad4e55 commit 65b4a93

File tree

6 files changed

+209
-6
lines changed

6 files changed

+209
-6
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { TokenResource } from '@clerk/shared/types';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { claimFreshness, shouldRejectToken } 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+
describe('claimFreshness', () => {
17+
it('returns oiat when present', () => {
18+
expect(claimFreshness(makeToken({ oiat: 100, iat: 200 }))).toBe(100);
19+
});
20+
21+
it('returns iat when oiat is absent', () => {
22+
expect(claimFreshness(makeToken({ iat: 200 }))).toBe(200);
23+
});
24+
25+
it('returns undefined when input has no jwt', () => {
26+
expect(claimFreshness(undefined)).toBeUndefined();
27+
});
28+
});
29+
30+
describe('shouldRejectToken', () => {
31+
describe('both have oiat', () => {
32+
it('row 1: rejects when existing oiat > incoming oiat', () => {
33+
expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ oiat: 90 }))).toBe(true);
34+
});
35+
36+
it('row 2: accepts when existing oiat < incoming oiat', () => {
37+
expect(shouldRejectToken(makeToken({ oiat: 90 }), makeToken({ oiat: 100 }))).toBe(false);
38+
});
39+
40+
it('row 3: rejects when oiat equal and existing iat > incoming iat', () => {
41+
expect(shouldRejectToken(makeToken({ oiat: 100, iat: 200 }), makeToken({ oiat: 100, iat: 150 }))).toBe(true);
42+
});
43+
44+
it('row 4: accepts when oiat equal and existing iat < incoming iat', () => {
45+
expect(shouldRejectToken(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 200 }))).toBe(false);
46+
});
47+
48+
it('row 5: rejects when oiat equal and iat equal (keep existing)', () => {
49+
expect(shouldRejectToken(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 150 }))).toBe(true);
50+
});
51+
});
52+
53+
describe('one has oiat, one does not', () => {
54+
it('row 6: accepts when existing oiat < incoming iat', () => {
55+
expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 120 }))).toBe(false);
56+
});
57+
58+
it('row 7: rejects when existing oiat > incoming iat', () => {
59+
expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 80 }))).toBe(true);
60+
});
61+
62+
it('row 8: accepts when existing oiat == incoming iat (different regimes, favor movement)', () => {
63+
expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 100 }))).toBe(false);
64+
});
65+
66+
it('row 9: rejects when existing iat > incoming oiat', () => {
67+
expect(shouldRejectToken(makeToken({ iat: 150 }), makeToken({ oiat: 100 }))).toBe(true);
68+
});
69+
70+
it('row 10: accepts when existing iat < incoming oiat', () => {
71+
expect(shouldRejectToken(makeToken({ iat: 90 }), makeToken({ oiat: 100 }))).toBe(false);
72+
});
73+
74+
it('row 11: accepts when existing iat == incoming oiat (different regimes, favor movement)', () => {
75+
expect(shouldRejectToken(makeToken({ iat: 100 }), makeToken({ oiat: 100 }))).toBe(false);
76+
});
77+
});
78+
79+
describe('neither has oiat', () => {
80+
it('row 12: rejects when existing iat > incoming iat', () => {
81+
expect(shouldRejectToken(makeToken({ iat: 200 }), makeToken({ iat: 150 }))).toBe(true);
82+
});
83+
84+
it('row 13: accepts when existing iat < incoming iat', () => {
85+
expect(shouldRejectToken(makeToken({ iat: 150 }), makeToken({ iat: 200 }))).toBe(false);
86+
});
87+
88+
it('row 14: rejects when iat equal (keep existing, avoid churn)', () => {
89+
expect(shouldRejectToken(makeToken({ iat: 150 }), makeToken({ iat: 150 }))).toBe(true);
90+
});
91+
92+
it("row 15: accepts when both iat null (can't compare, accept)", () => {
93+
expect(shouldRejectToken(makeToken(), makeToken())).toBe(false);
94+
});
95+
});
96+
});

packages/clerk-js/src/core/auth/AuthCookieService.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import type { Clerk, InstanceType } from '@clerk/shared/types';
1313
import { noop } from '@clerk/shared/utils';
1414

1515
import { debugLogger } from '@/utils/debug';
16+
import { decode } from '@/utils/jwt';
1617

18+
import { claimFreshness } from '../tokenFreshness';
1719
import { clerkMissingDevBrowser } from '../errors';
1820
import { eventBus, events } from '../events';
1921
import type { FapiClient } from '../fapiClient';
@@ -194,6 +196,29 @@ export class AuthCookieService {
194196
return;
195197
}
196198

199+
// Monotonic freshness guard: don't regress the cookie within the same session
200+
if (token) {
201+
const currentRaw = this.sessionCookie.get();
202+
if (currentRaw) {
203+
try {
204+
const current = decode(currentRaw);
205+
const incoming = decode(token);
206+
const currentSid = current.claims.sid;
207+
const incomingSid = incoming.claims.sid;
208+
// Only apply within the same session. Different sessions always allowed.
209+
if (currentSid && incomingSid && currentSid === incomingSid) {
210+
const currentFresh = claimFreshness(current);
211+
const incomingFresh = claimFreshness(incoming);
212+
if (currentFresh != null && incomingFresh != null && currentFresh > incomingFresh) {
213+
return;
214+
}
215+
}
216+
} catch {
217+
// If decode fails, allow the write (don't block on malformed tokens)
218+
}
219+
}
220+
}
221+
197222
if (!token && !isValidBrowserOnline()) {
198223
debugLogger.warn('Removing session cookie (offline)', { sessionId: this.clerk.session?.id }, 'authCookieService');
199224
}

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import type {
4141
} from '@clerk/shared/types';
4242
import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/shared/webauthn';
4343

44+
import { shouldRejectToken } from '@/core/tokenFreshness';
4445
import { unixEpochToDate } from '@/utils/date';
4546
import { debugLogger } from '@/utils/debug';
4647
import { TokenId } from '@/utils/tokenId';
@@ -455,7 +456,10 @@ export class Session extends BaseResource implements SessionResource {
455456
// Only emit token updates when we have an actual token — emitting with an empty
456457
// token causes AuthCookieService to remove the __session cookie (looks like sign-out).
457458
if (shouldDispatchTokenUpdate && cachedToken.getRawString()) {
458-
eventBus.emit(events.TokenUpdate, { token: cachedToken });
459+
const reject = this.lastActiveToken && shouldRejectToken(this.lastActiveToken, cachedToken);
460+
if (!reject) {
461+
eventBus.emit(events.TokenUpdate, { token: cachedToken });
462+
}
459463
}
460464
result = cachedToken.getRawString() || null;
461465
} else if (!isBrowserOnline()) {
@@ -504,6 +508,12 @@ export class Session extends BaseResource implements SessionResource {
504508
return;
505509
}
506510

511+
if (this.lastActiveToken) {
512+
if (shouldRejectToken(this.lastActiveToken, token)) {
513+
return;
514+
}
515+
}
516+
507517
eventBus.emit(events.TokenUpdate, { token });
508518

509519
if (token.jwt) {

packages/clerk-js/src/core/resources/__tests__/Session.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,9 @@ describe('Session', () => {
9898
expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled();
9999

100100
expect(token).toEqual(mockJwt);
101-
expect(dispatchSpy).toHaveBeenCalledTimes(2);
101+
// Cache hits with the same token as lastActiveToken suppress re-emission
102+
// to avoid unnecessary cookie writes (monotonic freshness guard).
103+
expect(dispatchSpy).toHaveBeenCalledTimes(0);
102104
});
103105

104106
it('returns same token without API call when Session is reconstructed', async () => {

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

Lines changed: 13 additions & 4 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 { shouldRejectToken } 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 (shouldRejectToken(existingToken, token)) {
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;
@@ -369,6 +369,15 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
369369

370370
entry.tokenResolver
371371
.then(newToken => {
372+
// Compare-and-swap: if another concurrent resolve already committed
373+
// a fresher token for this key, don't overwrite it.
374+
const currentValue = cache.get(key);
375+
if (currentValue?.entry?.resolvedToken && newToken) {
376+
if (shouldRejectToken(currentValue.entry.resolvedToken, newToken)) {
377+
return;
378+
}
379+
}
380+
372381
// Store resolved token for synchronous reads
373382
entry.resolvedToken = newToken;
374383

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { JWT, TokenResource } from '@clerk/shared/types';
2+
3+
/**
4+
* Returns the claim freshness of a token or raw JWT.
5+
*
6+
* - If the token has `oiat` (JWT header): that's when claims were last assembled from the DB.
7+
* Edge re-mints copy this value forward, so iat can be recent while oiat is old.
8+
* - If the token has no `oiat`: it's origin-minted (coupled FF means no Session Minter),
9+
* so iat IS when claims were last read from the DB.
10+
*
11+
* @internal
12+
*/
13+
export function claimFreshness(input: TokenResource | JWT | undefined | null): number | undefined {
14+
if (!input) {
15+
return undefined;
16+
}
17+
// TokenResource has .jwt wrapping the JWT; raw JWT has .header directly
18+
const jwt = 'getRawString' in input ? input.jwt : input;
19+
return jwt?.header?.oiat ?? jwt?.claims?.iat;
20+
}
21+
22+
/**
23+
* Determines whether an incoming token should be rejected in favor of the existing one.
24+
* Returns true if the incoming token is staler than the existing one.
25+
*
26+
* @internal
27+
*/
28+
export function shouldRejectToken(existing: TokenResource, incoming: TokenResource): boolean {
29+
const existingFreshness = claimFreshness(existing);
30+
const incomingFreshness = claimFreshness(incoming);
31+
32+
// Can't determine freshness: accept incoming as safe default
33+
if (existingFreshness == null || incomingFreshness == null) {
34+
return false;
35+
}
36+
37+
// Different freshness: the fresher token wins
38+
if (existingFreshness > incomingFreshness) {
39+
return true;
40+
}
41+
if (existingFreshness < incomingFreshness) {
42+
return false;
43+
}
44+
45+
// Equal freshness: tie-break depends on regime
46+
const existingHasOiat = existing.jwt?.header?.oiat != null;
47+
const incomingHasOiat = incoming.jwt?.header?.oiat != null;
48+
const sameRegime = existingHasOiat === incomingHasOiat;
49+
50+
if (sameRegime) {
51+
// Same regime, equal freshness.
52+
// Both have oiat: tie-break by iat (more recent mint wins). Equal iat: keep existing.
53+
// Neither has oiat: both origin, same DB snapshot. Keep existing (avoid churn).
54+
const existingIat = existing.jwt?.claims?.iat ?? 0;
55+
const incomingIat = incoming.jwt?.claims?.iat ?? 0;
56+
return existingIat >= incomingIat;
57+
}
58+
59+
// Different regimes, equal freshness. Transition is happening. Favor incoming.
60+
return false;
61+
}

0 commit comments

Comments
 (0)