Skip to content

Commit 48ab952

Browse files
committed
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.
1 parent cb0e1d0 commit 48ab952

7 files changed

Lines changed: 465 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
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.

packages/clerk-js/src/core/__tests__/clerk.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,167 @@ describe('Clerk singleton', () => {
739739
);
740740
});
741741

742+
describe('updateSessionCookie monotonic backstop', () => {
743+
const sessionId = 'sess_active';
744+
// The cookie guard treats an expired current cookie as no baseline, so test
745+
// tokens must carry real, non-expired timestamps rather than tiny literals.
746+
const T0 = Math.floor(Date.now() / 1000);
747+
748+
const createJwtWithOiat = (
749+
iat: number,
750+
oiat: number | undefined,
751+
opts: { sid?: string; org?: string; ttl?: number } = {},
752+
): string => {
753+
const { sid = sessionId, org, ttl = 60 } = opts;
754+
const header: Record<string, unknown> = { alg: 'HS256', typ: 'JWT' };
755+
if (oiat !== undefined) {
756+
header.oiat = oiat;
757+
}
758+
const payload: Record<string, unknown> = { sid, iat, exp: iat + ttl };
759+
if (org) {
760+
payload.org_id = org;
761+
}
762+
const b64 = (o: object) => btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
763+
return `${b64(header)}.${b64(payload)}.test-signature`;
764+
};
765+
766+
const loadClerkWithSession = async () => {
767+
const mockSession = {
768+
id: sessionId,
769+
status: 'active',
770+
user: {},
771+
getToken: vi.fn(),
772+
lastActiveToken: { getRawString: () => mockJwt },
773+
};
774+
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] }));
775+
const sut = new Clerk(productionPublishableKey);
776+
await sut.load();
777+
return sut;
778+
};
779+
780+
const emitToken = (raw: string | null) => {
781+
eventBus.emit(events.TokenUpdate, {
782+
token: raw === null ? null : ({ jwt: {}, getRawString: () => raw } as any),
783+
});
784+
};
785+
786+
it('drops a strictly-staler same-context token and keeps the fresher cookie', async () => {
787+
await loadClerkWithSession();
788+
789+
const fresh = createJwtWithOiat(T0, 200, { ttl: 600 });
790+
emitToken(fresh);
791+
expect(document.cookie).toContain(fresh);
792+
793+
const stale = createJwtWithOiat(T0 - 10, 100);
794+
emitToken(stale);
795+
expect(document.cookie).not.toContain(stale);
796+
expect(document.cookie).toContain(fresh);
797+
});
798+
799+
it('applies a lower-oiat token when the current cookie is expired (no freshness baseline)', async () => {
800+
await loadClerkWithSession();
801+
802+
const expiredFresher = createJwtWithOiat(T0 - 120, 500, { ttl: 60 });
803+
emitToken(expiredFresher);
804+
expect(document.cookie).toContain(expiredFresher);
805+
806+
const validStaler = createJwtWithOiat(T0, 100);
807+
emitToken(validStaler);
808+
expect(document.cookie).toContain(validStaler);
809+
});
810+
811+
it('applies a fresher same-context token', async () => {
812+
await loadClerkWithSession();
813+
814+
const older = createJwtWithOiat(T0, 100);
815+
emitToken(older);
816+
expect(document.cookie).toContain(older);
817+
818+
const newer = createJwtWithOiat(T0 + 10, 200);
819+
emitToken(newer);
820+
expect(document.cookie).toContain(newer);
821+
});
822+
823+
it('applies a token with equal oiat and iat (publish on tie)', async () => {
824+
await loadClerkWithSession();
825+
826+
const first = createJwtWithOiat(T0, 100, { ttl: 60 });
827+
emitToken(first);
828+
expect(document.cookie).toContain(first);
829+
830+
const second = createJwtWithOiat(T0, 100, { ttl: 120 });
831+
emitToken(second);
832+
expect(document.cookie).toContain(second);
833+
});
834+
835+
it('writes a token for a different session (cross-context cookies are not compared)', async () => {
836+
await loadClerkWithSession();
837+
838+
const otherSession = createJwtWithOiat(T0, 200, { sid: 'sess_other' });
839+
emitToken(otherSession);
840+
expect(document.cookie).toContain(otherSession);
841+
});
842+
843+
it('writes a token for a different organization (cross-context cookies are not compared)', async () => {
844+
await loadClerkWithSession();
845+
846+
const otherOrg = createJwtWithOiat(T0, 200, { org: 'org_other' });
847+
emitToken(otherOrg);
848+
expect(document.cookie).toContain(otherOrg);
849+
});
850+
851+
it('applies a personal-workspace token (no org) for the active personal workspace', async () => {
852+
await loadClerkWithSession();
853+
854+
const personal = createJwtWithOiat(T0, 200);
855+
emitToken(personal);
856+
expect(document.cookie).toContain(personal);
857+
});
858+
859+
it('applies an active-context token even when the current cookie is a different session with higher oiat', async () => {
860+
const sut = await loadClerkWithSession();
861+
862+
// Plant a different-session, higher-oiat cookie by temporarily making it the active context.
863+
(sut.session as any).id = 'sess_other';
864+
const otherContext = createJwtWithOiat(T0 + 20, 999, { sid: 'sess_other' });
865+
emitToken(otherContext);
866+
expect(document.cookie).toContain(otherContext);
867+
868+
// Restore the active session; a lower-oiat active-context token must still apply,
869+
// because the different-session cookie is not a valid freshness baseline.
870+
(sut.session as any).id = sessionId;
871+
const active = createJwtWithOiat(T0, 100, { sid: sessionId });
872+
emitToken(active);
873+
expect(document.cookie).toContain(active);
874+
});
875+
876+
it('applies a token without an oiat header (fail open)', async () => {
877+
await loadClerkWithSession();
878+
879+
const noOiat = createJwtWithOiat(T0, undefined);
880+
emitToken(noOiat);
881+
expect(document.cookie).toContain(noOiat);
882+
});
883+
884+
it('applies a malformed token (fail open)', async () => {
885+
await loadClerkWithSession();
886+
887+
emitToken('garbage.token');
888+
expect(document.cookie).toContain('garbage.token');
889+
});
890+
891+
it('removes the cookie when the token is null', async () => {
892+
await loadClerkWithSession();
893+
894+
const fresh = createJwtWithOiat(T0, 200);
895+
emitToken(fresh);
896+
expect(document.cookie).toContain(fresh);
897+
898+
emitToken(null);
899+
expect(document.cookie).not.toContain(fresh);
900+
});
901+
});
902+
742903
describe('.signOut()', () => {
743904
const mockClientDestroy = vi.fn();
744905
const mockClientRemoveSessions = vi.fn();

packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,39 @@
11
import type { JWT, TokenResource } from '@clerk/shared/types';
22
import { describe, expect, it } from 'vitest';
33

4-
import { pickFreshestJwt } from '../tokenFreshness';
4+
import { normalizeOrgId, pickFreshestJwt, tokenOiat, tokenOrgId, tokenSid } from '../tokenFreshness';
55

6-
function makeToken(opts: { oiat?: number; iat?: number } = {}): TokenResource {
6+
interface TokenOpts {
7+
oiat?: number;
8+
iat?: number;
9+
sid?: string;
10+
orgId?: string;
11+
oId?: string;
12+
}
13+
14+
function makeClaims(opts: TokenOpts) {
15+
return {
16+
...(opts.iat != null ? { iat: opts.iat } : {}),
17+
...(opts.sid != null ? { sid: opts.sid } : {}),
18+
...(opts.orgId != null ? { org_id: opts.orgId } : {}),
19+
...(opts.oId != null ? { o: { id: opts.oId } } : {}),
20+
};
21+
}
22+
23+
function makeToken(opts: TokenOpts = {}): TokenResource {
724
return {
825
jwt: {
926
header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) },
10-
claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) },
27+
claims: makeClaims(opts),
1128
},
1229
getRawString: () => 'mock-jwt',
1330
} as unknown as TokenResource;
1431
}
1532

