Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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/every-dryers-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

When fetching a new Session token, broadcast the token value to other tabs so they can pre-warm their in-memory Session Token cache with the most recent token.
5 changes: 5 additions & 0 deletions .changeset/slick-ducks-bet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Introduce deprecation warning for LocalStorageBroadcastChannel
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
"shared/is-valid-browser.mdx",
"shared/isomorphic-atob.mdx",
"shared/load-clerk-js-script.mdx",
"shared/local-storage-broadcast-channel.mdx",
"shared/pages-or-infinite-options.mdx",
"shared/paginated-hook-config.mdx",
"shared/paginated-resources.mdx",
Expand Down
230 changes: 230 additions & 0 deletions integration/tests/session-token-cache/multi-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { expect, test } from '@playwright/test';

import { appConfigs } from '../../presets';
import type { FakeUser } from '../../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';

/**
* Tests MemoryTokenCache session isolation in multi-session scenarios
*
* This suite validates that when multiple user sessions exist simultaneously,
* each session maintains its own isolated token cache. Tokens are not shared
* between different sessions, even within the same tab, ensuring proper
* security boundaries between users.
*/
testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
'MemoryTokenCache Multi-Session Integration @nextjs',
({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser1: FakeUser;
let fakeUser2: FakeUser;

test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser1 = u.services.users.createFakeUser();
fakeUser2 = u.services.users.createFakeUser();
await u.services.users.createBapiUser(fakeUser1);
await u.services.users.createBapiUser(fakeUser2);
});

test.afterAll(async () => {
await fakeUser1.deleteIfExists();
await fakeUser2.deleteIfExists();
await app.teardown();
});

