Skip to content
5 changes: 5 additions & 0 deletions .changeset/remove-expired-token-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Skip `expired_token` retry flow when Session Minter is enabled. When `sessionMinter` is on, the token is sent in the POST body, so the retry-with-expired-token fallback is unnecessary. The retry flow is preserved for non-Session Minter mode.
5 changes: 5 additions & 0 deletions .changeset/session-minter-force-origin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Send `force_origin=true` body param on `/tokens` requests when `skipCache` is true, so FAPI Proxy routes to origin instead of Session Minter.
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.
5 changes: 5 additions & 0 deletions .changeset/session-minter-oiat-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Add `oiat` (original_issued_at) field to `JwtHeader` type for Session Minter monotonic token freshness checks.
6 changes: 6 additions & 0 deletions .changeset/session-minter-send-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
---

Send previous session token on `/tokens` requests to support Session Minter edge token minting.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{ "path": "./dist/clerk.js", "maxSize": "931KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "87KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "129KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "67KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "68KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "123KB" },
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "126KB" },
{ "path": "./dist/vendors*.js", "maxSize": "50KB" },
Expand Down
91 changes: 72 additions & 19 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 @@ -96,7 +106,7 @@ describe('SessionTokenCache', () => {
},
} as MessageEvent<SessionTokenEvent>;

broadcastListener(event);
void broadcastListener(event);

expect(SessionTokenCache.size()).toBe(0);
});
Expand All @@ -113,7 +123,7 @@ describe('SessionTokenCache', () => {
},
} as MessageEvent<SessionTokenEvent>;

broadcastListener(event);
void broadcastListener(event);

expect(SessionTokenCache.size()).toBe(1);
});
Expand All @@ -130,7 +140,7 @@ describe('SessionTokenCache', () => {
},
} as MessageEvent<SessionTokenEvent>;

broadcastListener(event);
void broadcastListener(event);

expect(SessionTokenCache.size()).toBe(1);
});
Expand All @@ -148,7 +158,7 @@ describe('SessionTokenCache', () => {
} as MessageEvent<SessionTokenEvent>;

expect(() => {
broadcastListener(event);
void broadcastListener(event);
}).not.toThrow();

expect(SessionTokenCache.size()).toBe(0);
Expand All @@ -168,7 +178,7 @@ describe('SessionTokenCache', () => {
},
} as MessageEvent<SessionTokenEvent>;

broadcastListener(event);
void broadcastListener(event);

expect(SessionTokenCache.size()).toBe(0);
});
Expand All @@ -188,31 +198,33 @@ describe('SessionTokenCache', () => {
},
} as MessageEvent<SessionTokenEvent>;

broadcastListener(event);
void broadcastListener(event);

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);

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 cachedEntryAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' });
expect(cachedEntryAfterNewer).toBeDefined();
const newerCreatedAt = cachedEntryAfterNewer?.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,54 @@ describe('SessionTokenCache', () => {
},
} as MessageEvent<SessionTokenEvent>;

broadcastListener(olderEvent);
await broadcastListener(olderEvent);

const cachedEntryAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' });
expect(cachedEntryAfterOlder).toBeDefined();
expect(cachedEntryAfterOlder?.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 cachedEntryAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' });
expect(cachedEntryAfterOlder).toBeDefined();
const olderCreatedAt = cachedEntryAfterOlder?.createdAt;

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 cachedEntryAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' });
expect(cachedEntryAfterNewer).toBeDefined();
expect(cachedEntryAfterNewer?.createdAt).not.toBe(olderCreatedAt);
});

it('successfully updates cache with valid token', () => {
const event: MessageEvent<SessionTokenEvent> = {
data: {
Expand All @@ -243,7 +296,7 @@ describe('SessionTokenCache', () => {
},
} as MessageEvent<SessionTokenEvent>;

broadcastListener(event);
void broadcastListener(event);

const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' });
expect(cachedEntry).toBeDefined();
Expand All @@ -265,7 +318,7 @@ describe('SessionTokenCache', () => {
},
} as MessageEvent<SessionTokenEvent>;

broadcastListener(event);
void broadcastListener(event);

// Flush microtasks to let the tokenResolver promise settle without advancing timers
await Promise.resolve();
Expand Down Expand Up @@ -757,7 +810,7 @@ describe('SessionTokenCache', () => {
} as MessageEvent<SessionTokenEvent>;

expect(() => {
broadcastListener(event);
void broadcastListener(event);
}).not.toThrow();
});
});
Expand Down Expand Up @@ -801,7 +854,7 @@ describe('SessionTokenCache', () => {
},
} as MessageEvent<SessionTokenEvent>;

broadcastListener(broadcastEvent);
void broadcastListener(broadcastEvent);

await vi.waitFor(() => {
expect(SessionTokenCache.get({ tokenId: session2Id })).toBeDefined();
Expand Down Expand Up @@ -862,7 +915,7 @@ describe('SessionTokenCache', () => {
},
} as MessageEvent<SessionTokenEvent>;

broadcastListener(broadcastEvent);
void broadcastListener(broadcastEvent);

await vi.waitFor(async () => {
const updatedCached = SessionTokenCache.get({ tokenId: sessionId });
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);
});
});
});
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/resources/AuthConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
reverification: boolean = false;
singleSessionMode: boolean = false;
preferredChannels: Record<string, PhoneCodeChannel> | null = null;
sessionMinter: boolean = false;

public constructor(data: Partial<AuthConfigJSON> | null = null) {
super();
Expand All @@ -23,6 +24,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
this.reverification = this.withDefault(data.reverification, this.reverification);
this.singleSessionMode = this.withDefault(data.single_session_mode, this.singleSessionMode);
this.preferredChannels = this.withDefault(data.preferred_channels, this.preferredChannels);
this.sessionMinter = this.withDefault(data.session_minter, this.sessionMinter);
return this;
}

Expand All @@ -33,6 +35,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
object: 'auth_config',
reverification: this.reverification,
single_session_mode: this.singleSessionMode,
session_minter: this.sessionMinter,
};
}
}
Loading
Loading