From 98d0fcc6962b9ecb000a35b63c25eb920f5d9e5f Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 26 Jun 2026 23:31:05 +0300 Subject: [PATCH 01/15] fix(clerk-js): apply session tokens monotonically by oiat on a single tab A stale edge-minted session token (older oiat header) could overwrite a fresher one on the same tab. The only freshness guard ran solely in the cross-tab broadcast receiver, so the __session cookie, Session.lastActiveToken, and the token cache each accepted whatever token arrived last, letting a slow or out-of-order edge read revert a newer token. Reuse pickFreshestJwt across all three stores: - tokenCache.setInternal keeps the live entry's resolvedToken monotonic regardless of which resolver owns the slot, carries the prior token and its expiry forward, and derives expiry/refresh timers from the winner. - Session.#fetchToken and #refreshTokenInBackground resolve to the freshest of the fetched token and the live cache entry, so coalesced waiters and the dispatch receive the winner; lastActiveToken never regresses to a strictly-staler token. - Session.fromJSON reconciles an incoming last_active_token against the active cache slot, never adopting a strictly-staler or wrong-org token nor polluting the slot getToken reads. - AuthCookieService.updateSessionCookie drops tokens for a different active session/org or strictly staler than the same-context cookie, failing open for tokens without oiat. Freshness is decided by oiat then iat; drops happen only when both tokens carry oiat and the incoming is strictly older, so a missing oiat or a different context always lets the token through. --- .changeset/monotonic-session-token-guard.md | 5 + .../clerk-js/src/core/__tests__/clerk.test.ts | 146 +++++++ .../src/core/__tests__/tokenCache.test.ts | 207 ++++++++++ .../src/core/__tests__/tokenFreshness.test.ts | 145 ++++++- .../src/core/auth/AuthCookieService.ts | 46 +++ .../clerk-js/src/core/resources/Session.ts | 60 ++- .../core/resources/__tests__/Session.test.ts | 381 +++++++++++++++++- packages/clerk-js/src/core/tokenCache.ts | 74 +++- packages/clerk-js/src/core/tokenFreshness.ts | 40 ++ 9 files changed, 1059 insertions(+), 45 deletions(-) create mode 100644 .changeset/monotonic-session-token-guard.md diff --git a/.changeset/monotonic-session-token-guard.md b/.changeset/monotonic-session-token-guard.md new file mode 100644 index 00000000000..fc94483d5c6 --- /dev/null +++ b/.changeset/monotonic-session-token-guard.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Apply session tokens monotonically by origin-issued-at on a single tab, so a stale edge-minted token can no longer overwrite a fresher one in the `__session` cookie, the session's last active token, or the token cache. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 65a63bbaa3e..aff0d2cc3f1 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -819,6 +819,152 @@ describe('Clerk singleton', () => { ); }); + describe('updateSessionCookie monotonic backstop', () => { + const sessionId = 'sess_active'; + + const createJwtWithOiat = ( + iat: number, + oiat: number | undefined, + opts: { sid?: string; org?: string; ttl?: number } = {}, + ): string => { + const { sid = sessionId, org, ttl = 60 } = opts; + const header: Record = { alg: 'HS256', typ: 'JWT' }; + if (oiat !== undefined) { + header.oiat = oiat; + } + const payload: Record = { sid, iat, exp: iat + ttl }; + if (org) { + payload.org_id = org; + } + const b64 = (o: object) => btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + return `${b64(header)}.${b64(payload)}.test-signature`; + }; + + const loadClerkWithSession = async () => { + const mockSession = { + id: sessionId, + status: 'active', + user: {}, + getToken: vi.fn(), + lastActiveToken: { getRawString: () => mockJwt }, + }; + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + return sut; + }; + + const emitToken = (raw: string | null) => { + eventBus.emit(events.TokenUpdate, { + token: raw === null ? null : ({ jwt: {}, getRawString: () => raw } as any), + }); + }; + + it('drops a strictly-staler same-context token and keeps the fresher cookie', async () => { + await loadClerkWithSession(); + + const fresh = createJwtWithOiat(1000, 200); + emitToken(fresh); + expect(document.cookie).toContain(fresh); + + const stale = createJwtWithOiat(900, 100); + emitToken(stale); + expect(document.cookie).not.toContain(stale); + expect(document.cookie).toContain(fresh); + }); + + it('applies a fresher same-context token', async () => { + await loadClerkWithSession(); + + const older = createJwtWithOiat(1000, 100); + emitToken(older); + expect(document.cookie).toContain(older); + + const newer = createJwtWithOiat(1100, 200); + emitToken(newer); + expect(document.cookie).toContain(newer); + }); + + it('applies a token with equal oiat and iat (publish on tie)', async () => { + await loadClerkWithSession(); + + const first = createJwtWithOiat(1000, 100, { ttl: 60 }); + emitToken(first); + expect(document.cookie).toContain(first); + + const second = createJwtWithOiat(1000, 100, { ttl: 120 }); + emitToken(second); + expect(document.cookie).toContain(second); + }); + + it('drops a token for a different session', async () => { + await loadClerkWithSession(); + + const wrongSession = createJwtWithOiat(1000, 200, { sid: 'sess_other' }); + emitToken(wrongSession); + expect(document.cookie).not.toContain(wrongSession); + }); + + it('drops a token for a different organization', async () => { + await loadClerkWithSession(); + + const wrongOrg = createJwtWithOiat(1000, 200, { org: 'org_other' }); + emitToken(wrongOrg); + expect(document.cookie).not.toContain(wrongOrg); + }); + + it('applies a personal-workspace token (no org) for the active personal workspace', async () => { + await loadClerkWithSession(); + + const personal = createJwtWithOiat(1000, 200); + emitToken(personal); + expect(document.cookie).toContain(personal); + }); + + it('applies an active-context token even when the current cookie is a different session with higher oiat', async () => { + const sut = await loadClerkWithSession(); + + // Plant a different-session, higher-oiat cookie by temporarily making it the active context. + (sut.session as any).id = 'sess_other'; + const otherContext = createJwtWithOiat(2000, 999, { sid: 'sess_other' }); + emitToken(otherContext); + expect(document.cookie).toContain(otherContext); + + // Restore the active session; a lower-oiat active-context token must still apply, + // because the different-session cookie is not a valid freshness baseline. + (sut.session as any).id = sessionId; + const active = createJwtWithOiat(1000, 100, { sid: sessionId }); + emitToken(active); + expect(document.cookie).toContain(active); + }); + + it('applies a token without an oiat header (fail open)', async () => { + await loadClerkWithSession(); + + const noOiat = createJwtWithOiat(1000, undefined); + emitToken(noOiat); + expect(document.cookie).toContain(noOiat); + }); + + it('applies a malformed token (fail open)', async () => { + await loadClerkWithSession(); + + emitToken('garbage.token'); + expect(document.cookie).toContain('garbage.token'); + }); + + it('removes the cookie when the token is null', async () => { + await loadClerkWithSession(); + + const fresh = createJwtWithOiat(1000, 200); + emitToken(fresh); + expect(document.cookie).toContain(fresh); + + emitToken(null); + expect(document.cookie).not.toContain(fresh); + }); + }); + describe('.signOut()', () => { const mockClientDestroy = vi.fn(); const mockClientRemoveSessions = vi.fn(); diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index baad7691c7d..c86c7067d1f 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -285,6 +285,58 @@ describe('SessionTokenCache', () => { expect(resultAfterNewer?.entry.createdAt).toBe(1666648250); }); + it('ignores a broadcast staler than a carried-forward resolvedToken even when the resolver is staler', async () => { + // resolvedToken carry-forward can leave the live entry's resolvedToken FRESHER than + // the token its tokenResolver resolves to. The broadcast guard must compare against + // resolvedToken (the freshest known), not the staler resolver, otherwise a broadcast + // that is staler than resolvedToken slips past the guard and runs setInternal, which + // clears the refresh timer without reinstalling one. + const tokenId = 'session_123'; + const tick = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + const makeToken = (raw: string) => new Token({ id: tokenId, jwt: raw, object: 'token' }) as TokenResource; + + const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); + const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); + + // Cache the high-oiat token and let it resolve so resolvedToken = high. + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); + await tick(); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + + // Overwrite with a resolver that resolves to a LOWER-oiat token. Carry-forward keeps the + // live entry's resolvedToken = high while its tokenResolver resolves to low. + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) }); + await tick(); + const beforeEntry = SessionTokenCache.get({ tokenId })?.entry; + expect(beforeEntry?.resolvedToken?.getRawString()).toBe(highRaw); + const beforeCreatedAt = beforeEntry?.createdAt; + + // Broadcast a token staler than high (but fresher than low, so the staler resolver would + // have let it through under the bug). + const stalerRaw = createJwtWithOiat(1666648220, 1666648220, 120); + const stalerEvent: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId, + tokenRaw: stalerRaw, + traceId: 'test_trace_carry_forward', + }, + } as MessageEvent; + + await broadcastListener(stalerEvent); + + const afterEntry = SessionTokenCache.get({ tokenId })?.entry; + expect(afterEntry?.resolvedToken?.getRawString()).toBe(highRaw); + // createdAt unchanged proves the broadcast was dropped before setInternal ran; the bug + // would have replaced the entry, stamping it with the broadcast's iat (1666648220). + expect(afterEntry?.createdAt).toBe(beforeCreatedAt); + }); + it('successfully updates cache with valid token', () => { const event: MessageEvent = { data: { @@ -368,6 +420,161 @@ describe('SessionTokenCache', () => { }); }); + describe('same-tab monotonic resolve', () => { + const tokenId = 'session_123'; + + // Flush enough microtasks for setInternal's tokenResolver.then handler to run. + const tick = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + const deferred = () => { + let resolve!: (token: TokenResource) => void; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; + }; + + const makeToken = (raw: string) => new Token({ id: tokenId, jwt: raw, object: 'token' }) as TokenResource; + + it('keeps the fresher token when a staler set overwrites it, resolving high then low', async () => { + const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); + const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); + + const high = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined }); + + const low = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: low.promise, onRefresh: undefined }); + + high.resolve(makeToken(highRaw)); + await tick(); + low.resolve(makeToken(lowRaw)); + await tick(); + + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + }); + + it('keeps the fresher token when a staler set overwrites it, resolving low then high', async () => { + const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); + const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); + + const high = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined }); + + const low = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: low.promise, onRefresh: undefined }); + + low.resolve(makeToken(lowRaw)); + await tick(); + high.resolve(makeToken(highRaw)); + await tick(); + + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + }); + + it('publishes the later token on a full oiat+iat tie with different raw payloads', async () => { + const b64 = (o: object) => btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + const header = { alg: 'HS256', typ: 'JWT', oiat: 1666648250 }; + const firstRaw = `${b64(header)}.${b64({ sid: tokenId, sub: 'user_A', exp: 1666648370, iat: 1666648250 })}.sig`; + const laterRaw = `${b64(header)}.${b64({ sid: tokenId, sub: 'user_B', exp: 1666648370, iat: 1666648250 })}.sig`; + + const first = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: first.promise, onRefresh: undefined }); + + const later = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: later.promise, onRefresh: undefined }); + + first.resolve(makeToken(firstRaw)); + await tick(); + later.resolve(makeToken(laterRaw)); + await tick(); + + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(laterRaw); + }); + + it('carries the prior resolved token forward into a new pending entry', async () => { + const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); + + const high = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined }); + high.resolve(makeToken(highRaw)); + await tick(); + + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + + const pending = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: pending.promise, onRefresh: undefined }); + + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + }); + + it('does not resurrect a cleared key when its pending resolver settles', async () => { + const raw = createJwtWithOiat(1666648250, 1666648250, 120); + + const pending = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: pending.promise, onRefresh: undefined }); + + SessionTokenCache.clear(); + + pending.resolve(makeToken(raw)); + await tick(); + + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('derives the deletion timer from the winner, not from a later staler resolve', async () => { + // high: longer ttl AND fresher oiat; low: short ttl AND staler oiat. + const highRaw = createJwtWithOiat(1666648250, 1666648260, 300); + const lowRaw = createJwtWithOiat(1666648255, 1666648250, 60); + + const high = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined }); + + const low = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: low.promise, onRefresh: undefined }); + + // Winner resolves first, staler resolves second: the staler resolve must not + // replace the winner's deletion timer with its own short-ttl timer. + high.resolve(makeToken(highRaw)); + await tick(); + low.resolve(makeToken(lowRaw)); + await tick(); + + // Past low's 60s ttl but well before high's 300s ttl. + vi.advanceTimersByTime(120 * 1000); + + const result = SessionTokenCache.get({ tokenId }); + expect(result).toBeDefined(); + expect(result?.entry.resolvedToken?.getRawString()).toBe(highRaw); + }); + + it('expires the carried token by its real ttl while the replacement resolver stays pending', async () => { + const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); + + const high = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined }); + high.resolve(makeToken(highRaw)); + await tick(); + + // Cache holds high with its real 120s ttl (iat 1666648250, now 1666648260). + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + + // Replacement resolver never settles; high is carried forward into the pending entry. + const neverSettles = new Promise(() => {}); + SessionTokenCache.set({ tokenId, tokenResolver: neverSettles, onRefresh: undefined }); + + // The carry still serves high synchronously while the replacement is pending. + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + + // Past high's real 120s ttl: get() must evict the carried token, not serve it forever. + vi.advanceTimersByTime(130 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + }); + describe('token expiration with absolute time', () => { it('returns token when expiresAt is far in the future', async () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now diff --git a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts index 1c7c5c38ecc..7a821cd4221 100644 --- a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -1,22 +1,47 @@ import type { JWT, TokenResource } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; -import { pickFreshestJwt } from '../tokenFreshness'; +import { + isStrictlyStalerJwt, + normalizeOrgId, + pickFreshestJwt, + pickFreshestOrIncoming, + tokenOiat, + tokenOrgId, + tokenSid, +} from '../tokenFreshness'; -function makeToken(opts: { oiat?: number; iat?: number } = {}): TokenResource { +interface TokenOpts { + oiat?: number; + iat?: number; + sid?: string; + orgId?: string; + oId?: string; +} + +function makeClaims(opts: TokenOpts) { + return { + ...(opts.iat != null ? { iat: opts.iat } : {}), + ...(opts.sid != null ? { sid: opts.sid } : {}), + ...(opts.orgId != null ? { org_id: opts.orgId } : {}), + ...(opts.oId != null ? { o: { id: opts.oId } } : {}), + }; +} + +function makeToken(opts: TokenOpts = {}): TokenResource { return { jwt: { header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) }, - claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) }, + claims: makeClaims(opts), }, getRawString: () => 'mock-jwt', } as unknown as TokenResource; } -function makeJwt(opts: { oiat?: number; iat?: number } = {}): JWT { +function makeJwt(opts: TokenOpts = {}): JWT { return { header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) }, - claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) }, + claims: makeClaims(opts), } as unknown as JWT; } @@ -109,3 +134,113 @@ describe('pickFreshestJwt', () => { }); }); }); + +describe('isStrictlyStalerJwt', () => { + it('returns true when incoming oiat is older than baseline', () => { + expect(isStrictlyStalerJwt(makeToken({ oiat: 90 }), makeToken({ oiat: 100 }))).toBe(true); + }); + + it('returns false when incoming oiat is newer than baseline', () => { + expect(isStrictlyStalerJwt(makeToken({ oiat: 100 }), makeToken({ oiat: 90 }))).toBe(false); + }); + + it('returns true when oiat is equal and incoming iat is older', () => { + expect(isStrictlyStalerJwt(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 200 }))).toBe(true); + }); + + it('returns false when oiat and iat are both equal (full tie, fail open)', () => { + expect(isStrictlyStalerJwt(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 150 }))).toBe(false); + }); + + it('returns false when incoming is missing oiat', () => { + expect(isStrictlyStalerJwt(makeToken({ iat: 150 }), makeToken({ oiat: 100 }))).toBe(false); + }); + + it('returns false when baseline is missing oiat', () => { + expect(isStrictlyStalerJwt(makeToken({ oiat: 100 }), makeToken({ iat: 150 }))).toBe(false); + }); + + it('returns false when both are missing oiat', () => { + expect(isStrictlyStalerJwt(makeToken({ iat: 150 }), makeToken({ iat: 200 }))).toBe(false); + }); + + it('accepts raw decoded JWT inputs', () => { + expect(isStrictlyStalerJwt(makeJwt({ oiat: 90 }), makeJwt({ oiat: 100 }))).toBe(true); + }); +}); + +describe('pickFreshestOrIncoming', () => { + it('returns incoming when existing is null', () => { + const incoming = makeToken({ oiat: 100 }); + expect(pickFreshestOrIncoming(null, incoming)).toBe(incoming); + }); + + it('returns incoming when existing is undefined', () => { + const incoming = makeToken({ oiat: 100 }); + expect(pickFreshestOrIncoming(undefined, incoming)).toBe(incoming); + }); + + it('returns the newer token when existing is older than incoming', () => { + const existing = makeToken({ oiat: 90 }); + const incoming = makeToken({ oiat: 100 }); + expect(pickFreshestOrIncoming(existing, incoming)).toBe(incoming); + }); + + it('returns the newer token when existing is newer than incoming', () => { + const existing = makeToken({ oiat: 100 }); + const incoming = makeToken({ oiat: 90 }); + expect(pickFreshestOrIncoming(existing, incoming)).toBe(existing); + }); +}); + +describe('normalizeOrgId', () => { + it('returns empty string for undefined', () => { + expect(normalizeOrgId(undefined)).toBe(''); + }); + + it('returns empty string for null', () => { + expect(normalizeOrgId(null)).toBe(''); + }); + + it('returns empty string for empty string', () => { + expect(normalizeOrgId('')).toBe(''); + }); + + it('returns the org id when present', () => { + expect(normalizeOrgId('org_1')).toBe('org_1'); + }); +}); + +describe('tokenOrgId', () => { + it('reads org_id from claims', () => { + expect(tokenOrgId(makeToken({ orgId: 'org_1' }))).toBe('org_1'); + }); + + it('falls back to o.id when org_id is absent', () => { + expect(tokenOrgId(makeToken({ oId: 'org_2' }))).toBe('org_2'); + }); + + it('returns empty string when neither org_id nor o.id is present', () => { + expect(tokenOrgId(makeToken({ oiat: 100 }))).toBe(''); + }); +}); + +describe('tokenOiat', () => { + it('returns the oiat header when present', () => { + expect(tokenOiat(makeToken({ oiat: 100 }))).toBe(100); + }); + + it('returns undefined when oiat is absent', () => { + expect(tokenOiat(makeToken({ iat: 150 }))).toBeUndefined(); + }); +}); + +describe('tokenSid', () => { + it('returns the sid claim when present', () => { + expect(tokenSid(makeToken({ sid: 'session_1' }))).toBe('session_1'); + }); + + it('returns undefined when sid is absent', () => { + expect(tokenSid(makeToken({ oiat: 100 }))).toBeUndefined(); + }); +}); diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 6ccd2967b10..d8dab39c4c6 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -18,6 +18,8 @@ import { clerkMissingDevBrowser } from '../errors'; import { eventBus, events } from '../events'; import type { FapiClient } from '../fapiClient'; import { Environment } from '../resources/Environment'; +import { Token } from '../resources/Token'; +import { isStrictlyStalerJwt, normalizeOrgId, tokenOiat, tokenOrgId, tokenSid } from '../tokenFreshness'; import { createActiveContextCookie } from './cookies/activeContext'; import type { ClientUatCookieHandler } from './cookies/clientUat'; import { createClientUatCookie } from './cookies/clientUat'; @@ -194,6 +196,10 @@ export class AuthCookieService { return; } + if (token && this.#shouldDropStaleToken(token)) { + return; + } + if (!token && !isValidBrowserOnline()) { debugLogger.warn('Removing session cookie (offline)', { sessionId: this.clerk.session?.id }, 'authCookieService'); } @@ -203,6 +209,46 @@ export class AuthCookieService { return token ? this.sessionCookie.set(token) : this.sessionCookie.remove(); } + // Returns true only when `raw` is positively wrong-context or strictly staler than the + // SAME-context current cookie. Inert (false) for tokens without oiat and on any decode + // failure: stranding the user is worse than a transient revert. + #shouldDropStaleToken(raw: string): boolean { + const incoming = this.#decodeToken(raw); + if (!incoming || tokenOiat(incoming) == null) { + return false; + } + + const sid = tokenSid(incoming); + if (sid && sid !== (this.clerk.session?.id ?? '')) { + return true; + } + if (normalizeOrgId(tokenOrgId(incoming)) !== normalizeOrgId(this.clerk.organization?.id)) { + return true; + } + + const current = this.#decodeToken(this.sessionCookie.get()); + if (!current || tokenOiat(current) == null) { + return false; + } + // Only a same-context cookie is a valid freshness baseline. + if (tokenSid(current) !== sid || normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming))) { + return false; + } + return isStrictlyStalerJwt(incoming, current); + } + + #decodeToken(raw: string | undefined): Token | null { + if (!raw) { + return null; + } + try { + const token = new Token({ id: '__session', jwt: raw, object: 'token' }); + return token.jwt ? token : null; + } catch { + return null; + } + } + public setClientUatCookieForDevelopmentInstances() { if (this.instanceType !== 'production' && this.inCustomDevelopmentDomain()) { this.clientUat.set(this.clerk.client); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 068dfe1ea41..f2b4353d9c0 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -49,6 +49,7 @@ import { TokenId } from '@/utils/tokenId'; import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; import { eventBus, events } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; +import { isStrictlyStalerJwt, normalizeOrgId, pickFreshestOrIncoming, tokenOrgId } from '../tokenFreshness'; import { SessionTokenCache } from '../tokenCache'; import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; @@ -86,7 +87,6 @@ export class Session extends BaseResource implements SessionResource { super(); this.fromJSON(data); - this.#hydrateCache(this.lastActiveToken); } end = (): Promise => { @@ -209,19 +209,37 @@ export class Session extends BaseResource implements SessionResource { })(params); }; - #hydrateCache = (token: TokenResource | null) => { - if (token) { - const tokenId = this.#getCacheId(); - // Dispatch tokenUpdate for __session tokens with the session's active organization ID - const shouldDispatchTokenUpdate = true; + #applyIncomingLastActiveToken(raw: SessionJSON['last_active_token'] | null) { + if (!raw) { + this.lastActiveToken = null; + return; + } + + const incoming = new Token(raw); + const tokenId = this.#getCacheId(); + const current = SessionTokenCache.get({ tokenId })?.entry.resolvedToken ?? null; + + // A token whose explicit org claim differs from the active org is stale-context: it must + // not win lastActiveToken over a cached active-context token, nor be written into the + // active cache slot that getToken() reads. A token with no org claim is legacy/personal + // and counts as same-context. + const incomingOrg = tokenOrgId(incoming); + const sameContext = !incomingOrg || normalizeOrgId(incomingOrg) === normalizeOrgId(this.lastActiveOrganizationId); + + if (current && (!sameContext || isStrictlyStalerJwt(incoming, current))) { + this.lastActiveToken = current; + return; + } + + this.lastActiveToken = incoming; + if (sameContext && (!current || current.getRawString() !== incoming.getRawString())) { SessionTokenCache.set({ tokenId, - tokenResolver: Promise.resolve(token), - onRefresh: () => - this.#refreshTokenInBackground(undefined, this.lastActiveOrganizationId, tokenId, shouldDispatchTokenUpdate), + tokenResolver: Promise.resolve(incoming), + onRefresh: () => this.#refreshTokenInBackground(undefined, this.lastActiveOrganizationId, tokenId, true), }); } - }; + } // If it's a session token, retrieve it with their session id, otherwise it's a jwt template token // and retrieve it using the session id concatenated with the jwt template name. @@ -405,7 +423,7 @@ export class Session extends BaseResource implements SessionResource { this.publicUserData = new PublicUserData(data.public_user_data); } - this.lastActiveToken = data.last_active_token ? new Token(data.last_active_token) : null; + this.#applyIncomingLastActiveToken(data.last_active_token ?? null); return this; } @@ -520,7 +538,7 @@ export class Session extends BaseResource implements SessionResource { eventBus.emit(events.TokenUpdate, { token }); - if (token.jwt) { + if (token.jwt && (!this.lastActiveToken || !isStrictlyStalerJwt(token, this.lastActiveToken))) { this.lastActiveToken = token; eventBus.emit(events.SessionTokenResolved, null); } @@ -535,21 +553,25 @@ export class Session extends BaseResource implements SessionResource { ): Promise { debugLogger.info('Fetching new token from API', { organizationId, template, tokenId }, 'session'); - const tokenResolver = this.#createTokenResolver(template, organizationId, skipCache); + const fetchPromise = this.#createTokenResolver(template, organizationId, skipCache); + const tokenResolver = fetchPromise.then(fetched => + pickFreshestOrIncoming(SessionTokenCache.get({ tokenId })?.entry.resolvedToken, fetched), + ); + SessionTokenCache.set({ tokenId, tokenResolver, onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), }); - return tokenResolver.then(token => { - const rawString = token.getRawString(); + return tokenResolver.then(winner => { + const rawString = winner.getRawString(); if (!rawString) { // Throw so retry logic in getToken() can handle it, // rather than silently returning null (which callers interpret as "signed out"). throw new ClerkRuntimeError('Token fetch returned empty response', { code: 'network_error' }); } - this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); + this.#dispatchTokenEvents(winner, shouldDispatchTokenUpdate); return rawString; }); } @@ -600,14 +622,16 @@ export class Session extends BaseResource implements SessionResource { return; } + const winner = pickFreshestOrIncoming(SessionTokenCache.get({ tokenId })?.entry.resolvedToken, token); + // Cache the resolved token for future calls // Re-register onRefresh to handle the next refresh cycle when this token approaches expiration SessionTokenCache.set({ tokenId, - tokenResolver: Promise.resolve(token), + tokenResolver: Promise.resolve(winner), onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), }); - this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); + this.#dispatchTokenEvents(winner, shouldDispatchTokenUpdate); }) .catch(error => { // Log but don't propagate - callers already have stale token 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 aee7f42f614..2653dc8c62c 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -3,11 +3,12 @@ import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/shared/ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { clerkMock, createUser, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures'; +import { TokenId } from '@/utils/tokenId'; import { eventBus } from '../../events'; import { createFapiClient } from '../../fapiClient'; import { SessionTokenCache } from '../../tokenCache'; -import { BaseResource, Organization, Session } from '../internal'; +import { BaseResource, Organization, Session, Token } from '../internal'; const baseFapiClientOptions = { frontendApi: 'clerk.example.com', @@ -2042,4 +2043,382 @@ describe('Session', () => { expect(session.agent?.type).toBe('agent'); }); }); + + describe('session-minter monotonic guard', () => { + const NOW = 1666648250; + const DEFAULT_SID = 'sess_minter'; + + // Mirrors tokenCache.test.ts:37 — header carries `oiat`, payload carries `sid`/`iat`/`exp` + // (+ optional `org_id`). Pass `undefined` for oiat to emit a pre-feature token (no header). + function createJwtWithOiat( + iatSeconds: number, + oiatSeconds: number | undefined, + opts: { sid?: string; org?: string | null } = {}, + ): string { + const header: Record = { alg: 'HS256', typ: 'JWT' }; + if (typeof oiatSeconds === 'number') { + header.oiat = oiatSeconds; + } + const payload: Record = { + sid: opts.sid ?? DEFAULT_SID, + iat: iatSeconds, + exp: iatSeconds + 60, + }; + if (opts.org) { + payload.org_id = opts.org; + } + const b64 = (o: object) => btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + return `${b64(header)}.${b64(payload)}.test-signature`; + } + + function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + } + + const makeSession = (overrides: Partial = {}) => + new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: Date.now(), + updated_at: Date.now(), + ...overrides, + } as SessionJSON); + + let dispatchSpy: ReturnType; + let fetchSpy: ReturnType; + + beforeEach(() => { + SessionTokenCache.clear(); + dispatchSpy = vi.spyOn(eventBus, 'emit'); + fetchSpy = vi.spyOn(BaseResource, '_fetch' as any); + BaseResource.clerk = clerkMock({ + __internal_environment: { + authConfig: { sessionMinter: true }, + }, + }) as any; + }); + + afterEach(() => { + dispatchSpy?.mockRestore(); + fetchSpy?.mockRestore(); + BaseResource.clerk = null as any; + SessionTokenCache.clear(); + }); + + it('coalesced waiters and lastActiveToken keep the freshest token (resolve high then low)', async () => { + const session = makeSession(); + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + + const dHigh = deferred(); + const dLow = deferred(); + fetchSpy.mockReturnValueOnce(dHigh.promise as any).mockReturnValueOnce(dLow.promise as any); + + const pHigh = session.getToken({ skipCache: true }); + const pLow = session.getToken({ skipCache: true }); + + dHigh.resolve({ object: 'token', jwt: high }); + await expect(pHigh).resolves.toBe(high); + + // The low fetch resolves last but the guarded resolver hands back the freshest token. + dLow.resolve({ object: 'token', jwt: low }); + await expect(pLow).resolves.toBe(high); + + const tokenId = TokenId.build('session_1', undefined, null); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + expect(await session.getToken()).toBe(high); + expect(session.lastActiveToken?.getRawString()).toBe(high); + }); + + it('coalesced waiters and lastActiveToken keep the freshest token (resolve low then high)', async () => { + const session = makeSession(); + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + + const dFirst = deferred(); + const dSecond = deferred(); + fetchSpy.mockReturnValueOnce(dFirst.promise as any).mockReturnValueOnce(dSecond.promise as any); + + const pFirst = session.getToken({ skipCache: true }); + const pSecond = session.getToken({ skipCache: true }); + + dFirst.resolve({ object: 'token', jwt: low }); + await expect(pFirst).resolves.toBe(low); + + dSecond.resolve({ object: 'token', jwt: high }); + await expect(pSecond).resolves.toBe(high); + + const tokenId = TokenId.build('session_1', undefined, null); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + expect(await session.getToken()).toBe(high); + expect(session.lastActiveToken?.getRawString()).toBe(high); + }); + + it('a stale fetch after a fresh one does not regress lastActiveToken or the next /tokens token field', async () => { + const session = makeSession(); + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + + fetchSpy + .mockResolvedValueOnce({ object: 'token', jwt: high }) + .mockResolvedValueOnce({ object: 'token', jwt: low }) + .mockResolvedValueOnce({ object: 'token', jwt: low }); + + expect(await session.getToken()).toBe(high); + expect(await session.getToken({ skipCache: true })).toBe(high); + expect(await session.getToken({ skipCache: true })).toBe(high); + + // The first stale fetch carried high as the previous token, and the next request still does + // — proving lastActiveToken never regressed to the stale token. + expect((fetchSpy.mock.calls[1][0] as any).body.token).toBe(high); + expect((fetchSpy.mock.calls[2][0] as any).body.token).toBe(high); + + const tokenId = TokenId.build('session_1', undefined, null); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + expect(session.lastActiveToken?.getRawString()).toBe(high); + }); + + it('explicit organizationId does not write lastActiveToken but keeps its own cache monotonic', async () => { + const session = makeSession(); + const high = createJwtWithOiat(NOW, NOW + 30, { org: 'org_other' }); + const low = createJwtWithOiat(NOW, NOW, { org: 'org_other' }); + + const dHigh = deferred(); + const dLow = deferred(); + fetchSpy.mockReturnValueOnce(dHigh.promise as any).mockReturnValueOnce(dLow.promise as any); + + const pHigh = session.getToken({ organizationId: 'org_other', skipCache: true }); + const pLow = session.getToken({ organizationId: 'org_other', skipCache: true }); + + dHigh.resolve({ object: 'token', jwt: high }); + await expect(pHigh).resolves.toBe(high); + dLow.resolve({ object: 'token', jwt: low }); + await expect(pLow).resolves.toBe(high); + + const tokenId = TokenId.build('session_1', undefined, 'org_other'); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + expect(session.lastActiveToken).toBeNull(); + }); + + it('template tokens never write lastActiveToken and keep the template cache monotonic', async () => { + const session = makeSession(); + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + + const dHigh = deferred(); + const dLow = deferred(); + fetchSpy.mockReturnValueOnce(dHigh.promise as any).mockReturnValueOnce(dLow.promise as any); + + const pHigh = session.getToken({ template: 't', skipCache: true }); + const pLow = session.getToken({ template: 't', skipCache: true }); + + dHigh.resolve({ object: 'token', jwt: high }); + await expect(pHigh).resolves.toBe(high); + dLow.resolve({ object: 'token', jwt: low }); + await expect(pLow).resolves.toBe(high); + + const tokenId = TokenId.build('session_1', 't', null); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + expect(session.lastActiveToken).toBeNull(); + }); + + it("resolving one session's fetch never writes another session's lastActiveToken", async () => { + const sessionA = makeSession({ id: 'session_A' } as Partial); + const sessionB = makeSession({ id: 'session_B' } as Partial); + const tokenA = createJwtWithOiat(NOW, NOW + 30, { sid: 'sess_A' }); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: tokenA }); + + expect(await sessionA.getToken()).toBe(tokenA); + + expect(sessionA.lastActiveToken?.getRawString()).toBe(tokenA); + expect(sessionB.lastActiveToken).toBeNull(); + }); + + it('successive tokens without oiat keep writing lastActiveToken (fail open, no false drop)', async () => { + const session = makeSession(); + const first = createJwtWithOiat(NOW, undefined, { sid: 'sess_a' }); + const second = createJwtWithOiat(NOW + 5, undefined, { sid: 'sess_b' }); + + fetchSpy + .mockResolvedValueOnce({ object: 'token', jwt: first }) + .mockResolvedValueOnce({ object: 'token', jwt: second }); + + expect(await session.getToken()).toBe(first); + expect(session.lastActiveToken?.getRawString()).toBe(first); + + expect(await session.getToken({ skipCache: true })).toBe(second); + expect(session.lastActiveToken?.getRawString()).toBe(second); + }); + + describe('fromJSON piggyback guard (covers hydrate + touch)', () => { + const flush = async () => { + for (let i = 0; i < 3; i++) { + await Promise.resolve(); + } + }; + + const primeCache = async (raw: string) => { + const tokenId = TokenId.build('session_1', undefined, null); + SessionTokenCache.set({ + tokenId, + tokenResolver: Promise.resolve(new Token({ id: tokenId, jwt: raw, object: 'token' })), + }); + await flush(); + return tokenId; + }; + + const sessionJsonWith = (jwt: string): SessionJSON => + ({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: Date.now(), + updated_at: Date.now(), + last_active_token: { object: 'token', jwt }, + }) as SessionJSON; + + it('keeps the fresher cached token when fromJSON carries a strictly-staler last_active_token', async () => { + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + const session = makeSession(); + const tokenId = await primeCache(high); + + (session as any).fromJSON(sessionJsonWith(low)); + + expect(session.lastActiveToken?.getRawString()).toBe(high); + // The staler piggyback is rejected and the cache entry is left untouched. + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + }); + + it('adopts a fresher last_active_token and advances the cache', async () => { + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + const session = makeSession(); + const tokenId = await primeCache(low); + + (session as any).fromJSON(sessionJsonWith(high)); + await flush(); + + expect(session.lastActiveToken?.getRawString()).toBe(high); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + }); + + it('adopts the incoming token on a full oiat+iat tie even when the raw differs', async () => { + const cached = createJwtWithOiat(NOW, NOW + 30, { sid: 'sess_a' }); + const incoming = createJwtWithOiat(NOW, NOW + 30, { sid: 'sess_b' }); + const session = makeSession(); + const tokenId = await primeCache(cached); + + (session as any).fromJSON(sessionJsonWith(incoming)); + await flush(); + + expect(session.lastActiveToken?.getRawString()).toBe(incoming); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(incoming); + }); + + it('touch() keeps the fresher cached token and emits it, ignoring a staler piggyback', async () => { + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + const session = makeSession(); + const tokenId = await primeCache(high); + + fetchSpy.mockResolvedValueOnce(sessionJsonWith(low) as any); + dispatchSpy.mockClear(); + + await session.touch(); + + expect(session.lastActiveToken?.getRawString()).toBe(high); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + + const tokenUpdates = dispatchSpy.mock.calls.filter(call => call[0] === 'token:update'); + expect(tokenUpdates.length).toBeGreaterThan(0); + expect(tokenUpdates.every(call => (call[1] as any).token.getRawString() === high)).toBe(true); + }); + + it('does not re-set the cache when the same last_active_token is deserialized again (no churn)', async () => { + const token = createJwtWithOiat(NOW, NOW + 30); + const session = makeSession(); + const setSpy = vi.spyOn(SessionTokenCache, 'set'); + + (session as any).fromJSON(sessionJsonWith(token)); + expect(setSpy).toHaveBeenCalledTimes(1); + await flush(); + + setSpy.mockClear(); + (session as any).fromJSON(sessionJsonWith(token)); + await flush(); + + expect(setSpy).not.toHaveBeenCalled(); + const tokenId = TokenId.build('session_1', undefined, null); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(token); + }); + + it('org-switch transient: an org-A last_active_token never overwrites the cached active org-B token', async () => { + const orgBToken = createJwtWithOiat(NOW, NOW + 30, { org: 'org_B' }); + // Minted for org_A before the switch. Even though it is strictly fresher, it belongs to + // a different org and must not win the active org-B slot getToken() reads. + const orgAToken = createJwtWithOiat(NOW, NOW + 60, { org: 'org_A' }); + + const session = makeSession({ last_active_organization_id: 'org_B' } as Partial); + const tokenId = TokenId.build('session_1', undefined, 'org_B'); + SessionTokenCache.set({ + tokenId, + tokenResolver: Promise.resolve(new Token({ id: tokenId, jwt: orgBToken, object: 'token' })), + }); + await flush(); + + (session as any).fromJSON({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: 'org_B', + actor: null, + created_at: Date.now(), + updated_at: Date.now(), + last_active_token: { object: 'token', jwt: orgAToken }, + } as SessionJSON); + await flush(); + + expect(session.lastActiveToken?.getRawString()).toBe(orgBToken); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(orgBToken); + }); + + it('adopts a no-org legacy last_active_token under the active-org key when a non-personal org is active', async () => { + const legacy = createJwtWithOiat(NOW, NOW + 30); + const session = makeSession({ last_active_organization_id: 'org_active' } as Partial); + const tokenId = TokenId.build('session_1', undefined, 'org_active'); + + (session as any).fromJSON({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: 'org_active', + actor: null, + created_at: Date.now(), + updated_at: Date.now(), + last_active_token: { object: 'token', jwt: legacy }, + } as SessionJSON); + await flush(); + + expect(session.lastActiveToken?.getRawString()).toBe(legacy); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(legacy); + }); + }); + }); }); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index dd00df3dc64..4535bb6e012 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -5,7 +5,7 @@ import { TokenId } from '@/utils/tokenId'; import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller'; import { Token } from './resources/internal'; -import { pickFreshestJwt } from './tokenFreshness'; +import { pickFreshestJwt, pickFreshestOrIncoming } from './tokenFreshness'; /** * Identifies a cached token entry by tokenId and optional audience. @@ -288,7 +288,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { try { const result = get({ tokenId: data.tokenId }); if (result) { - const existingToken = await result.entry.tokenResolver; + const existingToken = result.entry.resolvedToken ?? (await result.entry.tokenResolver); if (pickFreshestJwt(existingToken, token) === existingToken) { debugLogger.debug( 'Ignoring staler token broadcast', @@ -357,10 +357,19 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { clearTimeout(existing?.timeoutId); clearTimeout(existing?.refreshTimeoutId); + if (existing?.entry.resolvedToken) { + entry.resolvedToken = pickFreshestOrIncoming(entry.resolvedToken, existing.entry.resolvedToken); + } + const nowSeconds = Math.floor(Date.now() / 1000); const createdAt = entry.createdAt ?? nowSeconds; const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined }; + if (existing?.entry.resolvedToken && existing.expiresIn !== undefined) { + value.createdAt = existing.createdAt; + value.expiresIn = existing.expiresIn; + } + const deleteKey = () => { const cachedValue = cache.get(key); if (cachedValue === value) { @@ -378,31 +387,54 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { entry.tokenResolver .then(newToken => { - // If this entry was overwritten by a newer set() call while our promise - // was pending, bail out to avoid installing orphaned timers. Monotonic - // replacement is enforced at the read sites (cookie + broadcast + Session) - // where the user-visible state lives. - if (cache.get(key) !== value) { + const live = cache.get(key); + if (!live) { + // Cleared while pending — do not resurrect. return; } - // Store resolved token for synchronous reads - entry.resolvedToken = newToken; + // Reconcile against the freshest token known for this key, regardless of + // which resolver owns the live slot. A staler resolve can never publish. + const prev = live.entry.resolvedToken; + live.entry.resolvedToken = pickFreshestOrIncoming(prev, newToken); + const winner = live.entry.resolvedToken; + // Skip only when the winner did not advance AND this live value already has + // its timers installed. A fresh set() clears the prior entry's timers and may + // carry its resolved token forward (so prev can equal the winner); that value + // still needs winner-derived timers, which an unconditional skip would drop. + if (winner === prev && live.timeoutId !== undefined) { + return; + } - const claims = newToken.jwt?.claims; + const claims = winner.jwt?.claims; if (!claims || typeof claims.exp !== 'number' || typeof claims.iat !== 'number') { - return deleteKey(); + if (cache.get(key) === live) { + clearTimeout(live.timeoutId); + clearTimeout(live.refreshTimeoutId); + cache.delete(key); + } + return; } - const expiresAt = claims.exp; + clearTimeout(live.timeoutId); + clearTimeout(live.refreshTimeoutId); + const issuedAt = claims.iat; - const expiresIn: Seconds = expiresAt - issuedAt; + const expiresIn: Seconds = claims.exp - issuedAt; - value.createdAt = issuedAt; - value.expiresIn = expiresIn; + live.createdAt = issuedAt; + live.expiresIn = expiresIn; + + const liveDeleteKey = () => { + if (cache.get(key) === live) { + clearTimeout(live.timeoutId); + clearTimeout(live.refreshTimeoutId); + cache.delete(key); + } + }; - const timeoutId = setTimeout(deleteKey, expiresIn * 1000); - value.timeoutId = timeoutId; + const timeoutId = setTimeout(liveDeleteKey, expiresIn * 1000); + live.timeoutId = timeoutId; // Teach ClerkJS not to block the exit of the event loop when used in Node environments. // More info at https://nodejs.org/api/timers.html#timeoutunref @@ -419,12 +451,12 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const leeway = Math.max(BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, minLeeway); const refreshFireTime = expiresIn - leeway - refreshLeadTime; - if (refreshFireTime > 0 && entry.onRefresh) { + if (refreshFireTime > 0 && live.entry.onRefresh) { const refreshTimeoutId = setTimeout(() => { - entry.onRefresh?.(); + live.entry.onRefresh?.(); }, refreshFireTime * 1000); - value.refreshTimeoutId = refreshTimeoutId; + live.refreshTimeoutId = refreshTimeoutId; if (typeof (refreshTimeoutId as any).unref === 'function') { (refreshTimeoutId as any).unref(); @@ -433,7 +465,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const channel = broadcastChannel; if (channel && options.broadcast) { - const tokenRaw = newToken.getRawString(); + const tokenRaw = winner.getRawString(); if (tokenRaw && claims.sid) { const sessionId = claims.sid; const organizationId = claims.org_id || (claims.o as any)?.id; diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index 9aa87fd9948..14f0db31a95 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -50,3 +50,43 @@ export function pickFreshestJwt(existing: T, inco const incomingIat = asJwt(incoming)?.claims?.iat ?? 0; return existingIat > incomingIat ? existing : incoming; } + +export function tokenOiat(input: TokenResource | JWT): number | undefined { + return asJwt(input)?.header?.oiat; +} + +export function tokenSid(input: TokenResource | JWT): string | undefined { + return asJwt(input)?.claims?.sid; +} + +export function tokenOrgId(input: TokenResource | JWT): string { + const claims = asJwt(input)?.claims; + return (claims?.org_id as string) || ((claims?.o as { id?: string } | undefined)?.id ?? ''); +} + +export function normalizeOrgId(orgId?: string | null): string { + return orgId || ''; +} + +export function pickFreshestOrIncoming(existing: T | null | undefined, incoming: T): T { + return existing == null ? incoming : pickFreshestJwt(existing, incoming); +} + +// The single drop/keep primitive. True ONLY when both carry oiat and `incoming` is +// strictly older. Missing oiat (either side) or a full oiat+iat tie => false (fail open). +export function isStrictlyStalerJwt(incoming: TokenResource | JWT, baseline: TokenResource | JWT): boolean { + const i = tokenOiat(incoming); + const b = tokenOiat(baseline); + if (i == null || b == null) { + return false; + } + if (i < b) { + return true; + } + if (i > b) { + return false; + } + const iIat = asJwt(incoming)?.claims?.iat ?? 0; + const bIat = asJwt(baseline)?.claims?.iat ?? 0; + return iIat < bIat; +} From 570b749947afc8d7f837b6424513c511bc6de70e Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Mon, 29 Jun 2026 10:51:27 +0300 Subject: [PATCH 02/15] fix(clerk-js): keep lastActiveToken monotonic on an empty cache slot #applyIncomingLastActiveToken only used the active-org cache slot as the freshness baseline. When that slot was empty (evicted, or an in-flight fetch leaves resolvedToken null) a strictly-staler or wrong-org last_active_token from fromJSON was adopted, regressing lastActiveToken. Fall back to the token already held as the freshness baseline when the cache slot is empty, but only when that held token is same-context, so a token left over from the previous org cannot veto a valid token for the active org. When no same-context baseline exists, adopt the incoming token instead of clearing lastActiveToken: a wrong-org or staler token is still caught downstream by the cookie guard, whereas an empty lastActiveToken is treated as sign-out and drops the session cookie. --- .../clerk-js/src/core/resources/Session.ts | 24 +++-- .../core/resources/__tests__/Session.test.ts | 91 +++++++++++++++++++ 2 files changed, 106 insertions(+), 9 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index f2b4353d9c0..dae49307398 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -219,15 +219,21 @@ export class Session extends BaseResource implements SessionResource { const tokenId = this.#getCacheId(); const current = SessionTokenCache.get({ tokenId })?.entry.resolvedToken ?? null; - // A token whose explicit org claim differs from the active org is stale-context: it must - // not win lastActiveToken over a cached active-context token, nor be written into the - // active cache slot that getToken() reads. A token with no org claim is legacy/personal - // and counts as same-context. - const incomingOrg = tokenOrgId(incoming); - const sameContext = !incomingOrg || normalizeOrgId(incomingOrg) === normalizeOrgId(this.lastActiveOrganizationId); - - if (current && (!sameContext || isStrictlyStalerJwt(incoming, current))) { - this.lastActiveToken = current; + // Tokens are kept monotonic against a freshness baseline: the active-org cache slot, or the + // token we already hold when that slot is empty (evicted, or an in-flight fetch leaves + // resolvedToken null). The baseline must be same-context (its org claim matches the active + // org, or it has none) so a leftover previous-org token cannot veto a fresh active-org token. + const isSameContext = (orgClaim: string) => + !orgClaim || normalizeOrgId(orgClaim) === normalizeOrgId(this.lastActiveOrganizationId); + const sameContext = isSameContext(tokenOrgId(incoming)); + const held = current ?? this.lastActiveToken; + const baseline = held && isSameContext(tokenOrgId(held)) ? held : null; + + // With a same-context baseline, drop a wrong-org or strictly-staler incoming token. With no + // baseline, adopt it anyway: a bad token is still dropped downstream by the cookie guard, + // whereas an empty lastActiveToken reads as a sign-out and clears __session. + if (baseline && (!sameContext || isStrictlyStalerJwt(incoming, baseline))) { + this.lastActiveToken = baseline; return; } 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 2653dc8c62c..cf29fe2c749 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -2291,6 +2291,19 @@ describe('Session', () => { last_active_token: { object: 'token', jwt }, }) as SessionJSON; + const sessionJsonWithOrg = (org: string | null, jwt: string): SessionJSON => + ({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: org, + actor: null, + created_at: Date.now(), + updated_at: Date.now(), + last_active_token: { object: 'token', jwt }, + }) as SessionJSON; + it('keeps the fresher cached token when fromJSON carries a strictly-staler last_active_token', async () => { const high = createJwtWithOiat(NOW, NOW + 30); const low = createJwtWithOiat(NOW, NOW); @@ -2419,6 +2432,84 @@ describe('Session', () => { expect(session.lastActiveToken?.getRawString()).toBe(legacy); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(legacy); }); + + it('keeps the held lastActiveToken when a strictly-staler same-context token arrives and the cache slot is empty', async () => { + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + const session = makeSession(); + + (session as any).fromJSON(sessionJsonWith(high)); + await flush(); + expect(session.lastActiveToken?.getRawString()).toBe(high); + + SessionTokenCache.clear(); + + (session as any).fromJSON(sessionJsonWith(low)); + await flush(); + + expect(session.lastActiveToken?.getRawString()).toBe(high); + }); + + it('rejects a wrong-org last_active_token even when the active-org cache slot is empty', async () => { + const orgBToken = createJwtWithOiat(NOW, NOW + 30, { org: 'org_B' }); + const orgAToken = createJwtWithOiat(NOW, NOW + 60, { org: 'org_A' }); + const session = makeSession({ last_active_organization_id: 'org_B' } as Partial); + const orgBJson = (jwt: string) => + ({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: 'org_B', + actor: null, + created_at: Date.now(), + updated_at: Date.now(), + last_active_token: { object: 'token', jwt }, + }) as SessionJSON; + + (session as any).fromJSON(orgBJson(orgBToken)); + await flush(); + expect(session.lastActiveToken?.getRawString()).toBe(orgBToken); + + SessionTokenCache.clear(); + + (session as any).fromJSON(orgBJson(orgAToken)); + await flush(); + + expect(session.lastActiveToken?.getRawString()).toBe(orgBToken); + }); + + it('with no same-context baseline, adopts a wrong-org token instead of clearing it (cookie guard handles removal)', async () => { + const orgAToken = createJwtWithOiat(NOW, NOW + 30, { org: 'org_A' }); + const session = makeSession({ last_active_organization_id: 'org_B' } as Partial); + + (session as any).fromJSON(sessionJsonWithOrg('org_B', orgAToken)); + await flush(); + + // A falsy lastActiveToken is read downstream as a sign-out and drops __session, so the + // wrong-org token is adopted here and dropped later by the cookie guard instead. It must + // not pollute the active org-B cache slot. + expect(session.lastActiveToken?.getRawString()).toBe(orgAToken); + const tokenId = TokenId.build('session_1', undefined, 'org_B'); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); + }); + + it('does not let a leftover previous-org token veto a fresh active-org token', async () => { + const prevOrgHigh = createJwtWithOiat(NOW, NOW + 60, { org: 'org_A' }); + const newOrgLow = createJwtWithOiat(NOW, NOW + 30, { org: 'org_B' }); + const session = makeSession({ last_active_organization_id: 'org_A' } as Partial); + + (session as any).fromJSON(sessionJsonWithOrg('org_A', prevOrgHigh)); + await flush(); + expect(session.lastActiveToken?.getRawString()).toBe(prevOrgHigh); + + SessionTokenCache.clear(); + + (session as any).fromJSON(sessionJsonWithOrg('org_B', newOrgLow)); + await flush(); + + expect(session.lastActiveToken?.getRawString()).toBe(newOrgLow); + }); }); }); }); From c9d8b5fb6986b1cd89666e4228d53dfad7a93964 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 30 Jun 2026 12:21:11 +0300 Subject: [PATCH 03/15] fix(clerk-js): restore Session.ts import sort order after merge --- packages/clerk-js/src/core/resources/Session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index dae49307398..b1fe0406a25 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -49,8 +49,8 @@ import { TokenId } from '@/utils/tokenId'; import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; import { eventBus, events } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; -import { isStrictlyStalerJwt, normalizeOrgId, pickFreshestOrIncoming, tokenOrgId } from '../tokenFreshness'; import { SessionTokenCache } from '../tokenCache'; +import { isStrictlyStalerJwt, normalizeOrgId, pickFreshestOrIncoming, tokenOrgId } from '../tokenFreshness'; import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; From 88a3199fdbeaed4099d64c0876b04c6151cf6bf0 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 30 Jun 2026 13:35:06 +0300 Subject: [PATCH 04/15] refactor(clerk-js): fold isStrictlyStalerJwt into pickFreshestJwt oiat is stamped at origin (origin-issued-at, set when claims are assembled from the DB), not by the edge token minter, so a token that lacks oiat is a pre-feature token and is genuinely staler than any token that has one. That is exactly how pickFreshestJwt already ranks tokens. isStrictlyStalerJwt reimplemented the same oiat-then-iat comparison only to fail open on a missing oiat, guarding a fresh-token-without-oiat case that cannot arise from edge rollout. Drop it and route the cookie, lastActiveToken hydrate, and token-dispatch guards through pickFreshestJwt, removing the duplicated comparison. --- .../src/core/__tests__/tokenFreshness.test.ts | 35 ------------------- .../src/core/auth/AuthCookieService.ts | 4 +-- .../clerk-js/src/core/resources/Session.ts | 6 ++-- .../core/resources/__tests__/Session.test.ts | 2 +- packages/clerk-js/src/core/tokenFreshness.ts | 19 ---------- 5 files changed, 6 insertions(+), 60 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts index 7a821cd4221..620721f7507 100644 --- a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -2,7 +2,6 @@ import type { JWT, TokenResource } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; import { - isStrictlyStalerJwt, normalizeOrgId, pickFreshestJwt, pickFreshestOrIncoming, @@ -135,40 +134,6 @@ describe('pickFreshestJwt', () => { }); }); -describe('isStrictlyStalerJwt', () => { - it('returns true when incoming oiat is older than baseline', () => { - expect(isStrictlyStalerJwt(makeToken({ oiat: 90 }), makeToken({ oiat: 100 }))).toBe(true); - }); - - it('returns false when incoming oiat is newer than baseline', () => { - expect(isStrictlyStalerJwt(makeToken({ oiat: 100 }), makeToken({ oiat: 90 }))).toBe(false); - }); - - it('returns true when oiat is equal and incoming iat is older', () => { - expect(isStrictlyStalerJwt(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 200 }))).toBe(true); - }); - - it('returns false when oiat and iat are both equal (full tie, fail open)', () => { - expect(isStrictlyStalerJwt(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 150 }))).toBe(false); - }); - - it('returns false when incoming is missing oiat', () => { - expect(isStrictlyStalerJwt(makeToken({ iat: 150 }), makeToken({ oiat: 100 }))).toBe(false); - }); - - it('returns false when baseline is missing oiat', () => { - expect(isStrictlyStalerJwt(makeToken({ oiat: 100 }), makeToken({ iat: 150 }))).toBe(false); - }); - - it('returns false when both are missing oiat', () => { - expect(isStrictlyStalerJwt(makeToken({ iat: 150 }), makeToken({ iat: 200 }))).toBe(false); - }); - - it('accepts raw decoded JWT inputs', () => { - expect(isStrictlyStalerJwt(makeJwt({ oiat: 90 }), makeJwt({ oiat: 100 }))).toBe(true); - }); -}); - describe('pickFreshestOrIncoming', () => { it('returns incoming when existing is null', () => { const incoming = makeToken({ oiat: 100 }); diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 80f314bf48a..8b1647fef39 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -20,7 +20,7 @@ import { eventBus, events } from '../events'; import type { FapiClient } from '../fapiClient'; import { Environment } from '../resources/Environment'; import { Token } from '../resources/Token'; -import { isStrictlyStalerJwt, normalizeOrgId, tokenOiat, tokenOrgId, tokenSid } from '../tokenFreshness'; +import { normalizeOrgId, pickFreshestJwt, tokenOiat, tokenOrgId, tokenSid } from '../tokenFreshness'; import { createActiveContextCookie } from './cookies/activeContext'; import type { ClientUatCookieHandler } from './cookies/clientUat'; import { createClientUatCookie } from './cookies/clientUat'; @@ -245,7 +245,7 @@ export class AuthCookieService { if (tokenSid(current) !== sid || normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming))) { return false; } - return isStrictlyStalerJwt(incoming, current); + return pickFreshestJwt(current, incoming) === current; } #decodeToken(raw: string | undefined): Token | null { diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index b1fe0406a25..b4fe7563042 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -50,7 +50,7 @@ import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../e import { eventBus, events } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; import { SessionTokenCache } from '../tokenCache'; -import { isStrictlyStalerJwt, normalizeOrgId, pickFreshestOrIncoming, tokenOrgId } from '../tokenFreshness'; +import { normalizeOrgId, pickFreshestJwt, pickFreshestOrIncoming, tokenOrgId } from '../tokenFreshness'; import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; @@ -232,7 +232,7 @@ export class Session extends BaseResource implements SessionResource { // With a same-context baseline, drop a wrong-org or strictly-staler incoming token. With no // baseline, adopt it anyway: a bad token is still dropped downstream by the cookie guard, // whereas an empty lastActiveToken reads as a sign-out and clears __session. - if (baseline && (!sameContext || isStrictlyStalerJwt(incoming, baseline))) { + if (baseline && (!sameContext || pickFreshestJwt(baseline, incoming) === baseline)) { this.lastActiveToken = baseline; return; } @@ -544,7 +544,7 @@ export class Session extends BaseResource implements SessionResource { eventBus.emit(events.TokenUpdate, { token }); - if (token.jwt && (!this.lastActiveToken || !isStrictlyStalerJwt(token, this.lastActiveToken))) { + if (token.jwt && (!this.lastActiveToken || pickFreshestOrIncoming(this.lastActiveToken, token) === token)) { this.lastActiveToken = token; eventBus.emit(events.SessionTokenResolved, null); } 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 cf29fe2c749..cba4f8a2f3e 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -2245,7 +2245,7 @@ describe('Session', () => { expect(sessionB.lastActiveToken).toBeNull(); }); - it('successive tokens without oiat keep writing lastActiveToken (fail open, no false drop)', async () => { + it('successive tokens without oiat keep writing lastActiveToken (equal rank, newest wins)', async () => { const session = makeSession(); const first = createJwtWithOiat(NOW, undefined, { sid: 'sess_a' }); const second = createJwtWithOiat(NOW + 5, undefined, { sid: 'sess_b' }); diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index 14f0db31a95..5f0e56b0ad1 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -71,22 +71,3 @@ export function normalizeOrgId(orgId?: string | null): string { export function pickFreshestOrIncoming(existing: T | null | undefined, incoming: T): T { return existing == null ? incoming : pickFreshestJwt(existing, incoming); } - -// The single drop/keep primitive. True ONLY when both carry oiat and `incoming` is -// strictly older. Missing oiat (either side) or a full oiat+iat tie => false (fail open). -export function isStrictlyStalerJwt(incoming: TokenResource | JWT, baseline: TokenResource | JWT): boolean { - const i = tokenOiat(incoming); - const b = tokenOiat(baseline); - if (i == null || b == null) { - return false; - } - if (i < b) { - return true; - } - if (i > b) { - return false; - } - const iIat = asJwt(incoming)?.claims?.iat ?? 0; - const bIat = asJwt(baseline)?.claims?.iat ?? 0; - return iIat < bIat; -} From 5111a917a62bcfada95f8330677244a1b041dc4e Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 30 Jun 2026 13:52:14 +0300 Subject: [PATCH 05/15] refactor(clerk-js): dedupe token-cache eviction and drop a dead guard setInternal repeated the same teardown (clear both timers, delete the slot if it still holds this value) in three places: the reject path, the malformed-claims branch, and the expiry timer. Collapse them into one dropIfCurrent helper, which also drops the redundant undefined-guards before clearTimeout (clearTimeout ignores undefined). #dispatchTokenEvents had a dead lastActiveToken null-check: pickFreshestOrIncoming already returns the incoming token when the existing one is null, so the extra branch could never change the result. --- .../clerk-js/src/core/resources/Session.ts | 2 +- packages/clerk-js/src/core/tokenCache.ts | 36 ++++++------------- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index b4fe7563042..2b5a05e2fd6 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -544,7 +544,7 @@ export class Session extends BaseResource implements SessionResource { eventBus.emit(events.TokenUpdate, { token }); - if (token.jwt && (!this.lastActiveToken || pickFreshestOrIncoming(this.lastActiveToken, token) === token)) { + if (token.jwt && pickFreshestOrIncoming(this.lastActiveToken, token) === token) { this.lastActiveToken = token; eventBus.emit(events.SessionTokenResolved, null); } diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 1ac6e665256..a637a1e649b 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -330,17 +330,15 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { value.expiresIn = existing.expiresIn; } - const deleteKey = () => { - const cachedValue = store.get(key); - if (cachedValue === value) { - if (cachedValue.timeoutId !== undefined) { - clearTimeout(cachedValue.timeoutId); - } - if (cachedValue.refreshTimeoutId !== undefined) { - clearTimeout(cachedValue.refreshTimeoutId); - } - store.delete(key); + // Clears both timers and drops the slot, but only if it still holds `target` + // (a newer set() may have replaced it while a promise/timer was pending). + const dropIfCurrent = (target: TokenCacheValue) => { + if (store.get(key) !== target) { + return; } + clearTimeout(target.timeoutId); + clearTimeout(target.refreshTimeoutId); + store.delete(key); }; store.set(key, value); @@ -368,11 +366,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { const claims = winner.jwt?.claims; if (!claims || typeof claims.exp !== 'number' || typeof claims.iat !== 'number') { - if (store.get(key) === live) { - clearTimeout(live.timeoutId); - clearTimeout(live.refreshTimeoutId); - store.delete(key); - } + dropIfCurrent(live); return; } @@ -385,15 +379,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { live.createdAt = issuedAt; live.expiresIn = expiresIn; - const liveDeleteKey = () => { - if (store.get(key) === live) { - clearTimeout(live.timeoutId); - clearTimeout(live.refreshTimeoutId); - store.delete(key); - } - }; - - const timeoutId = setTimeout(liveDeleteKey, expiresIn * 1000); + const timeoutId = setTimeout(() => dropIfCurrent(live), expiresIn * 1000); live.timeoutId = timeoutId; // Teach ClerkJS not to block the exit of the event loop when used in Node environments. @@ -473,7 +459,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { } }) .catch(() => { - deleteKey(); + dropIfCurrent(value); }); }; From 60c731b56710df3a178e686d42ca3e2ee71a7079 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 30 Jun 2026 14:51:19 +0300 Subject: [PATCH 06/15] refactor(clerk-js): fold pickFreshestOrIncoming into pickFreshestJwt pickFreshestOrIncoming only existed to return the incoming token when there was no baseline. Teach pickFreshestJwt to tolerate a null/undefined existing token directly and drop the wrapper, so the optional-baseline call sites and the ones with a guaranteed baseline share one function. --- .../src/core/__tests__/tokenFreshness.test.ts | 19 ++++++------------- .../clerk-js/src/core/resources/Session.ts | 8 ++++---- packages/clerk-js/src/core/tokenCache.ts | 6 +++--- packages/clerk-js/src/core/tokenFreshness.ts | 15 ++++++++------- 4 files changed, 21 insertions(+), 27 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts index 620721f7507..da167804486 100644 --- a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -1,14 +1,7 @@ import type { JWT, TokenResource } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; -import { - normalizeOrgId, - pickFreshestJwt, - pickFreshestOrIncoming, - tokenOiat, - tokenOrgId, - tokenSid, -} from '../tokenFreshness'; +import { normalizeOrgId, pickFreshestJwt, tokenOiat, tokenOrgId, tokenSid } from '../tokenFreshness'; interface TokenOpts { oiat?: number; @@ -134,27 +127,27 @@ describe('pickFreshestJwt', () => { }); }); -describe('pickFreshestOrIncoming', () => { +describe('pickFreshestJwt (optional baseline)', () => { it('returns incoming when existing is null', () => { const incoming = makeToken({ oiat: 100 }); - expect(pickFreshestOrIncoming(null, incoming)).toBe(incoming); + expect(pickFreshestJwt(null, incoming)).toBe(incoming); }); it('returns incoming when existing is undefined', () => { const incoming = makeToken({ oiat: 100 }); - expect(pickFreshestOrIncoming(undefined, incoming)).toBe(incoming); + expect(pickFreshestJwt(undefined, incoming)).toBe(incoming); }); it('returns the newer token when existing is older than incoming', () => { const existing = makeToken({ oiat: 90 }); const incoming = makeToken({ oiat: 100 }); - expect(pickFreshestOrIncoming(existing, incoming)).toBe(incoming); + expect(pickFreshestJwt(existing, incoming)).toBe(incoming); }); it('returns the newer token when existing is newer than incoming', () => { const existing = makeToken({ oiat: 100 }); const incoming = makeToken({ oiat: 90 }); - expect(pickFreshestOrIncoming(existing, incoming)).toBe(existing); + expect(pickFreshestJwt(existing, incoming)).toBe(existing); }); }); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 2b5a05e2fd6..96287180a8d 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -50,7 +50,7 @@ import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../e import { eventBus, events } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; import { SessionTokenCache } from '../tokenCache'; -import { normalizeOrgId, pickFreshestJwt, pickFreshestOrIncoming, tokenOrgId } from '../tokenFreshness'; +import { normalizeOrgId, pickFreshestJwt, tokenOrgId } from '../tokenFreshness'; import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; @@ -544,7 +544,7 @@ export class Session extends BaseResource implements SessionResource { eventBus.emit(events.TokenUpdate, { token }); - if (token.jwt && pickFreshestOrIncoming(this.lastActiveToken, token) === token) { + if (token.jwt && pickFreshestJwt(this.lastActiveToken, token) === token) { this.lastActiveToken = token; eventBus.emit(events.SessionTokenResolved, null); } @@ -561,7 +561,7 @@ export class Session extends BaseResource implements SessionResource { const fetchPromise = this.#createTokenResolver(template, organizationId, skipCache); const tokenResolver = fetchPromise.then(fetched => - pickFreshestOrIncoming(SessionTokenCache.get({ tokenId })?.entry.resolvedToken, fetched), + pickFreshestJwt(SessionTokenCache.get({ tokenId })?.entry.resolvedToken, fetched), ); SessionTokenCache.set({ @@ -628,7 +628,7 @@ export class Session extends BaseResource implements SessionResource { return; } - const winner = pickFreshestOrIncoming(SessionTokenCache.get({ tokenId })?.entry.resolvedToken, token); + const winner = pickFreshestJwt(SessionTokenCache.get({ tokenId })?.entry.resolvedToken, token); // Cache the resolved token for future calls // Re-register onRefresh to handle the next refresh cycle when this token approaches expiration diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index a637a1e649b..d41be19a434 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -6,7 +6,7 @@ import { TokenId } from '@/utils/tokenId'; import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller'; import { createKeyResolver, type TokenCacheKeyJSON } from './keyResolver'; import { Token } from './resources/internal'; -import { pickFreshestJwt, pickFreshestOrIncoming } from './tokenFreshness'; +import { pickFreshestJwt } from './tokenFreshness'; import { createTokenStore } from './tokenStore'; /** @@ -318,7 +318,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { clearTimeout(existing?.refreshTimeoutId); if (existing?.entry.resolvedToken) { - entry.resolvedToken = pickFreshestOrIncoming(entry.resolvedToken, existing.entry.resolvedToken); + entry.resolvedToken = pickFreshestJwt(entry.resolvedToken, existing.entry.resolvedToken); } const nowSeconds = Math.floor(Date.now() / 1000); @@ -354,7 +354,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { // Reconcile against the freshest token known for this key, regardless of // which resolver owns the live slot. A staler resolve can never publish. const prev = live.entry.resolvedToken; - live.entry.resolvedToken = pickFreshestOrIncoming(prev, newToken); + live.entry.resolvedToken = pickFreshestJwt(prev, newToken); const winner = live.entry.resolvedToken; // Skip only when the winner did not advance AND this live value already has // its timers installed. A fresh set() clears the prior entry's timers and may diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index 5f0e56b0ad1..73a060462fd 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -16,12 +16,17 @@ function asJwt(input: TokenResource | JWT): JWT | undefined { * `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. + * Returns `incoming` when `existing` is null/undefined (no baseline yet), so a + * caller with an optional baseline (a cache miss, a not-yet-set token) can pass + * it straight through. * * @internal */ -export function pickFreshestJwt(existing: T, incoming: T): T { +export function pickFreshestJwt(existing: T | null | undefined, incoming: T): T { + if (existing == null) { + return incoming; + } + const existingOiat = asJwt(existing)?.header?.oiat; const incomingOiat = asJwt(incoming)?.header?.oiat; @@ -67,7 +72,3 @@ export function tokenOrgId(input: TokenResource | JWT): string { export function normalizeOrgId(orgId?: string | null): string { return orgId || ''; } - -export function pickFreshestOrIncoming(existing: T | null | undefined, incoming: T): T { - return existing == null ? incoming : pickFreshestJwt(existing, incoming); -} From 87dd8cfb127e50a53fbad5d3932156c85c44988b Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 30 Jun 2026 18:52:13 +0300 Subject: [PATCH 07/15] fix(clerk-js): scope __session cookie monotonic guard to same session+org The cookie guard dropped any token whose sid/org did not match the active session/org. During a multi-session setActive({ session }) switch, clerk.session lags behind the dispatched token (it is committed after getToken), so the new session's token was dropped and the cookie held the old session's token until the next poll. Scope the guard to a same session+org comparison: it now only drops a token that is strictly staler than a same-context cookie, and writes through (fails open) on any cross-context token, matching pre-feature behavior across a session or org switch. Cross-context cookie protection is deferred to a follow-up. --- .../clerk-js/src/core/__tests__/clerk.test.ts | 16 ++++++------- .../src/core/auth/AuthCookieService.ts | 23 ++++++++----------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 776a037890f..900b46c33e8 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -898,20 +898,20 @@ describe('Clerk singleton', () => { expect(document.cookie).toContain(second); }); - it('drops a token for a different session', async () => { + it('writes a token for a different session (cross-context cookies are not compared)', async () => { await loadClerkWithSession(); - const wrongSession = createJwtWithOiat(1000, 200, { sid: 'sess_other' }); - emitToken(wrongSession); - expect(document.cookie).not.toContain(wrongSession); + const otherSession = createJwtWithOiat(1000, 200, { sid: 'sess_other' }); + emitToken(otherSession); + expect(document.cookie).toContain(otherSession); }); - it('drops a token for a different organization', async () => { + it('writes a token for a different organization (cross-context cookies are not compared)', async () => { await loadClerkWithSession(); - const wrongOrg = createJwtWithOiat(1000, 200, { org: 'org_other' }); - emitToken(wrongOrg); - expect(document.cookie).not.toContain(wrongOrg); + const otherOrg = createJwtWithOiat(1000, 200, { org: 'org_other' }); + emitToken(otherOrg); + expect(document.cookie).toContain(otherOrg); }); it('applies a personal-workspace token (no org) for the active personal workspace', async () => { diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 8b1647fef39..0bf8bb51a3e 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -220,31 +220,28 @@ export class AuthCookieService { return token ? this.sessionCookie.set(token) : this.sessionCookie.remove(); } - // Returns true only when `raw` is positively wrong-context or strictly staler than the - // SAME-context current cookie. Inert (false) for tokens without oiat and on any decode - // failure: stranding the user is worse than a transient revert. + // Returns true only when `raw` is strictly staler than the SAME session+org current cookie. + // Fails open (false) for tokens without oiat, decode failures, and cross-context tokens: the + // cookie enforces monotonicity within one session+org only, never across a session/org switch. #shouldDropStaleToken(raw: string): boolean { const incoming = this.#decodeToken(raw); if (!incoming || tokenOiat(incoming) == null) { return false; } - const sid = tokenSid(incoming); - if (sid && sid !== (this.clerk.session?.id ?? '')) { - return true; - } - if (normalizeOrgId(tokenOrgId(incoming)) !== normalizeOrgId(this.clerk.organization?.id)) { - return true; - } - const current = this.#decodeToken(this.sessionCookie.get()); if (!current || tokenOiat(current) == null) { return false; } - // Only a same-context cookie is a valid freshness baseline. - if (tokenSid(current) !== sid || normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming))) { + + // Only a same session+org cookie is a comparable freshness baseline; write through otherwise. + if ( + tokenSid(current) !== tokenSid(incoming) || + normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming)) + ) { return false; } + return pickFreshestJwt(current, incoming) === current; } From 30a175ae1da17ffb1f2bd88828183f2b82ddd34d Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 30 Jun 2026 19:11:38 +0300 Subject: [PATCH 08/15] refactor(clerk-js): correct lastActiveToken comment after scoping cookie guard The cookie guard no longer drops a cross-context token downstream (it now fails open on a session/org mismatch), so #applyIncomingLastActiveToken's note that a bad token 'is still dropped downstream by the cookie guard' was false. Adopting the incoming token with no same-context baseline is justified on its own: an empty lastActiveToken reads as a sign-out, and the adopted token is not cached under the active-org key. --- packages/clerk-js/src/core/resources/Session.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 96287180a8d..25e1d7ea45a 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -230,8 +230,9 @@ export class Session extends BaseResource implements SessionResource { const baseline = held && isSameContext(tokenOrgId(held)) ? held : null; // With a same-context baseline, drop a wrong-org or strictly-staler incoming token. With no - // baseline, adopt it anyway: a bad token is still dropped downstream by the cookie guard, - // whereas an empty lastActiveToken reads as a sign-out and clears __session. + // baseline, adopt it anyway: an empty lastActiveToken reads as a sign-out and clears __session, + // which is worse than briefly carrying a token the next fetch corrects. A wrong-context token + // adopted here is not cached under the active-org key. if (baseline && (!sameContext || pickFreshestJwt(baseline, incoming) === baseline)) { this.lastActiveToken = baseline; return; From 7e9a0c1cd08debdf36bb85c473ff554d33569d90 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 1 Jul 2026 13:58:31 +0300 Subject: [PATCH 09/15] refactor(js): extract pickSameContextFreshestJwt from hydrateCache Why: The inline org-context freshness guard in the session token hydration path was dense and mixed the wrong-org veto with the monotonic freshness pick. What changed: Moved the same-context selection into pickSameContextFreshestJwt and isSameOrgContext in tokenFreshness.ts and restored the #hydrateCache name. No behavior change; the piggyback and monotonic guard suites still pass. --- .../clerk-js/src/core/resources/Session.ts | 33 ++++++------------- packages/clerk-js/src/core/tokenFreshness.ts | 17 ++++++++++ 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 25e1d7ea45a..44bed9614b6 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -50,7 +50,7 @@ import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../e import { eventBus, events } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; import { SessionTokenCache } from '../tokenCache'; -import { normalizeOrgId, pickFreshestJwt, tokenOrgId } from '../tokenFreshness'; +import { isSameOrgContext, pickFreshestJwt, pickSameContextFreshestJwt } from '../tokenFreshness'; import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; @@ -209,7 +209,7 @@ export class Session extends BaseResource implements SessionResource { })(params); }; - #applyIncomingLastActiveToken(raw: SessionJSON['last_active_token'] | null) { + #hydrateCache(raw: SessionJSON['last_active_token'] | null) { if (!raw) { this.lastActiveToken = null; return; @@ -218,28 +218,15 @@ export class Session extends BaseResource implements SessionResource { const incoming = new Token(raw); const tokenId = this.#getCacheId(); const current = SessionTokenCache.get({ tokenId })?.entry.resolvedToken ?? null; - - // Tokens are kept monotonic against a freshness baseline: the active-org cache slot, or the - // token we already hold when that slot is empty (evicted, or an in-flight fetch leaves - // resolvedToken null). The baseline must be same-context (its org claim matches the active - // org, or it has none) so a leftover previous-org token cannot veto a fresh active-org token. - const isSameContext = (orgClaim: string) => - !orgClaim || normalizeOrgId(orgClaim) === normalizeOrgId(this.lastActiveOrganizationId); - const sameContext = isSameContext(tokenOrgId(incoming)); const held = current ?? this.lastActiveToken; - const baseline = held && isSameContext(tokenOrgId(held)) ? held : null; - - // With a same-context baseline, drop a wrong-org or strictly-staler incoming token. With no - // baseline, adopt it anyway: an empty lastActiveToken reads as a sign-out and clears __session, - // which is worse than briefly carrying a token the next fetch corrects. A wrong-context token - // adopted here is not cached under the active-org key. - if (baseline && (!sameContext || pickFreshestJwt(baseline, incoming) === baseline)) { - this.lastActiveToken = baseline; - return; - } - this.lastActiveToken = incoming; - if (sameContext && (!current || current.getRawString() !== incoming.getRawString())) { + this.lastActiveToken = pickSameContextFreshestJwt(held, incoming, this.lastActiveOrganizationId); + + if ( + this.lastActiveToken === incoming && + isSameOrgContext(incoming, this.lastActiveOrganizationId) && + current?.getRawString() !== incoming.getRawString() + ) { SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(incoming), @@ -430,7 +417,7 @@ export class Session extends BaseResource implements SessionResource { this.publicUserData = new PublicUserData(data.public_user_data); } - this.#applyIncomingLastActiveToken(data.last_active_token ?? null); + this.#hydrateCache(data.last_active_token ?? null); return this; } diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index 73a060462fd..553efab8daf 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -72,3 +72,20 @@ export function tokenOrgId(input: TokenResource | JWT): string { export function normalizeOrgId(orgId?: string | null): string { return orgId || ''; } + +export function isSameOrgContext(token: TokenResource | JWT, activeOrgId: string | null | undefined): boolean { + const org = tokenOrgId(token); + return !org || normalizeOrgId(org) === normalizeOrgId(activeOrgId); +} + +export function pickSameContextFreshestJwt( + baseline: T | null | undefined, + incoming: T, + activeOrgId: string | null | undefined, +): T { + const trusted = baseline && isSameOrgContext(baseline, activeOrgId) ? baseline : null; + if (trusted && !isSameOrgContext(incoming, activeOrgId)) { + return trusted; + } + return pickFreshestJwt(trusted, incoming); +} From 9dfd65c1702c36cf4b93ad24e3fce3a7f966290b Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 1 Jul 2026 15:01:46 +0300 Subject: [PATCH 10/15] revert(js): drop pickSameContextFreshestJwt extraction, restore inline hydrateCache guard --- .../clerk-js/src/core/resources/Session.ts | 33 +++++++++++++------ packages/clerk-js/src/core/tokenFreshness.ts | 17 ---------- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 44bed9614b6..25e1d7ea45a 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -50,7 +50,7 @@ import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../e import { eventBus, events } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; import { SessionTokenCache } from '../tokenCache'; -import { isSameOrgContext, pickFreshestJwt, pickSameContextFreshestJwt } from '../tokenFreshness'; +import { normalizeOrgId, pickFreshestJwt, tokenOrgId } from '../tokenFreshness'; import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; @@ -209,7 +209,7 @@ export class Session extends BaseResource implements SessionResource { })(params); }; - #hydrateCache(raw: SessionJSON['last_active_token'] | null) { + #applyIncomingLastActiveToken(raw: SessionJSON['last_active_token'] | null) { if (!raw) { this.lastActiveToken = null; return; @@ -218,15 +218,28 @@ export class Session extends BaseResource implements SessionResource { const incoming = new Token(raw); const tokenId = this.#getCacheId(); const current = SessionTokenCache.get({ tokenId })?.entry.resolvedToken ?? null; - const held = current ?? this.lastActiveToken; - this.lastActiveToken = pickSameContextFreshestJwt(held, incoming, this.lastActiveOrganizationId); + // Tokens are kept monotonic against a freshness baseline: the active-org cache slot, or the + // token we already hold when that slot is empty (evicted, or an in-flight fetch leaves + // resolvedToken null). The baseline must be same-context (its org claim matches the active + // org, or it has none) so a leftover previous-org token cannot veto a fresh active-org token. + const isSameContext = (orgClaim: string) => + !orgClaim || normalizeOrgId(orgClaim) === normalizeOrgId(this.lastActiveOrganizationId); + const sameContext = isSameContext(tokenOrgId(incoming)); + const held = current ?? this.lastActiveToken; + const baseline = held && isSameContext(tokenOrgId(held)) ? held : null; + + // With a same-context baseline, drop a wrong-org or strictly-staler incoming token. With no + // baseline, adopt it anyway: an empty lastActiveToken reads as a sign-out and clears __session, + // which is worse than briefly carrying a token the next fetch corrects. A wrong-context token + // adopted here is not cached under the active-org key. + if (baseline && (!sameContext || pickFreshestJwt(baseline, incoming) === baseline)) { + this.lastActiveToken = baseline; + return; + } - if ( - this.lastActiveToken === incoming && - isSameOrgContext(incoming, this.lastActiveOrganizationId) && - current?.getRawString() !== incoming.getRawString() - ) { + this.lastActiveToken = incoming; + if (sameContext && (!current || current.getRawString() !== incoming.getRawString())) { SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(incoming), @@ -417,7 +430,7 @@ export class Session extends BaseResource implements SessionResource { this.publicUserData = new PublicUserData(data.public_user_data); } - this.#hydrateCache(data.last_active_token ?? null); + this.#applyIncomingLastActiveToken(data.last_active_token ?? null); return this; } diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index 553efab8daf..73a060462fd 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -72,20 +72,3 @@ export function tokenOrgId(input: TokenResource | JWT): string { export function normalizeOrgId(orgId?: string | null): string { return orgId || ''; } - -export function isSameOrgContext(token: TokenResource | JWT, activeOrgId: string | null | undefined): boolean { - const org = tokenOrgId(token); - return !org || normalizeOrgId(org) === normalizeOrgId(activeOrgId); -} - -export function pickSameContextFreshestJwt( - baseline: T | null | undefined, - incoming: T, - activeOrgId: string | null | undefined, -): T { - const trusted = baseline && isSameOrgContext(baseline, activeOrgId) ? baseline : null; - if (trusted && !isSameOrgContext(incoming, activeOrgId)) { - return trusted; - } - return pickFreshestJwt(trusted, incoming); -} From 2998f6313959c2bce12055b284fb507d4419d2cc Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 1 Jul 2026 15:16:33 +0300 Subject: [PATCH 11/15] revert(js): drop the piggyback last_active_token guard from hydrateCache Why: The guard added monotonic and org-context handling when applying a piggybacked last_active_token, but its necessity on that path was not established: the reproduced regression was the fetch path, and its unit tests only restated the guard's own behavior. Falling back to the simpler hydration until piggyback staleness is confirmed. What changed: Restored main's #hydrateCache (plain cache seed) and the direct fromJSON assignment, and removed the fromJSON piggyback guard test suite. Fetch-path monotonic guards are unchanged. --- .../clerk-js/src/core/resources/Session.ts | 48 +--- .../core/resources/__tests__/Session.test.ts | 251 ------------------ 2 files changed, 12 insertions(+), 287 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 25e1d7ea45a..d94cf3aeae9 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -50,7 +50,7 @@ import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../e import { eventBus, events } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; import { SessionTokenCache } from '../tokenCache'; -import { normalizeOrgId, pickFreshestJwt, tokenOrgId } from '../tokenFreshness'; +import { pickFreshestJwt } from '../tokenFreshness'; import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; @@ -87,6 +87,7 @@ export class Session extends BaseResource implements SessionResource { super(); this.fromJSON(data); + this.#hydrateCache(this.lastActiveToken); } end = (): Promise => { @@ -209,44 +210,19 @@ export class Session extends BaseResource implements SessionResource { })(params); }; - #applyIncomingLastActiveToken(raw: SessionJSON['last_active_token'] | null) { - if (!raw) { - this.lastActiveToken = null; - return; - } - - const incoming = new Token(raw); - const tokenId = this.#getCacheId(); - const current = SessionTokenCache.get({ tokenId })?.entry.resolvedToken ?? null; - - // Tokens are kept monotonic against a freshness baseline: the active-org cache slot, or the - // token we already hold when that slot is empty (evicted, or an in-flight fetch leaves - // resolvedToken null). The baseline must be same-context (its org claim matches the active - // org, or it has none) so a leftover previous-org token cannot veto a fresh active-org token. - const isSameContext = (orgClaim: string) => - !orgClaim || normalizeOrgId(orgClaim) === normalizeOrgId(this.lastActiveOrganizationId); - const sameContext = isSameContext(tokenOrgId(incoming)); - const held = current ?? this.lastActiveToken; - const baseline = held && isSameContext(tokenOrgId(held)) ? held : null; - - // With a same-context baseline, drop a wrong-org or strictly-staler incoming token. With no - // baseline, adopt it anyway: an empty lastActiveToken reads as a sign-out and clears __session, - // which is worse than briefly carrying a token the next fetch corrects. A wrong-context token - // adopted here is not cached under the active-org key. - if (baseline && (!sameContext || pickFreshestJwt(baseline, incoming) === baseline)) { - this.lastActiveToken = baseline; - return; - } - - this.lastActiveToken = incoming; - if (sameContext && (!current || current.getRawString() !== incoming.getRawString())) { + #hydrateCache = (token: TokenResource | null) => { + if (token) { + const tokenId = this.#getCacheId(); + // Dispatch tokenUpdate for __session tokens with the session's active organization ID + const shouldDispatchTokenUpdate = true; SessionTokenCache.set({ tokenId, - tokenResolver: Promise.resolve(incoming), - onRefresh: () => this.#refreshTokenInBackground(undefined, this.lastActiveOrganizationId, tokenId, true), + tokenResolver: Promise.resolve(token), + onRefresh: () => + this.#refreshTokenInBackground(undefined, this.lastActiveOrganizationId, tokenId, shouldDispatchTokenUpdate), }); } - } + }; // If it's a session token, retrieve it with their session id, otherwise it's a jwt template token // and retrieve it using the session id concatenated with the jwt template name. @@ -430,7 +406,7 @@ export class Session extends BaseResource implements SessionResource { this.publicUserData = new PublicUserData(data.public_user_data); } - this.#applyIncomingLastActiveToken(data.last_active_token ?? null); + this.lastActiveToken = data.last_active_token ? new Token(data.last_active_token) : null; return this; } 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 cba4f8a2f3e..f391ec0bdd9 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -2260,256 +2260,5 @@ describe('Session', () => { expect(await session.getToken({ skipCache: true })).toBe(second); expect(session.lastActiveToken?.getRawString()).toBe(second); }); - - describe('fromJSON piggyback guard (covers hydrate + touch)', () => { - const flush = async () => { - for (let i = 0; i < 3; i++) { - await Promise.resolve(); - } - }; - - const primeCache = async (raw: string) => { - const tokenId = TokenId.build('session_1', undefined, null); - SessionTokenCache.set({ - tokenId, - tokenResolver: Promise.resolve(new Token({ id: tokenId, jwt: raw, object: 'token' })), - }); - await flush(); - return tokenId; - }; - - const sessionJsonWith = (jwt: string): SessionJSON => - ({ - status: 'active', - id: 'session_1', - object: 'session', - user: createUser({}), - last_active_organization_id: null, - actor: null, - created_at: Date.now(), - updated_at: Date.now(), - last_active_token: { object: 'token', jwt }, - }) as SessionJSON; - - const sessionJsonWithOrg = (org: string | null, jwt: string): SessionJSON => - ({ - status: 'active', - id: 'session_1', - object: 'session', - user: createUser({}), - last_active_organization_id: org, - actor: null, - created_at: Date.now(), - updated_at: Date.now(), - last_active_token: { object: 'token', jwt }, - }) as SessionJSON; - - it('keeps the fresher cached token when fromJSON carries a strictly-staler last_active_token', async () => { - const high = createJwtWithOiat(NOW, NOW + 30); - const low = createJwtWithOiat(NOW, NOW); - const session = makeSession(); - const tokenId = await primeCache(high); - - (session as any).fromJSON(sessionJsonWith(low)); - - expect(session.lastActiveToken?.getRawString()).toBe(high); - // The staler piggyback is rejected and the cache entry is left untouched. - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); - }); - - it('adopts a fresher last_active_token and advances the cache', async () => { - const high = createJwtWithOiat(NOW, NOW + 30); - const low = createJwtWithOiat(NOW, NOW); - const session = makeSession(); - const tokenId = await primeCache(low); - - (session as any).fromJSON(sessionJsonWith(high)); - await flush(); - - expect(session.lastActiveToken?.getRawString()).toBe(high); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); - }); - - it('adopts the incoming token on a full oiat+iat tie even when the raw differs', async () => { - const cached = createJwtWithOiat(NOW, NOW + 30, { sid: 'sess_a' }); - const incoming = createJwtWithOiat(NOW, NOW + 30, { sid: 'sess_b' }); - const session = makeSession(); - const tokenId = await primeCache(cached); - - (session as any).fromJSON(sessionJsonWith(incoming)); - await flush(); - - expect(session.lastActiveToken?.getRawString()).toBe(incoming); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(incoming); - }); - - it('touch() keeps the fresher cached token and emits it, ignoring a staler piggyback', async () => { - const high = createJwtWithOiat(NOW, NOW + 30); - const low = createJwtWithOiat(NOW, NOW); - const session = makeSession(); - const tokenId = await primeCache(high); - - fetchSpy.mockResolvedValueOnce(sessionJsonWith(low) as any); - dispatchSpy.mockClear(); - - await session.touch(); - - expect(session.lastActiveToken?.getRawString()).toBe(high); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); - - const tokenUpdates = dispatchSpy.mock.calls.filter(call => call[0] === 'token:update'); - expect(tokenUpdates.length).toBeGreaterThan(0); - expect(tokenUpdates.every(call => (call[1] as any).token.getRawString() === high)).toBe(true); - }); - - it('does not re-set the cache when the same last_active_token is deserialized again (no churn)', async () => { - const token = createJwtWithOiat(NOW, NOW + 30); - const session = makeSession(); - const setSpy = vi.spyOn(SessionTokenCache, 'set'); - - (session as any).fromJSON(sessionJsonWith(token)); - expect(setSpy).toHaveBeenCalledTimes(1); - await flush(); - - setSpy.mockClear(); - (session as any).fromJSON(sessionJsonWith(token)); - await flush(); - - expect(setSpy).not.toHaveBeenCalled(); - const tokenId = TokenId.build('session_1', undefined, null); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(token); - }); - - it('org-switch transient: an org-A last_active_token never overwrites the cached active org-B token', async () => { - const orgBToken = createJwtWithOiat(NOW, NOW + 30, { org: 'org_B' }); - // Minted for org_A before the switch. Even though it is strictly fresher, it belongs to - // a different org and must not win the active org-B slot getToken() reads. - const orgAToken = createJwtWithOiat(NOW, NOW + 60, { org: 'org_A' }); - - const session = makeSession({ last_active_organization_id: 'org_B' } as Partial); - const tokenId = TokenId.build('session_1', undefined, 'org_B'); - SessionTokenCache.set({ - tokenId, - tokenResolver: Promise.resolve(new Token({ id: tokenId, jwt: orgBToken, object: 'token' })), - }); - await flush(); - - (session as any).fromJSON({ - status: 'active', - id: 'session_1', - object: 'session', - user: createUser({}), - last_active_organization_id: 'org_B', - actor: null, - created_at: Date.now(), - updated_at: Date.now(), - last_active_token: { object: 'token', jwt: orgAToken }, - } as SessionJSON); - await flush(); - - expect(session.lastActiveToken?.getRawString()).toBe(orgBToken); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(orgBToken); - }); - - it('adopts a no-org legacy last_active_token under the active-org key when a non-personal org is active', async () => { - const legacy = createJwtWithOiat(NOW, NOW + 30); - const session = makeSession({ last_active_organization_id: 'org_active' } as Partial); - const tokenId = TokenId.build('session_1', undefined, 'org_active'); - - (session as any).fromJSON({ - status: 'active', - id: 'session_1', - object: 'session', - user: createUser({}), - last_active_organization_id: 'org_active', - actor: null, - created_at: Date.now(), - updated_at: Date.now(), - last_active_token: { object: 'token', jwt: legacy }, - } as SessionJSON); - await flush(); - - expect(session.lastActiveToken?.getRawString()).toBe(legacy); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(legacy); - }); - - it('keeps the held lastActiveToken when a strictly-staler same-context token arrives and the cache slot is empty', async () => { - const high = createJwtWithOiat(NOW, NOW + 30); - const low = createJwtWithOiat(NOW, NOW); - const session = makeSession(); - - (session as any).fromJSON(sessionJsonWith(high)); - await flush(); - expect(session.lastActiveToken?.getRawString()).toBe(high); - - SessionTokenCache.clear(); - - (session as any).fromJSON(sessionJsonWith(low)); - await flush(); - - expect(session.lastActiveToken?.getRawString()).toBe(high); - }); - - it('rejects a wrong-org last_active_token even when the active-org cache slot is empty', async () => { - const orgBToken = createJwtWithOiat(NOW, NOW + 30, { org: 'org_B' }); - const orgAToken = createJwtWithOiat(NOW, NOW + 60, { org: 'org_A' }); - const session = makeSession({ last_active_organization_id: 'org_B' } as Partial); - const orgBJson = (jwt: string) => - ({ - status: 'active', - id: 'session_1', - object: 'session', - user: createUser({}), - last_active_organization_id: 'org_B', - actor: null, - created_at: Date.now(), - updated_at: Date.now(), - last_active_token: { object: 'token', jwt }, - }) as SessionJSON; - - (session as any).fromJSON(orgBJson(orgBToken)); - await flush(); - expect(session.lastActiveToken?.getRawString()).toBe(orgBToken); - - SessionTokenCache.clear(); - - (session as any).fromJSON(orgBJson(orgAToken)); - await flush(); - - expect(session.lastActiveToken?.getRawString()).toBe(orgBToken); - }); - - it('with no same-context baseline, adopts a wrong-org token instead of clearing it (cookie guard handles removal)', async () => { - const orgAToken = createJwtWithOiat(NOW, NOW + 30, { org: 'org_A' }); - const session = makeSession({ last_active_organization_id: 'org_B' } as Partial); - - (session as any).fromJSON(sessionJsonWithOrg('org_B', orgAToken)); - await flush(); - - // A falsy lastActiveToken is read downstream as a sign-out and drops __session, so the - // wrong-org token is adopted here and dropped later by the cookie guard instead. It must - // not pollute the active org-B cache slot. - expect(session.lastActiveToken?.getRawString()).toBe(orgAToken); - const tokenId = TokenId.build('session_1', undefined, 'org_B'); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); - }); - - it('does not let a leftover previous-org token veto a fresh active-org token', async () => { - const prevOrgHigh = createJwtWithOiat(NOW, NOW + 60, { org: 'org_A' }); - const newOrgLow = createJwtWithOiat(NOW, NOW + 30, { org: 'org_B' }); - const session = makeSession({ last_active_organization_id: 'org_A' } as Partial); - - (session as any).fromJSON(sessionJsonWithOrg('org_A', prevOrgHigh)); - await flush(); - expect(session.lastActiveToken?.getRawString()).toBe(prevOrgHigh); - - SessionTokenCache.clear(); - - (session as any).fromJSON(sessionJsonWithOrg('org_B', newOrgLow)); - await flush(); - - expect(session.lastActiveToken?.getRawString()).toBe(newOrgLow); - }); - }); }); }); From 8aaf690cd41529da5266b080ee6e9517e3f130c6 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 1 Jul 2026 17:44:18 +0300 Subject: [PATCH 12/15] test(js): remove unused Token import in Session.test.ts Why: The Token import went unused after the piggyback last_active_token guard tests were dropped; eslint's unused-imports rule flagged it as an error and failed the Static analysis CI job. --- packages/clerk-js/src/core/resources/__tests__/Session.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f391ec0bdd9..4bf379a0815 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -8,7 +8,7 @@ import { TokenId } from '@/utils/tokenId'; import { eventBus } from '../../events'; import { createFapiClient } from '../../fapiClient'; import { SessionTokenCache } from '../../tokenCache'; -import { BaseResource, Organization, Session, Token } from '../internal'; +import { BaseResource, Organization, Session } from '../internal'; const baseFapiClientOptions = { frontendApi: 'clerk.example.com', From 57cfdd93ea54681f91137b6e4a9fb1a969bcfdba Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 2 Jul 2026 14:37:26 +0300 Subject: [PATCH 13/15] fix(js): keep token cache monotonic without changing getToken behavior Why: The carry-forward approach wrote the prior resolved token onto the new pending cache entry and reconciled getToken results through the cache. That went beyond stale-suppression: a getToken call issued while a fetch was in flight could be served the old token synchronously instead of awaiting the fetch, a skipCache caller could resolve to a token other than its own mint (bypassing the empty-response throw that drives retry logic), and the resolve-time cache read could evict the live entry so a freshly fetched token was never cached or broadcast. What changed: The cache value now keeps an internal baseline: the freshest claims-valid token seen for its key, chained across set() calls and folded on every resolver settle. resolvedToken is written only when the live slot itself resolves, so pending reads still await their resolver and serve-vs-await behavior is unchanged. A resolver replaced while pending can only advance the baseline; once the slot has resolved, a fresher late resolve advances the published token and re-derives its timers. An own resolve that is invalid or rejects still drops the slot. Session token fetch paths return to their prior shape (own-mint returns, empty-response throw intact); the lastActiveToken freshness guard on dispatch stays. Tests assert own-mint returns with a monotonic slot, and the changeset wording is trimmed. --- .changeset/monotonic-session-token-guard.md | 2 +- .../src/core/__tests__/tokenCache.test.ts | 182 ++++++++++++------ .../clerk-js/src/core/resources/Session.ts | 18 +- .../core/resources/__tests__/Session.test.ts | 31 +-- packages/clerk-js/src/core/tokenCache.ts | 79 +++++--- 5 files changed, 198 insertions(+), 114 deletions(-) diff --git a/.changeset/monotonic-session-token-guard.md b/.changeset/monotonic-session-token-guard.md index fc94483d5c6..cb66e78a4fc 100644 --- a/.changeset/monotonic-session-token-guard.md +++ b/.changeset/monotonic-session-token-guard.md @@ -2,4 +2,4 @@ '@clerk/clerk-js': patch --- -Apply session tokens monotonically by origin-issued-at on a single tab, so a stale edge-minted token can no longer overwrite a fresher one in the `__session` cookie, the session's last active token, or the token cache. +Prevent a staler session token from overwriting a fresher one on the same tab. Freshness is ranked by the JWT `oiat` header, then `iat`; tokens without `oiat` always pass through. diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 509095855ff..2259ff36c43 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -287,11 +287,11 @@ describe('SessionTokenCache', () => { expect(resultAfterNewer?.entry.createdAt).toBe(1666648250); }); - it('ignores a broadcast staler than a carried-forward resolvedToken even when the resolver is staler', async () => { - // resolvedToken carry-forward can leave the live entry's resolvedToken FRESHER than - // the token its tokenResolver resolves to. The broadcast guard must compare against - // resolvedToken (the freshest known), not the staler resolver, otherwise a broadcast - // that is staler than resolvedToken slips past the guard and runs setInternal, which + it('ignores a broadcast staler than the reconciled resolvedToken even when the resolver is staler', async () => { + // The resolver reconcile can leave the entry's resolvedToken FRESHER than the token its + // tokenResolver resolves to (a staler resolve keeps the previous token). The broadcast guard + // must compare against resolvedToken (the freshest known), not the staler resolver, otherwise + // a broadcast staler than resolvedToken slips past the guard and runs setInternal, which // clears the refresh timer without reinstalling one. const tokenId = 'session_123'; const tick = async () => { @@ -308,8 +308,9 @@ describe('SessionTokenCache', () => { await tick(); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); - // Overwrite with a resolver that resolves to a LOWER-oiat token. Carry-forward keeps the - // live entry's resolvedToken = high while its tokenResolver resolves to low. + // Overwrite with a resolver that resolves to a LOWER-oiat token. The resolver reconciles + // against the previous token, so the entry's resolvedToken stays high while its resolver + // resolved to low. SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) }); await tick(); const beforeEntry = SessionTokenCache.get({ tokenId })?.entry; @@ -441,37 +442,29 @@ describe('SessionTokenCache', () => { const makeToken = (raw: string) => new Token({ id: tokenId, jwt: raw, object: 'token' }) as TokenResource; - it('keeps the fresher token when a staler set overwrites it, resolving high then low', async () => { + it('keeps the fresher token when a staler set resolves into the slot after it', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); - const high = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined }); - - const low = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: low.promise, onRefresh: undefined }); - - high.resolve(makeToken(highRaw)); + // A fresher token is cached and resolved, then a staler set() replaces the slot and resolves. + // The staler resolve reconciles against the previous token (carried onto the new slot's + // baseline at set time) and loses. + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)), onRefresh: undefined }); await tick(); - low.resolve(makeToken(lowRaw)); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)), onRefresh: undefined }); await tick(); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); }); - it('keeps the fresher token when a staler set overwrites it, resolving low then high', async () => { + it('advances to a fresher token when it resolves into the slot after a staler one', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); - const high = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined }); - - const low = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: low.promise, onRefresh: undefined }); - - low.resolve(makeToken(lowRaw)); + // Inverse direction: a genuinely fresher set() must win, not stay pinned to the old token. + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)), onRefresh: undefined }); await tick(); - high.resolve(makeToken(highRaw)); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)), onRefresh: undefined }); await tick(); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); @@ -483,34 +476,29 @@ describe('SessionTokenCache', () => { const firstRaw = `${b64(header)}.${b64({ sid: tokenId, sub: 'user_A', exp: 1666648370, iat: 1666648250 })}.sig`; const laterRaw = `${b64(header)}.${b64({ sid: tokenId, sub: 'user_B', exp: 1666648370, iat: 1666648250 })}.sig`; - const first = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: first.promise, onRefresh: undefined }); - - const later = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: later.promise, onRefresh: undefined }); - - first.resolve(makeToken(firstRaw)); + // On a full oiat+iat tie the later resolve wins: pickFreshestJwt returns the incoming token. + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(firstRaw)), onRefresh: undefined }); await tick(); - later.resolve(makeToken(laterRaw)); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(laterRaw)), onRefresh: undefined }); await tick(); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(laterRaw); }); - it('carries the prior resolved token forward into a new pending entry', async () => { + it('leaves resolvedToken undefined while a replacement resolver is pending, so getToken awaits', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); - const high = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined }); - high.resolve(makeToken(highRaw)); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)), onRefresh: undefined }); await tick(); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + // A set() with a still-pending resolver must NOT synchronously serve the previous token. + // resolvedToken stays undefined during the pending window, so getToken() awaits the resolver + // instead of serving stale — matching behavior before the monotonic guard. const pending = deferred(); SessionTokenCache.set({ tokenId, tokenResolver: pending.promise, onRefresh: undefined }); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); }); it('does not resurrect a cleared key when its pending resolver settles', async () => { @@ -527,22 +515,16 @@ describe('SessionTokenCache', () => { expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); }); - it('derives the deletion timer from the winner, not from a later staler resolve', async () => { + it('derives the deletion timer from the fresher token, not from a later staler resolve', async () => { // high: longer ttl AND fresher oiat; low: short ttl AND staler oiat. const highRaw = createJwtWithOiat(1666648250, 1666648260, 300); const lowRaw = createJwtWithOiat(1666648255, 1666648250, 60); - const high = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined }); - - const low = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: low.promise, onRefresh: undefined }); - - // Winner resolves first, staler resolves second: the staler resolve must not - // replace the winner's deletion timer with its own short-ttl timer. - high.resolve(makeToken(highRaw)); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)), onRefresh: undefined }); await tick(); - low.resolve(makeToken(lowRaw)); + // A staler, shorter-ttl token resolves into the slot afterward; it must not replace + // the fresher token's deletion timer with its own short one. + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)), onRefresh: undefined }); await tick(); // Past low's 60s ttl but well before high's 300s ttl. @@ -553,27 +535,103 @@ describe('SessionTokenCache', () => { expect(result?.entry.resolvedToken?.getRawString()).toBe(highRaw); }); - it('expires the carried token by its real ttl while the replacement resolver stays pending', async () => { + it('overlapping resolvers, fresh resolves first then stale: slot keeps high and stamps high iat', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); + const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); + + // Two concurrent sets, both pending. The first (fresher) is replaced by the second (staler) + // before either resolves. When the fresher one resolves it is now foreign, so it only raises + // the live slot's baseline, leaving resolvedToken undefined. The staler live resolve then + // reconciles against that baseline and the slot publishes the fresher token. + const dHigh = deferred(); + const dLow = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise, onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: dLow.promise, onRefresh: undefined }); - const high = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined }); - high.resolve(makeToken(highRaw)); + dHigh.resolve(makeToken(highRaw)); await tick(); + // A foreign resolve never populates the pending slot's resolvedToken. + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); - // Cache holds high with its real 120s ttl (iat 1666648250, now 1666648260). + dLow.resolve(makeToken(lowRaw)); + await tick(); + + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + + // createdAt reflects high's iat (1666648250), not low's (1666648190): the slot's TTL is stamped + // from the published winner. A slot stamped with low's earlier iat would already be evicted at + // this point; one stamped with high's iat is still comfortably within its 120s TTL. + vi.advanceTimersByTime(50 * 1000); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + }); + + it('overlapping resolvers, stale resolves first then fresh: slot ends high', async () => { + const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); + const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); + + // Inverse ordering of the previous test: the staler set is replaced by the fresher one before + // either resolves. The staler foreign resolve lands first and only advances the baseline; the + // fresher live resolve then publishes high directly. + const dLow = deferred(); + const dHigh = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: dLow.promise, onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise, onRefresh: undefined }); - // Replacement resolver never settles; high is carried forward into the pending entry. - const neverSettles = new Promise(() => {}); - SessionTokenCache.set({ tokenId, tokenResolver: neverSettles, onRefresh: undefined }); + dLow.resolve(makeToken(lowRaw)); + await tick(); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); + + dHigh.resolve(makeToken(highRaw)); + await tick(); - // The carry still serves high synchronously while the replacement is pending. expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + }); - // Past high's real 120s ttl: get() must evict the carried token, not serve it forever. - vi.advanceTimersByTime(130 * 1000); - expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + it('advances an already-resolved staler slot when a fresher foreign resolve lands late', async () => { + const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); + const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); + + // The live slot (d2) resolves staler first and publishes low. The replaced resolver (d1) + // resolves fresher afterward. Because the slot is no longer pending, the fresher foreign token + // advances resolvedToken to high and re-derives the slot's timers, rather than only raising the + // baseline. + const dHigh = deferred(); + const dLow = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise, onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: dLow.promise, onRefresh: undefined }); + + dLow.resolve(makeToken(lowRaw)); + await tick(); + // The live slot resolved first, so it publishes the staler token. + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(lowRaw); + + dHigh.resolve(makeToken(highRaw)); + await tick(); + + // The late foreign resolve advances the already-resolved slot to the fresher token. + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + }); + + it('never exposes the baseline while a replacement is pending, then publishes high once it resolves', async () => { + const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); + const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); + + // The first set resolves to high, so the slot's resolvedToken and baseline are both high. + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)), onRefresh: undefined }); + await tick(); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + + // A replacement whose resolver never settles must not expose the carried baseline: the slot + // reads as pending (resolvedToken undefined) so callers keep awaiting the in-flight fetch. + const pending = deferred(); + SessionTokenCache.set({ tokenId, tokenResolver: pending.promise, onRefresh: undefined }); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); + + // When it finally resolves staler, the own resolve reconciles against the baseline and the slot + // publishes high. + pending.resolve(makeToken(lowRaw)); + await tick(); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); }); }); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index d94cf3aeae9..a6737039d1c 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -536,25 +536,21 @@ export class Session extends BaseResource implements SessionResource { ): Promise { debugLogger.info('Fetching new token from API', { organizationId, template, tokenId }, 'session'); - const fetchPromise = this.#createTokenResolver(template, organizationId, skipCache); - const tokenResolver = fetchPromise.then(fetched => - pickFreshestJwt(SessionTokenCache.get({ tokenId })?.entry.resolvedToken, fetched), - ); - + const tokenResolver = this.#createTokenResolver(template, organizationId, skipCache); SessionTokenCache.set({ tokenId, tokenResolver, onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), }); - return tokenResolver.then(winner => { - const rawString = winner.getRawString(); + return tokenResolver.then(token => { + const rawString = token.getRawString(); if (!rawString) { // Throw so retry logic in getToken() can handle it, // rather than silently returning null (which callers interpret as "signed out"). throw new ClerkRuntimeError('Token fetch returned empty response', { code: 'network_error' }); } - this.#dispatchTokenEvents(winner, shouldDispatchTokenUpdate); + this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); return rawString; }); } @@ -605,16 +601,14 @@ export class Session extends BaseResource implements SessionResource { return; } - const winner = pickFreshestJwt(SessionTokenCache.get({ tokenId })?.entry.resolvedToken, token); - // Cache the resolved token for future calls // Re-register onRefresh to handle the next refresh cycle when this token approaches expiration SessionTokenCache.set({ tokenId, - tokenResolver: Promise.resolve(winner), + tokenResolver: Promise.resolve(token), onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), }); - this.#dispatchTokenEvents(winner, shouldDispatchTokenUpdate); + this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); }) .catch(error => { // Log but don't propagate - callers already have stale token 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 4bf379a0815..d1a1e8e2194 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -2115,7 +2115,7 @@ describe('Session', () => { SessionTokenCache.clear(); }); - it('coalesced waiters and lastActiveToken keep the freshest token (resolve high then low)', async () => { + it('concurrent skipCache mints return their own token; cache slot and lastActiveToken stay freshest (resolve high then low)', async () => { const session = makeSession(); const high = createJwtWithOiat(NOW, NOW + 30); const low = createJwtWithOiat(NOW, NOW); @@ -2130,9 +2130,10 @@ describe('Session', () => { dHigh.resolve({ object: 'token', jwt: high }); await expect(pHigh).resolves.toBe(high); - // The low fetch resolves last but the guarded resolver hands back the freshest token. + // Each skipCache call returns its own mint (main parity): the low fetch resolves last and + // returns low, but it never regresses the cache slot or lastActiveToken. dLow.resolve({ object: 'token', jwt: low }); - await expect(pLow).resolves.toBe(high); + await expect(pLow).resolves.toBe(low); const tokenId = TokenId.build('session_1', undefined, null); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); @@ -2140,7 +2141,7 @@ describe('Session', () => { expect(session.lastActiveToken?.getRawString()).toBe(high); }); - it('coalesced waiters and lastActiveToken keep the freshest token (resolve low then high)', async () => { + it('concurrent skipCache mints return their own token; cache slot and lastActiveToken stay freshest (resolve low then high)', async () => { const session = makeSession(); const high = createJwtWithOiat(NOW, NOW + 30); const low = createJwtWithOiat(NOW, NOW); @@ -2152,6 +2153,7 @@ describe('Session', () => { const pFirst = session.getToken({ skipCache: true }); const pSecond = session.getToken({ skipCache: true }); + // Each skipCache call returns its own mint (main parity). dFirst.resolve({ object: 'token', jwt: low }); await expect(pFirst).resolves.toBe(low); @@ -2164,7 +2166,7 @@ describe('Session', () => { expect(session.lastActiveToken?.getRawString()).toBe(high); }); - it('a stale fetch after a fresh one does not regress lastActiveToken or the next /tokens token field', async () => { + it('a stale fetch after a fresh one returns its own mint but never regresses lastActiveToken or the next /tokens token field', async () => { const session = makeSession(); const high = createJwtWithOiat(NOW, NOW + 30); const low = createJwtWithOiat(NOW, NOW); @@ -2175,13 +2177,14 @@ describe('Session', () => { .mockResolvedValueOnce({ object: 'token', jwt: low }); expect(await session.getToken()).toBe(high); - expect(await session.getToken({ skipCache: true })).toBe(high); - expect(await session.getToken({ skipCache: true })).toBe(high); + // Each skipCache call returns its own mint (main parity), so the staler fetches return low. + expect(await session.getToken({ skipCache: true })).toBe(low); + expect(await session.getToken({ skipCache: true })).toBe(low); - // The first stale fetch carried high as the previous token, and the next request still does - // — proving lastActiveToken never regressed to the stale token. - expect((fetchSpy.mock.calls[1][0] as any).body.token).toBe(high); - expect((fetchSpy.mock.calls[2][0] as any).body.token).toBe(high); + // The first stale fetch carried high as the previous token, and the next request still does, + // proving lastActiveToken never regressed to the stale token. + expect(fetchSpy.mock.calls[1][0].body.token).toBe(high); + expect(fetchSpy.mock.calls[2][0].body.token).toBe(high); const tokenId = TokenId.build('session_1', undefined, null); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); @@ -2200,10 +2203,11 @@ describe('Session', () => { const pHigh = session.getToken({ organizationId: 'org_other', skipCache: true }); const pLow = session.getToken({ organizationId: 'org_other', skipCache: true }); + // Each skipCache call returns its own mint (main parity); the cache slot still stays freshest. dHigh.resolve({ object: 'token', jwt: high }); await expect(pHigh).resolves.toBe(high); dLow.resolve({ object: 'token', jwt: low }); - await expect(pLow).resolves.toBe(high); + await expect(pLow).resolves.toBe(low); const tokenId = TokenId.build('session_1', undefined, 'org_other'); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); @@ -2222,10 +2226,11 @@ describe('Session', () => { const pHigh = session.getToken({ template: 't', skipCache: true }); const pLow = session.getToken({ template: 't', skipCache: true }); + // Each skipCache call returns its own mint (main parity); the cache slot still stays freshest. dHigh.resolve({ object: 'token', jwt: high }); await expect(pHigh).resolves.toBe(high); dLow.resolve({ object: 'token', jwt: low }); - await expect(pLow).resolves.toBe(high); + await expect(pLow).resolves.toBe(low); const tokenId = TokenId.build('session_1', 't', null); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index d41be19a434..8526460feaf 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -43,6 +43,15 @@ type Seconds = number; * Internal cache value containing the entry, expiration metadata, and timers. */ interface TokenCacheValue { + /** + * Freshest claims-valid token observed for this key, chained across set() calls + * and updated by every resolver settle, including resolvers replaced by a newer + * set() while still pending. Internal bookkeeping only: readers only ever see + * entry.resolvedToken, so a pending entry still reads as pending and callers + * await its resolver. Folded into resolvedToken when the live entry resolves, + * which is what keeps a staler resolve from overwriting a fresher token. + */ + baseline?: TokenResource; createdAt: Seconds; entry: TokenCacheEntry; expiresIn?: Seconds; @@ -317,18 +326,14 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { clearTimeout(existing?.timeoutId); clearTimeout(existing?.refreshTimeoutId); - if (existing?.entry.resolvedToken) { - entry.resolvedToken = pickFreshestJwt(entry.resolvedToken, existing.entry.resolvedToken); - } - const nowSeconds = Math.floor(Date.now() / 1000); const createdAt = entry.createdAt ?? nowSeconds; const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined }; - if (existing?.entry.resolvedToken && existing.expiresIn !== undefined) { - value.createdAt = existing.createdAt; - value.expiresIn = existing.expiresIn; - } + // Chain the freshest known token across replacements. This never touches + // entry.resolvedToken: a pending entry reads as pending and callers await. + const prior = existing?.entry.resolvedToken; + value.baseline = prior ? pickFreshestJwt(existing?.baseline, prior) : existing?.baseline; // Clears both timers and drops the slot, but only if it still holds `target` // (a newer set() may have replaced it while a promise/timer was pending). @@ -347,25 +352,46 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { .then(newToken => { const live = store.get(key); if (!live) { - // Cleared while pending — do not resurrect. + // Cleared while pending; do not resurrect. + return; + } + + const claims = newToken.jwt?.claims; + const isValid = !!claims && typeof claims.exp === 'number' && typeof claims.iat === 'number'; + const isOwn = live === value; + + if (isOwn && !isValid) { + // The live slot's own fetch resolved unusable: drop the slot so the next + // read refetches. Keeping the baseline alive here would hide a broken + // token endpoint behind cache hits. + dropIfCurrent(live); + return; + } + if (!isValid) { + return; + } + + // Track the freshest token seen for this key, even when this resolver was + // replaced by a newer set() while it was pending. + const baseline = pickFreshestJwt(live.baseline, newToken); + live.baseline = baseline; + + // resolvedToken is only written once the live slot itself resolves. While + // the live slot is pending, readers must keep awaiting its resolver, so a + // replaced resolver may only advance the baseline above. + if (!isOwn && live.entry.resolvedToken === undefined) { return; } - // Reconcile against the freshest token known for this key, regardless of - // which resolver owns the live slot. A staler resolve can never publish. - const prev = live.entry.resolvedToken; - live.entry.resolvedToken = pickFreshestJwt(prev, newToken); - const winner = live.entry.resolvedToken; - // Skip only when the winner did not advance AND this live value already has - // its timers installed. A fresh set() clears the prior entry's timers and may - // carry its resolved token forward (so prev can equal the winner); that value - // still needs winner-derived timers, which an unconditional skip would drop. - if (winner === prev && live.timeoutId !== undefined) { + const winner = pickFreshestJwt(live.entry.resolvedToken, baseline); + if (winner === live.entry.resolvedToken) { + // Nothing advanced; the installed timers already match the winner. return; } + live.entry.resolvedToken = winner; - const claims = winner.jwt?.claims; - if (!claims || typeof claims.exp !== 'number' || typeof claims.iat !== 'number') { + const winnerClaims = winner.jwt?.claims; + if (!winnerClaims || typeof winnerClaims.exp !== 'number' || typeof winnerClaims.iat !== 'number') { dropIfCurrent(live); return; } @@ -373,8 +399,9 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { clearTimeout(live.timeoutId); clearTimeout(live.refreshTimeoutId); - const issuedAt = claims.iat; - const expiresIn: Seconds = claims.exp - issuedAt; + const expiresAt = winnerClaims.exp; + const issuedAt = winnerClaims.iat; + const expiresIn: Seconds = expiresAt - issuedAt; live.createdAt = issuedAt; live.expiresIn = expiresIn; @@ -415,9 +442,9 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { // channel close) must not reach the outer catch and evict the cached token (SDK-119). try { const tokenRaw = winner.getRawString(); - if (tokenRaw && claims.sid) { - const sessionId = claims.sid; - const organizationId = claims.org_id || (claims.o as any)?.id; + if (tokenRaw && winnerClaims.sid) { + const sessionId = winnerClaims.sid; + const organizationId = winnerClaims.org_id || (winnerClaims.o as any)?.id; const template = TokenId.extractTemplate(entry.tokenId, sessionId, organizationId); const expectedTokenId = TokenId.build(sessionId, template, organizationId); From 1e9d20908e596d8551a093d5fcd82f4d32618590 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 2 Jul 2026 15:05:44 +0300 Subject: [PATCH 14/15] test(js): dedupe token cache test helpers Why: tick and makeToken were defined twice (broadcast and same-tab describes), and the same-tab set() calls passed an explicit onRefresh: undefined that is identical to omitting the property. Hoist the helpers to file scope next to createJwtWithOiat and drop the noise. --- .../src/core/__tests__/tokenCache.test.ts | 61 +++++++++---------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 2259ff36c43..65487191174 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -41,6 +41,16 @@ function createJwtWithOiat(iatSeconds: number, oiatSeconds: number, ttlSeconds = return `${b64(header)}.${b64(payload)}.test-signature`; } +/** + * Flush enough microtasks for setInternal's tokenResolver.then handler to run. + */ +const tick = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +const makeToken = (raw: string, id = 'session_123') => new Token({ id, jwt: raw, object: 'token' }) as TokenResource; + describe('SessionTokenCache', () => { let mockBroadcastChannel: { addEventListener: ReturnType; @@ -294,11 +304,6 @@ describe('SessionTokenCache', () => { // a broadcast staler than resolvedToken slips past the guard and runs setInternal, which // clears the refresh timer without reinstalling one. const tokenId = 'session_123'; - const tick = async () => { - await Promise.resolve(); - await Promise.resolve(); - }; - const makeToken = (raw: string) => new Token({ id: tokenId, jwt: raw, object: 'token' }) as TokenResource; const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); @@ -426,12 +431,6 @@ describe('SessionTokenCache', () => { describe('same-tab monotonic resolve', () => { const tokenId = 'session_123'; - // Flush enough microtasks for setInternal's tokenResolver.then handler to run. - const tick = async () => { - await Promise.resolve(); - await Promise.resolve(); - }; - const deferred = () => { let resolve!: (token: TokenResource) => void; const promise = new Promise(r => { @@ -440,8 +439,6 @@ describe('SessionTokenCache', () => { return { promise, resolve }; }; - const makeToken = (raw: string) => new Token({ id: tokenId, jwt: raw, object: 'token' }) as TokenResource; - it('keeps the fresher token when a staler set resolves into the slot after it', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); @@ -449,9 +446,9 @@ describe('SessionTokenCache', () => { // A fresher token is cached and resolved, then a staler set() replaces the slot and resolves. // The staler resolve reconciles against the previous token (carried onto the new slot's // baseline at set time) and loses. - SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)), onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); - SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)), onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) }); await tick(); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); @@ -462,9 +459,9 @@ describe('SessionTokenCache', () => { const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); // Inverse direction: a genuinely fresher set() must win, not stay pinned to the old token. - SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)), onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) }); await tick(); - SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)), onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); @@ -477,9 +474,9 @@ describe('SessionTokenCache', () => { const laterRaw = `${b64(header)}.${b64({ sid: tokenId, sub: 'user_B', exp: 1666648370, iat: 1666648250 })}.sig`; // On a full oiat+iat tie the later resolve wins: pickFreshestJwt returns the incoming token. - SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(firstRaw)), onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(firstRaw)) }); await tick(); - SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(laterRaw)), onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(laterRaw)) }); await tick(); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(laterRaw); @@ -488,7 +485,7 @@ describe('SessionTokenCache', () => { it('leaves resolvedToken undefined while a replacement resolver is pending, so getToken awaits', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); - SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)), onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); @@ -496,7 +493,7 @@ describe('SessionTokenCache', () => { // resolvedToken stays undefined during the pending window, so getToken() awaits the resolver // instead of serving stale — matching behavior before the monotonic guard. const pending = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: pending.promise, onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: pending.promise }); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); }); @@ -505,7 +502,7 @@ describe('SessionTokenCache', () => { const raw = createJwtWithOiat(1666648250, 1666648250, 120); const pending = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: pending.promise, onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: pending.promise }); SessionTokenCache.clear(); @@ -520,11 +517,11 @@ describe('SessionTokenCache', () => { const highRaw = createJwtWithOiat(1666648250, 1666648260, 300); const lowRaw = createJwtWithOiat(1666648255, 1666648250, 60); - SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)), onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); // A staler, shorter-ttl token resolves into the slot afterward; it must not replace // the fresher token's deletion timer with its own short one. - SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)), onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) }); await tick(); // Past low's 60s ttl but well before high's 300s ttl. @@ -545,8 +542,8 @@ describe('SessionTokenCache', () => { // reconciles against that baseline and the slot publishes the fresher token. const dHigh = deferred(); const dLow = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise, onRefresh: undefined }); - SessionTokenCache.set({ tokenId, tokenResolver: dLow.promise, onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise }); + SessionTokenCache.set({ tokenId, tokenResolver: dLow.promise }); dHigh.resolve(makeToken(highRaw)); await tick(); @@ -574,8 +571,8 @@ describe('SessionTokenCache', () => { // fresher live resolve then publishes high directly. const dLow = deferred(); const dHigh = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: dLow.promise, onRefresh: undefined }); - SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise, onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: dLow.promise }); + SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise }); dLow.resolve(makeToken(lowRaw)); await tick(); @@ -597,8 +594,8 @@ describe('SessionTokenCache', () => { // baseline. const dHigh = deferred(); const dLow = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise, onRefresh: undefined }); - SessionTokenCache.set({ tokenId, tokenResolver: dLow.promise, onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise }); + SessionTokenCache.set({ tokenId, tokenResolver: dLow.promise }); dLow.resolve(makeToken(lowRaw)); await tick(); @@ -617,14 +614,14 @@ describe('SessionTokenCache', () => { const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); // The first set resolves to high, so the slot's resolvedToken and baseline are both high. - SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)), onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); // A replacement whose resolver never settles must not expose the carried baseline: the slot // reads as pending (resolvedToken undefined) so callers keep awaiting the in-flight fetch. const pending = deferred(); - SessionTokenCache.set({ tokenId, tokenResolver: pending.promise, onRefresh: undefined }); + SessionTokenCache.set({ tokenId, tokenResolver: pending.promise }); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); // When it finally resolves staler, the own resolve reconciles against the baseline and the slot From 3062124f23ae5a92f48266d1f75cb781c0012fa5 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 2 Jul 2026 17:43:29 +0300 Subject: [PATCH 15/15] fix(js): fail open on expired or cross-context freshness baselines Why: The monotonic guards had three gaps. An expired __session cookie could still act as a freshness baseline and suppress a valid fresh mint that carried a lower oiat, leaving an expired cookie in place where main would have replaced it. The lastActiveToken guard compared tokens across contexts, so an org-switch token minted by a stale edge could lose to the previous org's token and pin useAuth claims to the old org. And cache timers were scheduled from the winner's full lifetime, so an aged winner deferred eviction and proactive refresh past its real expiry. What changed: The cookie guard treats an expired current cookie as no baseline and writes through. lastActiveToken suppression now requires the existing token to match the incoming session and organization, mirroring the cookie guard's cross-context fail-open. Cache deletion and refresh timers are scheduled from the winner's remaining ttl (exp minus now) while the createdAt/expiresIn stamping is unchanged, and a winner already past expiry drops the slot. One test per fix; the cookie tests now use real timestamps since the guard is expiry-aware. --- .../clerk-js/src/core/__tests__/clerk.test.ts | 41 +++++++++++++------ .../src/core/__tests__/tokenCache.test.ts | 19 +++++++++ .../src/core/auth/AuthCookieService.ts | 12 +++++- .../clerk-js/src/core/resources/Session.ts | 22 +++++++++- .../core/resources/__tests__/Session.test.ts | 25 ++++++++++- packages/clerk-js/src/core/tokenCache.ts | 14 ++++++- 6 files changed, 112 insertions(+), 21 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 900b46c33e8..b36fb48a093 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -822,6 +822,9 @@ describe('Clerk singleton', () => { describe('updateSessionCookie monotonic backstop', () => { const sessionId = 'sess_active'; + // The cookie guard treats an expired current cookie as no baseline, so test + // tokens must carry real, non-expired timestamps rather than tiny literals. + const T0 = Math.floor(Date.now() / 1000); const createJwtWithOiat = ( iat: number, @@ -864,24 +867,36 @@ describe('Clerk singleton', () => { it('drops a strictly-staler same-context token and keeps the fresher cookie', async () => { await loadClerkWithSession(); - const fresh = createJwtWithOiat(1000, 200); + const fresh = createJwtWithOiat(T0, 200, { ttl: 600 }); emitToken(fresh); expect(document.cookie).toContain(fresh); - const stale = createJwtWithOiat(900, 100); + const stale = createJwtWithOiat(T0 - 10, 100); emitToken(stale); expect(document.cookie).not.toContain(stale); expect(document.cookie).toContain(fresh); }); + it('applies a lower-oiat token when the current cookie is expired (no freshness baseline)', async () => { + await loadClerkWithSession(); + + const expiredFresher = createJwtWithOiat(T0 - 120, 500, { ttl: 60 }); + emitToken(expiredFresher); + expect(document.cookie).toContain(expiredFresher); + + const validStaler = createJwtWithOiat(T0, 100); + emitToken(validStaler); + expect(document.cookie).toContain(validStaler); + }); + it('applies a fresher same-context token', async () => { await loadClerkWithSession(); - const older = createJwtWithOiat(1000, 100); + const older = createJwtWithOiat(T0, 100); emitToken(older); expect(document.cookie).toContain(older); - const newer = createJwtWithOiat(1100, 200); + const newer = createJwtWithOiat(T0 + 10, 200); emitToken(newer); expect(document.cookie).toContain(newer); }); @@ -889,11 +904,11 @@ describe('Clerk singleton', () => { it('applies a token with equal oiat and iat (publish on tie)', async () => { await loadClerkWithSession(); - const first = createJwtWithOiat(1000, 100, { ttl: 60 }); + const first = createJwtWithOiat(T0, 100, { ttl: 60 }); emitToken(first); expect(document.cookie).toContain(first); - const second = createJwtWithOiat(1000, 100, { ttl: 120 }); + const second = createJwtWithOiat(T0, 100, { ttl: 120 }); emitToken(second); expect(document.cookie).toContain(second); }); @@ -901,7 +916,7 @@ describe('Clerk singleton', () => { it('writes a token for a different session (cross-context cookies are not compared)', async () => { await loadClerkWithSession(); - const otherSession = createJwtWithOiat(1000, 200, { sid: 'sess_other' }); + const otherSession = createJwtWithOiat(T0, 200, { sid: 'sess_other' }); emitToken(otherSession); expect(document.cookie).toContain(otherSession); }); @@ -909,7 +924,7 @@ describe('Clerk singleton', () => { it('writes a token for a different organization (cross-context cookies are not compared)', async () => { await loadClerkWithSession(); - const otherOrg = createJwtWithOiat(1000, 200, { org: 'org_other' }); + const otherOrg = createJwtWithOiat(T0, 200, { org: 'org_other' }); emitToken(otherOrg); expect(document.cookie).toContain(otherOrg); }); @@ -917,7 +932,7 @@ describe('Clerk singleton', () => { it('applies a personal-workspace token (no org) for the active personal workspace', async () => { await loadClerkWithSession(); - const personal = createJwtWithOiat(1000, 200); + const personal = createJwtWithOiat(T0, 200); emitToken(personal); expect(document.cookie).toContain(personal); }); @@ -927,14 +942,14 @@ describe('Clerk singleton', () => { // Plant a different-session, higher-oiat cookie by temporarily making it the active context. (sut.session as any).id = 'sess_other'; - const otherContext = createJwtWithOiat(2000, 999, { sid: 'sess_other' }); + const otherContext = createJwtWithOiat(T0 + 20, 999, { sid: 'sess_other' }); emitToken(otherContext); expect(document.cookie).toContain(otherContext); // Restore the active session; a lower-oiat active-context token must still apply, // because the different-session cookie is not a valid freshness baseline. (sut.session as any).id = sessionId; - const active = createJwtWithOiat(1000, 100, { sid: sessionId }); + const active = createJwtWithOiat(T0, 100, { sid: sessionId }); emitToken(active); expect(document.cookie).toContain(active); }); @@ -942,7 +957,7 @@ describe('Clerk singleton', () => { it('applies a token without an oiat header (fail open)', async () => { await loadClerkWithSession(); - const noOiat = createJwtWithOiat(1000, undefined); + const noOiat = createJwtWithOiat(T0, undefined); emitToken(noOiat); expect(document.cookie).toContain(noOiat); }); @@ -957,7 +972,7 @@ describe('Clerk singleton', () => { it('removes the cookie when the token is null', async () => { await loadClerkWithSession(); - const fresh = createJwtWithOiat(1000, 200); + const fresh = createJwtWithOiat(T0, 200); emitToken(fresh); expect(document.cookie).toContain(fresh); diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 65487191174..27c0a458401 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -630,6 +630,25 @@ describe('SessionTokenCache', () => { await tick(); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); }); + + it('schedules timers from the winner remaining ttl, not a full lifetime from now', async () => { + // Aged winner: minted 30s before the mocked now with a 120s lifetime, so 90s + // actually remain. Refresh must fire at remaining - leeway - lead time + // (90 - 15 - 2 = 73s) and the slot must be evicted by its real expiry, not a + // full 120s from now. + const agedRaw = createJwtWithOiat(1666648230, 1666648230, 120); + const onRefresh = vi.fn(); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(agedRaw)), onRefresh }); + await tick(); + + vi.advanceTimersByTime(74 * 1000); + expect(onRefresh).toHaveBeenCalledTimes(1); + + // Past the real 90s expiry: the deletion timer has already dropped the slot. + vi.advanceTimersByTime(17 * 1000); + expect(SessionTokenCache.size()).toBe(0); + }); }); describe('token expiration with absolute time', () => { diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 0bf8bb51a3e..3ccc1dd4d38 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -221,8 +221,9 @@ export class AuthCookieService { } // Returns true only when `raw` is strictly staler than the SAME session+org current cookie. - // Fails open (false) for tokens without oiat, decode failures, and cross-context tokens: the - // cookie enforces monotonicity within one session+org only, never across a session/org switch. + // Fails open (false) for tokens without oiat, decode failures, cross-context tokens, and an + // already-expired current cookie: the cookie enforces monotonicity within one session+org + // only, never across a session/org switch. #shouldDropStaleToken(raw: string): boolean { const incoming = this.#decodeToken(raw); if (!incoming || tokenOiat(incoming) == null) { @@ -234,6 +235,13 @@ export class AuthCookieService { return false; } + // An expired cookie is not a freshness baseline: a valid fresh mint must always be + // able to replace it, even when a stale edge read gives it a lower oiat. + const currentExp = current.jwt?.claims?.exp; + if (typeof currentExp !== 'number' || currentExp <= Math.floor(Date.now() / 1000)) { + return false; + } + // Only a same session+org cookie is a comparable freshness baseline; write through otherwise. if ( tokenSid(current) !== tokenSid(incoming) || diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index a6737039d1c..419f41b028c 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -50,7 +50,7 @@ import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../e import { eventBus, events } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; import { SessionTokenCache } from '../tokenCache'; -import { pickFreshestJwt } from '../tokenFreshness'; +import { normalizeOrgId, pickFreshestJwt, tokenOrgId, tokenSid } from '../tokenFreshness'; import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; @@ -521,12 +521,30 @@ export class Session extends BaseResource implements SessionResource { eventBus.emit(events.TokenUpdate, { token }); - if (token.jwt && pickFreshestJwt(this.lastActiveToken, token) === token) { + if (token.jwt && !this.#shouldKeepExistingLastActiveToken(token)) { this.lastActiveToken = token; eventBus.emit(events.SessionTokenResolved, null); } } + // Mirrors the cookie guard: only a same session+org lastActiveToken is a comparable + // freshness baseline, so a session or org switch always adopts the incoming token. + // Without this, an org-switch token minted by a stale edge (lower oiat) would lose + // to the previous org's token and pin lastActiveToken to the old org's claims. + #shouldKeepExistingLastActiveToken(incoming: TokenResource): boolean { + const current = this.lastActiveToken; + if (!current?.jwt) { + return false; + } + if ( + tokenSid(current) !== tokenSid(incoming) || + normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming)) + ) { + return false; + } + return pickFreshestJwt(current, incoming) !== incoming; + } + #fetchToken( template: string | undefined, organizationId: string | undefined | null, 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 d1a1e8e2194..4a3268a967d 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -2252,8 +2252,8 @@ describe('Session', () => { it('successive tokens without oiat keep writing lastActiveToken (equal rank, newest wins)', async () => { const session = makeSession(); - const first = createJwtWithOiat(NOW, undefined, { sid: 'sess_a' }); - const second = createJwtWithOiat(NOW + 5, undefined, { sid: 'sess_b' }); + const first = createJwtWithOiat(NOW, undefined); + const second = createJwtWithOiat(NOW + 5, undefined); fetchSpy .mockResolvedValueOnce({ object: 'token', jwt: first }) @@ -2265,5 +2265,26 @@ describe('Session', () => { expect(await session.getToken({ skipCache: true })).toBe(second); expect(session.lastActiveToken?.getRawString()).toBe(second); }); + + it('an org-switch token minted with a lower oiat still replaces the previous org lastActiveToken', async () => { + const session = makeSession(); + const personalHigh = createJwtWithOiat(NOW, NOW + 30); + const orgLow = createJwtWithOiat(NOW + 5, NOW, { org: 'org_next' }); + + fetchSpy + .mockResolvedValueOnce({ object: 'token', jwt: personalHigh }) + .mockResolvedValueOnce({ object: 'token', jwt: orgLow }); + + expect(await session.getToken()).toBe(personalHigh); + expect(session.lastActiveToken?.getRawString()).toBe(personalHigh); + + // setActive commits the new organization before its first token fetch. + session.lastActiveOrganizationId = 'org_next'; + + expect(await session.getToken()).toBe(orgLow); + // A cross-org lastActiveToken is not a freshness baseline: the new org's token + // wins even though a stale edge minted it with a lower oiat. + expect(session.lastActiveToken?.getRawString()).toBe(orgLow); + }); }); }); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 8526460feaf..4c67ca3aad6 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -402,11 +402,21 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { const expiresAt = winnerClaims.exp; const issuedAt = winnerClaims.iat; const expiresIn: Seconds = expiresAt - issuedAt; + // Timers run relative to now, while createdAt/expiresIn describe the token's + // real validity window for get(). An aged winner (alive for part of its + // lifetime already) must be evicted and refreshed by its real expiry, not a + // full lifetime from now. + const remainingTtl: Seconds = expiresAt - Math.floor(Date.now() / 1000); live.createdAt = issuedAt; live.expiresIn = expiresIn; - const timeoutId = setTimeout(() => dropIfCurrent(live), expiresIn * 1000); + if (remainingTtl <= 0) { + dropIfCurrent(live); + return; + } + + const timeoutId = setTimeout(() => dropIfCurrent(live), remainingTtl * 1000); live.timeoutId = timeoutId; // Teach ClerkJS not to block the exit of the event loop when used in Node environments. @@ -422,7 +432,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { const refreshLeadTime = 2; const minLeeway = POLLER_INTERVAL_IN_MS / 1000; // Minimum is poller interval (5s) const leeway = Math.max(BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, minLeeway); - const refreshFireTime = expiresIn - leeway - refreshLeadTime; + const refreshFireTime = remainingTtl - leeway - refreshLeadTime; if (refreshFireTime > 0 && live.entry.onRefresh) { const refreshTimeoutId = setTimeout(() => {