/**
* Test Flow:
* 1. Tab1: Sign in as user1, fetch and cache their token
* 2. Tab2: Opens and inherits user1's session via cookies
* 3. Tab2: Sign in as user2 using programmatic sign-in (preserves both sessions)
* 4. Tab2: Now has two active sessions (user1 and user2)
* 5. Tab2: Switch between sessions and fetch tokens for each
* 6. Verify no network requests occur (tokens served from cache)
* 7. Tab1: Verify it still has user1 as active session (tab independence)
*
* Expected Behavior:
* - Each session has its own isolated token cache
* - Switching sessions in tab2 returns different tokens
* - Both tokens are served from cache (no network requests)
* - Tab1 remains unaffected by tab2's session changes
* - Multi-session state is properly maintained per-tab
*/
test('MemoryTokenCache multi-session - multiple users in different tabs with separate token caches', async ({
context,
}) => {
const page1 = await context.newPage();
await page1.goto(app.serverUrl);
await page1.waitForFunction(() => (window as any).Clerk?.loaded);

const u1 = createTestUtils({ app, page: page1 });
await u1.po.signIn.goTo();
await u1.po.signIn.setIdentifier(fakeUser1.email);
await u1.po.signIn.continue();
await u1.po.signIn.setPassword(fakeUser1.password);
await u1.po.signIn.continue();
await u1.po.expect.toBeSignedIn();

const user1SessionInfo = await page1.evaluate(() => {
const clerk = (window as any).Clerk;
return {
sessionId: clerk?.session?.id,
userId: clerk?.user?.id,
};
});

expect(user1SessionInfo.sessionId).toBeDefined();
expect(user1SessionInfo.userId).toBeDefined();

const user1Token = await page1.evaluate(async () => {
const clerk = (window as any).Clerk;
return await clerk.session?.getToken({ skipCache: true });
});

expect(user1Token).toBeTruthy();

const page2 = await context.newPage();
await page2.goto(app.serverUrl);
await page2.waitForFunction(() => (window as any).Clerk?.loaded);

// eslint-disable-next-line playwright/no-wait-for-timeout
await page2.waitForTimeout(1000);

const u2 = createTestUtils({ app, page: page2 });
await u2.po.expect.toBeSignedIn();

const page2User1SessionInfo = await page2.evaluate(() => {
const clerk = (window as any).Clerk;
return {
sessionId: clerk?.session?.id,
userId: clerk?.user?.id,
};
});

expect(page2User1SessionInfo.userId).toBe(user1SessionInfo.userId);
expect(page2User1SessionInfo.sessionId).toBe(user1SessionInfo.sessionId);

// Use clerk.client.signIn.create() instead of navigating to /sign-in
// because navigating replaces the session by default (transferable: true)
const signInResult = await page2.evaluate(
async ({ email, password }) => {
const clerk = (window as any).Clerk;

try {
const signIn = await clerk.client.signIn.create({
identifier: email,
password: password,
});

await clerk.setActive({
session: signIn.createdSessionId,
});

return {
allSessions: clerk?.client?.sessions?.map((s: any) => ({ id: s.id, userId: s.userId })) || [],
sessionCount: clerk?.client?.sessions?.length || 0,
success: true,
};
} catch (error: any) {
return {
error: error.message || String(error),
success: false,
};
}
},
{ email: fakeUser2.email, password: fakeUser2.password },
);

expect(signInResult.success).toBe(true);
expect(signInResult.sessionCount).toBe(2);

await u2.po.expect.toBeSignedIn();

const user2SessionInfo = await page2.evaluate(() => {
const clerk = (window as any).Clerk;
return {
allSessions: clerk?.client?.sessions?.map((s: any) => ({ id: s.id, userId: s.userId })) || [],
sessionCount: clerk?.client?.sessions?.length || 0,
sessionId: clerk?.session?.id,
userId: clerk?.user?.id,
};
});

expect(user2SessionInfo.sessionId).toBeDefined();
expect(user2SessionInfo.userId).toBeDefined();
expect(user2SessionInfo.sessionId).not.toBe(user1SessionInfo.sessionId);
expect(user2SessionInfo.userId).not.toBe(user1SessionInfo.userId);

const user2Token = await page2.evaluate(async () => {
const clerk = (window as any).Clerk;
return await clerk.session?.getToken({ skipCache: true });
});

expect(user2Token).toBeTruthy();
expect(user2Token).not.toBe(user1Token);

const page2MultiSessionInfo = await page2.evaluate(() => {
const clerk = (window as any).Clerk;
return {
activeSessionId: clerk?.session?.id,
allSessionIds: clerk?.client?.sessions?.map((s: any) => s.id) || [],
sessionCount: clerk?.client?.sessions?.length || 0,
};
});

expect(page2MultiSessionInfo.sessionCount).toBe(2);
expect(page2MultiSessionInfo.allSessionIds).toContain(user1SessionInfo.sessionId);
expect(page2MultiSessionInfo.allSessionIds).toContain(user2SessionInfo.sessionId);
expect(page2MultiSessionInfo.activeSessionId).toBe(user2SessionInfo.sessionId);

const tokenFetchRequests: Array<{ sessionId: string; url: string }> = [];
await context.route('**/v1/client/sessions/*/tokens*', async route => {
const url = route.request().url();
const sessionIdMatch = url.match(/sessions\/([^/]+)\/tokens/);
const sessionId = sessionIdMatch?.[1] || 'unknown';
tokenFetchRequests.push({ sessionId, url });
await route.continue();
});

const tokenIsolation = await page2.evaluate(
async ({ user1SessionId, user2SessionId }) => {
const clerk = (window as any).Clerk;

await clerk.setActive({ session: user1SessionId });
const user1Token = await clerk.session?.getToken();

await clerk.setActive({ session: user2SessionId });
const user2Token = await clerk.session?.getToken();

return {
tokensAreDifferent: user1Token !== user2Token,
user1Token,
user2Token,
};
},
{ user1SessionId: user1SessionInfo.sessionId, user2SessionId: user2SessionInfo.sessionId },
);

expect(tokenIsolation.tokensAreDifferent).toBe(true);
expect(tokenIsolation.user1Token).toBeTruthy();
expect(tokenIsolation.user2Token).toBeTruthy();
expect(tokenFetchRequests.length).toBe(0);

await context.unroute('**/v1/client/sessions/*/tokens*');

// In multi-session apps, each tab can have a different active session
const tab1FinalInfo = await page1.evaluate(() => {
const clerk = (window as any).Clerk;
return {
activeSessionId: clerk?.session?.id,
userId: clerk?.user?.id,
};
});

// Tab1 should STILL have user1 as the active session (independent per tab)
expect(tab1FinalInfo.userId).toBe(user1SessionInfo.userId);
expect(tab1FinalInfo.activeSessionId).toBe(user1SessionInfo.sessionId);
});
},
);
132 changes: 132 additions & 0 deletions integration/tests/session-token-cache/single-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { expect, test } from '@playwright/test';

