Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/session-minter-monotonic-guard.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/session-minter-sdk-params.md
Original file line number Diff line number Diff line change
@@ -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.
96 changes: 96 additions & 0 deletions packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
25 changes: 25 additions & 0 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
}
Expand Down
12 changes: 11 additions & 1 deletion packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
17 changes: 13 additions & 4 deletions packages/clerk-js/src/core/tokenCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
61 changes: 61 additions & 0 deletions packages/clerk-js/src/core/tokenFreshness.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading