From 48ab952c67cdcc4f53b2202483bfff67dfb9006f Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Mon, 6 Jul 2026 17:07:30 +0300 Subject: [PATCH] fix(clerk-js): Backport monotonic session token guard for Core 2 Backport of #9030 to the Core 2 maintenance line. Prevents a staler session token from overwriting a fresher one on the same tab, protecting the __session cookie and the in-memory lastActiveToken. Freshness is ranked by the JWT oiat header, then iat; tokens without oiat pass through. Core 2 predates the token-cache substrate the original built on (no resolvedToken or baseline chaining and no background-refresh timers), so the tokenCache rework and its tests are omitted. The two write-site guards, the AuthCookieService cookie write and the Session lastActiveToken assignment, deliver the user-facing guarantee on their own. --- .changeset/monotonic-session-token-guard.md | 5 + .../clerk-js/src/core/__tests__/clerk.test.ts | 161 ++++++++++++++++++ .../src/core/__tests__/tokenFreshness.test.ts | 103 ++++++++++- .../src/core/auth/AuthCookieService.ts | 51 ++++++ .../clerk-js/src/core/resources/Session.ts | 21 ++- .../core/resources/__tests__/Session.test.ts | 105 ++++++++++++ packages/clerk-js/src/core/tokenFreshness.ts | 28 ++- 7 files changed, 465 insertions(+), 9 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..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 ca2c1d5e1dd..e71a792f50b 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -739,6 +739,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__/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 36acddede0d..c79c3b81981 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -13,6 +13,8 @@ import { clerkMissingDevBrowserJwt } 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'; @@ -189,6 +191,10 @@ export class AuthCookieService { return; } + if (token && this.#shouldDropStaleToken(token)) { + return; + } + const sessions = (this.clerk.client as any)?.sessions; if (sessions?.length > 1 && token) { debugLogger.info( @@ -211,6 +217,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 c43fada497b..bcd93eceffe 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -40,6 +40,7 @@ import { TokenId } from '@/utils/tokenId'; import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; import { eventBus, events } from '../events'; import { SessionTokenCache } from '../tokenCache'; +import { normalizeOrgId, pickFreshestJwt, tokenOrgId, tokenSid } from '../tokenFreshness'; import { BaseResource, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; @@ -437,7 +438,7 @@ export class Session extends BaseResource implements SessionResource { if (shouldDispatchTokenUpdate) { eventBus.emit(events.TokenUpdate, { token }); - if (token.jwt) { + if (token.jwt && !this.#shouldKeepExistingLastActiveToken(token)) { this.lastActiveToken = token; // Emits the updated session with the new token to the state listeners eventBus.emit(events.SessionTokenResolved, null); @@ -449,6 +450,24 @@ export class Session extends BaseResource implements SessionResource { }); } + // 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; + } + get currentTask() { const [task] = this.tasks ?? []; return task; 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 9d0d183525a..a549bbabfb1 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -429,6 +429,111 @@ describe('Session', () => { }); }); + describe('getToken() monotonic lastActiveToken guard', () => { + const NOW = 1666648250; + const DEFAULT_SID = 'session_1'; + + // 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`; + } + + 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 fetchSpy: ReturnType; + + beforeEach(() => { + SessionTokenCache.clear(); + fetchSpy = vi.spyOn(BaseResource, '_fetch' as any); + BaseResource.clerk = clerkMock(); + }); + + afterEach(() => { + fetchSpy?.mockRestore(); + BaseResource.clerk = null as any; + SessionTokenCache.clear(); + }); + + it('keeps the fresher lastActiveToken when a staler same-context token is minted afterward', 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 }); + + expect(await session.getToken()).toBe(high); + expect(session.lastActiveToken?.getRawString()).toBe(high); + + // The later staler mint still returns its own token, but must not regress lastActiveToken. + expect(await session.getToken({ skipCache: true })).toBe(low); + expect(session.lastActiveToken?.getRawString()).toBe(high); + }); + + it('advances lastActiveToken when a fresher same-context token is minted afterward', async () => { + const session = makeSession(); + const low = createJwtWithOiat(NOW, NOW); + const high = createJwtWithOiat(NOW, NOW + 30); + fetchSpy + .mockResolvedValueOnce({ object: 'token', jwt: low }) + .mockResolvedValueOnce({ object: 'token', jwt: high }); + + expect(await session.getToken()).toBe(low); + expect(session.lastActiveToken?.getRawString()).toBe(low); + + expect(await session.getToken({ skipCache: true })).toBe(high); + expect(session.lastActiveToken?.getRawString()).toBe(high); + }); + + it('adopts an org-switch token even when it is minted with a lower oiat than the previous org token', async () => { + const session = makeSession(); + const personalHigh = createJwtWithOiat(NOW, NOW + 30); + const orgLow = createJwtWithOiat(NOW, NOW, { org: 'org_x' }); + fetchSpy + .mockResolvedValueOnce({ object: 'token', jwt: personalHigh }) + .mockResolvedValueOnce({ object: 'token', jwt: orgLow }); + + expect(await session.getToken()).toBe(personalHigh); + expect(session.lastActiveToken?.getRawString()).toBe(personalHigh); + + // Switch the active org so the org token dispatches (shouldDispatchTokenUpdate needs + // organizationId === lastActiveOrganizationId). A different-org lastActiveToken is not a + // freshness baseline, so the lower-oiat org token is adopted rather than dropped. + session.lastActiveOrganizationId = 'org_x'; + expect(await session.getToken({ skipCache: true })).toBe(orgLow); + expect(session.lastActiveToken?.getRawString()).toBe(orgLow); + }); + }); + describe('touch()', () => { let dispatchSpy: ReturnType; 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 || ''; +}