16-
function makeJwt(opts: { oiat?: number; iat?: number } = {}): JWT {
33+
function makeJwt(opts: TokenOpts = {}): JWT {
1734
return {
1835
header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) },
19-
claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) },
36+
claims: makeClaims(opts),
2037
} as unknown as JWT;
2138
}
2239

@@ -109,3 +126,79 @@ describe('pickFreshestJwt', () => {
109126
});
110127
});
111128
});
129+
130+
describe('pickFreshestJwt (optional baseline)', () => {
131+
it('returns incoming when existing is null', () => {
132+
const incoming = makeToken({ oiat: 100 });
133+
expect(pickFreshestJwt(null, incoming)).toBe(incoming);
134+
});
135+
136+
it('returns incoming when existing is undefined', () => {
137+
const incoming = makeToken({ oiat: 100 });
138+
expect(pickFreshestJwt(undefined, incoming)).toBe(incoming);
139+
});
140+
141+
it('returns the newer token when existing is older than incoming', () => {
142+
const existing = makeToken({ oiat: 90 });
143+
const incoming = makeToken({ oiat: 100 });
144+
expect(pickFreshestJwt(existing, incoming)).toBe(incoming);
145+
});
146+
147+
it('returns the newer token when existing is newer than incoming', () => {
148+
const existing = makeToken({ oiat: 100 });
149+
const incoming = makeToken({ oiat: 90 });
150+
expect(pickFreshestJwt(existing, incoming)).toBe(existing);
151+
});
152+
});
153+
154+
describe('normalizeOrgId', () => {
155+
it('returns empty string for undefined', () => {
156+
expect(normalizeOrgId(undefined)).toBe('');
157+
});
158+
159+
it('returns empty string for null', () => {
160+
expect(normalizeOrgId(null)).toBe('');
161+
});
162+
163+
it('returns empty string for empty string', () => {
164+
expect(normalizeOrgId('')).toBe('');
165+
});
166+
167+
it('returns the org id when present', () => {
168+
expect(normalizeOrgId('org_1')).toBe('org_1');
169+
});
170+
});
171+
172+
describe('tokenOrgId', () => {
173+
it('reads org_id from claims', () => {
174+
expect(tokenOrgId(makeToken({ orgId: 'org_1' }))).toBe('org_1');
175+
});
176+
177+
it('falls back to o.id when org_id is absent', () => {
178+
expect(tokenOrgId(makeToken({ oId: 'org_2' }))).toBe('org_2');
179+
});
180+
181+
it('returns empty string when neither org_id nor o.id is present', () => {
182+
expect(tokenOrgId(makeToken({ oiat: 100 }))).toBe('');
183+
});
184+
});
185+
186+
describe('tokenOiat', () => {
187+
it('returns the oiat header when present', () => {
188+
expect(tokenOiat(makeToken({ oiat: 100 }))).toBe(100);
189+
});
190+
191+
it('returns undefined when oiat is absent', () => {
192+
expect(tokenOiat(makeToken({ iat: 150 }))).toBeUndefined();
193+
});
194+
});
195+
196+
describe('tokenSid', () => {
197+
it('returns the sid claim when present', () => {
198+
expect(tokenSid(makeToken({ sid: 'session_1' }))).toBe('session_1');
199+
});
200+
201+
it('returns undefined when sid is absent', () => {
202+
expect(tokenSid(makeToken({ oiat: 100 }))).toBeUndefined();
203+
});
204+
});

