diff --git a/.changeset/monotonic-session-token-guard.md b/.changeset/monotonic-session-token-guard.md new file mode 100644 index 00000000000..cb66e78a4fc --- /dev/null +++ b/.changeset/monotonic-session-token-guard.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +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__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 521f9c4b55e..b36fb48a093 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -820,6 +820,167 @@ 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, + 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(T0, 200, { ttl: 600 }); + emitToken(fresh); + expect(document.cookie).toContain(fresh); + + 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(T0, 100); + emitToken(older); + expect(document.cookie).toContain(older); + + const newer = createJwtWithOiat(T0 + 10, 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(T0, 100, { ttl: 60 }); + emitToken(first); + expect(document.cookie).toContain(first); + + const second = createJwtWithOiat(T0, 100, { ttl: 120 }); + emitToken(second); + expect(document.cookie).toContain(second); + }); + + it('writes a token for a different session (cross-context cookies are not compared)', async () => { + await loadClerkWithSession(); + + const otherSession = createJwtWithOiat(T0, 200, { sid: 'sess_other' }); + emitToken(otherSession); + expect(document.cookie).toContain(otherSession); + }); + + it('writes a token for a different organization (cross-context cookies are not compared)', async () => { + await loadClerkWithSession(); + + const otherOrg = createJwtWithOiat(T0, 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 () => { + await loadClerkWithSession(); + + const personal = createJwtWithOiat(T0, 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(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(T0, 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(T0, 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(T0, 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 41be211dc3f..27c0a458401 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; @@ -287,6 +297,54 @@ describe('SessionTokenCache', () => { expect(resultAfterNewer?.entry.createdAt).toBe(1666648250); }); + 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 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. 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; + 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: { @@ -370,6 +428,229 @@ describe('SessionTokenCache', () => { }); }); + describe('same-tab monotonic resolve', () => { + const tokenId = 'session_123'; + + const deferred = () => { + let resolve!: (token: TokenResource) => void; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; + }; + + 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); + + // 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)) }); + await tick(); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) }); + await tick(); + + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + }); + + 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); + + // Inverse direction: a genuinely fresher set() must win, not stay pinned to the old token. + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) }); + await tick(); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.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`; + + // On a full oiat+iat tie the later resolve wins: pickFreshestJwt returns the incoming token. + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(firstRaw)) }); + await tick(); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(laterRaw)) }); + await tick(); + + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(laterRaw); + }); + + 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)) }); + 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 }); + + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); + }); + + 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 }); + + SessionTokenCache.clear(); + + pending.resolve(makeToken(raw)); + await tick(); + + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + 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); + + 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)) }); + 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('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 }); + SessionTokenCache.set({ tokenId, tokenResolver: dLow.promise }); + + dHigh.resolve(makeToken(highRaw)); + await tick(); + // A foreign resolve never populates the pending slot's resolvedToken. + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); + + 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 }); + SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise }); + + dLow.resolve(makeToken(lowRaw)); + await tick(); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); + + dHigh.resolve(makeToken(highRaw)); + await tick(); + + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + }); + + 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 }); + SessionTokenCache.set({ tokenId, tokenResolver: dLow.promise }); + + 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)) }); + 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 }); + 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); + }); + + 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', () => { 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..da167804486 100644 --- a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -1,22 +1,39 @@ import type { JWT, TokenResource } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; -import { pickFreshestJwt } from '../tokenFreshness'; +import { normalizeOrgId, pickFreshestJwt, 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 +126,79 @@ describe('pickFreshestJwt', () => { }); }); }); + +describe('pickFreshestJwt (optional baseline)', () => { + it('returns incoming when existing is null', () => { + const incoming = makeToken({ oiat: 100 }); + expect(pickFreshestJwt(null, incoming)).toBe(incoming); + }); + + it('returns incoming when existing is undefined', () => { + const incoming = makeToken({ oiat: 100 }); + 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(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(pickFreshestJwt(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 7af95fb601e..3ccc1dd4d38 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -19,6 +19,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 { normalizeOrgId, pickFreshestJwt, tokenOiat, tokenOrgId, tokenSid } from '../tokenFreshness'; import { createActiveContextCookie } from './cookies/activeContext'; import type { ClientUatCookieHandler } from './cookies/clientUat'; import { createClientUatCookie } from './cookies/clientUat'; @@ -205,6 +207,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'); } @@ -214,6 +220,51 @@ export class AuthCookieService { return token ? this.sessionCookie.set(token) : this.sessionCookie.remove(); } + // Returns true only when `raw` is strictly staler than the SAME session+org current cookie. + // 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) { + return false; + } + + const current = this.#decodeToken(this.sessionCookie.get()); + if (!current || tokenOiat(current) == null) { + 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) || + normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming)) + ) { + return false; + } + + return pickFreshestJwt(current, 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..419f41b028c 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -50,6 +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, tokenSid } from '../tokenFreshness'; import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; @@ -520,12 +521,30 @@ export class Session extends BaseResource implements SessionResource { eventBus.emit(events.TokenUpdate, { token }); - if (token.jwt) { + 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 aee7f42f614..4a3268a967d 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -3,6 +3,7 @@ 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'; @@ -2042,4 +2043,248 @@ 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('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); + + 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); + + // 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(low); + + 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('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); + + 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 }); + + // Each skipCache call returns its own mint (main parity). + 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 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); + + fetchSpy + .mockResolvedValueOnce({ object: 'token', jwt: high }) + .mockResolvedValueOnce({ object: 'token', jwt: low }) + .mockResolvedValueOnce({ object: 'token', jwt: low }); + + expect(await session.getToken()).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].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); + 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 }); + + // 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(low); + + 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 }); + + // 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(low); + + 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 (equal rank, newest wins)', async () => { + const session = makeSession(); + const first = createJwtWithOiat(NOW, undefined); + const second = createJwtWithOiat(NOW + 5, undefined); + + 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); + }); + + 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 217a89b1b04..4c67ca3aad6 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; @@ -250,7 +259,7 @@ const MemoryTokenCache = (prefix?: string): 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', @@ -321,48 +330,94 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { const createdAt = entry.createdAt ?? nowSeconds; const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined }; - 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); + // 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). + const dropIfCurrent = (target: TokenCacheValue) => { + if (store.get(key) !== target) { + return; } + clearTimeout(target.timeoutId); + clearTimeout(target.refreshTimeoutId); + store.delete(key); }; store.set(key, value); 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 (store.get(key) !== value) { + const live = store.get(key); + if (!live) { + // Cleared while pending; do not resurrect. return; } - // Store resolved token for synchronous reads - entry.resolvedToken = newToken; - const claims = newToken.jwt?.claims; - if (!claims || typeof claims.exp !== 'number' || typeof claims.iat !== 'number') { - return deleteKey(); + 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; + } + + 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 winnerClaims = winner.jwt?.claims; + if (!winnerClaims || typeof winnerClaims.exp !== 'number' || typeof winnerClaims.iat !== 'number') { + dropIfCurrent(live); + return; } - const expiresAt = claims.exp; - const issuedAt = claims.iat; + clearTimeout(live.timeoutId); + clearTimeout(live.refreshTimeoutId); + + 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); - value.createdAt = issuedAt; - value.expiresIn = expiresIn; + live.createdAt = issuedAt; + live.expiresIn = expiresIn; + + if (remainingTtl <= 0) { + dropIfCurrent(live); + return; + } - const timeoutId = setTimeout(deleteKey, expiresIn * 1000); - value.timeoutId = timeoutId; + 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. // More info at https://nodejs.org/api/timers.html#timeoutunref @@ -377,14 +432,14 @@ 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 && 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(); @@ -396,10 +451,10 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { // Best-effort side effect: a broadcast failure (e.g. postMessage racing a // channel close) must not reach the outer catch and evict the cached token (SDK-119). try { - const tokenRaw = newToken.getRawString(); - if (tokenRaw && claims.sid) { - const sessionId = claims.sid; - const organizationId = claims.org_id || (claims.o as any)?.id; + const tokenRaw = winner.getRawString(); + 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); @@ -441,7 +496,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { } }) .catch(() => { - deleteKey(); + dropIfCurrent(value); }); }; diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index 9aa87fd9948..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; @@ -50,3 +55,20 @@ 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 || ''; +}