Skip to content
Merged
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/duplicate-session-cookies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Recover from partitioned-cookie startup races by removing stale non-partitioned cookies when partitioned cookies become available.
9 changes: 6 additions & 3 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,11 @@ 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();
void this.refreshSessionToken({ updateCookieImmediately: true });
this.setClientUatCookieForDevelopmentInstances();
});

this.refreshTokenOnFocus();
Expand Down Expand Up @@ -266,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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
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),
}));

vi.mock('../cookies/session', () => ({ createSessionCookie: () => mocks.sessionCookie }));
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<Record<string, unknown>>();
return { ...actual, inCrossOriginIframe: () => mocks.inCrossOriginIframe() };
Expand Down Expand Up @@ -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');
});
Expand Down Expand Up @@ -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();
});
});
88 changes: 77 additions & 11 deletions packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ 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();

beforeEach(() => {
Expand All @@ -32,9 +32,13 @@ describe('createClientUatCookie', () => {
(requiresSameSiteNone as ReturnType<typeof vi.fn>).mockReturnValue(false);
(getCookieDomain as ReturnType<typeof vi.fn>).mockReturnValue(mockDomain);
(getSecureAttribute as ReturnType<typeof vi.fn>).mockReturnValue(true);
(createCookieHandler as ReturnType<typeof vi.fn>).mockImplementation(() => ({
set: mockSet,
remove: mockRemove,
(createCookieHandler as ReturnType<typeof vi.fn>).mockImplementation((name: string) => ({
set: (value: string, attributes?: object) => {
mockSet(name, value, attributes);
},
remove: (attributes?: object) => {
mockRemove(name, attributes);
},
get: mockGet,
}));
});
Expand All @@ -55,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', () => {
Expand All @@ -73,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',
Expand All @@ -86,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',
Expand All @@ -103,7 +108,7 @@ describe('createClientUatCookie', () => {
signedInSessions: [],
});

expect(mockSet).toHaveBeenCalledWith('0', {
expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '0', {
domain: mockDomain,
expires: mockExpires,
sameSite: 'Strict',
Expand Down Expand Up @@ -139,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',
Expand All @@ -156,12 +161,73 @@ describe('createClientUatCookie', () => {
signedInSessions: ['session1'],
});

expect(mockSet).toHaveBeenCalledWith('1704067200', {
expect(mockSet).toHaveBeenCalledWith('__client_uat_test-suffix', '1704067200', {
domain: mockDomain,
expires: mockExpires,
sameSite: 'None',
secure: true,
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;
mockSet.mockClear();
mockRemove.mockClear();
cookieHandler.set(client);
Comment on lines +173 to +188

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -i 'clientUat.test.ts' -t f --exec rg -nP -C2 'cookieHandler\.set\(|as any|as unknown as'

Repository: clerk/javascript

Length of output: 2065


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## tsconfig files\n'
fd -a 'tsconfig*.json' packages/clerk-js . | sed 's#^\./##' | sort

printf '\n## package.json test/typecheck scripts\n'
rg -n '"(test|typecheck|check|lint|tsc)"\s*:\s*' packages/clerk-js/package.json package.json -n

printf '\n## ClientResource and createClientUatCookie definitions\n'
rg -n 'type ClientResource|interface ClientResource|createClientUatCookie|set\(client' packages/clerk-js/src -g '!**/dist/**' -g '!**/build/**'

printf '\n## test file excerpt\n'
sed -n '1,260p' packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts

Repository: clerk/javascript

Length of output: 14517


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## relevant tsconfig contents\n'
for f in packages/clerk-js/tsconfig.json packages/clerk-js/tsconfig.*.json; do
  [ -f "$f" ] && { echo "--- $f"; cat "$f"; echo; }
done

printf '\n## source types around ClientResource\n'
sed -n '1,220p' $(rg -l 'ClientResource' packages/clerk-js/src | head -n 5)

printf '\n## test file lines 160-200\n'
sed -n '160,200p' packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts

Repository: clerk/javascript

Length of output: 11138


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '## package tsconfig references'
rg -n '"include"|"exclude"|__tests__|test' packages/clerk-js/tsconfig*.json packages/clerk-js/package.json

echo
echo '## ClientResource definition'
rg -n 'export (type|interface) ClientResource|type ClientResource|interface ClientResource' packages/clerk-js/src packages/clerk-js -g '!**/dist/**' -g '!**/build/**'

echo
echo '## createClientUatCookie signature'
rg -n 'function createClientUatCookie|const createClientUatCookie|createClientUatCookie\s*=' packages/clerk-js/src -g '!**/dist/**' -g '!**/build/**'

Repository: clerk/javascript

Length of output: 922


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '## ClientResource definition candidates\n'
rg -n --hidden --glob '!**/dist/**' --glob '!**/build/**' 'export (type|interface) ClientResource|type ClientResource =|interface ClientResource' packages/shared packages/clerk-js packages -g '!**/*.map' | head -n 50

printf '\n## ClientResource related re-exports\n'
rg -n --hidden --glob '!**/dist/**' --glob '!**/build/**' 'ClientResource' packages/shared packages/clerk-js/src | head -n 80

Repository: clerk/javascript

Length of output: 4730


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# locate the shared type definition and the exact shape used by ClientUatCookieHandler
files=$(rg -l --hidden --glob '!**/dist/**' --glob '!**/build/**' 'ClientResource' packages/shared packages/clerk-js/src | head -n 20)
printf '%s\n' "$files"

printf '\n## definition excerpts\n'
for f in $files; do
  echo "--- $f"
  sed -n '1,240p' "$f" | rg -n 'ClientResource|signedInSessions|updatedAt|id:' -n -C2 || true
done

Repository: clerk/javascript

Length of output: 10076


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '## files named around shared types\n'
fd -a 'types.ts' packages/shared packages/clerk-js/src | head -n 50
printf '\n## files with ClientResource in shared package\n'
rg -l --hidden --glob '!**/dist/**' --glob '!**/build/**' 'ClientResource' packages/shared | head -n 50

Repository: clerk/javascript

Length of output: 1448


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '## locate ClientResource definition'
rg -n --hidden --glob '!**/dist/**' --glob '!**/build/**' 'ClientResource' packages/shared packages/clerk-js/src | head -n 80

echo
echo '## package/shared type files'
fd -a 'types*.ts' packages/shared | head -n 50

Repository: clerk/javascript

Length of output: 5444


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for the shared type declaration and inspect the minimal surrounding context.
type_file=$(rg -l --hidden --glob '!**/dist/**' --glob '!**/build/**' 'export (type|interface) ClientResource|type ClientResource =|interface ClientResource' packages/shared packages/clerk-js/src | head -n 1)
echo "## type file: $type_file"
[ -n "$type_file" ] && sed -n '1,260p' "$type_file"

echo
echo "## direct references in clientUat.ts"
sed -n '1,220p' packages/clerk-js/src/core/auth/cookies/clientUat.ts

Repository: clerk/javascript

Length of output: 7485


This fixture isn't a ClientResource. client only has id, updatedAt, and signedInSessions, so this test file will fail tsc unless you use a typed helper or an explicit cast for the minimal test shape.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/clerk-js/src/core/auth/cookies/__tests__/clientUat.test.ts` around
lines 173 - 188, Update the client fixture in the “clears non-partitioned domain
variants before writing partitioned cookies” test to satisfy the ClientResource
type, using an existing typed helper or an explicit cast for this minimal shape.
Keep the fixture’s runtime fields and test behavior unchanged.


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,
},
],
[
'__client_uat',
'1704067200',
{
domain: mockDomain,
expires: mockExpires,
sameSite: 'None',
secure: true,
partitioned: true,
},
],
]);
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]);
});
});
82 changes: 68 additions & 14 deletions packages/clerk-js/src/core/auth/cookies/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ 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();

beforeEach(() => {
Expand All @@ -29,9 +29,13 @@ describe('createSessionCookie', () => {
(inCrossOriginIframe as ReturnType<typeof vi.fn>).mockReturnValue(false);
(requiresSameSiteNone as ReturnType<typeof vi.fn>).mockReturnValue(false);
(getSecureAttribute as ReturnType<typeof vi.fn>).mockReturnValue(true);
(createCookieHandler as ReturnType<typeof vi.fn>).mockImplementation(() => ({
set: mockSet,
remove: mockRemove,
(createCookieHandler as ReturnType<typeof vi.fn>).mockImplementation((name: string) => ({
set: (value: string, attributes?: object) => {
mockSet(name, value, attributes);
},
remove: (attributes?: object) => {
mockRemove(name, attributes);
},
get: mockGet,
}));
});
Expand All @@ -48,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,
Expand All @@ -61,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,
Expand All @@ -87,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', () => {
Expand All @@ -123,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,
Expand All @@ -135,12 +139,62 @@ describe('createSessionCookie', () => {
const cookieHandler = createSessionCookie(mockCookieSuffix, { usePartitionedCookies: () => true });
cookieHandler.set(mockToken);

expect(mockRemove).toHaveBeenCalledTimes(2);
expect(mockSet).toHaveBeenCalledWith(mockToken, {
expect(mockRemove).toHaveBeenCalledTimes(4);
expect(mockSet).toHaveBeenCalledWith('__session', mockToken, {
expires: mockExpires,
sameSite: 'None',
secure: true,
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;
mockSet.mockClear();
mockRemove.mockClear();
cookieHandler.set('partitioned-token');

expect(mockRemove.mock.calls).toEqual([
['__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([
[
'__session',
'partitioned-token',
{
expires: mockExpires,
sameSite: 'None',
secure: true,
partitioned: true,
},
],
[
'__session_test-suffix',
'partitioned-token',
{
expires: mockExpires,
sameSite: 'None',
secure: true,
partitioned: true,
},
],
]);
const firstInvocationOrder = mockRemove.mock.invocationCallOrder[0];
expect(mockRemove.mock.invocationCallOrder).toEqual([
firstInvocationOrder,
firstInvocationOrder + 1,
firstInvocationOrder + 2,
firstInvocationOrder + 3,
]);
expect(mockSet.mock.invocationCallOrder).toEqual([firstInvocationOrder + 4, firstInvocationOrder + 5]);
});
});
Loading
Loading