import { appConfigs } from '../../presets';
import type { FakeUser } from '../../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';

/**
* Tests MemoryTokenCache cross-tab token sharing via BroadcastChannel
*
* This suite validates that when multiple browser tabs share the same user session,
* token fetches in one tab are automatically broadcast and cached in other tabs,
* eliminating redundant network requests.
*/
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })(
'MemoryTokenCache Multi-Tab Integration @generic',
({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;

test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await u.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

/**
* Test Flow:
* 1. Open two tabs with the same browser context (shared cookies)
* 2. Sign in on tab1, which creates a session
* 3. Reload tab2 to pick up the session from cookies
* 4. Clear token cache on both tabs
* 5. Fetch token on tab1 (triggers network request + broadcast)
* 6. Fetch token on tab2 (should use broadcasted token, no network request)
*
* Expected Behavior:
* - Both tabs receive identical tokens
* - Only ONE network request is made (from tab1)
* - Tab2 gets the token via BroadcastChannel, proving cross-tab cache sharing
*/
test('MemoryTokenCache multi-tab token sharing', async ({ context }) => {
const page1 = await context.newPage();
const page2 = await context.newPage();

await page1.goto(app.serverUrl);
await page2.goto(app.serverUrl);

await page1.waitForFunction(() => (window as any).Clerk?.loaded);
await page2.waitForFunction(() => (window as any).Clerk?.loaded);

const u1 = createTestUtils({ app, page: page1 });
await u1.po.signIn.goTo();
await u1.po.signIn.setIdentifier(fakeUser.email);
await u1.po.signIn.continue();
await u1.po.signIn.setPassword(fakeUser.password);
await u1.po.signIn.continue();
await u1.po.expect.toBeSignedIn();

// eslint-disable-next-line playwright/no-wait-for-timeout
await page1.waitForTimeout(1000);

await page2.reload();
await page2.waitForFunction(() => (window as any).Clerk?.loaded);

const u2 = createTestUtils({ app, page: page2 });
await u2.po.expect.toBeSignedIn();

const page1SessionInfo = await page1.evaluate(() => {
const clerk = (window as any).Clerk;
return {
sessionId: clerk?.session?.id,
userId: clerk?.user?.id,
};
});

expect(page1SessionInfo.sessionId).toBeDefined();
expect(page1SessionInfo.userId).toBeDefined();

await Promise.all([
page1.evaluate(() => (window as any).Clerk.session?.clearCache()),
page2.evaluate(() => (window as any).Clerk.session?.clearCache()),
]);

// Track token fetch requests to verify only one network call happens
const tokenRequests: string[] = [];
await context.route('**/v1/client/sessions/*/tokens*', async route => {
tokenRequests.push(route.request().url());
await route.continue();
});

const page1Token = await page1.evaluate(async () => {
const clerk = (window as any).Clerk;
return await clerk.session?.getToken({ skipCache: true });
});

expect(page1Token).toBeTruthy();

// Wait for broadcast to propagate between tabs (broadcast is nearly instant, but we add buffer)
// eslint-disable-next-line playwright/no-wait-for-timeout
await page2.waitForTimeout(2000);

const page2Result = await page2.evaluate(async () => {
const clerk = (window as any).Clerk;

const token = await clerk.session?.getToken();

return {
sessionId: clerk?.session?.id,
token,
userId: clerk?.user?.id,
};
});

expect(page2Result.sessionId).toBe(page1SessionInfo.sessionId);
expect(page2Result.userId).toBe(page1SessionInfo.userId);

// If BroadcastChannel worked, both tabs should have the EXACT same token
expect(page2Result.token).toBe(page1Token);

// Verify only one token fetch happened (page1), proving page2 got it from BroadcastChannel
expect(tokenRequests.length).toBe(1);
Copy link
Member Author

Choose a reason for hiding this comment

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

This is the key expectation, that only 1 token fetch requests happened between the 2 tabs

});
},
);
1 change: 1 addition & 0 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"files": [
{ "path": "./dist/clerk.js", "maxSize": "821KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "81KB" },
{ "path": "./dist/clerk.channel.browser.js", "maxSize": "81KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "123KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "63KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "117.1KB" },
Expand Down
Loading
Loading