Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9ad4e55
feat(shared): Add oiat field to JwtHeader type
nikosdouvlis Mar 18, 2026
65b4a93
feat(clerk-js): Monotonic token replacement based on oiat
nikosdouvlis Mar 17, 2026
91b664b
chore: Add changesets and plan doc for Session Minter SDK changes
nikosdouvlis Mar 18, 2026
c4755d1
chore(clerk-js): merge main into session-minter-sdk-changes
nikosdouvlis May 13, 2026
ee8afe8
fix(clerk-js): use shouldRejectToken at cookie write path
nikosdouvlis May 12, 2026
f56f274
test(clerk-js): cover JWT-input path and oiat:0 legacy in tokenFreshness
nikosdouvlis May 12, 2026
153e99d
test(clerk-js): fix misleading oiat: 0 test label
nikosdouvlis May 12, 2026
6a144bb
refactor(clerk-js): simplify shouldRejectToken; oiat is now universal
nikosdouvlis May 13, 2026
be30dfd
chore(clerk-js): drop redundant comment in shouldRejectToken
nikosdouvlis May 13, 2026
d5f66c5
test(clerk-js): update broadcast monotonicity test to use oiat tokens
nikosdouvlis May 13, 2026
58ee789
refactor(clerk-js): rename shouldRejectToken -> pickFreshestJwt
nikosdouvlis May 13, 2026
00fa5c7
test(clerk-js): document same-object behavior in pickFreshestJwt
nikosdouvlis May 13, 2026
c22e898
style(clerk-js): format tokenCache.test.ts per prettier
nikosdouvlis May 13, 2026
d265058
style(clerk-js): fix import sort order in AuthCookieService
nikosdouvlis May 13, 2026
78b3328
revert(clerk-js): remove cookie write monotonic guard
nikosdouvlis May 13, 2026
7efa1af
fix(clerk-js): pickFreshestJwt returns incoming on full tie
nikosdouvlis May 13, 2026
64ab4ae
revert(clerk-js): drop Session.ts monotonic guards
nikosdouvlis May 13, 2026
7161778
test(clerk-js): broadcast handler accepts fresher-oiat token over old…
nikosdouvlis May 13, 2026
46fbc01
test(clerk-js): await broadcast handler in monotonicity test
nikosdouvlis May 13, 2026
67d38b2
docs(clerk-js): update pickFreshestJwt doc to match incoming-on-tie b…
nikosdouvlis May 14, 2026
daa86bc
Merge branch 'main' into nikos/session-minter-sdk-changes
nikosdouvlis May 14, 2026
20d47e6
test(clerk-js): use ttl=120 for monotonicity test tokens
nikosdouvlis May 14, 2026
e157d4d
Merge branch 'nikos/session-minter-sdk-changes' of github.com:clerk/j…
nikosdouvlis May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/session-minter-monotonic-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

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.
20 changes: 16 additions & 4 deletions packages/clerk-js/src/core/__tests__/tokenCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ function createJwtWithTtl(iatSeconds: number, ttlSeconds: number): string {
return `${headerB64}.${payloadB64}.${signature}`;
}

/**
* Helper to create a JWT with custom iat AND oiat header for monotonic-freshness tests
*/
function createJwtWithOiat(iatSeconds: number, oiatSeconds: number, ttlSeconds = 60): string {
const header = { alg: 'HS256', typ: 'JWT', oiat: oiatSeconds };
const payload = { sid: 'session_123', exp: iatSeconds + ttlSeconds, iat: iatSeconds };
const b64 = (o: object) => btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return `${b64(header)}.${b64(payload)}.test-signature`;
}

