Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.
70 changes: 62 additions & 8 deletions packages/clerk-js/src/core/__tests__/tokenCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,23 @@ 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>;
close: ReturnType<typeof vi.fn>;
postMessage: ReturnType<typeof vi.fn>;
};
let broadcastListener: (e: MessageEvent<SessionTokenEvent>) => void;
let broadcastListener: (e: MessageEvent<SessionTokenEvent>) => void | Promise<void>;
let originalBroadcastChannel: any;

beforeEach(() => {
Expand Down Expand Up @@ -193,26 +203,28 @@ describe('SessionTokenCache', () => {
expect(SessionTokenCache.size()).toBe(0);
});

it('enforces monotonicity: does not overwrite newer token with older one', () => {
it('enforces monotonicity: does not overwrite newer token with older one', async () => {
// 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>;

broadcastListener(newerEvent);
await broadcastListener(newerEvent);
const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' });
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 All @@ -224,13 +236,55 @@ describe('SessionTokenCache', () => {
},
} as MessageEvent<SessionTokenEvent>;

broadcastListener(olderEvent);
await broadcastListener(olderEvent);

const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' });
expect(resultAfterOlder).toBeDefined();
expect(resultAfterOlder?.entry.createdAt).toBe(newerCreatedAt);
});

it('enforces monotonicity: replaces older cached token when a fresher-oiat broadcast arrives', async () => {
// Inverse of the previous test: a fresher-oiat broadcast must overwrite
// an older-oiat token already in cache. Use ttl=120 so both tokens stay
// valid against the test clock (nowSec=1666648260) — cache.get drops
// entries past their expiry.
const olderJwt = createJwtWithOiat(1666648190, 1666648190, 120);
const newerJwt = createJwtWithOiat(1666648250, 1666648250, 120);

const olderEvent: MessageEvent<SessionTokenEvent> = {
data: {
organizationId: null,
sessionId: 'session_123',
template: undefined,
tokenId: 'session_123',
tokenRaw: olderJwt,
traceId: 'test_trace_older_first',
},
} as MessageEvent<SessionTokenEvent>;

await broadcastListener(olderEvent);
const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' });
expect(resultAfterOlder).toBeDefined();
expect(resultAfterOlder?.entry.createdAt).toBe(1666648190);

const newerEvent: MessageEvent<SessionTokenEvent> = {
data: {
organizationId: null,
sessionId: 'session_123',
template: undefined,
tokenId: 'session_123',
tokenRaw: newerJwt,
traceId: 'test_trace_newer_second',
},
} as MessageEvent<SessionTokenEvent>;

await broadcastListener(newerEvent);

const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' });
expect(resultAfterNewer).toBeDefined();
expect(resultAfterNewer?.entry.createdAt).toBe(1666648250);
});

it('successfully updates cache with valid token', () => {
const event: MessageEvent<SessionTokenEvent> = {
data: {
Expand Down
111 changes: 111 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,111 @@
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 incoming when oiat equal and iat equal (other claims may differ)', () => {
// Two tokens with identical oiat+iat may still differ in other claims
// (azp, org_id, etc.) during a token-format rollout. Only suppress when
// existing is strictly fresher; on full ties, let incoming through.
const existing = makeToken({ oiat: 100, iat: 150 });
const incoming = makeToken({ oiat: 100, iat: 150 });
expect(pickFreshestJwt(existing, incoming)).toBe(incoming);
});

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);
});
});
});
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
52 changes: 52 additions & 0 deletions packages/clerk-js/src/core/tokenFreshness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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 full tie (same oiat AND same iat) it returns
* `incoming`, since two tokens with identical timestamps can still differ in
* other claims (azp, org_id, etc.) during a token-format rollout, so the
* guard should only suppress when existing is strictly fresher.
*
* 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.
*
* Used by the cross-tab broadcast handler in tokenCache to drop stale
* edge-minted tokens that would otherwise clobber a fresher cached entry.
*
* @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;
}

// Equal oiat: tie-break by iat (more recent mint wins). On a full tie,
// return incoming: two tokens with identical oiat+iat may still differ
// in other claims (azp, org_id, etc.) added in a token-format rollout,
// so we only suppress when existing is strictly fresher.
const existingIat = asJwt(existing)?.claims?.iat ?? 0;
const incomingIat = asJwt(incoming)?.claims?.iat ?? 0;
return existingIat > incomingIat ? existing : incoming;
}
Loading