diff --git a/.changeset/fix-dev-browser-partitioned-cookie.md b/.changeset/fix-dev-browser-partitioned-cookie.md new file mode 100644 index 00000000000..e4d561b52c6 --- /dev/null +++ b/.changeset/fix-dev-browser-partitioned-cookie.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix dev browser token being read from a stale non-partitioned cookie when `partitionedCookies` is enabled. The token is now kept in memory so FAPI requests always use the authoritative value. diff --git a/integration/tests/dev-browser-partitioned-cookies.test.ts b/integration/tests/dev-browser-partitioned-cookies.test.ts new file mode 100644 index 00000000000..400d7fd3c77 --- /dev/null +++ b/integration/tests/dev-browser-partitioned-cookies.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from '@playwright/test'; +import { parsePublishableKey } from '@clerk/shared/keys'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( + 'dev browser partitioned cookies @generic', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('URL query param dev browser token takes precedence over existing partitioned cookie on initial load', async ({ + page, + context, + }) => { + const pk = app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const { frontendApi } = parsePublishableKey(pk)!; + const fapiOrigin = `https://${frontendApi}`; + + // Obtain a valid dev browser token directly from FAPI before any page load + const devBrowserRes = await page.request.post(`${fapiOrigin}/v1/dev_browser`); + expect(devBrowserRes.ok()).toBe(true); + const { id: freshToken } = await devBrowserRes.json(); + expect(freshToken).toBeTruthy(); + + // Pre-set a stale __clerk_db_jwt cookie before the page ever loads. + // This simulates the partitioned cookie that already exists in the browser + // from a previous session. + const appUrl = new URL(app.serverUrl); + await context.addCookies([ + { + name: '__clerk_db_jwt', + value: 'stale_partitioned_value', + domain: appUrl.hostname, + path: '/', + }, + ]); + + // Collect every dev browser token attached to FAPI requests + const fapiTokens: string[] = []; + page.on('request', req => { + if (req.url().includes('__clerk_db_jwt') && req.url().includes('/v1/')) { + const url = new URL(req.url()); + const token = url.searchParams.get('__clerk_db_jwt'); + if (token) { + fapiTokens.push(token); + } + } + }); + + // Initial page load with the fresh token in the URL query param, + // simulating a redirect back from Clerk's Account Portal. + const signInUrl = new URL(app.serverUrl + '/sign-in'); + signInUrl.searchParams.set('__clerk_db_jwt', freshToken); + + await page.goto(signInUrl.toString()); + await page.waitForLoadState('networkidle'); + + // Every FAPI request during initial load must use the URL token, + // not the stale partitioned cookie. + expect(fapiTokens.length).toBeGreaterThan(0); + for (const token of fapiTokens) { + expect(token).toBe(freshToken); + expect(token).not.toBe('stale_partitioned_value'); + } + + // Verify clerk-js is functional: sign in should succeed + const u = createTestUtils({ app, page, context }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + }); + }, +); diff --git a/packages/clerk-js/src/core/auth/devBrowser.ts b/packages/clerk-js/src/core/auth/devBrowser.ts index 2bea7c52049..4572221f21d 100644 --- a/packages/clerk-js/src/core/auth/devBrowser.ts +++ b/packages/clerk-js/src/core/auth/devBrowser.ts @@ -37,15 +37,22 @@ export function createDevBrowser({ }: CreateDevBrowserOptions): DevBrowser { const devBrowserCookie = createDevBrowserCookie(cookieSuffix, cookieOptions); + // Hold the dev browser token in memory so it's always available to FAPI + // interceptors, even before Environment resolves and cookies can be written + // with the correct Partitioned attribute. + let devBrowserInMemory: string | undefined; + function getDevBrowser() { - return devBrowserCookie.get(); + return devBrowserInMemory || devBrowserCookie.get(); } function setDevBrowser(devBrowser: string) { + devBrowserInMemory = devBrowser; devBrowserCookie.set(devBrowser); } function removeDevBrowser() { + devBrowserInMemory = undefined; devBrowserCookie.remove(); } @@ -81,7 +88,9 @@ export function createDevBrowser({ } // 2. If no dev browser is found in the first step, check if one is already available in the __clerk_db_jwt JS cookie - if (devBrowserCookie.get()) { + const existingDevBrowser = devBrowserCookie.get(); + if (existingDevBrowser) { + devBrowserInMemory = existingDevBrowser; return; }