describe('SessionTokenCache', () => {
let mockBroadcastChannel: {
addEventListener: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -194,13 +204,18 @@ describe('SessionTokenCache', () => {
});

it('enforces monotonicity: does not overwrite newer token with older one', () => {
// Both tokens carry oiat (the production case post-rollout). Older oiat
// broadcast must not clobber the newer one already in cache.
const newerJwt = createJwtWithOiat(1666648250, 1666648250);
const olderJwt = createJwtWithOiat(1666648190, 1666648190);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const newerEvent: MessageEvent<SessionTokenEvent> = {
data: {
organizationId: null,
sessionId: 'session_123',
template: undefined,
tokenId: 'session_123',
tokenRaw: mockJwt,
tokenRaw: newerJwt,
traceId: 'test_trace_7',
},
} as MessageEvent<SessionTokenEvent>;
Expand All @@ -210,9 +225,6 @@ describe('SessionTokenCache', () => {
expect(resultAfterNewer).toBeDefined();
const newerCreatedAt = resultAfterNewer?.entry.createdAt;

// mockJwt has iat: 1666648250, so create an older one with iat: 1666648190 (60 seconds earlier)
const olderJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2NDg4NTAsImlhdCI6MTY2NjY0ODE5MH0.Z1BC47lImYvaAtluJlY-kBo0qOoAk42Xb-gNrB2SxJg';
const olderEvent: MessageEvent<SessionTokenEvent> = {
data: {
organizationId: null,
Expand Down
108 changes: 108 additions & 0 deletions packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { JWT, TokenResource } from '@clerk/shared/types';
import { describe, expect, it } from 'vitest';

import { pickFreshestJwt } from '../tokenFreshness';

function makeToken(opts: { oiat?: number; iat?: number } = {}): TokenResource {
return {
jwt: {
header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) },
claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) },
},
getRawString: () => 'mock-jwt',
} as unknown as TokenResource;
}

function makeJwt(opts: { oiat?: number; iat?: number } = {}): JWT {
return {
header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) },
claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) },
} as unknown as JWT;
}

describe('pickFreshestJwt', () => {
describe('both have oiat (the only reachable path post-rollout)', () => {
it('picks existing when existing oiat > incoming oiat', () => {
const existing = makeToken({ oiat: 100 });
const incoming = makeToken({ oiat: 90 });
expect(pickFreshestJwt(existing, incoming)).toBe(existing);
});

it('picks incoming when existing oiat < incoming oiat', () => {
const existing = makeToken({ oiat: 90 });
const incoming = makeToken({ oiat: 100 });
expect(pickFreshestJwt(existing, incoming)).toBe(incoming);
});

it('picks existing when oiat equal and existing iat > incoming iat', () => {
const existing = makeToken({ oiat: 100, iat: 200 });
const incoming = makeToken({ oiat: 100, iat: 150 });
expect(pickFreshestJwt(existing, incoming)).toBe(existing);
});

it('picks incoming when oiat equal and existing iat < incoming iat', () => {
const existing = makeToken({ oiat: 100, iat: 150 });
const incoming = makeToken({ oiat: 100, iat: 200 });
expect(pickFreshestJwt(existing, incoming)).toBe(incoming);
});

it('picks existing when oiat equal and iat equal (identical, no churn)', () => {
const existing = makeToken({ oiat: 100, iat: 150 });
const incoming = makeToken({ oiat: 100, iat: 150 });
expect(pickFreshestJwt(existing, incoming)).toBe(existing);
});

it('picks existing when oiat equal and incoming iat missing (treated as 0)', () => {
const existing = makeToken({ oiat: 100, iat: 150 });
const incoming = makeToken({ oiat: 100 });
expect(pickFreshestJwt(existing, incoming)).toBe(existing);
});
});

describe('legacy (missing oiat) safety net', () => {
it('picks existing when incoming is legacy (no oiat) and existing has oiat', () => {
const existing = makeToken({ oiat: 100 });
const incoming = makeToken({ iat: 9999 });
expect(pickFreshestJwt(existing, incoming)).toBe(existing);
});

it('picks incoming when existing is legacy and incoming has oiat', () => {
const existing = makeToken({ iat: 9999 });
const incoming = makeToken({ oiat: 100 });
expect(pickFreshestJwt(existing, incoming)).toBe(incoming);
});

it('picks incoming when both sides are legacy (cannot rank, safe default)', () => {
const existing = makeToken({ iat: 200 });
const incoming = makeToken({ iat: 100 });
expect(pickFreshestJwt(existing, incoming)).toBe(incoming);
});
});

describe('same object reference', () => {
// When the cache hands back the same object that is already stored as
// lastActiveToken, callers use `pickFreshestJwt(a, b) === a` to detect
// "existing won, suppress redundant emit". This test documents that
// intentional behavior.
it('returns the same reference when both args are the same object', () => {
const token = makeToken({ oiat: 100, iat: 150 });
expect(pickFreshestJwt(token, token)).toBe(token);
});
});

describe('JWT input (cookie path)', () => {
it('accepts raw decoded JWT for both arguments', () => {
const a = makeJwt({ oiat: 100 });
const b = makeJwt({ oiat: 200 });
expect(pickFreshestJwt(a, b)).toBe(b);
expect(pickFreshestJwt(b, a)).toBe(b);
});

it('tie-breaks by iat on equal oiat for raw JWT inputs', () => {
const a = makeJwt({ oiat: 100, iat: 150 });
const b = makeJwt({ oiat: 100, iat: 200 });
expect(pickFreshestJwt(a, b)).toBe(b);
expect(pickFreshestJwt(b, a)).toBe(b);
});
});
});
11 changes: 10 additions & 1 deletion packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type {
} from '@clerk/shared/types';
import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/shared/webauthn';

import { pickFreshestJwt } from '@/core/tokenFreshness';
import { unixEpochToDate } from '@/utils/date';
import { debugLogger } from '@/utils/debug';
import { TokenId } from '@/utils/tokenId';
Expand Down Expand Up @@ -458,7 +459,11 @@ export class Session extends BaseResource implements SessionResource {
// Only emit token updates when we have an actual token — emitting with an empty
// token causes AuthCookieService to remove the __session cookie (looks like sign-out).
if (shouldDispatchTokenUpdate && cachedToken.getRawString()) {
eventBus.emit(events.TokenUpdate, { token: cachedToken });
const isStaler =
this.lastActiveToken && pickFreshestJwt(this.lastActiveToken, cachedToken) === this.lastActiveToken;
if (!isStaler) {
eventBus.emit(events.TokenUpdate, { token: cachedToken });
}
}
result = cachedToken.getRawString() || null;
} else if (!isBrowserOnline()) {
Expand Down Expand Up @@ -518,6 +523,10 @@ export class Session extends BaseResource implements SessionResource {
return;
}

if (this.lastActiveToken && pickFreshestJwt(this.lastActiveToken, token) === this.lastActiveToken) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suppression test at Session.test.ts:103 only proves the guard can suppress, not that it does so for the right reason.

And every test that asserts dispatch=2 (lines 186, 242, 60) constructs the session without last_active_token, so the this.lastActiveToken && … short-circuit means the comparator is never actually called.

We should probably add a test where lastActiveToken is set with stale oiat, a fresher-oiat token arrives via cache (broadcast from another tab) or fetch, and assert token:update fires with the fresher token

return;
}

eventBus.emit(events.TokenUpdate, { token });

if (token.jwt) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ describe('Session', () => {
expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled();

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

it('returns same token without API call when Session is reconstructed', async () => {
Expand Down
12 changes: 7 additions & 5 deletions packages/clerk-js/src/core/tokenCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TokenId } from '@/utils/tokenId';

import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller';
import { Token } from './resources/internal';
import { pickFreshestJwt } from './tokenFreshness';

/**
* Identifies a cached token entry by tokenId and optional audience.
Expand Down Expand Up @@ -288,11 +289,10 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
const result = get({ tokenId: data.tokenId });
if (result) {
const existingToken = await result.entry.tokenResolver;
const existingIat = existingToken.jwt?.claims?.iat;
if (existingIat && existingIat >= iat) {
if (pickFreshestJwt(existingToken, token) === existingToken) {
debugLogger.debug(
'Ignoring older token broadcast',
{ existingIat, incomingIat: iat, tabId, tokenId: data.tokenId, traceId: data.traceId },
'Ignoring staler token broadcast',
{ tokenId: data.tokenId, traceId: data.traceId },
'tokenCache',
);
return;
Expand Down Expand Up @@ -379,7 +379,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
entry.tokenResolver
.then(newToken => {
// If this entry was overwritten by a newer set() call while our promise
// was pending, bail out to avoid installing orphaned timers.
// was pending, bail out to avoid installing orphaned timers. Monotonic
// replacement is enforced at the read sites (cookie + broadcast + Session)
// where the user-visible state lives.
if (cache.get(key) !== value) {
return;
}
Expand Down
48 changes: 48 additions & 0 deletions packages/clerk-js/src/core/tokenFreshness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { JWT, TokenResource } from '@clerk/shared/types';

function asJwt(input: TokenResource | JWT): JWT | undefined {
return 'getRawString' in input ? input.jwt : input;
}

/**
* Picks the freshest of two tokens. Returns whichever argument has the more
* recent claim freshness; on a tie, returns `existing` (no churn).
*
* All origin-minted tokens carry the `oiat` JWT header (origin-issued-at;
* timestamp when claims were last assembled from the DB). A token without
* `oiat` is from a pre-feature codebase and is by definition staler than any
* token that has one.
*
* Coverage: invoked at /tokens responses, broadcast events, and cookie writes.
* Handshake-installed __session cookies are intentionally NOT gated:
* handshake is a redirect-based full auth state resync, the browser commits
* the Set-Cookie before any SDK code runs, and there is no in-flight race
* window for the gate to protect.
*
* @internal
*/
export function pickFreshestJwt<T extends TokenResource | JWT>(existing: T, incoming: T): T {
const existingOiat = asJwt(existing)?.header?.oiat;
const incomingOiat = asJwt(incoming)?.header?.oiat;

if (existingOiat == null && incomingOiat == null) {
return incoming;
}
if (incomingOiat == null) {
return existing;
}
if (existingOiat == null) {
return incoming;
}

if (existingOiat > incomingOiat) {
return existing;
}
if (existingOiat < incomingOiat) {
return incoming;
}

const existingIat = asJwt(existing)?.claims?.iat ?? 0;
const incomingIat = asJwt(incoming)?.claims?.iat ?? 0;
return existingIat >= incomingIat ? existing : incoming;
}
Loading