packages/clerk-js/src/core/auth/AuthCookieService.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { clerkMissingDevBrowserJwt } from '../errors';
1313
import { eventBus, events } from '../events';
1414
import type { FapiClient } from '../fapiClient';
1515
import { Environment } from '../resources/Environment';
16+
import { Token } from '../resources/Token';
17+
import { normalizeOrgId, pickFreshestJwt, tokenOiat, tokenOrgId, tokenSid } from '../tokenFreshness';
1618
import { createActiveContextCookie } from './cookies/activeContext';
1719
import type { ClientUatCookieHandler } from './cookies/clientUat';
1820
import { createClientUatCookie } from './cookies/clientUat';
@@ -189,6 +191,10 @@ export class AuthCookieService {
189191
return;
190192
}
191193

194+
if (token && this.#shouldDropStaleToken(token)) {
195+
return;
196+
}
197+
192198
const sessions = (this.clerk.client as any)?.sessions;
193199
if (sessions?.length > 1 && token) {
194200
debugLogger.info(
@@ -211,6 +217,51 @@ export class AuthCookieService {
211217
return token ? this.sessionCookie.set(token) : this.sessionCookie.remove();
212218
}
213219

220+
// Returns true only when `raw` is strictly staler than the SAME session+org current cookie.
221+
// Fails open (false) for tokens without oiat, decode failures, cross-context tokens, and an
222+
// already-expired current cookie: the cookie enforces monotonicity within one session+org
223+
// only, never across a session/org switch.
224+
#shouldDropStaleToken(raw: string): boolean {
225+
const incoming = this.#decodeToken(raw);
226+
if (!incoming || tokenOiat(incoming) == null) {
227+
return false;
228+
}
229+
230+
const current = this.#decodeToken(this.sessionCookie.get());
231+
if (!current || tokenOiat(current) == null) {
232+
return false;
233+
}
234+
235+
// An expired cookie is not a freshness baseline: a valid fresh mint must always be
236+
// able to replace it, even when a stale edge read gives it a lower oiat.
237+
const currentExp = current.jwt?.claims?.exp;
238+
if (typeof currentExp !== 'number' || currentExp <= Math.floor(Date.now() / 1000)) {
239+
return false;
240+
}
241+
242+
// Only a same session+org cookie is a comparable freshness baseline; write through otherwise.
243+
if (
244+
tokenSid(current) !== tokenSid(incoming) ||
245+
normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming))
246+
) {
247+
return false;
248+
}
249+
250+
return pickFreshestJwt(current, incoming) === current;
251+
}
252+
253+
#decodeToken(raw: string | undefined): Token | null {
254+
if (!raw) {
255+
return null;
256+
}
257+
try {
258+
const token = new Token({ id: '__session', jwt: raw, object: 'token' });
259+
return token.jwt ? token : null;
260+
} catch {
261+
return null;
262+
}
263+
}
264+
214265
public setClientUatCookieForDevelopmentInstances() {
215266
if (this.instanceType !== 'production' && this.inCustomDevelopmentDomain()) {
216267
this.clientUat.set(this.clerk.client);

0 commit comments

Comments
 (0)