From 2f848428713b96f43a2aa6bfddddbb19cfebdaae Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 30 Jul 2026 08:53:51 -0400 Subject: [PATCH 1/8] fix(*): recover from duplicate session cookies --- .changeset/duplicate-session-cookies.md | 6 + .../__tests__/authenticateContext.test.ts | 61 ++++++++++ .../src/tokens/__tests__/clerkRequest.test.ts | 15 +++ .../backend/src/tokens/authenticateContext.ts | 105 +++++++++++++++--- packages/backend/src/tokens/clerkRequest.ts | 59 +++++++++- .../src/core/auth/AuthCookieService.ts | 10 +- .../auth/__tests__/AuthCookieService.test.ts | 29 +++-- .../auth/cookies/__tests__/clientUat.test.ts | 85 +++++++++++++- .../auth/cookies/__tests__/session.test.ts | 58 +++++++++- .../src/core/auth/cookies/clientUat.ts | 12 ++ 10 files changed, 407 insertions(+), 33 deletions(-) create mode 100644 .changeset/duplicate-session-cookies.md diff --git a/.changeset/duplicate-session-cookies.md b/.changeset/duplicate-session-cookies.md new file mode 100644 index 00000000000..4212f9398d7 --- /dev/null +++ b/.changeset/duplicate-session-cookies.md @@ -0,0 +1,6 @@ +--- +'@clerk/backend': patch +'@clerk/clerk-js': patch +--- + +Recover from partitioned-cookie startup races and prefer fresh session tokens when duplicate session cookies are present. diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index 5df5e550ba8..fd8ca83efa2 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../runtime', () => ({ runtime: { crypto: globalThis.crypto } })); + import { createCookieHeader, createJwt, mockJwtPayload, pkLive, pkTest } from '../../fixtures'; import { runtime } from '../../runtime'; import { getCookieSuffix } from '../../util/shared'; @@ -13,6 +15,15 @@ describe('AuthenticateContext', () => { const session = createJwt(); const sessionWithInvalidIssuer = createJwt({ payload: { iss: 'http:whatever' } }); const newSession = createJwt({ payload: { iat: nowTimestampInSec + 60 } }); + const expiredSession = createJwt({ + payload: { exp: nowTimestampInSec - 60, iat: nowTimestampInSec - 120 }, + }); + const staleSession = createJwt({ + payload: { exp: nowTimestampInSec + 60, iat: nowTimestampInSec - 60 }, + }); + const freshSession = createJwt({ + payload: { exp: nowTimestampInSec + 300, iat: nowTimestampInSec }, + }); const clientUat = '1717490192'; const suffixedClientUat = '1717490193'; @@ -212,6 +223,56 @@ describe('AuthenticateContext', () => { expect(context.clientUat.toString()).toBe('0'); }); }); + + describe('duplicate session cookies', () => { + it('uses a later unexpired duplicate when an expired cookie appears first', async () => { + const headers = new Headers({ + cookie: [`__client_uat=${clientUat}`, `__session=${expiredSession}`, `__session=${freshSession}`].join('; '), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive }); + + expect(context.usesSuffixedCookies()).toBe(false); + expect(context.sessionTokenInCookie).toBe(freshSession); + }); + + it('uses the freshest valid duplicate for the selected cookie name', async () => { + const headers = new Headers({ + cookie: [`__client_uat=${clientUat}`, `__session=${staleSession}`, `__session=${freshSession}`].join('; '), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive }); + + expect(context.sessionTokenInCookie).toBe(freshSession); + }); + + it('uses the fresh duplicate for suffixed cookies', async () => { + const headers = new Headers({ + cookie: [ + `__client_uat=${clientUat}`, + `__client_uat_MqCvchyS=${suffixedClientUat}`, + `__session=${session}`, + `__session_MqCvchyS=${expiredSession}`, + `__session_MqCvchyS=${freshSession}`, + ].join('; '), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive }); + + expect(context.usesSuffixedCookies()).toBe(true); + expect(context.sessionTokenInCookie).toBe(freshSession); + }); + + it('keeps the first malformed duplicate when no usable session token exists', async () => { + const headers = new Headers({ + cookie: [`__client_uat=${clientUat}`, '__session=not-a-jwt', '__session=also-not-a-jwt'].join('; '), + }); + const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); + const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive }); + + expect(context.sessionTokenInCookie).toBe('not-a-jwt'); + }); + }); }); describe('relative proxyUrl resolution', () => { diff --git a/packages/backend/src/tokens/__tests__/clerkRequest.test.ts b/packages/backend/src/tokens/__tests__/clerkRequest.test.ts index 1b75b88cd44..409af32f8e9 100644 --- a/packages/backend/src/tokens/__tests__/clerkRequest.test.ts +++ b/packages/backend/src/tokens/__tests__/clerkRequest.test.ts @@ -97,6 +97,21 @@ describe('createClerkRequest', () => { expect(req.cookies.get('baz')).toBe('qux'); }); + it('preserves duplicate cookie values in request order', () => { + const req = createClerkRequest( + new Request('http://localhost:3000', { + headers: new Headers({ + cookie: '__session=stale; __session=fresh; __session_suffix=stale-suffix; __session_suffix=fresh-suffix', + }), + }), + ); + + expect(req.cookies.get('__session')).toBe('stale'); + expect(req.cookies.getAll('__session')).toEqual(['stale', 'fresh']); + expect(req.cookies.get('__session_suffix')).toBe('stale-suffix'); + expect(req.cookies.getAll('__session_suffix')).toEqual(['stale-suffix', 'fresh-suffix']); + }); + it('should parse and return cookies even if no cookie header exists', () => { const req = createClerkRequest(new Request('http://localhost:3000', { headers: new Headers() })); expect(req.cookies.get('foo')).toBeUndefined(); diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index aaf1d8f7e04..13f6437dbbf 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -109,8 +109,8 @@ class AuthenticateContext implements AuthenticateContext { public usesSuffixedCookies(): boolean { const suffixedClientUat = this.getSuffixedCookie(constants.Cookies.ClientUat); const clientUat = this.getCookie(constants.Cookies.ClientUat); - const suffixedSession = this.getSuffixedCookie(constants.Cookies.Session) || ''; - const session = this.getCookie(constants.Cookies.Session) || ''; + const suffixedSession = this.getSuffixedSessionCookie() || ''; + const session = this.getSessionCookie(constants.Cookies.Session) || ''; // In the case of malformed session cookies (eg missing the iss claim), we should // use the un-suffixed cookies to return signed-out state instead of triggering @@ -130,9 +130,9 @@ class AuthenticateContext implements AuthenticateContext { return false; } - const { data: sessionData } = decodeJwt(session); + const sessionData = this.decodeSessionToken(session); const sessionIat = sessionData?.payload.iat || 0; - const { data: suffixedSessionData } = decodeJwt(suffixedSession); + const suffixedSessionData = this.decodeSessionToken(suffixedSession); const suffixedSessionIat = suffixedSessionData?.payload.iat || 0; // Both indicate signed in, but un-suffixed is newer @@ -309,7 +309,7 @@ class AuthenticateContext implements AuthenticateContext { private initCookieValues() { // suffixedCookies needs to be set first because it's used in getMultipleAppsCookie - this.sessionTokenInCookie = this.getSuffixedOrUnSuffixedCookie(constants.Cookies.Session); + this.sessionTokenInCookie = this.getSuffixedOrUnSuffixedSessionCookie(); this.refreshTokenInCookie = this.getSuffixedCookie(constants.Cookies.Refresh); this.clientUat = Number.parseInt(this.getSuffixedOrUnSuffixedCookie(constants.Cookies.ClientUat) || '') || 0; } @@ -342,6 +342,10 @@ class AuthenticateContext implements AuthenticateContext { return this.getCookie(getSuffixedCookieName(name, this.cookieSuffix)) || undefined; } + private getSuffixedSessionCookie() { + return this.getSessionCookie(getSuffixedCookieName(constants.Cookies.Session, this.cookieSuffix)); + } + private getSuffixedOrUnSuffixedCookie(cookieName: string) { if (this.usesSuffixedCookies()) { return this.getSuffixedCookie(cookieName); @@ -349,6 +353,72 @@ class AuthenticateContext implements AuthenticateContext { return this.getCookie(cookieName); } + private getSuffixedOrUnSuffixedSessionCookie() { + if (this.usesSuffixedCookies()) { + return this.getSuffixedSessionCookie(); + } + return this.getSessionCookie(constants.Cookies.Session); + } + + private getSessionCookie(name: string) { + const tokens = this.getCookieValues(name); + + if (tokens.length === 0) { + return undefined; + } + + return this.selectBestSessionToken(tokens); + } + + private getCookieValues(name: string) { + const values = this.clerkRequest.cookies.getAll?.(name); + if (values?.length) { + return values; + } + + const value = this.clerkRequest.cookies.get(name); + return value ? [value] : []; + } + + private selectBestSessionToken(tokens: string[]) { + const fallbackToken = tokens[0]; + const usableCandidates: Array<{ token: string; data: Jwt }> = []; + + for (const token of tokens) { + const data = this.decodeSessionToken(token); + + if (data && this.sessionTokenUsable(data)) { + usableCandidates.push({ token, data }); + } + } + + const candidatesForInstance = usableCandidates.filter(candidate => + this.sessionTokenBelongsToInstance(candidate.data), + ); + const candidates = candidatesForInstance.length ? candidatesForInstance : usableCandidates; + const freshestCandidate = candidates.reduce<(typeof candidates)[number] | undefined>( + (freshest, candidate) => + !freshest || candidate.data.payload.iat > freshest.data.payload.iat ? candidate : freshest, + undefined, + ); + + return freshestCandidate?.token || fallbackToken; + } + + private decodeSessionToken(token: string): Jwt | undefined { + try { + const { data, errors } = decodeJwt(token); + + if (errors) { + return undefined; + } + + return data; + } catch { + return undefined; + } + } + private parseAuthorizationHeader(authorizationHeader: string | undefined | null): string | undefined { if (!authorizationHeader) { return undefined; @@ -370,11 +440,8 @@ class AuthenticateContext implements AuthenticateContext { } private tokenHasIssuer(token: string): boolean { - const { data, errors } = decodeJwt(token); - if (errors) { - return false; - } - return !!data.payload.iss; + const data = this.decodeSessionToken(token); + return !!data?.payload.iss; } private tokenBelongsToInstance(token: string): boolean { @@ -382,11 +449,23 @@ class AuthenticateContext implements AuthenticateContext { return false; } - const { data, errors } = decodeJwt(token); - if (errors) { + const data = this.decodeSessionToken(token); + if (!data) { + return false; + } + return this.sessionTokenBelongsToInstance(data); + } + + private sessionTokenUsable(jwt: Jwt): boolean { + return !!jwt.payload.iss && typeof jwt.payload.exp === 'number' && !this.sessionExpired(jwt); + } + + private sessionTokenBelongsToInstance(jwt: Jwt): boolean { + if (!jwt.payload.iss) { return false; } - const tokenIssuer = data.payload.iss.replace(/https?:\/\//gi, ''); + + const tokenIssuer = jwt.payload.iss.replace(/https?:\/\//gi, ''); // Use original frontend API for token validation since tokens are issued by the actual Clerk API, not proxy return this.originalFrontendApi === tokenIssuer; } diff --git a/packages/backend/src/tokens/clerkRequest.ts b/packages/backend/src/tokens/clerkRequest.ts index 8b02266c643..6683e3065f9 100644 --- a/packages/backend/src/tokens/clerkRequest.ts +++ b/packages/backend/src/tokens/clerkRequest.ts @@ -4,6 +4,26 @@ import { constants } from '../constants'; import type { ClerkUrl } from './clerkUrl'; import { createClerkUrl } from './clerkUrl'; +export type ClerkRequestCookieMap = Map & { + getAll(name: string): string[]; +}; + +class CookieMap extends Map implements ClerkRequestCookieMap { + private readonly valuesByName = new Map(); + + public append(name: string, value: string) { + if (!this.has(name)) { + this.set(name, value); + } + + this.valuesByName.set(name, [...(this.valuesByName.get(name) || []), value]); + } + + public getAll(name: string): string[] { + return this.valuesByName.get(name) || []; + } +} + /** * A class that extends the native Request class, * adds cookies helpers and a normalised clerkUrl that is constructed by using the values found @@ -11,7 +31,7 @@ import { createClerkUrl } from './clerkUrl'; */ class ClerkRequest extends Request { readonly clerkUrl: ClerkUrl; - readonly cookies: Map; + readonly cookies: ClerkRequestCookieMap; public constructor(input: ClerkRequest | Request | RequestInfo, init?: RequestInit) { // The usual way to duplicate a request object is to @@ -94,8 +114,41 @@ class ClerkRequest extends Request { } private parseCookies(req: Request) { - const cookiesRecord = parse(this.decodeCookieValue(req.headers.get('cookie') || '')); - return new Map(Object.entries(cookiesRecord)); + const cookieHeader = this.decodeCookieValue(req.headers.get('cookie') || ''); + const cookiesRecord = parse(cookieHeader); + const cookies = new CookieMap(); + + for (const [name, value] of Object.entries(cookiesRecord)) { + cookies.set(name, value); + } + + for (const [name, value] of this.parseCookiePairs(cookieHeader)) { + cookies.append(name, value); + } + + return cookies; + } + + private parseCookiePairs(cookieHeader: string) { + if (!cookieHeader) { + return []; + } + + return cookieHeader + .split(';') + .map(pair => { + const separatorIndex = pair.indexOf('='); + + if (separatorIndex === -1) { + return undefined; + } + + const name = pair.slice(0, separatorIndex).trim(); + const value = pair.slice(separatorIndex + 1).trim(); + + return name ? ([name, value] as const) : undefined; + }) + .filter((pair): pair is readonly [string, string] => !!pair); } private decodeCookieValue(str: string) { diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 3ccc1dd4d38..69a9145aaf1 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -83,11 +83,15 @@ export class AuthCookieService { eventBus.on(events.UserSignOut, () => this.handleSignOut()); - // After Environment resolves, re-write dev browser cookies with correct - // partitioned attributes. Dev browser cookies are initially written before - // Environment is fetched, so they may have stale attributes. + // Environment can resolve after auth cookies are first written. eventBus.on(events.EnvironmentUpdate, () => { this.devBrowser.refreshCookies(); + if (Environment.getInstance().partitionedCookies) { + void this.refreshSessionToken({ updateCookieImmediately: true }); + if (this.clerk.client) { + this.setClientUatCookieForDevelopmentInstances(); + } + } }); this.refreshTokenOnFocus(); diff --git a/packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts b/packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts index b85c1552e05..e71356e5a16 100644 --- a/packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts +++ b/packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts @@ -1,11 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { eventBus, events } from '../../events'; +import { Environment } from '../../resources/Environment'; const mocks = vi.hoisted(() => ({ sessionCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn() }, clientUatCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn(() => 0) }, activeContextCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn<() => string | undefined>(() => undefined) }, + devBrowser: { + clear: vi.fn(), + setup: vi.fn(() => Promise.resolve()), + getDevBrowser: vi.fn(() => 'deadbeef'), + refreshCookies: vi.fn(), + }, inCrossOriginIframe: vi.fn(() => false), })); @@ -13,14 +20,7 @@ vi.mock('../cookies/session', () => ({ createSessionCookie: () => mocks.sessionC vi.mock('../cookies/clientUat', () => ({ createClientUatCookie: () => mocks.clientUatCookie })); vi.mock('../cookies/activeContext', () => ({ createActiveContextCookie: () => mocks.activeContextCookie })); vi.mock('../cookieSuffix', () => ({ getCookieSuffix: vi.fn(() => Promise.resolve('suffix')) })); -vi.mock('../devBrowser', () => ({ - createDevBrowser: () => ({ - clear: vi.fn(), - setup: vi.fn(() => Promise.resolve()), - getDevBrowser: vi.fn(() => 'deadbeef'), - refreshCookies: vi.fn(), - }), -})); +vi.mock('../devBrowser', () => ({ createDevBrowser: () => mocks.devBrowser })); vi.mock('@clerk/shared/internal/clerk-js/runtime', async importOriginal => { const actual = await importOriginal>(); return { ...actual, inCrossOriginIframe: () => mocks.inCrossOriginIframe() }; @@ -58,6 +58,7 @@ describe('AuthCookieService session cookie refresh', () => { mocks.inCrossOriginIframe.mockReturnValue(false); mocks.activeContextCookie.get.mockReturnValue(undefined); getToken.mockResolvedValue('fresh-jwt'); + Environment.getInstance().partitionedCookies = false; setFocus(true); setVisibility('visible'); }); @@ -136,4 +137,16 @@ describe('AuthCookieService session cookie refresh', () => { expect(getToken).toHaveBeenCalled(); }); + + it('rewrites the session cookie after partitioned cookies resolve', async () => { + service = await createService(); + getToken.mockResolvedValue('jwt-after-environment'); + Environment.getInstance().partitionedCookies = true; + mocks.sessionCookie.set.mockClear(); + + eventBus.emit(events.EnvironmentUpdate, null); + + await vi.waitFor(() => expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-after-environment')); + expect(mocks.devBrowser.refreshCookies).toHaveBeenCalled(); + }); }); diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts index 82f832dc6f5..652072fdfa0 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts @@ -23,18 +23,31 @@ describe('createClientUatCookie', () => { const mockSet = vi.fn(); const mockRemove = vi.fn(); const mockGet = vi.fn(); + const mockCookieCalls: Array<{ + type: 'set' | 'remove'; + name: string; + value?: string; + attributes?: object; + }> = []; beforeEach(() => { vi.clearAllMocks(); + mockCookieCalls.length = 0; mockGet.mockReset(); (addYears as ReturnType).mockReturnValue(mockExpires); (inCrossOriginIframe as ReturnType).mockReturnValue(false); (requiresSameSiteNone as ReturnType).mockReturnValue(false); (getCookieDomain as ReturnType).mockReturnValue(mockDomain); (getSecureAttribute as ReturnType).mockReturnValue(true); - (createCookieHandler as ReturnType).mockImplementation(() => ({ - set: mockSet, - remove: mockRemove, + (createCookieHandler as ReturnType).mockImplementation((name: string) => ({ + set: (value: string, attributes?: object) => { + mockSet(value, attributes); + mockCookieCalls.push({ type: 'set', name, value, attributes }); + }, + remove: (attributes?: object) => { + mockRemove(attributes); + mockCookieCalls.push({ type: 'remove', name, attributes }); + }, get: mockGet, })); }); @@ -164,4 +177,70 @@ describe('createClientUatCookie', () => { partitioned: true, }); }); + + it('clears non-partitioned domain variants before writing partitioned cookies', () => { + let usePartitionedCookies = false; + const cookieHandler = createClientUatCookie(mockCookieSuffix, { + usePartitionedCookies: () => usePartitionedCookies, + }); + const client = { + id: 'test-client', + updatedAt: new Date('2024-01-01'), + signedInSessions: ['session1'], + }; + + cookieHandler.set(client); + usePartitionedCookies = true; + mockCookieCalls.length = 0; + cookieHandler.set(client); + + expect(mockCookieCalls).toEqual([ + { type: 'remove', name: '__client_uat_test-suffix', attributes: undefined }, + { type: 'remove', name: '__client_uat', attributes: undefined }, + { + type: 'remove', + name: '__client_uat_test-suffix', + attributes: { domain: mockDomain, sameSite: 'Strict', secure: true, partitioned: false }, + }, + { + type: 'remove', + name: '__client_uat', + attributes: { domain: mockDomain, sameSite: 'Strict', secure: true, partitioned: false }, + }, + { + type: 'remove', + name: '__client_uat_test-suffix', + attributes: { domain: mockDomain, sameSite: 'None', secure: true, partitioned: false }, + }, + { + type: 'remove', + name: '__client_uat', + attributes: { domain: mockDomain, sameSite: 'None', secure: true, partitioned: false }, + }, + { + type: 'set', + name: '__client_uat_test-suffix', + value: '1704067200', + attributes: { + domain: mockDomain, + expires: mockExpires, + sameSite: 'None', + secure: true, + partitioned: true, + }, + }, + { + type: 'set', + name: '__client_uat', + value: '1704067200', + attributes: { + domain: mockDomain, + expires: mockExpires, + sameSite: 'None', + secure: true, + partitioned: true, + }, + }, + ]); + }); }); diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts index 2b418d38a4f..f156bcad079 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts @@ -21,17 +21,30 @@ describe('createSessionCookie', () => { const mockSet = vi.fn(); const mockRemove = vi.fn(); const mockGet = vi.fn(); + const mockCookieCalls: Array<{ + type: 'set' | 'remove'; + name: string; + value?: string; + attributes?: object; + }> = []; beforeEach(() => { vi.clearAllMocks(); + mockCookieCalls.length = 0; mockGet.mockReset(); (addYears as ReturnType).mockReturnValue(mockExpires); (inCrossOriginIframe as ReturnType).mockReturnValue(false); (requiresSameSiteNone as ReturnType).mockReturnValue(false); (getSecureAttribute as ReturnType).mockReturnValue(true); - (createCookieHandler as ReturnType).mockImplementation(() => ({ - set: mockSet, - remove: mockRemove, + (createCookieHandler as ReturnType).mockImplementation((name: string) => ({ + set: (value: string, attributes?: object) => { + mockSet(value, attributes); + mockCookieCalls.push({ type: 'set', name, value, attributes }); + }, + remove: (attributes?: object) => { + mockRemove(attributes); + mockCookieCalls.push({ type: 'remove', name, attributes }); + }, get: mockGet, })); }); @@ -143,4 +156,43 @@ describe('createSessionCookie', () => { partitioned: true, }); }); + + it('clears non-partitioned variants before writing partitioned cookies after the environment changes', () => { + let usePartitionedCookies = false; + const cookieHandler = createSessionCookie(mockCookieSuffix, { + usePartitionedCookies: () => usePartitionedCookies, + }); + + cookieHandler.set('non-partitioned-token'); + usePartitionedCookies = true; + mockCookieCalls.length = 0; + cookieHandler.set('partitioned-token'); + + expect(mockCookieCalls).toEqual([ + { type: 'remove', name: '__session', attributes: undefined }, + { type: 'remove', name: '__session_test-suffix', attributes: undefined }, + { + type: 'set', + name: '__session', + value: 'partitioned-token', + attributes: { + expires: mockExpires, + sameSite: 'None', + secure: true, + partitioned: true, + }, + }, + { + type: 'set', + name: '__session_test-suffix', + value: 'partitioned-token', + attributes: { + expires: mockExpires, + sameSite: 'None', + secure: true, + partitioned: true, + }, + }, + ]); + }); }); diff --git a/packages/clerk-js/src/core/auth/cookies/clientUat.ts b/packages/clerk-js/src/core/auth/cookies/clientUat.ts index 4591f348a77..0187b73be75 100644 --- a/packages/clerk-js/src/core/auth/cookies/clientUat.ts +++ b/packages/clerk-js/src/core/auth/cookies/clientUat.ts @@ -63,6 +63,18 @@ export const createClientUatCookie = ( suffixedClientUatCookie.remove(); clientUatCookie.remove(); + if (partitioned) { + const nonPartitionedCookieAttributes = [ + { domain, sameSite: 'Strict', secure: getSecureAttribute('Strict'), partitioned: false }, + { domain, sameSite: 'None', secure: getSecureAttribute('None'), partitioned: false }, + ] as const; + + for (const attributes of nonPartitionedCookieAttributes) { + suffixedClientUatCookie.remove(attributes); + clientUatCookie.remove(attributes); + } + } + suffixedClientUatCookie.set(val, { domain, expires, partitioned, sameSite, secure }); clientUatCookie.set(val, { domain, expires, partitioned, sameSite, secure }); }; From 567715075212b62401ffebee37e6dc71834668f3 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 30 Jul 2026 09:19:52 -0400 Subject: [PATCH 2/8] test(clerk-js): use mock call tracking --- .../core/auth/cookies/__tests__/clientUat.test.ts | 14 +++++++------- .../core/auth/cookies/__tests__/session.test.ts | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts index 652072fdfa0..03434254509 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts @@ -23,16 +23,16 @@ describe('createClientUatCookie', () => { const mockSet = vi.fn(); const mockRemove = vi.fn(); const mockGet = vi.fn(); - const mockCookieCalls: Array<{ + type CookieCall = { type: 'set' | 'remove'; name: string; value?: string; attributes?: object; - }> = []; + }; + const mockCookieCall = vi.fn<(call: CookieCall) => void>(); beforeEach(() => { vi.clearAllMocks(); - mockCookieCalls.length = 0; mockGet.mockReset(); (addYears as ReturnType).mockReturnValue(mockExpires); (inCrossOriginIframe as ReturnType).mockReturnValue(false); @@ -42,11 +42,11 @@ describe('createClientUatCookie', () => { (createCookieHandler as ReturnType).mockImplementation((name: string) => ({ set: (value: string, attributes?: object) => { mockSet(value, attributes); - mockCookieCalls.push({ type: 'set', name, value, attributes }); + mockCookieCall({ type: 'set', name, value, attributes }); }, remove: (attributes?: object) => { mockRemove(attributes); - mockCookieCalls.push({ type: 'remove', name, attributes }); + mockCookieCall({ type: 'remove', name, attributes }); }, get: mockGet, })); @@ -191,10 +191,10 @@ describe('createClientUatCookie', () => { cookieHandler.set(client); usePartitionedCookies = true; - mockCookieCalls.length = 0; + mockCookieCall.mockClear(); cookieHandler.set(client); - expect(mockCookieCalls).toEqual([ + expect(mockCookieCall.mock.calls.map(([call]) => call)).toEqual([ { type: 'remove', name: '__client_uat_test-suffix', attributes: undefined }, { type: 'remove', name: '__client_uat', attributes: undefined }, { diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts index f156bcad079..17116fac80b 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts @@ -21,16 +21,16 @@ describe('createSessionCookie', () => { const mockSet = vi.fn(); const mockRemove = vi.fn(); const mockGet = vi.fn(); - const mockCookieCalls: Array<{ + type CookieCall = { type: 'set' | 'remove'; name: string; value?: string; attributes?: object; - }> = []; + }; + const mockCookieCall = vi.fn<(call: CookieCall) => void>(); beforeEach(() => { vi.clearAllMocks(); - mockCookieCalls.length = 0; mockGet.mockReset(); (addYears as ReturnType).mockReturnValue(mockExpires); (inCrossOriginIframe as ReturnType).mockReturnValue(false); @@ -39,11 +39,11 @@ describe('createSessionCookie', () => { (createCookieHandler as ReturnType).mockImplementation((name: string) => ({ set: (value: string, attributes?: object) => { mockSet(value, attributes); - mockCookieCalls.push({ type: 'set', name, value, attributes }); + mockCookieCall({ type: 'set', name, value, attributes }); }, remove: (attributes?: object) => { mockRemove(attributes); - mockCookieCalls.push({ type: 'remove', name, attributes }); + mockCookieCall({ type: 'remove', name, attributes }); }, get: mockGet, })); @@ -165,10 +165,10 @@ describe('createSessionCookie', () => { cookieHandler.set('non-partitioned-token'); usePartitionedCookies = true; - mockCookieCalls.length = 0; + mockCookieCall.mockClear(); cookieHandler.set('partitioned-token'); - expect(mockCookieCalls).toEqual([ + expect(mockCookieCall.mock.calls.map(([call]) => call)).toEqual([ { type: 'remove', name: '__session', attributes: undefined }, { type: 'remove', name: '__session_test-suffix', attributes: undefined }, { From 72d2f53838eb87c7b478b0f74f151f67d0d62562 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 30 Jul 2026 09:24:30 -0400 Subject: [PATCH 3/8] test(clerk-js): reuse cookie mocks --- .../auth/cookies/__tests__/clientUat.test.ts | 90 ++++++++----------- .../auth/cookies/__tests__/session.test.ts | 69 +++++++------- 2 files changed, 67 insertions(+), 92 deletions(-) diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts index 03434254509..d1b9c4347d3 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts @@ -20,16 +20,9 @@ describe('createClientUatCookie', () => { const mockExpires = new Date('2024-12-31'); const mockDomain = 'test.domain'; const defaultOptions = { usePartitionedCookies: () => false }; - const mockSet = vi.fn(); - const mockRemove = vi.fn(); + const mockSet = vi.fn<(name: string, value: string, attributes?: object) => void>(); + const mockRemove = vi.fn<(name: string, attributes?: object) => void>(); const mockGet = vi.fn(); - type CookieCall = { - type: 'set' | 'remove'; - name: string; - value?: string; - attributes?: object; - }; - const mockCookieCall = vi.fn<(call: CookieCall) => void>(); beforeEach(() => { vi.clearAllMocks(); @@ -41,12 +34,10 @@ describe('createClientUatCookie', () => { (getSecureAttribute as ReturnType).mockReturnValue(true); (createCookieHandler as ReturnType).mockImplementation((name: string) => ({ set: (value: string, attributes?: object) => { - mockSet(value, attributes); - mockCookieCall({ type: 'set', name, value, attributes }); + mockSet(name, value, attributes); }, remove: (attributes?: object) => { - mockRemove(attributes); - mockCookieCall({ type: 'remove', name, attributes }); + mockRemove(name, attributes); }, get: mockGet, })); @@ -68,13 +59,14 @@ describe('createClientUatCookie', () => { }); expect(mockSet).toHaveBeenCalledTimes(2); - expect(mockSet).toHaveBeenCalledWith('1704067200', { + expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '1704067200', { domain: mockDomain, expires: mockExpires, sameSite: 'Strict', secure: true, partitioned: false, }); + expect(mockSet).toHaveBeenCalledWith('__client_uat', '1704067200', expect.any(Object)); }); it('should set cookies with None sameSite in cross-origin context', () => { @@ -86,7 +78,7 @@ describe('createClientUatCookie', () => { signedInSessions: ['session1'], }); - expect(mockSet).toHaveBeenCalledWith('1704067200', { + expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '1704067200', { domain: mockDomain, expires: mockExpires, sameSite: 'None', @@ -99,7 +91,7 @@ describe('createClientUatCookie', () => { const cookieHandler = createClientUatCookie(mockCookieSuffix, defaultOptions); cookieHandler.set(undefined); - expect(mockSet).toHaveBeenCalledWith('0', { + expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '0', { domain: mockDomain, expires: mockExpires, sameSite: 'Strict', @@ -116,7 +108,7 @@ describe('createClientUatCookie', () => { signedInSessions: [], }); - expect(mockSet).toHaveBeenCalledWith('0', { + expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '0', { domain: mockDomain, expires: mockExpires, sameSite: 'Strict', @@ -152,7 +144,7 @@ describe('createClientUatCookie', () => { signedInSessions: ['session1'], }); - expect(mockSet).toHaveBeenCalledWith('1704067200', { + expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '1704067200', { domain: mockDomain, expires: mockExpires, sameSite: 'None', @@ -169,7 +161,7 @@ describe('createClientUatCookie', () => { signedInSessions: ['session1'], }); - expect(mockSet).toHaveBeenCalledWith('1704067200', { + expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '1704067200', { domain: mockDomain, expires: mockExpires, sameSite: 'None', @@ -191,56 +183,44 @@ describe('createClientUatCookie', () => { cookieHandler.set(client); usePartitionedCookies = true; - mockCookieCall.mockClear(); + mockSet.mockClear(); + mockRemove.mockClear(); cookieHandler.set(client); - expect(mockCookieCall.mock.calls.map(([call]) => call)).toEqual([ - { type: 'remove', name: '__client_uat_test-suffix', attributes: undefined }, - { type: 'remove', name: '__client_uat', attributes: undefined }, - { - type: 'remove', - name: '__client_uat_test-suffix', - attributes: { domain: mockDomain, sameSite: 'Strict', secure: true, partitioned: false }, - }, - { - type: 'remove', - name: '__client_uat', - attributes: { domain: mockDomain, sameSite: 'Strict', secure: true, partitioned: false }, - }, - { - type: 'remove', - name: '__client_uat_test-suffix', - attributes: { domain: mockDomain, sameSite: 'None', secure: true, partitioned: false }, - }, - { - type: 'remove', - name: '__client_uat', - attributes: { domain: mockDomain, sameSite: 'None', secure: true, partitioned: false }, - }, - { - type: 'set', - name: '__client_uat_test-suffix', - value: '1704067200', - attributes: { + expect(mockRemove.mock.calls).toEqual([ + ['__client_uat_test-suffix', undefined], + ['__client_uat', undefined], + ['__client_uat_test-suffix', { domain: mockDomain, sameSite: 'Strict', secure: true, partitioned: false }], + ['__client_uat', { domain: mockDomain, sameSite: 'Strict', secure: true, partitioned: false }], + ['__client_uat_test-suffix', { domain: mockDomain, sameSite: 'None', secure: true, partitioned: false }], + ['__client_uat', { domain: mockDomain, sameSite: 'None', secure: true, partitioned: false }], + ]); + expect(mockSet.mock.calls).toEqual([ + [ + '__client_uat_test-suffix', + '1704067200', + { domain: mockDomain, expires: mockExpires, sameSite: 'None', secure: true, partitioned: true, }, - }, - { - type: 'set', - name: '__client_uat', - value: '1704067200', - attributes: { + ], + [ + '__client_uat', + '1704067200', + { domain: mockDomain, expires: mockExpires, sameSite: 'None', secure: true, partitioned: true, }, - }, + ], ]); + expect(Math.max(...mockRemove.mock.invocationCallOrder)).toBeLessThan( + Math.min(...mockSet.mock.invocationCallOrder), + ); }); }); diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts index 17116fac80b..3c846e14a96 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts @@ -18,16 +18,9 @@ describe('createSessionCookie', () => { const mockToken = 'test-token'; const mockExpires = new Date('2024-12-31'); const defaultOptions = { usePartitionedCookies: () => false }; - const mockSet = vi.fn(); - const mockRemove = vi.fn(); + const mockSet = vi.fn<(name: string, value: string, attributes?: object) => void>(); + const mockRemove = vi.fn<(name: string, attributes?: object) => void>(); const mockGet = vi.fn(); - type CookieCall = { - type: 'set' | 'remove'; - name: string; - value?: string; - attributes?: object; - }; - const mockCookieCall = vi.fn<(call: CookieCall) => void>(); beforeEach(() => { vi.clearAllMocks(); @@ -38,12 +31,10 @@ describe('createSessionCookie', () => { (getSecureAttribute as ReturnType).mockReturnValue(true); (createCookieHandler as ReturnType).mockImplementation((name: string) => ({ set: (value: string, attributes?: object) => { - mockSet(value, attributes); - mockCookieCall({ type: 'set', name, value, attributes }); + mockSet(name, value, attributes); }, remove: (attributes?: object) => { - mockRemove(attributes); - mockCookieCall({ type: 'remove', name, attributes }); + mockRemove(name, attributes); }, get: mockGet, })); @@ -61,7 +52,7 @@ describe('createSessionCookie', () => { cookieHandler.set(mockToken); expect(mockSet).toHaveBeenCalledTimes(2); - expect(mockSet).toHaveBeenCalledWith(mockToken, { + expect(mockSet).toHaveBeenCalledWith('__session', mockToken, { expires: mockExpires, sameSite: 'Lax', secure: true, @@ -74,7 +65,7 @@ describe('createSessionCookie', () => { const cookieHandler = createSessionCookie(mockCookieSuffix, defaultOptions); cookieHandler.set(mockToken); - expect(mockSet).toHaveBeenCalledWith(mockToken, { + expect(mockSet).toHaveBeenCalledWith('__session', mockToken, { expires: mockExpires, sameSite: 'None', secure: true, @@ -100,17 +91,17 @@ describe('createSessionCookie', () => { partitioned: false, }; - expect(mockSet).toHaveBeenCalledWith(mockToken, { + expect(mockSet).toHaveBeenCalledWith('__session', mockToken, { expires: mockExpires, sameSite: 'Lax', secure: true, partitioned: false, }); - expect(mockRemove).toHaveBeenCalledWith(expectedAttributes); + expect(mockRemove).toHaveBeenCalledWith('__session', expectedAttributes); expect(mockRemove).toHaveBeenCalledTimes(2); - expect(mockRemove).toHaveBeenNthCalledWith(1, expectedAttributes); - expect(mockRemove).toHaveBeenNthCalledWith(2, expectedAttributes); + expect(mockRemove).toHaveBeenNthCalledWith(1, '__session', expectedAttributes); + expect(mockRemove).toHaveBeenNthCalledWith(2, '__session_test-suffix', expectedAttributes); }); it('should get cookie value from suffixed cookie first, then fallback to non-suffixed', () => { @@ -136,7 +127,7 @@ describe('createSessionCookie', () => { const cookieHandler = createSessionCookie(mockCookieSuffix, defaultOptions); cookieHandler.set(mockToken); - expect(mockSet).toHaveBeenCalledWith(mockToken, { + expect(mockSet).toHaveBeenCalledWith('__session', mockToken, { expires: mockExpires, sameSite: 'None', secure: true, @@ -149,7 +140,7 @@ describe('createSessionCookie', () => { cookieHandler.set(mockToken); expect(mockRemove).toHaveBeenCalledTimes(2); - expect(mockSet).toHaveBeenCalledWith(mockToken, { + expect(mockSet).toHaveBeenCalledWith('__session', mockToken, { expires: mockExpires, sameSite: 'None', secure: true, @@ -165,34 +156,38 @@ describe('createSessionCookie', () => { cookieHandler.set('non-partitioned-token'); usePartitionedCookies = true; - mockCookieCall.mockClear(); + mockSet.mockClear(); + mockRemove.mockClear(); cookieHandler.set('partitioned-token'); - expect(mockCookieCall.mock.calls.map(([call]) => call)).toEqual([ - { type: 'remove', name: '__session', attributes: undefined }, - { type: 'remove', name: '__session_test-suffix', attributes: undefined }, - { - type: 'set', - name: '__session', - value: 'partitioned-token', - attributes: { + expect(mockRemove.mock.calls).toEqual([ + ['__session', undefined], + ['__session_test-suffix', undefined], + ]); + expect(mockSet.mock.calls).toEqual([ + [ + '__session', + 'partitioned-token', + { expires: mockExpires, sameSite: 'None', secure: true, partitioned: true, }, - }, - { - type: 'set', - name: '__session_test-suffix', - value: 'partitioned-token', - attributes: { + ], + [ + '__session_test-suffix', + 'partitioned-token', + { expires: mockExpires, sameSite: 'None', secure: true, partitioned: true, }, - }, + ], ]); + expect(Math.max(...mockRemove.mock.invocationCallOrder)).toBeLessThan( + Math.min(...mockSet.mock.invocationCallOrder), + ); }); }); From 4f9add35228b991b28ae6c195570041d2101a47e Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 30 Jul 2026 09:38:52 -0400 Subject: [PATCH 4/8] test(clerk-js): assert cookie call order --- .../src/core/auth/cookies/__tests__/clientUat.test.ts | 4 ++-- .../clerk-js/src/core/auth/cookies/__tests__/session.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts index d1b9c4347d3..7b5802b6b12 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts @@ -219,8 +219,8 @@ describe('createClientUatCookie', () => { }, ], ]); - expect(Math.max(...mockRemove.mock.invocationCallOrder)).toBeLessThan( - Math.min(...mockSet.mock.invocationCallOrder), + expect([...mockRemove.mock.invocationCallOrder, ...mockSet.mock.invocationCallOrder]).toEqual( + [...mockRemove.mock.invocationCallOrder, ...mockSet.mock.invocationCallOrder].toSorted((a, b) => a - b), ); }); }); diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts index 3c846e14a96..c0a8dc9e388 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts @@ -186,8 +186,8 @@ describe('createSessionCookie', () => { }, ], ]); - expect(Math.max(...mockRemove.mock.invocationCallOrder)).toBeLessThan( - Math.min(...mockSet.mock.invocationCallOrder), + expect([...mockRemove.mock.invocationCallOrder, ...mockSet.mock.invocationCallOrder]).toEqual( + [...mockRemove.mock.invocationCallOrder, ...mockSet.mock.invocationCallOrder].toSorted((a, b) => a - b), ); }); }); From 3556e1178bb35c5f7a142cdebf0a92d77f2f9cf2 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 30 Jul 2026 09:41:38 -0400 Subject: [PATCH 5/8] test(clerk-js): assert cookie invocation arrays --- .../core/auth/cookies/__tests__/clientUat.test.ts | 13 ++++++++++--- .../src/core/auth/cookies/__tests__/session.test.ts | 6 +++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts index 7b5802b6b12..d0b1dff6fd6 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts @@ -219,8 +219,15 @@ describe('createClientUatCookie', () => { }, ], ]); - expect([...mockRemove.mock.invocationCallOrder, ...mockSet.mock.invocationCallOrder]).toEqual( - [...mockRemove.mock.invocationCallOrder, ...mockSet.mock.invocationCallOrder].toSorted((a, b) => a - b), - ); + const firstInvocationOrder = mockRemove.mock.invocationCallOrder[0]; + expect(mockRemove.mock.invocationCallOrder).toEqual([ + firstInvocationOrder, + firstInvocationOrder + 1, + firstInvocationOrder + 4, + firstInvocationOrder + 5, + firstInvocationOrder + 6, + firstInvocationOrder + 7, + ]); + expect(mockSet.mock.invocationCallOrder).toEqual([firstInvocationOrder + 8, firstInvocationOrder + 9]); }); }); diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts index c0a8dc9e388..95bf5f65bf9 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts @@ -186,8 +186,8 @@ describe('createSessionCookie', () => { }, ], ]); - expect([...mockRemove.mock.invocationCallOrder, ...mockSet.mock.invocationCallOrder]).toEqual( - [...mockRemove.mock.invocationCallOrder, ...mockSet.mock.invocationCallOrder].toSorted((a, b) => a - b), - ); + const firstInvocationOrder = mockRemove.mock.invocationCallOrder[0]; + expect(mockRemove.mock.invocationCallOrder).toEqual([firstInvocationOrder, firstInvocationOrder + 1]); + expect(mockSet.mock.invocationCallOrder).toEqual([firstInvocationOrder + 2, firstInvocationOrder + 3]); }); }); From dbf96f4f1dd28b3c9b9019cf4bd404ba64e6776c Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 30 Jul 2026 10:51:51 -0400 Subject: [PATCH 6/8] fix(clerk-js): clear legacy session cookie attributes --- .../auth/cookies/__tests__/session.test.ts | 17 +++++++++----- .../clerk-js/src/core/auth/cookies/session.ts | 22 ++++++++++++------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts index 95bf5f65bf9..22d686e8872 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts @@ -139,7 +139,7 @@ describe('createSessionCookie', () => { const cookieHandler = createSessionCookie(mockCookieSuffix, { usePartitionedCookies: () => true }); cookieHandler.set(mockToken); - expect(mockRemove).toHaveBeenCalledTimes(2); + expect(mockRemove).toHaveBeenCalledTimes(4); expect(mockSet).toHaveBeenCalledWith('__session', mockToken, { expires: mockExpires, sameSite: 'None', @@ -161,8 +161,10 @@ describe('createSessionCookie', () => { cookieHandler.set('partitioned-token'); expect(mockRemove.mock.calls).toEqual([ - ['__session', undefined], - ['__session_test-suffix', undefined], + ['__session', { sameSite: 'Lax', secure: true, partitioned: false }], + ['__session_test-suffix', { sameSite: 'Lax', secure: true, partitioned: false }], + ['__session', { sameSite: 'None', secure: true, partitioned: false }], + ['__session_test-suffix', { sameSite: 'None', secure: true, partitioned: false }], ]); expect(mockSet.mock.calls).toEqual([ [ @@ -187,7 +189,12 @@ describe('createSessionCookie', () => { ], ]); const firstInvocationOrder = mockRemove.mock.invocationCallOrder[0]; - expect(mockRemove.mock.invocationCallOrder).toEqual([firstInvocationOrder, firstInvocationOrder + 1]); - expect(mockSet.mock.invocationCallOrder).toEqual([firstInvocationOrder + 2, firstInvocationOrder + 3]); + expect(mockRemove.mock.invocationCallOrder).toEqual([ + firstInvocationOrder, + firstInvocationOrder + 1, + firstInvocationOrder + 2, + firstInvocationOrder + 3, + ]); + expect(mockSet.mock.invocationCallOrder).toEqual([firstInvocationOrder + 4, firstInvocationOrder + 5]); }); }); diff --git a/packages/clerk-js/src/core/auth/cookies/session.ts b/packages/clerk-js/src/core/auth/cookies/session.ts index 06a7e9fb2e1..2d49e0115f1 100644 --- a/packages/clerk-js/src/core/auth/cookies/session.ts +++ b/packages/clerk-js/src/core/auth/cookies/session.ts @@ -35,16 +35,25 @@ export const createSessionCookie = (cookieSuffix: string, options: SessionCookie const sessionCookie = createCookieHandler(SESSION_COOKIE_NAME); const suffixedSessionCookie = createCookieHandler(getSuffixedCookieName(SESSION_COOKIE_NAME, cookieSuffix)); + const removeNonPartitionedCookies = () => { + const nonPartitionedCookieAttributes = [ + { sameSite: 'Lax', secure: getSecureAttribute('Lax'), partitioned: false }, + { sameSite: 'None', secure: getSecureAttribute('None'), partitioned: false }, + ] as const; + + for (const attributes of nonPartitionedCookieAttributes) { + sessionCookie.remove(attributes); + suffixedSessionCookie.remove(attributes); + } + }; + const remove = () => { const attributes = getCookieAttributes(options); sessionCookie.remove(attributes); suffixedSessionCookie.remove(attributes); - // Also remove non-partitioned variants — the browser treats partitioned and - // non-partitioned cookies with the same name as distinct cookies. if (attributes.partitioned) { - sessionCookie.remove(); - suffixedSessionCookie.remove(); + removeNonPartitionedCookies(); } }; @@ -52,11 +61,8 @@ export const createSessionCookie = (cookieSuffix: string, options: SessionCookie const expires = addYears(Date.now(), 1); const { sameSite, secure, partitioned } = getCookieAttributes(options); - // Remove old non-partitioned cookies — the browser treats partitioned and - // non-partitioned cookies with the same name as distinct cookies. if (partitioned) { - sessionCookie.remove(); - suffixedSessionCookie.remove(); + removeNonPartitionedCookies(); } sessionCookie.set(token, { expires, sameSite, secure, partitioned }); From c046539b8206ab8a53a639c6ff795a65f7669a77 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 30 Jul 2026 11:50:30 -0400 Subject: [PATCH 7/8] revert(backend): remove duplicate cookie selection --- .changeset/duplicate-session-cookies.md | 3 +- .../__tests__/authenticateContext.test.ts | 61 ---------- .../src/tokens/__tests__/clerkRequest.test.ts | 15 --- .../backend/src/tokens/authenticateContext.ts | 105 +++--------------- packages/backend/src/tokens/clerkRequest.ts | 59 +--------- 5 files changed, 17 insertions(+), 226 deletions(-) diff --git a/.changeset/duplicate-session-cookies.md b/.changeset/duplicate-session-cookies.md index 4212f9398d7..77652f37d54 100644 --- a/.changeset/duplicate-session-cookies.md +++ b/.changeset/duplicate-session-cookies.md @@ -1,6 +1,5 @@ --- -'@clerk/backend': patch '@clerk/clerk-js': patch --- -Recover from partitioned-cookie startup races and prefer fresh session tokens when duplicate session cookies are present. +Recover from partitioned-cookie startup races by removing stale non-partitioned cookies when partitioned cookies become available. diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index fd8ca83efa2..5df5e550ba8 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -1,7 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../runtime', () => ({ runtime: { crypto: globalThis.crypto } })); - import { createCookieHeader, createJwt, mockJwtPayload, pkLive, pkTest } from '../../fixtures'; import { runtime } from '../../runtime'; import { getCookieSuffix } from '../../util/shared'; @@ -15,15 +13,6 @@ describe('AuthenticateContext', () => { const session = createJwt(); const sessionWithInvalidIssuer = createJwt({ payload: { iss: 'http:whatever' } }); const newSession = createJwt({ payload: { iat: nowTimestampInSec + 60 } }); - const expiredSession = createJwt({ - payload: { exp: nowTimestampInSec - 60, iat: nowTimestampInSec - 120 }, - }); - const staleSession = createJwt({ - payload: { exp: nowTimestampInSec + 60, iat: nowTimestampInSec - 60 }, - }); - const freshSession = createJwt({ - payload: { exp: nowTimestampInSec + 300, iat: nowTimestampInSec }, - }); const clientUat = '1717490192'; const suffixedClientUat = '1717490193'; @@ -223,56 +212,6 @@ describe('AuthenticateContext', () => { expect(context.clientUat.toString()).toBe('0'); }); }); - - describe('duplicate session cookies', () => { - it('uses a later unexpired duplicate when an expired cookie appears first', async () => { - const headers = new Headers({ - cookie: [`__client_uat=${clientUat}`, `__session=${expiredSession}`, `__session=${freshSession}`].join('; '), - }); - const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive }); - - expect(context.usesSuffixedCookies()).toBe(false); - expect(context.sessionTokenInCookie).toBe(freshSession); - }); - - it('uses the freshest valid duplicate for the selected cookie name', async () => { - const headers = new Headers({ - cookie: [`__client_uat=${clientUat}`, `__session=${staleSession}`, `__session=${freshSession}`].join('; '), - }); - const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive }); - - expect(context.sessionTokenInCookie).toBe(freshSession); - }); - - it('uses the fresh duplicate for suffixed cookies', async () => { - const headers = new Headers({ - cookie: [ - `__client_uat=${clientUat}`, - `__client_uat_MqCvchyS=${suffixedClientUat}`, - `__session=${session}`, - `__session_MqCvchyS=${expiredSession}`, - `__session_MqCvchyS=${freshSession}`, - ].join('; '), - }); - const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive }); - - expect(context.usesSuffixedCookies()).toBe(true); - expect(context.sessionTokenInCookie).toBe(freshSession); - }); - - it('keeps the first malformed duplicate when no usable session token exists', async () => { - const headers = new Headers({ - cookie: [`__client_uat=${clientUat}`, '__session=not-a-jwt', '__session=also-not-a-jwt'].join('; '), - }); - const clerkRequest = createClerkRequest(new Request('http://example.com', { headers })); - const context = await createAuthenticateContext(clerkRequest, { publishableKey: pkLive }); - - expect(context.sessionTokenInCookie).toBe('not-a-jwt'); - }); - }); }); describe('relative proxyUrl resolution', () => { diff --git a/packages/backend/src/tokens/__tests__/clerkRequest.test.ts b/packages/backend/src/tokens/__tests__/clerkRequest.test.ts index 409af32f8e9..1b75b88cd44 100644 --- a/packages/backend/src/tokens/__tests__/clerkRequest.test.ts +++ b/packages/backend/src/tokens/__tests__/clerkRequest.test.ts @@ -97,21 +97,6 @@ describe('createClerkRequest', () => { expect(req.cookies.get('baz')).toBe('qux'); }); - it('preserves duplicate cookie values in request order', () => { - const req = createClerkRequest( - new Request('http://localhost:3000', { - headers: new Headers({ - cookie: '__session=stale; __session=fresh; __session_suffix=stale-suffix; __session_suffix=fresh-suffix', - }), - }), - ); - - expect(req.cookies.get('__session')).toBe('stale'); - expect(req.cookies.getAll('__session')).toEqual(['stale', 'fresh']); - expect(req.cookies.get('__session_suffix')).toBe('stale-suffix'); - expect(req.cookies.getAll('__session_suffix')).toEqual(['stale-suffix', 'fresh-suffix']); - }); - it('should parse and return cookies even if no cookie header exists', () => { const req = createClerkRequest(new Request('http://localhost:3000', { headers: new Headers() })); expect(req.cookies.get('foo')).toBeUndefined(); diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 13f6437dbbf..aaf1d8f7e04 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -109,8 +109,8 @@ class AuthenticateContext implements AuthenticateContext { public usesSuffixedCookies(): boolean { const suffixedClientUat = this.getSuffixedCookie(constants.Cookies.ClientUat); const clientUat = this.getCookie(constants.Cookies.ClientUat); - const suffixedSession = this.getSuffixedSessionCookie() || ''; - const session = this.getSessionCookie(constants.Cookies.Session) || ''; + const suffixedSession = this.getSuffixedCookie(constants.Cookies.Session) || ''; + const session = this.getCookie(constants.Cookies.Session) || ''; // In the case of malformed session cookies (eg missing the iss claim), we should // use the un-suffixed cookies to return signed-out state instead of triggering @@ -130,9 +130,9 @@ class AuthenticateContext implements AuthenticateContext { return false; } - const sessionData = this.decodeSessionToken(session); + const { data: sessionData } = decodeJwt(session); const sessionIat = sessionData?.payload.iat || 0; - const suffixedSessionData = this.decodeSessionToken(suffixedSession); + const { data: suffixedSessionData } = decodeJwt(suffixedSession); const suffixedSessionIat = suffixedSessionData?.payload.iat || 0; // Both indicate signed in, but un-suffixed is newer @@ -309,7 +309,7 @@ class AuthenticateContext implements AuthenticateContext { private initCookieValues() { // suffixedCookies needs to be set first because it's used in getMultipleAppsCookie - this.sessionTokenInCookie = this.getSuffixedOrUnSuffixedSessionCookie(); + this.sessionTokenInCookie = this.getSuffixedOrUnSuffixedCookie(constants.Cookies.Session); this.refreshTokenInCookie = this.getSuffixedCookie(constants.Cookies.Refresh); this.clientUat = Number.parseInt(this.getSuffixedOrUnSuffixedCookie(constants.Cookies.ClientUat) || '') || 0; } @@ -342,10 +342,6 @@ class AuthenticateContext implements AuthenticateContext { return this.getCookie(getSuffixedCookieName(name, this.cookieSuffix)) || undefined; } - private getSuffixedSessionCookie() { - return this.getSessionCookie(getSuffixedCookieName(constants.Cookies.Session, this.cookieSuffix)); - } - private getSuffixedOrUnSuffixedCookie(cookieName: string) { if (this.usesSuffixedCookies()) { return this.getSuffixedCookie(cookieName); @@ -353,72 +349,6 @@ class AuthenticateContext implements AuthenticateContext { return this.getCookie(cookieName); } - private getSuffixedOrUnSuffixedSessionCookie() { - if (this.usesSuffixedCookies()) { - return this.getSuffixedSessionCookie(); - } - return this.getSessionCookie(constants.Cookies.Session); - } - - private getSessionCookie(name: string) { - const tokens = this.getCookieValues(name); - - if (tokens.length === 0) { - return undefined; - } - - return this.selectBestSessionToken(tokens); - } - - private getCookieValues(name: string) { - const values = this.clerkRequest.cookies.getAll?.(name); - if (values?.length) { - return values; - } - - const value = this.clerkRequest.cookies.get(name); - return value ? [value] : []; - } - - private selectBestSessionToken(tokens: string[]) { - const fallbackToken = tokens[0]; - const usableCandidates: Array<{ token: string; data: Jwt }> = []; - - for (const token of tokens) { - const data = this.decodeSessionToken(token); - - if (data && this.sessionTokenUsable(data)) { - usableCandidates.push({ token, data }); - } - } - - const candidatesForInstance = usableCandidates.filter(candidate => - this.sessionTokenBelongsToInstance(candidate.data), - ); - const candidates = candidatesForInstance.length ? candidatesForInstance : usableCandidates; - const freshestCandidate = candidates.reduce<(typeof candidates)[number] | undefined>( - (freshest, candidate) => - !freshest || candidate.data.payload.iat > freshest.data.payload.iat ? candidate : freshest, - undefined, - ); - - return freshestCandidate?.token || fallbackToken; - } - - private decodeSessionToken(token: string): Jwt | undefined { - try { - const { data, errors } = decodeJwt(token); - - if (errors) { - return undefined; - } - - return data; - } catch { - return undefined; - } - } - private parseAuthorizationHeader(authorizationHeader: string | undefined | null): string | undefined { if (!authorizationHeader) { return undefined; @@ -440,8 +370,11 @@ class AuthenticateContext implements AuthenticateContext { } private tokenHasIssuer(token: string): boolean { - const data = this.decodeSessionToken(token); - return !!data?.payload.iss; + const { data, errors } = decodeJwt(token); + if (errors) { + return false; + } + return !!data.payload.iss; } private tokenBelongsToInstance(token: string): boolean { @@ -449,23 +382,11 @@ class AuthenticateContext implements AuthenticateContext { return false; } - const data = this.decodeSessionToken(token); - if (!data) { - return false; - } - return this.sessionTokenBelongsToInstance(data); - } - - private sessionTokenUsable(jwt: Jwt): boolean { - return !!jwt.payload.iss && typeof jwt.payload.exp === 'number' && !this.sessionExpired(jwt); - } - - private sessionTokenBelongsToInstance(jwt: Jwt): boolean { - if (!jwt.payload.iss) { + const { data, errors } = decodeJwt(token); + if (errors) { return false; } - - const tokenIssuer = jwt.payload.iss.replace(/https?:\/\//gi, ''); + const tokenIssuer = data.payload.iss.replace(/https?:\/\//gi, ''); // Use original frontend API for token validation since tokens are issued by the actual Clerk API, not proxy return this.originalFrontendApi === tokenIssuer; } diff --git a/packages/backend/src/tokens/clerkRequest.ts b/packages/backend/src/tokens/clerkRequest.ts index 6683e3065f9..8b02266c643 100644 --- a/packages/backend/src/tokens/clerkRequest.ts +++ b/packages/backend/src/tokens/clerkRequest.ts @@ -4,26 +4,6 @@ import { constants } from '../constants'; import type { ClerkUrl } from './clerkUrl'; import { createClerkUrl } from './clerkUrl'; -export type ClerkRequestCookieMap = Map & { - getAll(name: string): string[]; -}; - -class CookieMap extends Map implements ClerkRequestCookieMap { - private readonly valuesByName = new Map(); - - public append(name: string, value: string) { - if (!this.has(name)) { - this.set(name, value); - } - - this.valuesByName.set(name, [...(this.valuesByName.get(name) || []), value]); - } - - public getAll(name: string): string[] { - return this.valuesByName.get(name) || []; - } -} - /** * A class that extends the native Request class, * adds cookies helpers and a normalised clerkUrl that is constructed by using the values found @@ -31,7 +11,7 @@ class CookieMap extends Map implements ClerkRequestC */ class ClerkRequest extends Request { readonly clerkUrl: ClerkUrl; - readonly cookies: ClerkRequestCookieMap; + readonly cookies: Map; public constructor(input: ClerkRequest | Request | RequestInfo, init?: RequestInit) { // The usual way to duplicate a request object is to @@ -114,41 +94,8 @@ class ClerkRequest extends Request { } private parseCookies(req: Request) { - const cookieHeader = this.decodeCookieValue(req.headers.get('cookie') || ''); - const cookiesRecord = parse(cookieHeader); - const cookies = new CookieMap(); - - for (const [name, value] of Object.entries(cookiesRecord)) { - cookies.set(name, value); - } - - for (const [name, value] of this.parseCookiePairs(cookieHeader)) { - cookies.append(name, value); - } - - return cookies; - } - - private parseCookiePairs(cookieHeader: string) { - if (!cookieHeader) { - return []; - } - - return cookieHeader - .split(';') - .map(pair => { - const separatorIndex = pair.indexOf('='); - - if (separatorIndex === -1) { - return undefined; - } - - const name = pair.slice(0, separatorIndex).trim(); - const value = pair.slice(separatorIndex + 1).trim(); - - return name ? ([name, value] as const) : undefined; - }) - .filter((pair): pair is readonly [string, string] => !!pair); + const cookiesRecord = parse(this.decodeCookieValue(req.headers.get('cookie') || '')); + return new Map(Object.entries(cookiesRecord)); } private decodeCookieValue(str: string) { From 03688290ca9692d77da7545b2089fcaf20c31a95 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 30 Jul 2026 16:22:14 -0400 Subject: [PATCH 8/8] pr feedback --- packages/clerk-js/src/core/auth/AuthCookieService.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 69a9145aaf1..a1c7dd72068 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -86,12 +86,8 @@ export class AuthCookieService { // Environment can resolve after auth cookies are first written. eventBus.on(events.EnvironmentUpdate, () => { this.devBrowser.refreshCookies(); - if (Environment.getInstance().partitionedCookies) { - void this.refreshSessionToken({ updateCookieImmediately: true }); - if (this.clerk.client) { - this.setClientUatCookieForDevelopmentInstances(); - } - } + void this.refreshSessionToken({ updateCookieImmediately: true }); + this.setClientUatCookieForDevelopmentInstances(); }); this.refreshTokenOnFocus(); @@ -270,6 +266,9 @@ export class AuthCookieService { } public setClientUatCookieForDevelopmentInstances() { + if (!this.clerk.client) { + return; + } if (this.instanceType !== 'production' && this.inCustomDevelopmentDomain()) { this.clientUat.set(this.clerk.client); }