Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-dev-browsers.md
Original file line number Diff line number Diff line change
@@ -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.
147 changes: 147 additions & 0 deletions packages/clerk-js/src/core/auth/cookies/__tests__/devBrowser.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>(),
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');
});
});
38 changes: 20 additions & 18 deletions packages/clerk-js/src/core/auth/cookies/devBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
Loading