Skip to content
5 changes: 5 additions & 0 deletions .changeset/remove-expired-token-retry.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -757,7 +810,7 @@ describe('SessionTokenCache', () => {
} as MessageEvent<SessionTokenEvent>;

expect(() => {
broadcastListener(event);
void broadcastListener(event);
}).not.toThrow();
});
});
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(clerk-js,shared): Backport Session Minter SDK changes to core-2 by nikosdouvlis · Pull Request #8643 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/remove-expired-token-retry.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -757,7 +810,7 @@ describe('SessionTokenCache', () => {
} as MessageEvent<SessionTokenEvent>;

expect(() => {
broadcastListener(event);
void broadcastListener(event);
}).not.toThrow();
});
});
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(clerk-js,shared): Backport Session Minter SDK changes to core-2 by nikosdouvlis · Pull Request #8643 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/remove-expired-token-retry.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -757,7 +810,7 @@ describe('SessionTokenCache', () => {
} as MessageEvent<SessionTokenEvent>;

expect(() => {
broadcastListener(event);
void broadcastListener(event);
}).not.toThrow();
});
});
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(clerk-js,shared): Backport Session Minter SDK changes to core-2 by nikosdouvlis · Pull Request #8643 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/remove-expired-token-retry.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -757,7 +810,7 @@ describe('SessionTokenCache', () => {
} as MessageEvent<SessionTokenEvent>;

expect(() => {
broadcastListener(event);
void broadcastListener(event);
}).not.toThrow();
});
});
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(clerk-js,shared): Backport Session Minter SDK changes to core-2 by nikosdouvlis · Pull Request #8643 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/remove-expired-token-retry.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -757,7 +810,7 @@ describe('SessionTokenCache', () => {
} as MessageEvent<SessionTokenEvent>;

expect(() => {
broadcastListener(event);
void broadcastListener(event);
}).not.toThrow();
});
});
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(clerk-js,shared): Backport Session Minter SDK changes to core-2 by nikosdouvlis · Pull Request #8643 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/remove-expired-token-retry.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -757,7 +810,7 @@ describe('SessionTokenCache', () => {
} as MessageEvent<SessionTokenEvent>;

expect(() => {
broadcastListener(event);
void broadcastListener(event);
}).not.toThrow();
});
});
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(clerk-js,shared): Backport Session Minter SDK changes to core-2 by nikosdouvlis · Pull Request #8643 · clerk/javascript · GitHub
Skip to content
5 changes: 5 additions & 0 deletions .changeset/remove-expired-token-retry.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -757,7 +810,7 @@ describe('SessionTokenCache', () => {
} as MessageEvent<SessionTokenEvent>;

expect(() => {
broadcastListener(event);
void broadcastListener(event);
}).not.toThrow();
});
});
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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