From 87eab690834b1b3b9d35a0bd3ac782546208a7be Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 26 May 2026 16:15:45 +0300 Subject: [PATCH 1/7] feat(shared): Add oiat field to JwtHeader type (core-2 backport) --- .changeset/session-minter-oiat-type.md | 5 +++++ packages/shared/src/types/jwtv2.ts | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 .changeset/session-minter-oiat-type.md diff --git a/.changeset/session-minter-oiat-type.md b/.changeset/session-minter-oiat-type.md new file mode 100644 index 00000000000..860ee52193c --- /dev/null +++ b/.changeset/session-minter-oiat-type.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Add `oiat` (original_issued_at) field to `JwtHeader` type for Session Minter monotonic token freshness checks. diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index 54feffaa5db..f6e56f28456 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -25,6 +25,8 @@ export interface JwtHeader { 'x5t#S256'?: string; x5t?: string; x5c?: string | string[]; + /** @internal - used by Session Minter for monotonic token freshness checks. Do not depend on this field. */ + oiat?: number; } declare global { From 3802629e1a8345783c07fcc53a6ca4fa9df55d2e Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 26 May 2026 16:23:34 +0300 Subject: [PATCH 2/7] feat(js): Monotonic token replacement based on oiat (core-2 backport) --- .changeset/session-minter-monotonic-guard.md | 5 + .../src/core/__tests__/tokenCache.test.ts | 69 +++++++++-- .../src/core/__tests__/tokenFreshness.test.ts | 111 ++++++++++++++++++ packages/clerk-js/src/core/tokenCache.ts | 8 +- packages/clerk-js/src/core/tokenFreshness.ts | 52 ++++++++ 5 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 .changeset/session-minter-monotonic-guard.md create mode 100644 packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts create mode 100644 packages/clerk-js/src/core/tokenFreshness.ts diff --git a/.changeset/session-minter-monotonic-guard.md b/.changeset/session-minter-monotonic-guard.md new file mode 100644 index 00000000000..2b64724cfc1 --- /dev/null +++ b/.changeset/session-minter-monotonic-guard.md @@ -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. diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index a5d7f892bb8..f0593e8c85b 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -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; close: ReturnType; postMessage: ReturnType; }; - let broadcastListener: (e: MessageEvent) => void; + let broadcastListener: (e: MessageEvent) => void | Promise; let originalBroadcastChannel: any; beforeEach(() => { @@ -193,26 +203,28 @@ describe('SessionTokenCache', () => { expect(SessionTokenCache.size()).toBe(0); }); - it('enforces monotonicity: does not overwrite newer token with older one', () => { + it('enforces monotonicity: does not overwrite newer token with older one', async () => { + // Both tokens carry oiat (the production case post-rollout). Older oiat + // broadcast must not clobber the newer one already in cache. + const newerJwt = createJwtWithOiat(1666648250, 1666648250); + const olderJwt = createJwtWithOiat(1666648190, 1666648190); + const newerEvent: MessageEvent = { data: { organizationId: null, sessionId: 'session_123', template: undefined, tokenId: 'session_123', - tokenRaw: mockJwt, + tokenRaw: newerJwt, traceId: 'test_trace_7', }, } as MessageEvent; - 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 = { data: { organizationId: null, @@ -224,13 +236,54 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - 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 = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: olderJwt, + traceId: 'test_trace_older_first', + }, + } as MessageEvent; + + await broadcastListener(olderEvent); + const cachedEntryAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(cachedEntryAfterOlder).toBeDefined(); + const olderCreatedAt = cachedEntryAfterOlder?.createdAt; + + const newerEvent: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: newerJwt, + traceId: 'test_trace_newer_second', + }, + } as MessageEvent; + + 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 = { data: { diff --git a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts new file mode 100644 index 00000000000..1c7c5c38ecc --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -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); + }); + }); +}); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 74697b80c63..c95faf79cb5 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -4,6 +4,7 @@ import { debugLogger } from '@/utils/debug'; import { TokenId } from '@/utils/tokenId'; import { Token } from './resources/internal'; +import { pickFreshestJwt } from './tokenFreshness'; /** * Identifies a cached token entry by tokenId and optional audience. @@ -252,11 +253,10 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const existingEntry = get({ tokenId: data.tokenId }); if (existingEntry) { const existingToken = await existingEntry.tokenResolver; - const existingIat = existingToken.jwt?.claims?.iat; - if (existingIat && existingIat >= iat) { + if (pickFreshestJwt(existingToken, token) === existingToken) { debugLogger.debug( - 'Ignoring older token broadcast', - { existingIat, incomingIat: iat, tabId, tokenId: data.tokenId, traceId: data.traceId }, + 'Ignoring staler token broadcast', + { tokenId: data.tokenId, traceId: data.traceId }, 'tokenCache', ); return; diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts new file mode 100644 index 00000000000..9aa87fd9948 --- /dev/null +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -0,0 +1,52 @@ +import type { JWT, TokenResource } from '@clerk/shared/types'; + +function asJwt(input: TokenResource | JWT): JWT | undefined { + return 'getRawString' in input ? input.jwt : input; +} + +/** + * Picks the freshest of two tokens. Returns whichever argument has the more + * recent claim freshness. On a full tie (same oiat AND same iat) it returns + * `incoming`, since two tokens with identical timestamps can still differ in + * other claims (azp, org_id, etc.) during a token-format rollout, so the + * guard should only suppress when existing is strictly fresher. + * + * All origin-minted tokens carry the `oiat` JWT header (origin-issued-at; + * timestamp when claims were last assembled from the DB). A token without + * `oiat` is from a pre-feature codebase and is by definition staler than any + * token that has one. + * + * Used by the cross-tab broadcast handler in tokenCache to drop stale + * edge-minted tokens that would otherwise clobber a fresher cached entry. + * + * @internal + */ +export function pickFreshestJwt(existing: T, incoming: T): T { + const existingOiat = asJwt(existing)?.header?.oiat; + const incomingOiat = asJwt(incoming)?.header?.oiat; + + if (existingOiat == null && incomingOiat == null) { + return incoming; + } + if (incomingOiat == null) { + return existing; + } + if (existingOiat == null) { + return incoming; + } + + if (existingOiat > incomingOiat) { + return existing; + } + if (existingOiat < incomingOiat) { + return incoming; + } + + // Equal oiat: tie-break by iat (more recent mint wins). On a full tie, + // return incoming: two tokens with identical oiat+iat may still differ + // in other claims (azp, org_id, etc.) added in a token-format rollout, + // so we only suppress when existing is strictly fresher. + const existingIat = asJwt(existing)?.claims?.iat ?? 0; + const incomingIat = asJwt(incoming)?.claims?.iat ?? 0; + return existingIat > incomingIat ? existing : incoming; +} From 8897ecd543e916692ae81a2e436b5f71fe92c262 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 26 May 2026 16:27:42 +0300 Subject: [PATCH 3/7] feat(js): Send previous session token on /tokens requests (core-2 backport) --- .changeset/session-minter-send-token.md | 6 ++++++ packages/clerk-js/src/core/clerk.ts | 5 +++++ packages/clerk-js/src/core/resources/AuthConfig.ts | 3 +++ packages/clerk-js/src/core/resources/Session.ts | 8 +++++++- packages/shared/src/types/authConfig.ts | 5 +++++ packages/shared/src/types/json.ts | 1 + 6 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 .changeset/session-minter-send-token.md diff --git a/.changeset/session-minter-send-token.md b/.changeset/session-minter-send-token.md new file mode 100644 index 00000000000..31eb8a62612 --- /dev/null +++ b/.changeset/session-minter-send-token.md @@ -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. diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index afb90685645..f75f02fd599 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -231,6 +231,11 @@ export class Clerk implements ClerkInterface { // converted to protected environment to support `updateEnvironment` type assertion protected environment?: EnvironmentResource | null; + /** @internal Expose protected environment to internal Resource callers (e.g. Session token requests). */ + get __internal_environment(): EnvironmentResource | null | undefined { + return this.environment; + } + #queryClient: QueryClient | undefined; #publishableKey = ''; #domain: DomainOrProxyUrl['domain']; diff --git a/packages/clerk-js/src/core/resources/AuthConfig.ts b/packages/clerk-js/src/core/resources/AuthConfig.ts index b95dfaf5ba2..3bfc61dbacf 100644 --- a/packages/clerk-js/src/core/resources/AuthConfig.ts +++ b/packages/clerk-js/src/core/resources/AuthConfig.ts @@ -8,6 +8,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource { reverification: boolean = false; singleSessionMode: boolean = false; preferredChannels: Record | null = null; + sessionMinter: boolean = false; public constructor(data: Partial | null = null) { super(); @@ -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; } @@ -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, }; } } diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 8ce3b919336..2ac780d1600 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -400,8 +400,14 @@ export class Session extends BaseResource implements SessionResource { const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; + const sessionMinterEnabled = Session.clerk?.__internal_environment?.authConfig?.sessionMinter; // TODO: update template endpoint to accept organizationId - const params: Record = template ? {} : { organizationId }; + const params: Record = template + ? {} + : { + organizationId, + ...(sessionMinterEnabled && this.lastActiveToken ? { token: this.lastActiveToken.getRawString() } : {}), + }; const lastActiveToken = this.lastActiveToken?.getRawString(); diff --git a/packages/shared/src/types/authConfig.ts b/packages/shared/src/types/authConfig.ts index 3af9b4171c9..7f346df8b07 100644 --- a/packages/shared/src/types/authConfig.ts +++ b/packages/shared/src/types/authConfig.ts @@ -20,5 +20,10 @@ export interface AuthConfigResource extends ClerkResource { * Preferred channels for phone code providers. */ preferredChannels: Record | null; + /** + * Whether the Session Minter (edge token minting) is enabled at the instance level. + * @internal + */ + sessionMinter: boolean; __internal_toSnapshot: () => AuthConfigJSONSnapshot; } diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index ad2107cf069..89d5c2c96bb 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -352,6 +352,7 @@ export interface AuthConfigJSON extends ClerkResourceJSON { claimed_at: number | null; reverification: boolean; preferred_channels?: Record; + session_minter?: boolean; } export interface VerificationJSON extends ClerkResourceJSON { From 7a35819a7699d00ac18e0def6138162b7bab783f Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 26 May 2026 16:29:02 +0300 Subject: [PATCH 4/7] feat(js): Skip expired_token retry when Session Minter is enabled (core-2 backport) --- .changeset/remove-expired-token-retry.md | 5 +++++ packages/clerk-js/src/core/resources/Session.ts | 16 ++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 .changeset/remove-expired-token-retry.md diff --git a/.changeset/remove-expired-token-retry.md b/.changeset/remove-expired-token-retry.md new file mode 100644 index 00000000000..b88db85a405 --- /dev/null +++ b/.changeset/remove-expired-token-retry.md @@ -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. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 2ac780d1600..ab4aecbe918 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -411,12 +411,16 @@ export class Session extends BaseResource implements SessionResource { const lastActiveToken = this.lastActiveToken?.getRawString(); - const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { - if (MissingExpiredTokenError.is(e) && lastActiveToken) { - return Token.create(path, { ...params }, { expired_token: lastActiveToken }); - } - throw e; - }); + const tokenResolver = sessionMinterEnabled + ? // Session Minter sends the token in the body, no expired_token retry needed + Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined) + : // TODO: Remove this expired_token retry flow when the sessionMinter flag is removed + Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { + if (MissingExpiredTokenError.is(e) && lastActiveToken) { + return Token.create(path, { ...params }, { expired_token: lastActiveToken }); + } + throw e; + }); SessionTokenCache.set({ tokenId, tokenResolver }); return tokenResolver.then(token => { From 270c10d3991491491a9f9fb9aa615209c044e44d Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 26 May 2026 16:29:21 +0300 Subject: [PATCH 5/7] feat(js): Send force_origin on skipCache token requests (core-2 backport) --- .changeset/session-minter-force-origin.md | 5 +++++ packages/clerk-js/src/core/resources/Session.ts | 1 + .../clerk-js/src/core/resources/__tests__/AuthConfig.test.ts | 1 + 3 files changed, 7 insertions(+) create mode 100644 .changeset/session-minter-force-origin.md diff --git a/.changeset/session-minter-force-origin.md b/.changeset/session-minter-force-origin.md new file mode 100644 index 00000000000..f80ea7c3b2b --- /dev/null +++ b/.changeset/session-minter-force-origin.md @@ -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. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ab4aecbe918..fd0b21f93a6 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -407,6 +407,7 @@ export class Session extends BaseResource implements SessionResource { : { organizationId, ...(sessionMinterEnabled && this.lastActiveToken ? { token: this.lastActiveToken.getRawString() } : {}), + ...(sessionMinterEnabled && skipCache ? { forceOrigin: 'true' } : {}), }; const lastActiveToken = this.lastActiveToken?.getRawString(); diff --git a/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts b/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts index bfce1d5c021..3d16c8d430b 100644 --- a/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts @@ -46,6 +46,7 @@ describe('AuthConfig', () => { id: '', reverification: true, single_session_mode: true, + session_minter: false, }); }); }); From c369b59d4aca14e8b01bc4c7fbbc4dfa6b6f7539 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 26 May 2026 16:43:17 +0300 Subject: [PATCH 6/7] refactor(js): Use __unstable__environment and backport Session tests (core-2) --- packages/clerk-js/src/core/clerk.ts | 5 - .../clerk-js/src/core/resources/Session.ts | 2 +- .../core/resources/__tests__/Session.test.ts | 226 ++++++++++++++++++ 3 files changed, 227 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index f75f02fd599..afb90685645 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -231,11 +231,6 @@ export class Clerk implements ClerkInterface { // converted to protected environment to support `updateEnvironment` type assertion protected environment?: EnvironmentResource | null; - /** @internal Expose protected environment to internal Resource callers (e.g. Session token requests). */ - get __internal_environment(): EnvironmentResource | null | undefined { - return this.environment; - } - #queryClient: QueryClient | undefined; #publishableKey = ''; #domain: DomainOrProxyUrl['domain']; diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index fd0b21f93a6..c43fada497b 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -400,7 +400,7 @@ export class Session extends BaseResource implements SessionResource { const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; - const sessionMinterEnabled = Session.clerk?.__internal_environment?.authConfig?.sessionMinter; + const sessionMinterEnabled = Session.clerk?.__unstable__environment?.authConfig?.sessionMinter; // TODO: update template endpoint to accept organizationId const params: Record = template ? {} diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 6092b09f08d..7c0a8730219 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1373,4 +1373,230 @@ describe('Session', () => { expect(session.agent?.type).toBe('agent'); }); }); + + describe('sends previous token in /tokens request body', () => { + let dispatchSpy: ReturnType; + let fetchSpy: ReturnType; + + beforeEach(() => { + dispatchSpy = vi.spyOn(eventBus, 'emit'); + fetchSpy = vi.spyOn(BaseResource, '_fetch' as any); + BaseResource.clerk = clerkMock({ + __unstable__environment: { + authConfig: { sessionMinter: true }, + }, + }) as any; + }); + + afterEach(() => { + dispatchSpy?.mockRestore(); + fetchSpy?.mockRestore(); + BaseResource.clerk = null as any; + }); + + it('includes token in request body when lastActiveToken exists', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toMatchObject({ + path: '/client/sessions/session_1/tokens', + method: 'POST', + body: { organizationId: null, token: mockJwt }, + }); + }); + + it('does not include token key in request body when lastActiveToken is null (first mint)', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as unknown as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toMatchObject({ + path: '/client/sessions/session_1/tokens', + method: 'POST', + body: { organizationId: null }, + }); + expect(fetchSpy.mock.calls[0][0].body).not.toHaveProperty('token'); + }); + + it('does not include token in request body for template token requests', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken({ template: 'my-template' }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toMatchObject({ + path: '/client/sessions/session_1/tokens/my-template', + method: 'POST', + }); + expect(fetchSpy.mock.calls[0][0].body).toEqual({}); + }); + + it('token value matches lastActiveToken.getRawString() exactly', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken(); + + expect(fetchSpy.mock.calls[0][0].body.token).toBe(mockJwt); + }); + }); + + describe('sends force_origin in /tokens request body when skipCache is true', () => { + let dispatchSpy: ReturnType; + let fetchSpy: ReturnType; + + beforeEach(() => { + dispatchSpy = vi.spyOn(eventBus, 'emit'); + fetchSpy = vi.spyOn(BaseResource, '_fetch' as any); + BaseResource.clerk = clerkMock({ + __unstable__environment: { + authConfig: { sessionMinter: true }, + }, + }) as any; + }); + + afterEach(() => { + dispatchSpy?.mockRestore(); + fetchSpy?.mockRestore(); + BaseResource.clerk = null as any; + }); + + it('includes forceOrigin in body when skipCache is true', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken({ skipCache: true }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toMatchObject({ + path: '/client/sessions/session_1/tokens', + method: 'POST', + body: expect.objectContaining({ forceOrigin: 'true' }), + search: { debug: 'skip_cache' }, + }); + expect(fetchSpy.mock.calls[0][0].body).not.toHaveProperty('debug'); + }); + + it('does not include forceOrigin in body when skipCache is false or undefined', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0].body).not.toHaveProperty('forceOrigin'); + }); + + it('does not include forceOrigin when sessionMinter is false even with skipCache true', async () => { + BaseResource.clerk = clerkMock({ + __unstable__environment: { + authConfig: { sessionMinter: false }, + }, + }) as any; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken({ skipCache: true }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0].body).not.toHaveProperty('forceOrigin'); + }); + }); }); From ccff619da9eca342d1b51873b0e0750a72a71602 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 26 May 2026 17:05:29 +0300 Subject: [PATCH 7/7] fix(js): bump headless bundlewatch limit, silence floating-promise lint warnings --- packages/clerk-js/bundlewatch.config.json | 2 +- .../src/core/__tests__/tokenCache.test.ts | 22 +++++++++---------- .../core/resources/__tests__/Session.test.ts | 18 +++++++-------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 2e536d4906d..6e54779e0af 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -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" }, diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index f0593e8c85b..52e69871800 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -106,7 +106,7 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(event); + void broadcastListener(event); expect(SessionTokenCache.size()).toBe(0); }); @@ -123,7 +123,7 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(event); + void broadcastListener(event); expect(SessionTokenCache.size()).toBe(1); }); @@ -140,7 +140,7 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(event); + void broadcastListener(event); expect(SessionTokenCache.size()).toBe(1); }); @@ -158,7 +158,7 @@ describe('SessionTokenCache', () => { } as MessageEvent; expect(() => { - broadcastListener(event); + void broadcastListener(event); }).not.toThrow(); expect(SessionTokenCache.size()).toBe(0); @@ -178,7 +178,7 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(event); + void broadcastListener(event); expect(SessionTokenCache.size()).toBe(0); }); @@ -198,7 +198,7 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(event); + void broadcastListener(event); expect(SessionTokenCache.size()).toBe(0); }); @@ -296,7 +296,7 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(event); + void broadcastListener(event); const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' }); expect(cachedEntry).toBeDefined(); @@ -318,7 +318,7 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(event); + void broadcastListener(event); // Flush microtasks to let the tokenResolver promise settle without advancing timers await Promise.resolve(); @@ -810,7 +810,7 @@ describe('SessionTokenCache', () => { } as MessageEvent; expect(() => { - broadcastListener(event); + void broadcastListener(event); }).not.toThrow(); }); }); @@ -854,7 +854,7 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(broadcastEvent); + void broadcastListener(broadcastEvent); await vi.waitFor(() => { expect(SessionTokenCache.get({ tokenId: session2Id })).toBeDefined(); @@ -915,7 +915,7 @@ describe('SessionTokenCache', () => { }, } as MessageEvent; - broadcastListener(broadcastEvent); + void broadcastListener(broadcastEvent); await vi.waitFor(async () => { const updatedCached = SessionTokenCache.get({ tokenId: sessionId }); diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 7c0a8730219..9d0d183525a 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1385,7 +1385,7 @@ describe('Session', () => { __unstable__environment: { authConfig: { sessionMinter: true }, }, - }) as any; + } as any) as any; }); afterEach(() => { @@ -1445,7 +1445,7 @@ describe('Session', () => { method: 'POST', body: { organizationId: null }, }); - expect(fetchSpy.mock.calls[0][0].body).not.toHaveProperty('token'); + expect((fetchSpy.mock.calls[0][0] as any).body).not.toHaveProperty('token'); }); it('does not include token in request body for template token requests', async () => { @@ -1472,7 +1472,7 @@ describe('Session', () => { path: '/client/sessions/session_1/tokens/my-template', method: 'POST', }); - expect(fetchSpy.mock.calls[0][0].body).toEqual({}); + expect((fetchSpy.mock.calls[0][0] as any).body).toEqual({}); }); it('token value matches lastActiveToken.getRawString() exactly', async () => { @@ -1494,7 +1494,7 @@ describe('Session', () => { await session.getToken(); - expect(fetchSpy.mock.calls[0][0].body.token).toBe(mockJwt); + expect((fetchSpy.mock.calls[0][0] as any).body.token).toBe(mockJwt); }); }); @@ -1509,7 +1509,7 @@ describe('Session', () => { __unstable__environment: { authConfig: { sessionMinter: true }, }, - }) as any; + } as any) as any; }); afterEach(() => { @@ -1544,7 +1544,7 @@ describe('Session', () => { body: expect.objectContaining({ forceOrigin: 'true' }), search: { debug: 'skip_cache' }, }); - expect(fetchSpy.mock.calls[0][0].body).not.toHaveProperty('debug'); + expect((fetchSpy.mock.calls[0][0] as any).body).not.toHaveProperty('debug'); }); it('does not include forceOrigin in body when skipCache is false or undefined', async () => { @@ -1567,7 +1567,7 @@ describe('Session', () => { await session.getToken(); expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(fetchSpy.mock.calls[0][0].body).not.toHaveProperty('forceOrigin'); + expect((fetchSpy.mock.calls[0][0] as any).body).not.toHaveProperty('forceOrigin'); }); it('does not include forceOrigin when sessionMinter is false even with skipCache true', async () => { @@ -1575,7 +1575,7 @@ describe('Session', () => { __unstable__environment: { authConfig: { sessionMinter: false }, }, - }) as any; + } as any) as any; const session = new Session({ status: 'active', @@ -1596,7 +1596,7 @@ describe('Session', () => { await session.getToken({ skipCache: true }); expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(fetchSpy.mock.calls[0][0].body).not.toHaveProperty('forceOrigin'); + expect((fetchSpy.mock.calls[0][0] as any).body).not.toHaveProperty('forceOrigin'); }); }); });