From f75414257e91281fae7e69483f34478e2556f595 Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 12 May 2026 18:05:25 -0500 Subject: [PATCH 1/3] Fix dev browser partitioned cookie cleanup --- .../auth/cookies/__tests__/devBrowser.test.ts | 118 ++++++++++++++++++ .../src/core/auth/cookies/devBrowser.ts | 38 +++--- 2 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts new file mode 100644 index 00000000000..e0527ebabc7 --- /dev/null +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts @@ -0,0 +1,118 @@ +import { createCookieHandler } from '@clerk/shared/cookie'; +import { addYears } from '@clerk/shared/date'; +import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSecureAttribute } from '../../getSecureAttribute'; +import { createDevBrowserCookie } from '../devBrowser'; +import { requiresSameSiteNone } from '../requireSameSiteNone'; + +vi.mock('@clerk/shared/cookie'); +vi.mock('@clerk/shared/date'); +vi.mock('@clerk/shared/internal/clerk-js/runtime'); +vi.mock('../../getSecureAttribute'); +vi.mock('../requireSameSiteNone'); + +describe('createDevBrowserCookie', () => { + const mockCookieSuffix = 'test-suffix'; + const mockDevBrowser = 'test-dev-browser'; + const mockExpires = new Date('2024-12-31'); + const defaultOptions = { usePartitionedCookies: () => false }; + const mockSet = vi.fn(); + const mockRemove = vi.fn(); + const mockGet = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + 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, + get: mockGet, + })); + }); + + it('should create both suffixed and non-suffixed cookie handlers', () => { + createDevBrowserCookie(mockCookieSuffix, defaultOptions); + + expect(createCookieHandler).toHaveBeenCalledTimes(2); + expect(createCookieHandler).toHaveBeenCalledWith('__clerk_db_jwt'); + expect(createCookieHandler).toHaveBeenCalledWith('__clerk_db_jwt_test-suffix'); + }); + + it('should remove non-partitioned and partitioned cookies even when partitioned cookies are disabled', () => { + const cookieHandler = createDevBrowserCookie(mockCookieSuffix, defaultOptions); + cookieHandler.remove(); + + const currentAttributes = { + sameSite: 'Lax', + secure: true, + partitioned: false, + }; + const partitionedAttributes = { + sameSite: 'None', + secure: true, + partitioned: true, + }; + + expect(mockRemove).toHaveBeenCalledTimes(6); + expect(mockRemove).toHaveBeenNthCalledWith(1, currentAttributes); + expect(mockRemove).toHaveBeenNthCalledWith(2); + expect(mockRemove).toHaveBeenNthCalledWith(3, partitionedAttributes); + expect(mockRemove).toHaveBeenNthCalledWith(4, currentAttributes); + expect(mockRemove).toHaveBeenNthCalledWith(5); + expect(mockRemove).toHaveBeenNthCalledWith(6, partitionedAttributes); + }); + + it('should clear stale partitioned cookies before setting a new non-partitioned cookie', () => { + const cookieHandler = createDevBrowserCookie(mockCookieSuffix, defaultOptions); + cookieHandler.set(mockDevBrowser); + + expect(mockRemove).toHaveBeenCalledWith({ + sameSite: 'None', + secure: true, + partitioned: true, + }); + expect(mockSet).toHaveBeenCalledTimes(2); + expect(mockSet).toHaveBeenCalledWith(mockDevBrowser, { + expires: mockExpires, + sameSite: 'Lax', + secure: true, + partitioned: false, + }); + }); + + it('should set partitioned cookies when usePartitionedCookies returns true', () => { + const cookieHandler = createDevBrowserCookie(mockCookieSuffix, { usePartitionedCookies: () => true }); + cookieHandler.set(mockDevBrowser); + + expect(mockSet).toHaveBeenCalledWith(mockDevBrowser, { + expires: mockExpires, + sameSite: 'None', + secure: true, + partitioned: true, + }); + }); + + it('should get cookie value from suffixed cookie first, then fallback to non-suffixed', () => { + mockGet.mockImplementationOnce(() => 'suffixed-value').mockImplementationOnce(() => 'non-suffixed-value'); + + const cookieHandler = createDevBrowserCookie(mockCookieSuffix, defaultOptions); + const result = cookieHandler.get(); + + expect(result).toBe('suffixed-value'); + }); + + it('should fallback to non-suffixed cookie when suffixed cookie is not present', () => { + mockGet.mockImplementationOnce(() => undefined).mockImplementationOnce(() => 'non-suffixed-value'); + + const cookieHandler = createDevBrowserCookie(mockCookieSuffix, defaultOptions); + const result = cookieHandler.get(); + + expect(result).toBe('non-suffixed-value'); + }); +}); diff --git a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts index 0c69fb2d369..d4244e6de41 100644 --- a/packages/clerk-js/src/core/auth/cookies/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/cookies/devBrowser.ts @@ -25,6 +25,12 @@ const getCookieAttributes = (options: DevBrowserCookieOptions) => { return { sameSite, secure, partitioned } as const; }; +const partitionedCookieAttributes = { + sameSite: 'None', + secure: true, + partitioned: true, +} as const; + /** * Create a long-lived JS cookie to store the dev browser token * ONLY for development instances. @@ -40,33 +46,29 @@ export const createDevBrowserCookie = ( const get = () => suffixedDevBrowserCookie.get() || devBrowserCookie.get(); + const removeAll = () => { + const attributes = getCookieAttributes(options); + + for (const cookie of [suffixedDevBrowserCookie, devBrowserCookie]) { + cookie.remove(attributes); + cookie.remove(); + cookie.remove(partitionedCookieAttributes); + } + }; + const set = (devBrowser: string) => { 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) { - suffixedDevBrowserCookie.remove(); - devBrowserCookie.remove(); - } + // Remove stale variants before writing. The environment may not be loaded + // yet, so the current partitioned-cookies setting cannot be trusted. + removeAll(); suffixedDevBrowserCookie.set(devBrowser, { expires, sameSite, secure, partitioned }); devBrowserCookie.set(devBrowser, { expires, sameSite, secure, partitioned }); }; - const remove = () => { - const attributes = getCookieAttributes(options); - suffixedDevBrowserCookie.remove(attributes); - devBrowserCookie.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) { - suffixedDevBrowserCookie.remove(); - devBrowserCookie.remove(); - } - }; + const remove = () => removeAll(); return { get, From 89e7ca6dc0c0cd4e662d9d026151eb147022f954 Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 12 May 2026 18:07:22 -0500 Subject: [PATCH 2/3] Add dev browser cleanup changeset --- .changeset/clean-dev-browsers.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clean-dev-browsers.md diff --git a/.changeset/clean-dev-browsers.md b/.changeset/clean-dev-browsers.md new file mode 100644 index 00000000000..22ad7280a9d --- /dev/null +++ b/.changeset/clean-dev-browsers.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix dev browser recovery by clearing stale partitioned and non-partitioned dev browser cookie variants before minting a new dev browser. From 1de724c1873c6cc5f2283d958f63085d28561f9b Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 12 May 2026 18:13:03 -0500 Subject: [PATCH 3/3] Improve dev browser cookie cleanup tests --- .../auth/cookies/__tests__/devBrowser.test.ts | 211 ++++++++++-------- 1 file changed, 120 insertions(+), 91 deletions(-) diff --git a/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts b/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts index e0527ebabc7..49176d9966b 100644 --- a/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts @@ -1,118 +1,147 @@ -import { createCookieHandler } from '@clerk/shared/cookie'; -import { addYears } from '@clerk/shared/date'; -import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getSecureAttribute } from '../../getSecureAttribute'; import { createDevBrowserCookie } from '../devBrowser'; -import { requiresSameSiteNone } from '../requireSameSiteNone'; -vi.mock('@clerk/shared/cookie'); -vi.mock('@clerk/shared/date'); -vi.mock('@clerk/shared/internal/clerk-js/runtime'); -vi.mock('../../getSecureAttribute'); -vi.mock('../requireSameSiteNone'); +const { cookieStore, removeCalls, setCalls } = vi.hoisted(() => ({ + cookieStore: new Map(), + removeCalls: [] as Array<{ name: string; attributes?: object }>, + setCalls: [] as Array<{ name: string; value: string; attributes?: object }>, +})); + +vi.mock('@clerk/shared/cookie', () => ({ + createCookieHandler: (name: string) => ({ + get: () => cookieStore.get(name), + remove: (attributes?: object) => { + removeCalls.push({ name, attributes }); + cookieStore.delete(name); + }, + set: (value: string, attributes?: object) => { + setCalls.push({ name, value, attributes }); + cookieStore.set(name, value); + }, + }), +})); describe('createDevBrowserCookie', () => { - const mockCookieSuffix = 'test-suffix'; - const mockDevBrowser = 'test-dev-browser'; - const mockExpires = new Date('2024-12-31'); + const cookieSuffix = 'test-suffix'; + const suffixedCookieName = '__clerk_db_jwt_test-suffix'; + const unsuffixedCookieName = '__clerk_db_jwt'; + const devBrowser = 'test-dev-browser'; + const now = new Date('2024-01-01T00:00:00.000Z'); + const expires = new Date('2025-01-01T00:00:00.000Z'); const defaultOptions = { usePartitionedCookies: () => false }; - const mockSet = vi.fn(); - const mockRemove = vi.fn(); - const mockGet = vi.fn(); beforeEach(() => { - vi.clearAllMocks(); - 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, - get: mockGet, - })); + vi.useFakeTimers(); + vi.setSystemTime(now); + cookieStore.clear(); + removeCalls.length = 0; + setCalls.length = 0; }); - it('should create both suffixed and non-suffixed cookie handlers', () => { - createDevBrowserCookie(mockCookieSuffix, defaultOptions); - - expect(createCookieHandler).toHaveBeenCalledTimes(2); - expect(createCookieHandler).toHaveBeenCalledWith('__clerk_db_jwt'); - expect(createCookieHandler).toHaveBeenCalledWith('__clerk_db_jwt_test-suffix'); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); }); - it('should remove non-partitioned and partitioned cookies even when partitioned cookies are disabled', () => { - const cookieHandler = createDevBrowserCookie(mockCookieSuffix, defaultOptions); + it('removes current, non-partitioned, and partitioned cookie variants for both dev browser cookie names', () => { + const cookieHandler = createDevBrowserCookie(cookieSuffix, defaultOptions); + cookieHandler.remove(); - const currentAttributes = { - sameSite: 'Lax', - secure: true, - partitioned: false, - }; - const partitionedAttributes = { - sameSite: 'None', - secure: true, - partitioned: true, - }; - - expect(mockRemove).toHaveBeenCalledTimes(6); - expect(mockRemove).toHaveBeenNthCalledWith(1, currentAttributes); - expect(mockRemove).toHaveBeenNthCalledWith(2); - expect(mockRemove).toHaveBeenNthCalledWith(3, partitionedAttributes); - expect(mockRemove).toHaveBeenNthCalledWith(4, currentAttributes); - expect(mockRemove).toHaveBeenNthCalledWith(5); - expect(mockRemove).toHaveBeenNthCalledWith(6, partitionedAttributes); + expect(removeCalls).toEqual([ + { + name: suffixedCookieName, + attributes: { + sameSite: 'Lax', + secure: false, + partitioned: false, + }, + }, + { name: suffixedCookieName, attributes: undefined }, + { + name: suffixedCookieName, + attributes: { + sameSite: 'None', + secure: true, + partitioned: true, + }, + }, + { + name: unsuffixedCookieName, + attributes: { + sameSite: 'Lax', + secure: false, + partitioned: false, + }, + }, + { name: unsuffixedCookieName, attributes: undefined }, + { + name: unsuffixedCookieName, + attributes: { + sameSite: 'None', + secure: true, + partitioned: true, + }, + }, + ]); }); - it('should clear stale partitioned cookies before setting a new non-partitioned cookie', () => { - const cookieHandler = createDevBrowserCookie(mockCookieSuffix, defaultOptions); - cookieHandler.set(mockDevBrowser); + it('clears stale partitioned cookie variants before writing a new dev browser', () => { + const cookieHandler = createDevBrowserCookie(cookieSuffix, defaultOptions); - expect(mockRemove).toHaveBeenCalledWith({ - sameSite: 'None', - secure: true, - partitioned: true, - }); - expect(mockSet).toHaveBeenCalledTimes(2); - expect(mockSet).toHaveBeenCalledWith(mockDevBrowser, { - expires: mockExpires, - sameSite: 'Lax', - secure: true, - partitioned: false, - }); - }); + cookieHandler.set(devBrowser); - it('should set partitioned cookies when usePartitionedCookies returns true', () => { - const cookieHandler = createDevBrowserCookie(mockCookieSuffix, { usePartitionedCookies: () => true }); - cookieHandler.set(mockDevBrowser); - - expect(mockSet).toHaveBeenCalledWith(mockDevBrowser, { - expires: mockExpires, - sameSite: 'None', - secure: true, - partitioned: true, + expect(removeCalls).toContainEqual({ + name: suffixedCookieName, + attributes: { + sameSite: 'None', + secure: true, + partitioned: true, + }, + }); + expect(removeCalls).toContainEqual({ + name: unsuffixedCookieName, + attributes: { + sameSite: 'None', + secure: true, + partitioned: true, + }, }); + expect(setCalls).toEqual([ + { + name: suffixedCookieName, + value: devBrowser, + attributes: { + expires, + sameSite: 'Lax', + secure: false, + partitioned: false, + }, + }, + { + name: unsuffixedCookieName, + value: devBrowser, + attributes: { + expires, + sameSite: 'Lax', + secure: false, + partitioned: false, + }, + }, + ]); }); - it('should get cookie value from suffixed cookie first, then fallback to non-suffixed', () => { - mockGet.mockImplementationOnce(() => 'suffixed-value').mockImplementationOnce(() => 'non-suffixed-value'); + it('reads the suffixed cookie before falling back to the unsuffixed cookie', () => { + const cookieHandler = createDevBrowserCookie(cookieSuffix, defaultOptions); - const cookieHandler = createDevBrowserCookie(mockCookieSuffix, defaultOptions); - const result = cookieHandler.get(); - - expect(result).toBe('suffixed-value'); - }); + cookieStore.set(unsuffixedCookieName, 'unsuffixed-value'); + cookieStore.set(suffixedCookieName, 'suffixed-value'); - it('should fallback to non-suffixed cookie when suffixed cookie is not present', () => { - mockGet.mockImplementationOnce(() => undefined).mockImplementationOnce(() => 'non-suffixed-value'); + expect(cookieHandler.get()).toBe('suffixed-value'); - const cookieHandler = createDevBrowserCookie(mockCookieSuffix, defaultOptions); - const result = cookieHandler.get(); + cookieStore.delete(suffixedCookieName); - expect(result).toBe('non-suffixed-value'); + expect(cookieHandler.get()).toBe('unsuffixed-value'); }); });