Skip to content

Commit dc3c6e9

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 51ce56d commit dc3c6e9

File tree

4 files changed

+106
-4
lines changed

4 files changed

+106
-4
lines changed

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: 7 additions & 0 deletions
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';
@@ -513,6 +514,12 @@ export class Session extends BaseResource implements SessionResource {
513514
return;
514515
}
515516

517+
if (this.lastActiveToken) {
518+
if (shouldRejectToken(this.lastActiveToken, token)) {
519+
return;
520+
}
521+
}
522+
516523
eventBus.emit(events.TokenUpdate, { token });
517524

518525
if (token.jwt) {

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)