diff --git a/.changeset/session-minter-monotonic-guard.md b/.changeset/session-minter-monotonic-guard.md new file mode 100644 index 00000000000..2b64724cfc1 --- /dev/null +++ b/.changeset/session-minter-monotonic-guard.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Add monotonic token replacement based on `oiat` to prevent edge-minted tokens with stale claims from overwriting fresher DB-minted tokens in multi-tab scenarios. diff --git a/.changeset/session-minter-sdk-params.md b/.changeset/session-minter-sdk-params.md new file mode 100644 index 00000000000..29dc8b78d96 --- /dev/null +++ b/.changeset/session-minter-sdk-params.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': patch +'@clerk/clerk-js': patch +--- + +Add `oiat` field to `JwtHeader` type. Send previous session token and `force_origin` param on `/tokens` requests to support Session Minter edge token minting. diff --git a/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts new file mode 100644 index 00000000000..ce826dc2322 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts @@ -0,0 +1,96 @@ +import type { TokenResource } from '@clerk/shared/types'; +import { describe, expect, it } from 'vitest'; + +import { claimFreshness, shouldRejectToken } from '../tokenFreshness'; + +function makeToken(opts: { oiat?: number; iat?: number } = {}): TokenResource { + return { + jwt: { + header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) }, + claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) }, + }, + getRawString: () => 'mock-jwt', + } as unknown as TokenResource; +} + +describe('claimFreshness', () => { + it('returns oiat when present', () => { + expect(claimFreshness(makeToken({ oiat: 100, iat: 200 }))).toBe(100); + }); + + it('returns iat when oiat is absent', () => { + expect(claimFreshness(makeToken({ iat: 200 }))).toBe(200); + }); + + it('returns undefined when input has no jwt', () => { + expect(claimFreshness(undefined)).toBeUndefined(); + }); +}); + +describe('shouldRejectToken', () => { + describe('both have oiat', () => { + it('row 1: rejects when existing oiat > incoming oiat', () => { + expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ oiat: 90 }))).toBe(true); + }); + + it('row 2: accepts when existing oiat < incoming oiat', () => { + expect(shouldRejectToken(makeToken({ oiat: 90 }), makeToken({ oiat: 100 }))).toBe(false); + }); + + it('row 3: rejects when oiat equal and existing iat > incoming iat', () => { + expect(shouldRejectToken(makeToken({ oiat: 100, iat: 200 }), makeToken({ oiat: 100, iat: 150 }))).toBe(true); + }); + + it('row 4: accepts when oiat equal and existing iat < incoming iat', () => { + expect(shouldRejectToken(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 200 }))).toBe(false); + }); + + it('row 5: rejects when oiat equal and iat equal (keep existing)', () => { + expect(shouldRejectToken(makeToken({ oiat: 100, iat: 150 }), makeToken({ oiat: 100, iat: 150 }))).toBe(true); + }); + }); + + describe('one has oiat, one does not', () => { + it('row 6: accepts when existing oiat < incoming iat', () => { + expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 120 }))).toBe(false); + }); + + it('row 7: rejects when existing oiat > incoming iat', () => { + expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 80 }))).toBe(true); + }); + + it('row 8: accepts when existing oiat == incoming iat (different regimes, favor movement)', () => { + expect(shouldRejectToken(makeToken({ oiat: 100 }), makeToken({ iat: 100 }))).toBe(false); + }); + + it('row 9: rejects when existing iat > incoming oiat', () => { + expect(shouldRejectToken(makeToken({ iat: 150 }), makeToken({ oiat: 100 }))).toBe(true); + }); + + it('row 10: accepts when existing iat < incoming oiat', () => { + expect(shouldRejectToken(makeToken({ iat: 90 }), makeToken({ oiat: 100 }))).toBe(false); + }); + + it('row 11: accepts when existing iat == incoming oiat (different regimes, favor movement)', () => { + expect(shouldRejectToken(makeToken({ iat: 100 }), makeToken({ oiat: 100 }))).toBe(false); + }); + }); + + describe('neither has oiat', () => { + it('row 12: rejects when existing iat > incoming iat', () => { + expect(shouldRejectToken(makeToken({ iat: 200 }), makeToken({ iat: 150 }))).toBe(true); + }); + + it('row 13: accepts when existing iat < incoming iat', () => { + expect(shouldRejectToken(makeToken({ iat: 150 }), makeToken({ iat: 200 }))).toBe(false); + }); + + it('row 14: rejects when iat equal (keep existing, avoid churn)', () => { + expect(shouldRejectToken(makeToken({ iat: 150 }), makeToken({ iat: 150 }))).toBe(true); + }); + + it("row 15: accepts when both iat null (can't compare, accept)", () => { + expect(shouldRejectToken(makeToken(), makeToken())).toBe(false); + }); + }); +}); diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 7a384a70866..cafe6fdef1b 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -13,7 +13,9 @@ import type { Clerk, InstanceType } from '@clerk/shared/types'; import { noop } from '@clerk/shared/utils'; import { debugLogger } from '@/utils/debug'; +import { decode } from '@/utils/jwt'; +import { claimFreshness } from '../tokenFreshness'; import { clerkMissingDevBrowser } from '../errors'; import { eventBus, events } from '../events'; import type { FapiClient } from '../fapiClient'; @@ -194,6 +196,29 @@ export class AuthCookieService { return; } + // Monotonic freshness guard: don't regress the cookie within the same session + if (token) { + const currentRaw = this.sessionCookie.get(); + if (currentRaw) { + try { + const current = decode(currentRaw); + const incoming = decode(token); + const currentSid = current.claims.sid; + const incomingSid = incoming.claims.sid; + // Only apply within the same session. Different sessions always allowed. + if (currentSid && incomingSid && currentSid === incomingSid) { + const currentFresh = claimFreshness(current); + const incomingFresh = claimFreshness(incoming); + if (currentFresh != null && incomingFresh != null && currentFresh > incomingFresh) { + return; + } + } + } catch { + // If decode fails, allow the write (don't block on malformed tokens) + } + } + } + if (!token && !isValidBrowserOnline()) { debugLogger.warn('Removing session cookie (offline)', { sessionId: this.clerk.session?.id }, 'authCookieService'); } diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ea5e796dbb8..023b644ad70 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -41,6 +41,7 @@ import type { } from '@clerk/shared/types'; import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/shared/webauthn'; +import { shouldRejectToken } from '@/core/tokenFreshness'; import { unixEpochToDate } from '@/utils/date'; import { debugLogger } from '@/utils/debug'; import { TokenId } from '@/utils/tokenId'; @@ -455,7 +456,10 @@ export class Session extends BaseResource implements SessionResource { // Only emit token updates when we have an actual token — emitting with an empty // token causes AuthCookieService to remove the __session cookie (looks like sign-out). if (shouldDispatchTokenUpdate && cachedToken.getRawString()) { - eventBus.emit(events.TokenUpdate, { token: cachedToken }); + const reject = this.lastActiveToken && shouldRejectToken(this.lastActiveToken, cachedToken); + if (!reject) { + eventBus.emit(events.TokenUpdate, { token: cachedToken }); + } } result = cachedToken.getRawString() || null; } else if (!isBrowserOnline()) { @@ -504,6 +508,12 @@ export class Session extends BaseResource implements SessionResource { return; } + if (this.lastActiveToken) { + if (shouldRejectToken(this.lastActiveToken, token)) { + return; + } + } + eventBus.emit(events.TokenUpdate, { token }); if (token.jwt) { 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 4ccae5510e2..5e49e46908b 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -98,7 +98,9 @@ describe('Session', () => { expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled(); expect(token).toEqual(mockJwt); - expect(dispatchSpy).toHaveBeenCalledTimes(2); + // Cache hits with the same token as lastActiveToken suppress re-emission + // to avoid unnecessary cookie writes (monotonic freshness guard). + expect(dispatchSpy).toHaveBeenCalledTimes(0); }); it('returns same token without API call when Session is reconstructed', async () => { diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 98bfaa25fae..71d9601a9fa 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -5,6 +5,7 @@ import { TokenId } from '@/utils/tokenId'; import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller'; import { Token } from './resources/internal'; +import { shouldRejectToken } from './tokenFreshness'; /** * Identifies a cached token entry by tokenId and optional audience. @@ -288,11 +289,10 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const result = get({ tokenId: data.tokenId }); if (result) { const existingToken = await result.entry.tokenResolver; - const existingIat = existingToken.jwt?.claims?.iat; - if (existingIat && existingIat >= iat) { + if (shouldRejectToken(existingToken, token)) { debugLogger.debug( - 'Ignoring older token broadcast', - { existingIat, incomingIat: iat, tabId, tokenId: data.tokenId, traceId: data.traceId }, + 'Ignoring staler token broadcast', + { tokenId: data.tokenId, traceId: data.traceId }, 'tokenCache', ); return; @@ -369,6 +369,15 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { entry.tokenResolver .then(newToken => { + // Compare-and-swap: if another concurrent resolve already committed + // a fresher token for this key, don't overwrite it. + const currentValue = cache.get(key); + if (currentValue?.entry?.resolvedToken && newToken) { + if (shouldRejectToken(currentValue.entry.resolvedToken, newToken)) { + return; + } + } + // Store resolved token for synchronous reads entry.resolvedToken = newToken; diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts new file mode 100644 index 00000000000..6203a877d4b --- /dev/null +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -0,0 +1,61 @@ +import type { JWT, TokenResource } from '@clerk/shared/types'; + +/** + * Returns the claim freshness of a token or raw JWT. + * + * - If the token has `oiat` (JWT header): that's when claims were last assembled from the DB. + * Edge re-mints copy this value forward, so iat can be recent while oiat is old. + * - If the token has no `oiat`: it's origin-minted (coupled FF means no Session Minter), + * so iat IS when claims were last read from the DB. + * + * @internal + */ +export function claimFreshness(input: TokenResource | JWT | undefined | null): number | undefined { + if (!input) { + return undefined; + } + // TokenResource has .jwt wrapping the JWT; raw JWT has .header directly + const jwt = 'getRawString' in input ? input.jwt : input; + return jwt?.header?.oiat ?? jwt?.claims?.iat; +} + +/** + * Determines whether an incoming token should be rejected in favor of the existing one. + * Returns true if the incoming token is staler than the existing one. + * + * @internal + */ +export function shouldRejectToken(existing: TokenResource, incoming: TokenResource): boolean { + const existingFreshness = claimFreshness(existing); + const incomingFreshness = claimFreshness(incoming); + + // Can't determine freshness: accept incoming as safe default + if (existingFreshness == null || incomingFreshness == null) { + return false; + } + + // Different freshness: the fresher token wins + if (existingFreshness > incomingFreshness) { + return true; + } + if (existingFreshness < incomingFreshness) { + return false; + } + + // Equal freshness: tie-break depends on regime + const existingHasOiat = existing.jwt?.header?.oiat != null; + const incomingHasOiat = incoming.jwt?.header?.oiat != null; + const sameRegime = existingHasOiat === incomingHasOiat; + + if (sameRegime) { + // Same regime, equal freshness. + // Both have oiat: tie-break by iat (more recent mint wins). Equal iat: keep existing. + // Neither has oiat: both origin, same DB snapshot. Keep existing (avoid churn). + const existingIat = existing.jwt?.claims?.iat ?? 0; + const incomingIat = incoming.jwt?.claims?.iat ?? 0; + return existingIat >= incomingIat; + } + + // Different regimes, equal freshness. Transition is happening. Favor incoming. + return false; +}