Skip to content
Open
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/monotonic-session-token-guard.md
Original file line number Diff line number Diff line change
@@ -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.
161 changes: 161 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { alg: 'HS256', typ: 'JWT' };
if (oiat !== undefined) {
header.oiat = oiat;
}
const payload: Record<string, unknown> = { 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();
Expand Down
103 changes: 98 additions & 5 deletions packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down Expand Up @@ -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();
});
});
51 changes: 51 additions & 0 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down
Loading
Loading