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. 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..49176d9966b --- /dev/null +++ b/packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createDevBrowserCookie } from '../devBrowser'; + +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 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 }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + cookieStore.clear(); + removeCalls.length = 0; + setCalls.length = 0; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('removes current, non-partitioned, and partitioned cookie variants for both dev browser cookie names', () => { + const cookieHandler = createDevBrowserCookie(cookieSuffix, defaultOptions); + + cookieHandler.remove(); + + 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('clears stale partitioned cookie variants before writing a new dev browser', () => { + const cookieHandler = createDevBrowserCookie(cookieSuffix, defaultOptions); + + cookieHandler.set(devBrowser); + + 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('reads the suffixed cookie before falling back to the unsuffixed cookie', () => { + const cookieHandler = createDevBrowserCookie(cookieSuffix, defaultOptions); + + cookieStore.set(unsuffixedCookieName, 'unsuffixed-value'); + cookieStore.set(suffixedCookieName, 'suffixed-value'); + + expect(cookieHandler.get()).toBe('suffixed-value'); + + cookieStore.delete(suffixedCookieName); + + expect(cookieHandler.get()).toBe('unsuffixed-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,