From 60a7e8fa3b85aa25630f65675a53620cb4d5c73b Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 2 Oct 2025 14:26:04 -0500 Subject: [PATCH 1/7] feat(clerk-js): Dedupe getToken requests --- .changeset/every-dryers-refuse.md | 5 + .changeset/slick-ducks-bet.md | 5 + .../__snapshots__/file-structure.test.ts.snap | 1 + .../session-token-cache/multi-session.test.ts | 230 +++++ .../single-session.test.ts | 132 +++ .../src/core/__tests__/tokenCache.test.ts | 898 ++++++++++++++---- packages/clerk-js/src/core/clerk.ts | 16 +- .../clerk-js/src/core/resources/Session.ts | 31 +- .../core/resources/__tests__/Session.test.ts | 105 ++ packages/clerk-js/src/core/tokenCache.ts | 342 ++++++- .../__tests__/useFormattedPhoneNumber.test.ts | 3 +- .../src/utils/__tests__/tokenId.test.ts | 174 ++++ packages/clerk-js/src/utils/index.ts | 1 + packages/clerk-js/src/utils/tokenId.ts | 79 ++ .../src/localStorageBroadcastChannel.ts | 7 + 15 files changed, 1800 insertions(+), 229 deletions(-) create mode 100644 .changeset/every-dryers-refuse.md create mode 100644 .changeset/slick-ducks-bet.md create mode 100644 integration/tests/session-token-cache/multi-session.test.ts create mode 100644 integration/tests/session-token-cache/single-session.test.ts create mode 100644 packages/clerk-js/src/utils/__tests__/tokenId.test.ts create mode 100644 packages/clerk-js/src/utils/tokenId.ts diff --git a/.changeset/every-dryers-refuse.md b/.changeset/every-dryers-refuse.md new file mode 100644 index 00000000000..e698dee3107 --- /dev/null +++ b/.changeset/every-dryers-refuse.md @@ -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. diff --git a/.changeset/slick-ducks-bet.md b/.changeset/slick-ducks-bet.md new file mode 100644 index 00000000000..870893e0b07 --- /dev/null +++ b/.changeset/slick-ducks-bet.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Introduce deprecation warning for LocalStorageBroadcastChannel diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index e723ceefd8a..3547c4d43a9 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -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", diff --git a/integration/tests/session-token-cache/multi-session.test.ts b/integration/tests/session-token-cache/multi-session.test.ts new file mode 100644 index 00000000000..2f05eab18c3 --- /dev/null +++ b/integration/tests/session-token-cache/multi-session.test.ts @@ -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); + }); + }, +); diff --git a/integration/tests/session-token-cache/single-session.test.ts b/integration/tests/session-token-cache/single-session.test.ts new file mode 100644 index 00000000000..03b5bd24953 --- /dev/null +++ b/integration/tests/session-token-cache/single-session.test.ts @@ -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); + }); + }, +); diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 476e7622a22..09223d355ca 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -1,271 +1,815 @@ import type { TokenResource } from '@clerk/types'; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { mockJwt } from '@/test/core-fixtures'; import { Token } from '../resources/internal'; import { SessionTokenCache } from '../tokenCache'; -// This is required since abstract TS methods are undefined in Jest -vi.mock('../resources/Base', () => { - class BaseResource {} +interface SessionTokenEvent { + organizationId?: string | null; + sessionId: string; + template?: string; + tokenId: string; + tokenRaw: string; + traceId: string; +} - return { - BaseResource, +/** + * Helper to create a JWT with custom TTL for testing expiration scenarios + */ +function createJwtWithTtl(iatSeconds: number, ttlSeconds: number): string { + const payload = { + data: 'test-data', + exp: iatSeconds + ttlSeconds, + iat: iatSeconds, }; -}); + const payloadString = JSON.stringify(payload); + const payloadB64 = btoa(payloadString).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + const headerB64 = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'; + const signature = 'test-signature'; + return `${headerB64}.${payloadB64}.${signature}`; +} -const jwt = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NzU4NzY3OTAsImRhdGEiOiJmb29iYXIiLCJpYXQiOjE2NzU4NzY3MzB9.Z1BC47lImYvaAtluJlY-kBo0qOoAk42Xb-gNrB2SxJg'; +describe('SessionTokenCache', () => { + let mockBroadcastChannel: { + addEventListener: ReturnType; + close: ReturnType; + postMessage: ReturnType; + }; + let broadcastListener: (e: MessageEvent) => void; + let originalBroadcastChannel: any; -// Helper function to create JWT with custom exp and iat values using the same structure as the working JWT -function createJwtWithTtl(ttlSeconds: number): string { - // Use the existing JWT as template - const baseJwt = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NzU4NzY3OTAsImRhdGEiOiJmb29iYXIiLCJpYXQiOjE2NzU4NzY3MzB9.Z1BC47lImYvaAtluJlY-kBo0qOoAk42Xb-gNrB2SxJg'; - const [headerB64, , signature] = baseJwt.split('.'); + beforeEach(() => { + // Mock Date.now() to make the test tokens appear valid + // mockJwt has iat: 1666648250, exp: 1666648310 + // Set current time to 1666648260 (10 seconds after iat, 50 seconds before exp) + vi.useFakeTimers(); + vi.setSystemTime(new Date(1666648260 * 1000)); + + mockBroadcastChannel = { + addEventListener: vi.fn((eventName, listener) => { + if (eventName === 'message') { + broadcastListener = listener; + } + }), + close: vi.fn(), + postMessage: vi.fn(), + }; - // Use the same iat as the original working JWT to maintain consistency with test environment - // Original JWT: iat: 1675876730, exp: 1675876790 (60 second TTL) - const baseIat = 1675876730; - const payload = { - exp: baseIat + ttlSeconds, - data: 'foobar', // Keep same data as original - iat: baseIat, - }; + originalBroadcastChannel = global.BroadcastChannel; - // Encode the new payload using base64url encoding (like JWT standard) - const payloadString = JSON.stringify(payload); - // Use proper base64url encoding: standard base64 but replace + with -, / with _, and remove padding = - const newPayloadB64 = btoa(payloadString).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + // Close any existing BroadcastChannel from module load or previous tests + SessionTokenCache.close(); - return `${headerB64}.${newPayloadB64}.${signature}`; -} + // Now mock BroadcastChannel so next initialization uses the mock + global.BroadcastChannel = vi.fn(() => mockBroadcastChannel) as any; -describe('MemoryTokenCache', () => { - beforeAll(() => { - vi.useFakeTimers(); + SessionTokenCache.clear(); + + // Trigger broadcast channel initialization by calling set() once + // This ensures broadcastListener is set up for tests that simulate broadcast messages + SessionTokenCache.set({ + tokenId: '__init__', + tokenResolver: Promise.resolve({} as any), + }); + SessionTokenCache.clear(); }); - afterAll(() => { + afterEach(() => { + SessionTokenCache.clear(); + SessionTokenCache.close(); + global.BroadcastChannel = originalBroadcastChannel; vi.useRealTimers(); }); + describe('broadcast message handling', () => { + it('ignores broadcasts with mismatched tokenId', () => { + const event: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'wrong-token-id', + tokenRaw: mockJwt, + traceId: 'test_trace_1', + }, + } as MessageEvent; + + broadcastListener(event); + + expect(SessionTokenCache.size()).toBe(0); + }); + + it('validates tokenId matches expected format for templates', () => { + const event: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: 'my-template', + tokenId: 'session_123-my-template', + tokenRaw: mockJwt, + traceId: 'test_trace_2', + }, + } as MessageEvent; + + broadcastListener(event); + + expect(SessionTokenCache.size()).toBe(1); + }); + + it('validates tokenId matches expected format for organization tokens', () => { + const event: MessageEvent = { + data: { + organizationId: 'org_456', + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123-org_456', + tokenRaw: mockJwt, + traceId: 'test_trace_3', + }, + } as MessageEvent; + + broadcastListener(event); + + expect(SessionTokenCache.size()).toBe(1); + }); + + it('gracefully handles invalid JWT without crashing', () => { + const event: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: 'invalid.jwt.token', + traceId: 'test_trace_4', + }, + } as MessageEvent; + + expect(() => { + broadcastListener(event); + }).not.toThrow(); + + expect(SessionTokenCache.size()).toBe(0); + }); + + it('skips token with missing iat claim', () => { + const invalidJwtWithoutIat = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'; + const event: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: invalidJwtWithoutIat, + traceId: 'test_trace_5', + }, + } as MessageEvent; + + broadcastListener(event); + + expect(SessionTokenCache.size()).toBe(0); + }); + + it('skips token with missing exp claim', () => { + // JWT with iat but no exp: {iat: 1666648250} + const invalidJwtWithoutExp = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NjY2NDgyNTB9.NjdkMzg4YzYwZTQxZWQ2MTJkNmQ1ZDQ5YzY4ZTQxNjI'; + const event: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: invalidJwtWithoutExp, + traceId: 'test_trace_6', + }, + } as MessageEvent; + + broadcastListener(event); + + expect(SessionTokenCache.size()).toBe(0); + }); + + it('enforces monotonicity: does not overwrite newer token with older one', () => { + const newerEvent: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: mockJwt, + traceId: 'test_trace_7', + }, + } as MessageEvent; + + broadcastListener(newerEvent); + const cachedEntryAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(cachedEntryAfterNewer).toBeDefined(); + const newerCreatedAt = cachedEntryAfterNewer?.createdAt; + + // mockJwt has iat: 1666648250, so create an older one with iat: 1666648190 (60 seconds earlier) + const olderJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2NDg4NTAsImlhdCI6MTY2NjY0ODE5MH0.Z1BC47lImYvaAtluJlY-kBo0qOoAk42Xb-gNrB2SxJg'; + const olderEvent: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: olderJwt, + traceId: 'test_trace_8', + }, + } as MessageEvent; + + broadcastListener(olderEvent); + + const cachedEntryAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(cachedEntryAfterOlder).toBeDefined(); + expect(cachedEntryAfterOlder?.createdAt).toBe(newerCreatedAt); + }); + + it('successfully updates cache with valid token', () => { + const event: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: mockJwt, + traceId: 'test_trace_9', + }, + } as MessageEvent; + + broadcastListener(event); + + const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(cachedEntry).toBeDefined(); + expect(cachedEntry?.tokenId).toBe('session_123'); + }); + }); + + describe('token expiration with absolute time', () => { + it('returns token when expiresAt is far in the future', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + const futureJwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa( + JSON.stringify({ iat: Math.floor(Date.now() / 1000), exp: futureExp }), + )}.signature`; + + const tokenResolver = Promise.resolve({ + getRawString: () => futureJwt, + jwt: { claims: { exp: futureExp, iat: Math.floor(Date.now() / 1000) } }, + } as any); + + SessionTokenCache.set({ tokenId: 'future_token', tokenResolver }); + + // Wait for promise to resolve + await tokenResolver; + + const cachedEntry = SessionTokenCache.get({ tokenId: 'future_token' }); + expect(cachedEntry).toBeDefined(); + expect(cachedEntry?.tokenId).toBe('future_token'); + }); + + it('removes token when expiresAt is in the past', async () => { + const pastExp = Math.floor(Date.now() / 1000) - 60; // 60 seconds ago + const pastJwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa( + JSON.stringify({ iat: Math.floor(Date.now() / 1000) - 120, exp: pastExp }), + )}.signature`; + + const tokenResolver = Promise.resolve({ + getRawString: () => pastJwt, + jwt: { claims: { exp: pastExp, iat: Math.floor(Date.now() / 1000) - 120 } }, + } as any); + + SessionTokenCache.set({ tokenId: 'expired_token', tokenResolver }); + + await tokenResolver; + + const cachedEntry = SessionTokenCache.get({ tokenId: 'expired_token' }); + expect(cachedEntry).toBeUndefined(); + }); + + it('removes token when it expires within the leeway threshold', async () => { + const soonExp = Math.floor(Date.now() / 1000) + 8; // 8 seconds from now (less than LEEWAY + SYNC_LEEWAY = 15) + const soonJwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa( + JSON.stringify({ iat: Math.floor(Date.now() / 1000) - 10, exp: soonExp }), + )}.signature`; + + const tokenResolver = Promise.resolve({ + getRawString: () => soonJwt, + jwt: { claims: { exp: soonExp, iat: Math.floor(Date.now() / 1000) - 10 } }, + } as any); + + SessionTokenCache.set({ tokenId: 'soon_expired_token', tokenResolver }); + + // Wait for promise to resolve + await tokenResolver; + + const cachedEntry = SessionTokenCache.get({ tokenId: 'soon_expired_token' }); + expect(cachedEntry).toBeUndefined(); + }); + + it('returns token when expiresAt is undefined (promise not yet resolved)', () => { + // Create a promise that never resolves + const pendingTokenResolver = new Promise(() => {}); + + SessionTokenCache.set({ tokenId: 'pending_token', tokenResolver: pendingTokenResolver }); + + const cachedEntry = SessionTokenCache.get({ tokenId: 'pending_token' }); + expect(cachedEntry).toBeDefined(); + expect(cachedEntry?.tokenId).toBe('pending_token'); + }); + }); + + describe('broadcast sending', () => { + it('broadcasts automatically when token resolves with valid claims', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600; + const tokenResolver = Promise.resolve({ + getRawString: () => mockJwt, + jwt: { claims: { exp: futureExp, iat: 1675876730, sid: 'session_123' } }, + } as any); + + SessionTokenCache.set({ + tokenId: 'session_123', + tokenResolver, + }); + + // Wait for the token to resolve and broadcast to happen + await tokenResolver; + + expect(mockBroadcastChannel.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: undefined, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: mockJwt, + traceId: expect.stringMatching(/^bc_\d+_[a-z0-9]+$/), + }), + ); + }); + + it('does not broadcast when token has no sid claim', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600; + const tokenResolver = Promise.resolve({ + getRawString: () => mockJwt, + jwt: { claims: { exp: futureExp, iat: 1675876730 } }, + } as any); + + SessionTokenCache.set({ + tokenId: 'session_123', + tokenResolver, + }); + + await tokenResolver; + + expect(mockBroadcastChannel.postMessage).not.toHaveBeenCalled(); + }); + + it('validates tokenId matches expected format before broadcasting', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600; + const tokenResolver = Promise.resolve({ + getRawString: () => mockJwt, + jwt: { claims: { exp: futureExp, iat: 1675876730, sid: 'session_123' } }, + } as any); + + SessionTokenCache.set({ + tokenId: 'wrong-token-id', + tokenResolver, + }); + + await tokenResolver; + + expect(mockBroadcastChannel.postMessage).not.toHaveBeenCalled(); + }); + }); + describe('clear()', () => { - it('removes all entries', () => { - const cache = SessionTokenCache; + it('removes all entries and clears timeouts', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600; + const futureJwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa( + JSON.stringify({ data: 'test', exp: futureExp, iat: Math.floor(Date.now() / 1000) }), + )}.signature`; + const token = new Token({ + id: 'test-token', + jwt: futureJwt, object: 'token', - id: 'foo', - jwt, }); - const tokenResolver = new Promise(resolve => setTimeout(() => resolve(token), 100)); + const tokenResolver = Promise.resolve(token); const key = { - tokenId: 'foo', - audience: 'bar', + audience: 'test-audience', + tokenId: 'test-token', }; - // Add a tokenResolver to cache - cache.set({ ...key, tokenResolver }); + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; - expect(cache.get(key)).toBeDefined(); - expect(cache.size()).toEqual(1); + expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.size()).toBe(1); - cache.clear(); + SessionTokenCache.clear(); - expect(cache.get(key)).toBeUndefined(); - expect(cache.size()).toEqual(0); + expect(SessionTokenCache.get(key)).toBeUndefined(); + expect(SessionTokenCache.size()).toBe(0); + + vi.advanceTimersByTime(3600 * 1000); + expect(SessionTokenCache.size()).toBe(0); }); }); - it('caches tokenResolver while is pending until the JWT expiration', async () => { - const cache = SessionTokenCache; - const token = new Token({ - object: 'token', - id: 'foo', - jwt, - }); + describe('token lifecycle with async resolution', () => { + it('caches tokenResolver while pending, after resolved, then auto-deletes on expiration', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); - let isResolved = false; - const tokenResolver = new Promise(resolve => - setTimeout(() => { - isResolved = true; - resolve(token); - }, 100), - ); - - const key = { - tokenId: 'foo', - audience: 'bar', - }; + const token = new Token({ + id: 'lifecycle-token', + jwt, + object: 'token', + }); - // Cache is empty - expect(cache.get(key)).toBeUndefined(); + let isResolved = false; + const tokenResolver = new Promise(resolve => + setTimeout(() => { + isResolved = true; + resolve(token); + }, 100), + ); - // Add a tokenResolver to cache - cache.set({ ...key, tokenResolver }); + const key = { + audience: 'lifecycle-test', + tokenId: 'lifecycle-token', + }; - // Cache is not empty, retrieve the unresolved tokenResolver - expect(cache.get(key)).toEqual({ - ...key, - tokenResolver, - }); - expect(isResolved).toBe(false); + expect(SessionTokenCache.get(key)).toBeUndefined(); - // Wait tokenResolver to resolve - vi.advanceTimersByTime(100); - await tokenResolver; + SessionTokenCache.set({ ...key, tokenResolver }); - // Cache is not empty, retrieve the resolved tokenResolver - expect(isResolved).toBe(true); - expect(cache.get(key)).toEqual({ - ...key, - tokenResolver, - }); + const cachedWhilePending = SessionTokenCache.get(key); + expect(cachedWhilePending).toBeDefined(); + expect(cachedWhilePending?.tokenId).toBe('lifecycle-token'); + expect(isResolved).toBe(false); + + vi.advanceTimersByTime(100); + await tokenResolver; - // Advance the timer to force the JWT expiration - vi.advanceTimersByTime(60 * 1000); + const cachedAfterResolved = SessionTokenCache.get(key); + expect(isResolved).toBe(true); + expect(cachedAfterResolved).toBeDefined(); + expect(cachedAfterResolved?.tokenId).toBe('lifecycle-token'); - // Cache is empty, tokenResolver has been removed due to JWT expiration - expect(cache.get(key)).toBeUndefined(); + vi.advanceTimersByTime(60 * 1000); - // Add another tokenResolver to cache - cache.set({ ...key, tokenResolver }); + const cachedAfterExpiration = SessionTokenCache.get(key); + expect(cachedAfterExpiration).toBeUndefined(); + }); }); - describe('get(key, leeway)', () => { - it('includes 5 seconds sync leeway', async () => { - const cache = SessionTokenCache; + describe('leeway precision', () => { + it('includes 5 second sync leeway on top of default 10 second leeway', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + const token = new Token({ - object: 'token', - id: 'foo', + id: 'leeway-token', jwt, + object: 'token', }); - const tokenResolver = Promise.resolve(token); - const key = { tokenId: 'foo', audience: 'bar' }; + const tokenResolver = Promise.resolve(token); + const key = { audience: 'leeway-test', tokenId: 'leeway-token' }; - cache.set({ ...key, tokenResolver }); + SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(cache.get(key)).toMatchObject(key); + expect(SessionTokenCache.get(key)).toMatchObject({ tokenId: 'leeway-token' }); + + vi.advanceTimersByTime(44 * 1000); + expect(SessionTokenCache.get(key)).toBeDefined(); - // 44s since token created - vi.advanceTimersByTime(45 * 1000); - expect(cache.get(key)).toMatchObject(key); + vi.advanceTimersByTime(1 * 1000); + expect(SessionTokenCache.get(key)).toBeDefined(); - // 46s since token created vi.advanceTimersByTime(1 * 1000); - expect(cache.get(key)).toBeUndefined(); + expect(SessionTokenCache.get(key)).toBeUndefined(); }); - it('includes 5 seconds sync leeway even if leeway is removed', async () => { - const cache = SessionTokenCache; + it('enforces minimum 5 second sync leeway even when leeway is set to 0', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + const token = new Token({ - object: 'token', - id: 'foo', + id: 'zero-leeway-token', jwt, + object: 'token', }); - const tokenResolver = Promise.resolve(token); - const key = { tokenId: 'foo', audience: 'bar' }; + const tokenResolver = Promise.resolve(token); + const key = { audience: 'zero-leeway-test', tokenId: 'zero-leeway-token' }; - cache.set({ ...key, tokenResolver }); + SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(cache.get(key)).toMatchObject(key); + expect(SessionTokenCache.get(key, 0)).toMatchObject({ tokenId: 'zero-leeway-token' }); - // 45s since token created - vi.advanceTimersByTime(45 * 1000); - expect(cache.get(key, 0)).toMatchObject(key); + vi.advanceTimersByTime(54 * 1000); + expect(SessionTokenCache.get(key, 0)).toBeDefined(); - // 54s since token created - vi.advanceTimersByTime(9 * 1000); - expect(cache.get(key, 0)).toMatchObject(key); - - // 55s since token created - vi.advanceTimersByTime(1 * 1000); - expect(cache.get(key, 0)).toBeUndefined(); + vi.advanceTimersByTime(2 * 1000); + expect(SessionTokenCache.get(key, 0)).toBeUndefined(); }); }); describe('dynamic TTL calculation', () => { - let dateNowSpy: ReturnType; + it('handles tokens with short TTL (30 seconds)', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 30); - afterEach(() => { - dateNowSpy.mockRestore(); + const token = new Token({ + id: 'short-ttl', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { audience: 'short-ttl-test', tokenId: 'short-ttl' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + expect(SessionTokenCache.get(key)).toBeDefined(); + + vi.advanceTimersByTime(30 * 1000); + + expect(SessionTokenCache.get(key)).toBeUndefined(); }); - it('calculates expiresIn from JWT exp and iat claims and sets timeout based on calculated TTL', async () => { - const cache = SessionTokenCache; + it('handles tokens with long TTL (120 seconds)', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 120); + + const token = new Token({ + id: 'long-ttl', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { audience: 'long-ttl-test', tokenId: 'long-ttl' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + expect(SessionTokenCache.get(key)).toBeDefined(); - // Mock Date.now to return a fixed timestamp initially - const initialTime = 1675876730000; // Same as our JWT's iat in milliseconds - dateNowSpy = vi.spyOn(Date, 'now').mockImplementation(() => initialTime); + vi.advanceTimersByTime(90 * 1000); + expect(SessionTokenCache.get(key)).toBeDefined(); - // Test with a 30-second TTL - const shortTtlJwt = createJwtWithTtl(30); - const shortTtlToken = new Token({ + vi.advanceTimersByTime(30 * 1000); + expect(SessionTokenCache.get(key)).toBeUndefined(); + }); + + it('handles tokens with various TTLs correctly', async () => { + const testCases = [ + { label: 'fresh-20s', ttl: 20 }, + { label: 'fresh-45s', ttl: 45 }, + { label: 'fresh-300s', ttl: 300 }, + ]; + + for (const { ttl, label } of testCases) { + const iat = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(iat, ttl); + const token = new Token({ + id: label, + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + SessionTokenCache.set({ tokenId: label, tokenResolver }); + await tokenResolver; + + expect(SessionTokenCache.get({ tokenId: label })).toBeDefined(); + + vi.advanceTimersByTime(ttl * 1000); + expect(SessionTokenCache.get({ tokenId: label })).toBeUndefined(); + } + }); + }); + + describe('audience parameter support', () => { + it('caches and retrieves tokens with audience parameter', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'audience-token', + jwt, object: 'token', - id: 'short-ttl', - jwt: shortTtlJwt, }); - const shortTtlKey = { tokenId: 'short-ttl', audience: 'test' }; - const shortTtlResolver = Promise.resolve(shortTtlToken); - cache.set({ ...shortTtlKey, tokenResolver: shortTtlResolver }); - await shortTtlResolver; + const tokenResolver = Promise.resolve(token); + const keyWithAudience = { + audience: 'https://api.example.com', + tokenId: 'audience-token', + }; + + SessionTokenCache.set({ ...keyWithAudience, tokenResolver }); + await tokenResolver; + + const cached = SessionTokenCache.get(keyWithAudience); + expect(cached).toBeDefined(); + expect(cached?.audience).toBe('https://api.example.com'); + }); + + it('treats tokens with different audiences as separate entries', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt1 = createJwtWithTtl(nowSeconds, 60); + const jwt2 = createJwtWithTtl(nowSeconds, 60); + + const token1 = new Token({ id: 'same-id', jwt: jwt1, object: 'token' }); + const token2 = new Token({ id: 'same-id', jwt: jwt2, object: 'token' }); + + const resolver1 = Promise.resolve(token1); + const resolver2 = Promise.resolve(token2); + + const key1 = { audience: 'audience-1', tokenId: 'same-id' }; + const key2 = { audience: 'audience-2', tokenId: 'same-id' }; + + SessionTokenCache.set({ ...key1, tokenResolver: resolver1 }); + SessionTokenCache.set({ ...key2, tokenResolver: resolver2 }); + + await Promise.all([resolver1, resolver2]); + + expect(SessionTokenCache.size()).toBe(2); + expect(SessionTokenCache.get(key1)).toBeDefined(); + expect(SessionTokenCache.get(key2)).toBeDefined(); + }); + }); + + describe('error handling', () => { + it('removes token from cache when tokenResolver promise rejects', async () => { + const tokenResolver = Promise.reject(new Error('Token fetch failed')); - const cachedEntry = cache.get(shortTtlKey); - expect(cachedEntry).toMatchObject(shortTtlKey); + const key = { tokenId: 'failing-token' }; - // Advance both the timer and the mocked current time - const advanceBy = 31 * 1000; - vi.advanceTimersByTime(advanceBy); - dateNowSpy.mockImplementation(() => initialTime + advanceBy); + SessionTokenCache.set({ ...key, tokenResolver }); - const cachedEntry2 = cache.get(shortTtlKey); - expect(cachedEntry2).toBeUndefined(); + await expect(tokenResolver).rejects.toThrow('Token fetch failed'); + + await vi.waitFor(() => { + expect(SessionTokenCache.get(key)).toBeUndefined(); + }); }); - it('handles tokens with TTL greater than 60 seconds correctly', async () => { - const cache = SessionTokenCache; + it('handles errors during broadcast token comparison gracefully', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const brokenToken = { + getRawString: () => { + throw new Error('Token broken'); + }, + jwt: { claims: { exp: nowSeconds + 60, iat: nowSeconds } }, + } as any; + + const brokenResolver = Promise.resolve(brokenToken); + SessionTokenCache.set({ tokenId: 'session_123', tokenResolver: brokenResolver }); + + await brokenResolver; + + const event: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: jwt, + traceId: 'test_trace_10', + }, + } as MessageEvent; + + expect(() => { + broadcastListener(event); + }).not.toThrow(); + }); + }); - // Mock Date.now to return a fixed timestamp initially - const initialTime = 1675876730000; // Same as our JWT's iat in milliseconds - dateNowSpy = vi.spyOn(Date, 'now').mockImplementation(() => initialTime); + describe('multi-session isolation', () => { + it('stores tokens from different session IDs separately without interference', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const session1Id = 'sess_user1_abc123'; + const session2Id = 'sess_user2_xyz789'; - // Test with a 120-second TTL - const longTtlJwt = createJwtWithTtl(120); - const longTtlToken = new Token({ + const session1Jwt = createJwtWithTtl(nowSeconds, 60); + const session1Token = new Token({ + id: session1Id, + jwt: session1Jwt, object: 'token', - id: 'long-ttl', - jwt: longTtlJwt, }); + const session1Resolver = Promise.resolve(session1Token as TokenResource); - const longTtlKey = { tokenId: 'long-ttl', audience: 'test' }; - const longTtlResolver = Promise.resolve(longTtlToken); - cache.set({ ...longTtlKey, tokenResolver: longTtlResolver }); - await longTtlResolver; + SessionTokenCache.set({ + audience: undefined, + createdAt: nowSeconds, + tokenId: session1Id, + tokenResolver: session1Resolver, + }); - // Check token is cached initially - const cachedEntry = cache.get(longTtlKey); - expect(cachedEntry).toMatchObject(longTtlKey); + await session1Resolver; - // Advance 90 seconds - token should still be cached - const firstAdvance = 90 * 1000; - vi.advanceTimersByTime(firstAdvance); - dateNowSpy.mockImplementation(() => initialTime + firstAdvance); + expect(SessionTokenCache.get({ tokenId: session1Id })).toBeDefined(); + const initialSize = SessionTokenCache.size(); + expect(initialSize).toBe(1); - const cachedEntryAfter90s = cache.get(longTtlKey); - expect(cachedEntryAfter90s).toMatchObject(longTtlKey); + const session2Jwt = createJwtWithTtl(nowSeconds, 60); + const broadcastEvent: MessageEvent = { + data: { + organizationId: null, + sessionId: session2Id, + template: undefined, + tokenId: session2Id, + tokenRaw: session2Jwt, + traceId: 'test_trace_multisession', + }, + } as MessageEvent; - // Advance to 121 seconds - token should be removed - const secondAdvance = 31 * 1000; - vi.advanceTimersByTime(secondAdvance); - dateNowSpy.mockImplementation(() => initialTime + firstAdvance + secondAdvance); + broadcastListener(broadcastEvent); + + await vi.waitFor(() => { + expect(SessionTokenCache.get({ tokenId: session2Id })).toBeDefined(); + }); + + expect(SessionTokenCache.size()).toBe(2); + + // Critical: Verify that requesting session1's token still returns session1's token + // (not session2's token) - tokens are isolated by tokenId + const retrievedSession1Token = SessionTokenCache.get({ tokenId: session1Id }); + expect(retrievedSession1Token).toBeDefined(); + const resolvedSession1Token = await retrievedSession1Token!.tokenResolver; + expect(resolvedSession1Token.jwt?.claims?.iat).toBe(nowSeconds); + expect(retrievedSession1Token!.tokenId).toBe(session1Id); + + // Verify session2's token is separate + const retrievedSession2Token = SessionTokenCache.get({ tokenId: session2Id }); + expect(retrievedSession2Token).toBeDefined(); + expect(retrievedSession2Token!.tokenId).toBe(session2Id); + expect(retrievedSession2Token!.tokenId).not.toBe(session1Id); + }); + + it('accepts broadcast messages from the same session ID', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const sessionId = 'sess_same_user'; + + const initialJwt = createJwtWithTtl(nowSeconds - 10, 60); + const initialToken = new Token({ + id: sessionId, + jwt: initialJwt, + object: 'token', + }); + const initialResolver = Promise.resolve(initialToken as TokenResource); + + SessionTokenCache.set({ + audience: undefined, + createdAt: nowSeconds - 10, + tokenId: sessionId, + tokenResolver: initialResolver, + }); + + await initialResolver; + + const cachedToken = SessionTokenCache.get({ tokenId: sessionId }); + expect(cachedToken).toBeDefined(); + const resolvedToken = await cachedToken!.tokenResolver; + expect(resolvedToken.jwt?.claims?.iat).toBe(nowSeconds - 10); + + const newerJwt = createJwtWithTtl(nowSeconds, 60); + const broadcastEvent: MessageEvent = { + data: { + organizationId: null, + sessionId: sessionId, + template: undefined, + tokenId: sessionId, + tokenRaw: newerJwt, + traceId: 'test_trace_same_session', + }, + } as MessageEvent; + + broadcastListener(broadcastEvent); + + await vi.waitFor(async () => { + const updatedCached = SessionTokenCache.get({ tokenId: sessionId }); + expect(updatedCached).toBeDefined(); + const updatedToken = await updatedCached!.tokenResolver; + expect(updatedToken.jwt?.claims?.iat).toBe(nowSeconds); + }); - const cachedEntryAfter121s = cache.get(longTtlKey); - expect(cachedEntryAfter121s).toBeUndefined(); + expect(SessionTokenCache.size()).toBe(1); }); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index d9c3d53aef8..8d1862961e3 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -10,7 +10,6 @@ import { isClerkRuntimeError, } from '@clerk/shared/error'; import { parsePublishableKey } from '@clerk/shared/keys'; -import { LocalStorageBroadcastChannel } from '@clerk/shared/localStorageBroadcastChannel'; import { logger } from '@clerk/shared/logger'; import { CLERK_NETLIFY_CACHE_BUST_PARAM } from '@clerk/shared/netlifyCacheHandler'; import { isHttpOrHttps, isValidProxyUrl, proxyUrlToAbsoluteURL } from '@clerk/shared/proxy'; @@ -164,8 +163,6 @@ import { warnings } from './warnings'; type SetActiveHook = (intent?: 'sign-out') => void | Promise; -export type ClerkCoreBroadcastChannelEvent = { type: 'signout' }; - declare global { interface Window { Clerk?: Clerk; @@ -227,7 +224,7 @@ export class Clerk implements ClerkInterface { #proxyUrl: DomainOrProxyUrl['proxyUrl']; #authService?: AuthCookieService; #captchaHeartbeat?: CaptchaHeartbeat; - #broadcastChannel: LocalStorageBroadcastChannel | null = null; + #broadcastChannel: BroadcastChannel | null = null; #componentControls?: ReturnType | null; //@ts-expect-error with being undefined even though it's not possible - related to issue with ts and error thrower #fapiClient: FapiClient; @@ -2553,7 +2550,10 @@ export class Clerk implements ClerkInterface { */ this.#pageLifecycle = createPageLifecycle(); - this.#broadcastChannel = new LocalStorageBroadcastChannel('clerk'); + if (typeof BroadcastChannel !== 'undefined') { + this.#broadcastChannel = new BroadcastChannel('clerk'); + } + this.#setupBrowserListeners(); const isInAccountsHostedPages = isDevAccountPortalOrigin(window?.location.hostname); @@ -2762,10 +2762,10 @@ export class Clerk implements ClerkInterface { }); /** - * Background tabs get notified of a signout event from active tab. + * Background tabs get notified of cross-tab signout events. */ - this.#broadcastChannel?.addEventListener('message', ({ data }) => { - if (data.type === 'signout') { + this.#broadcastChannel?.addEventListener('message', (event: MessageEvent) => { + if (event.data?.type === 'signout') { void this.handleUnauthenticated({ broadcast: false }); } }); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 3308c9a9cb3..08161325f27 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -25,12 +25,15 @@ import type { UserResource, } from '@clerk/types'; -import { unixEpochToDate } from '../../utils/date'; +import { unixEpochToDate } from '@/utils/date'; +import { debugLogger } from '@/utils/debug'; import { convertJSONToPublicKeyRequestOptions, serializePublicKeyCredentialAssertion, webAuthnGetCredential as webAuthnGetCredentialOnWindow, -} from '../../utils/passkeys'; +} from '@/utils/passkeys'; +import { TokenId } from '@/utils/tokenId'; + import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; import { eventBus, events } from '../events'; import { SessionTokenCache } from '../tokenCache'; @@ -142,7 +145,7 @@ export class Session extends BaseResource implements SessionResource { #getCacheId(template?: string, organizationId?: string | null) { const resolvedOrganizationId = typeof organizationId === 'undefined' ? this.lastActiveOrganizationId : organizationId; - return [this.id, template, resolvedOrganizationId, this.updatedAt.getTime()].filter(Boolean).join('-'); + return TokenId.build(this.id, template, resolvedOrganizationId); } startVerification = async ({ level }: SessionVerifyCreateParams): Promise => { @@ -351,12 +354,20 @@ export class Session extends BaseResource implements SessionResource { } const tokenId = this.#getCacheId(template, organizationId); + const cachedEntry = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; if (cachedEntry) { + debugLogger.debug( + 'Using cached token (no fetch needed)', + { + tokenId, + }, + 'session', + ); const cachedToken = await cachedEntry.tokenResolver; if (shouldDispatchTokenUpdate) { eventBus.emit(events.TokenUpdate, { token: cachedToken }); @@ -364,12 +375,25 @@ export class Session extends BaseResource implements SessionResource { // Return null when raw string is empty to indicate that there it's signed-out return cachedToken.getRawString() || null; } + + debugLogger.info( + 'Fetching new token from API', + { + organizationId, + template, + tokenId, + }, + 'session', + ); + const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; // TODO: update template endpoint to accept organizationId const params: Record = template ? {} : { organizationId }; const tokenResolver = Token.create(path, params); + + // Cache the promise immediately to prevent concurrent calls from triggering duplicate requests SessionTokenCache.set({ tokenId, tokenResolver }); return tokenResolver.then(token => { @@ -382,6 +406,7 @@ export class Session extends BaseResource implements SessionResource { eventBus.emit(events.SessionTokenResolved, null); } } + // Return null when raw string is empty to indicate that there it's signed-out return token.getRawString() || null; }); diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 5611ff63982..8edd1261296 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -17,8 +17,17 @@ const baseFapiClientOptions = { }; describe('Session', () => { + beforeEach(() => { + // Mock Date.now() to make the test tokens appear valid + // mockJwt has iat: 1666648250, exp: 1666648310 + // Set current time to 1666648260 (10 seconds after iat, 50 seconds before exp) + vi.useFakeTimers(); + vi.setSystemTime(new Date(1666648260 * 1000)); + }); + afterEach(() => { SessionTokenCache.clear(); + vi.useRealTimers(); }); describe('getToken()', () => { @@ -250,6 +259,102 @@ describe('Session', () => { body: { organizationId: 'newActiveOrganization' }, }); }); + + it('deduplicates concurrent getToken calls to prevent multiple API requests', async () => { + BaseResource.clerk = clerkMock() as any; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + requestSpy.mockClear(); + + const [token1, token2, token3] = await Promise.all([session.getToken(), session.getToken(), session.getToken()]); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(token1).toEqual(mockJwt); + expect(token2).toEqual(mockJwt); + expect(token3).toEqual(mockJwt); + }); + + it('deduplicates concurrent getToken calls with same template', async () => { + BaseResource.clerk = clerkMock() as any; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + requestSpy.mockClear(); + + const [token1, token2] = await Promise.all([ + session.getToken({ template: 'custom-template' }), + session.getToken({ template: 'custom-template' }), + ]); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(token1).toEqual(mockJwt); + expect(token2).toEqual(mockJwt); + }); + + it('does not deduplicate getToken calls with different templates', async () => { + BaseResource.clerk = clerkMock() as any; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + requestSpy.mockClear(); + + await Promise.all([session.getToken({ template: 'template1' }), session.getToken({ template: 'template2' })]); + + expect(requestSpy).toHaveBeenCalledTimes(2); + }); + + it('does not deduplicate getToken calls with different organization IDs', async () => { + BaseResource.clerk = clerkMock() as any; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + requestSpy.mockClear(); + + await Promise.all([session.getToken({ organizationId: 'org_1' }), session.getToken({ organizationId: 'org_2' })]); + + expect(requestSpy).toHaveBeenCalledTimes(2); + }); }); describe('touch()', () => { diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 67ef55c2f40..4cbb46563fb 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -1,26 +1,83 @@ import type { TokenResource } from '@clerk/types'; +import { debugLogger } from '@/utils/debug'; +import { TokenId } from '@/utils/tokenId'; + +import { Token } from './resources/internal'; + +/** + * Identifies a cached token entry by tokenId and optional audience. + */ interface TokenCacheKeyJSON { audience?: string; tokenId: string; } +/** + * Cache entry containing token metadata and resolver. + * Extends TokenCacheKeyJSON with additional properties for expiration tracking and token retrieval. + */ interface TokenCacheEntry extends TokenCacheKeyJSON { + /** + * Timestamp in seconds since UNIX epoch when the entry was created. + * Used for expiration and cleanup scheduling. + */ + createdAt?: Seconds; + /** + * Promise that resolves to the TokenResource. + * May be pending and should be awaited before accessing token data. + */ tokenResolver: Promise; } type Seconds = number; +/** + * Internal cache value containing the entry, expiration metadata, and cleanup timer. + */ interface TokenCacheValue { - entry: TokenCacheEntry; createdAt: Seconds; + entry: TokenCacheEntry; + expiresAt?: Seconds; expiresIn?: Seconds; + timeoutId?: ReturnType; } -interface TokenCache { - set(entry: TokenCacheEntry): void; - get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheEntry | undefined; +export interface TokenCache { + /** + * Removes all cached entries and clears associated timeouts. + * Side effects: Clears all scheduled expiration timers and empties the cache. + */ clear(): void; + + /** + * Closes the BroadcastChannel connection and releases resources. + * Side effects: Disconnects from multi-tab synchronization channel. + */ + close(): void; + + /** + * Retrieves a cached token entry if it exists and has not expired. + * + * @param cacheKeyJSON - Object containing tokenId and optional audience to identify the cached entry + * @param leeway - Optional seconds before expiration to treat token as expired (default: 10s). Combined with 5s sync leeway. + * @returns The cached TokenCacheEntry if found and valid, undefined otherwise + */ + get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheEntry | undefined; + + /** + * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. + * + * @param entry - TokenCacheEntry containing tokenId, tokenResolver, and optional audience + * Side effects: Schedules automatic expiration cleanup, broadcasts to other tabs when token resolves + */ + set(entry: TokenCacheEntry): void; + + /** + * Returns the current number of cached entries. + * + * @returns The count of entries currently stored in the cache + */ size(): number; } @@ -30,7 +87,14 @@ const LEEWAY = 10; // This value should have the same value as the INTERVAL_IN_MS in SessionCookiePoller const SYNC_LEEWAY = 5; +/** + * Converts between cache key objects and string representations. + * Format: `prefix::tokenId::audience` + */ export class TokenCacheKey { + /** + * Parses a cache key string into a TokenCacheKey instance. + */ static fromKey(key: string): TokenCacheKey { const [prefix, tokenId, audience = ''] = key.split(DELIMITER); return new TokenCacheKey(prefix, { audience, tokenId }); @@ -44,27 +108,189 @@ export class TokenCacheKey { this.data = data; } + /** + * Converts the key to its string representation for Map storage. + */ toKey(): string { const { tokenId, audience } = this.data; return [this.prefix, tokenId, audience || ''].join(DELIMITER); } } +/** + * Message format for BroadcastChannel token synchronization between tabs. + */ +interface SessionTokenEvent { + organizationId?: string | null; + sessionId: string; + template?: string; + tokenId: string; + tokenRaw: string; + traceId: string; +} + +/** + * Creates an in-memory token cache with BroadcastChannel synchronization across tabs. + * Automatically manages token expiration and cleanup via scheduled timeouts. + */ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const cache = new Map(); - let timer: ReturnType; + let broadcastChannel: BroadcastChannel | null = null; - const size = () => { - return cache.size; + const ensureBroadcastChannel = (): BroadcastChannel | null => { + if (broadcastChannel) { + return broadcastChannel; + } + + if (typeof BroadcastChannel !== 'undefined') { + broadcastChannel = new BroadcastChannel('clerk:session_token'); + broadcastChannel.addEventListener('message', (e: MessageEvent) => { + void handleBroadcastMessage(e); + }); + } + + return broadcastChannel; }; + ensureBroadcastChannel(); + const clear = () => { - clearTimeout(timer); + cache.forEach(value => { + if (value.timeoutId !== undefined) { + clearTimeout(value.timeoutId); + } + }); cache.clear(); }; + const get = (cacheKeyJSON: TokenCacheKeyJSON, leeway = LEEWAY): TokenCacheEntry | undefined => { + ensureBroadcastChannel(); + + const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); + const value = cache.get(cacheKey.toKey()); + + if (!value) { + debugLogger.debug('Token not found in cache', { tokenId: cacheKeyJSON.tokenId }, 'tokenCache'); + return; + } + + const nowSeconds = Math.floor(Date.now() / 1000); + const remainingSeconds = value.expiresAt !== undefined ? value.expiresAt - nowSeconds : 0; + + // Include poller interval as part of the leeway to ensure the cache value + // will be valid for more than the SYNC_LEEWAY or the leeway in the next poll. + const threshold = (leeway ?? 0) + SYNC_LEEWAY; + const expiresSoon = value.expiresAt !== undefined && remainingSeconds < threshold; + + if (expiresSoon) { + if (value.timeoutId !== undefined) { + clearTimeout(value.timeoutId); + } + cache.delete(cacheKey.toKey()); + return; + } + + return value.entry; + }; + + /** + * Processes token updates from other tabs via BroadcastChannel. + * Validates token ID, parses JWT, and updates cache if token is newer than existing entry. + */ + const handleBroadcastMessage = async ({ data }: MessageEvent) => { + const expectedTokenId = TokenId.build(data.sessionId, data.template, data.organizationId); + if (data.tokenId !== expectedTokenId) { + debugLogger.warn( + 'Ignoring token broadcast with mismatched tokenId', + { + expectedTokenId, + organizationId: data.organizationId, + receivedTokenId: data.tokenId, + template: data.template, + traceId: data.traceId, + }, + 'tokenCache', + ); + return; + } + + let token: Token; + try { + token = new Token({ id: data.tokenId, jwt: data.tokenRaw, object: 'token' }); + } catch (error) { + debugLogger.warn( + 'Failed to parse token from broadcast, skipping cache update', + { error, tokenId: data.tokenId, traceId: data.traceId }, + 'tokenCache', + ); + return; + } + + const iat = token.jwt?.claims?.iat; + const exp = token.jwt?.claims?.exp; + if (!iat || !exp) { + debugLogger.warn( + 'Token missing iat/exp claim, skipping cache update', + { tokenId: data.tokenId, traceId: data.traceId }, + 'tokenCache', + ); + return; + } + + try { + const existingEntry = get({ tokenId: data.tokenId }); + if (existingEntry) { + const existingToken = await existingEntry.tokenResolver; + const existingIat = existingToken.jwt?.claims?.iat; + if (existingIat && existingIat >= iat) { + debugLogger.debug( + 'Ignoring older token broadcast', + { existingIat, incomingIat: iat, tokenId: data.tokenId, traceId: data.traceId }, + 'tokenCache', + ); + return; + } + } + } catch (error) { + debugLogger.warn( + 'Existing entry compare failed; proceeding with broadcast update', + { error, tokenId: data.tokenId, traceId: data.traceId }, + 'tokenCache', + ); + } + + debugLogger.info( + 'Updating token cache from broadcast', + { + iat, + organizationId: data.organizationId, + template: data.template, + tokenId: data.tokenId, + traceId: data.traceId, + }, + 'tokenCache', + ); + + setInternal({ + createdAt: iat, + tokenId: data.tokenId, + tokenResolver: Promise.resolve(token), + }); + }; + const set = (entry: TokenCacheEntry) => { + ensureBroadcastChannel(); + + setInternal(entry); + }; + + /** + * Internal cache setter that stores an entry and schedules expiration cleanup. + * Resolves the token promise to extract expiration claims and set a deletion timeout. + * Automatically broadcasts to other tabs when the token resolves. + */ + const setInternal = (entry: TokenCacheEntry) => { const cacheKey = new TokenCacheKey(prefix, { audience: entry.audience, tokenId: entry.tokenId, @@ -72,33 +298,82 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const key = cacheKey.toKey(); - const createdAt = Math.floor(Date.now() / 1000); - const value: TokenCacheValue = { entry, createdAt }; + const nowSeconds = Math.floor(Date.now() / 1000); + const createdAt = entry.createdAt ?? nowSeconds; + const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined, expiresAt: undefined }; const deleteKey = () => { - if (cache.get(key) === value) { + const cachedValue = cache.get(key); + if (cachedValue === value) { + if (cachedValue.timeoutId !== undefined) { + clearTimeout(cachedValue.timeoutId); + } cache.delete(key); } }; entry.tokenResolver .then(newToken => { - if (!newToken.jwt) { + const claims = newToken.jwt?.claims; + if (!claims || typeof claims.exp !== 'number') { return deleteKey(); } - const expiresAt = newToken.jwt.claims.exp; - const issuedAt = newToken.jwt.claims.iat; - const expiresIn: Seconds = expiresAt - issuedAt; + const expiresAt = claims.exp; + value.expiresAt = expiresAt; + + // Keep expiresIn for backward-compatible logging, but it is no longer used for validity. + if (typeof claims.iat === 'number') { + value.expiresIn = expiresAt - claims.iat; + } - // Mutate cached value and set expirations - value.expiresIn = expiresIn; - timer = setTimeout(deleteKey, expiresIn * 1000); + const now = Math.floor(Date.now() / 1000); + const remainingMs = Math.max(0, (expiresAt - now) * 1000); + const timeoutId = setTimeout(deleteKey, remainingMs); + value.timeoutId = timeoutId; // Teach ClerkJS not to block the exit of the event loop when used in Node environments. // More info at https://nodejs.org/api/timers.html#timeoutunref - if (typeof timer.unref === 'function') { - timer.unref(); + if (typeof (timeoutId as any).unref === 'function') { + (timeoutId as any).unref(); + } + + const channel = broadcastChannel; + if (channel) { + const tokenRaw = newToken.getRawString(); + if (tokenRaw && claims.sid) { + const sessionId = claims.sid; + const organizationId = claims.org_id || (claims.o as any)?.id; + const template = TokenId.extractTemplate(entry.tokenId, sessionId, organizationId); + + const expectedTokenId = TokenId.build(sessionId, template, organizationId); + if (entry.tokenId === expectedTokenId) { + const traceId = `bc_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + + debugLogger.info( + 'Broadcasting token update to other tabs', + { + organizationId, + sessionId, + template, + tokenId: entry.tokenId, + traceId, + }, + 'tokenCache', + ); + + const message: SessionTokenEvent = { + organizationId, + sessionId, + template, + tokenId: entry.tokenId, + tokenRaw, + traceId, + }; + + channel.postMessage(message); + } + } } }) .catch(() => { @@ -108,29 +383,18 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { cache.set(key, value); }; - const get = (cacheKeyJSON: TokenCacheKeyJSON, leeway = LEEWAY): TokenCacheEntry | undefined => { - const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); - const value = cache.get(cacheKey.toKey()); - - if (!value) { - return; - } - - const nowSeconds = Math.floor(Date.now() / 1000); - const elapsedSeconds = nowSeconds - value.createdAt; - // We will include the authentication poller interval as part of the leeway to ensure - // that the cache value will be valid for more than the SYNC_LEEWAY or the leeway in the next poll. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const expiresSoon = value.expiresIn! - elapsedSeconds < (leeway || 1) + SYNC_LEEWAY; - if (expiresSoon) { - cache.delete(cacheKey.toKey()); - return; + const close = () => { + if (broadcastChannel) { + broadcastChannel.close(); + broadcastChannel = null; } + }; - return value.entry; + const size = () => { + return cache.size; }; - return { get, set, clear, size }; + return { clear, close, get, set, size }; }; export const SessionTokenCache = MemoryTokenCache(); diff --git a/packages/clerk-js/src/ui/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts b/packages/clerk-js/src/ui/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts index 457f7caf6df..9801aec8695 100644 --- a/packages/clerk-js/src/ui/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts +++ b/packages/clerk-js/src/ui/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts @@ -1,5 +1,4 @@ -import { waitFor } from '@testing-library/react'; -import { renderHook } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; import { useFormattedPhoneNumber } from '../useFormattedPhoneNumber'; diff --git a/packages/clerk-js/src/utils/__tests__/tokenId.test.ts b/packages/clerk-js/src/utils/__tests__/tokenId.test.ts new file mode 100644 index 00000000000..b8a3c13696d --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/tokenId.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { TokenId } from '../tokenId'; + +describe('TokenId.build', () => { + it('should build token ID with only sessionId', () => { + expect(TokenId.build('sess_123')).toBe('sess_123'); + }); + + it('should build token ID with sessionId and template', () => { + expect(TokenId.build('sess_123', 'custom')).toBe('sess_123-custom'); + }); + + it('should build token ID with sessionId and organizationId', () => { + expect(TokenId.build('sess_123', undefined, 'org_456')).toBe('sess_123-org_456'); + }); + + it('should build token ID with all parameters', () => { + expect(TokenId.build('sess_123', 'custom', 'org_456')).toBe('sess_123-custom-org_456'); + }); + + it('should omit null organizationId', () => { + expect(TokenId.build('sess_123', 'custom', null)).toBe('sess_123-custom'); + }); + + it('should omit undefined template', () => { + expect(TokenId.build('sess_123', undefined, 'org_456')).toBe('sess_123-org_456'); + }); + + it('should handle empty string values by omitting them', () => { + expect(TokenId.build('sess_123', '', '')).toBe('sess_123'); + }); + + it('should handle all optional parameters being undefined', () => { + expect(TokenId.build('sess_123', undefined, undefined)).toBe('sess_123'); + }); +}); + +describe('TokenId.extractTemplate', () => { + it('should return undefined when tokenId is just sessionId', () => { + expect(TokenId.extractTemplate('sess_123', 'sess_123')).toBeUndefined(); + }); + + it('should extract template when tokenId is sessionId-template', () => { + expect(TokenId.extractTemplate('sess_123-custom', 'sess_123')).toBe('custom'); + }); + + it('should return undefined when tokenId is sessionId-organizationId', () => { + expect(TokenId.extractTemplate('sess_123-org_456', 'sess_123', 'org_456')).toBeUndefined(); + }); + + it('should extract template when tokenId is sessionId-template-organizationId', () => { + expect(TokenId.extractTemplate('sess_123-custom-org_456', 'sess_123', 'org_456')).toBe('custom'); + }); + + it('should handle template with multiple hyphens', () => { + expect(TokenId.extractTemplate('sess_123-my-custom-template', 'sess_123')).toBe('my-custom-template'); + }); + + it('should extract template with hyphens when organizationId is present', () => { + expect(TokenId.extractTemplate('sess_123-my-custom-template-org_456', 'sess_123', 'org_456')).toBe( + 'my-custom-template', + ); + }); + + it('should handle null organizationId same as undefined', () => { + expect(TokenId.extractTemplate('sess_123-custom', 'sess_123', null)).toBe('custom'); + }); + + it('should return undefined when organizationId is present but tokenId has no template', () => { + expect(TokenId.extractTemplate('sess_123-org_456', 'sess_123', 'org_456')).toBeUndefined(); + }); + + it('should extract template when organizationId is undefined but present in tokenId', () => { + expect(TokenId.extractTemplate('sess_123-custom-org_456', 'sess_123', undefined)).toBe('custom-org_456'); + }); +}); + +describe('TokenId.parse', () => { + it('should parse token ID with only sessionId', () => { + const result = TokenId.parse('sess_123', 'sess_123'); + expect(result).toEqual({ + organizationId: undefined, + sessionId: 'sess_123', + template: undefined, + }); + }); + + it('should parse token ID with sessionId and template', () => { + const result = TokenId.parse('sess_123-custom', 'sess_123'); + expect(result).toEqual({ + organizationId: undefined, + sessionId: 'sess_123', + template: 'custom', + }); + }); + + it('should parse token ID with sessionId and organizationId', () => { + const result = TokenId.parse('sess_123-org_456', 'sess_123', 'org_456'); + expect(result).toEqual({ + organizationId: 'org_456', + sessionId: 'sess_123', + template: undefined, + }); + }); + + it('should parse token ID with all components', () => { + const result = TokenId.parse('sess_123-custom-org_456', 'sess_123', 'org_456'); + expect(result).toEqual({ + organizationId: 'org_456', + sessionId: 'sess_123', + template: 'custom', + }); + }); +}); + +describe('TokenId.build and TokenId.extractTemplate compatibility', () => { + it('should round-trip: sessionId only', () => { + const sessionId = 'sess_123'; + const tokenId = TokenId.build(sessionId); + const extracted = TokenId.extractTemplate(tokenId, sessionId); + expect(extracted).toBeUndefined(); + }); + + it('should round-trip: sessionId + template', () => { + const sessionId = 'sess_123'; + const template = 'custom'; + const tokenId = TokenId.build(sessionId, template); + const extracted = TokenId.extractTemplate(tokenId, sessionId); + expect(extracted).toBe(template); + }); + + it('should round-trip: sessionId + organizationId (no template)', () => { + const sessionId = 'sess_123'; + const organizationId = 'org_456'; + const tokenId = TokenId.build(sessionId, undefined, organizationId); + const extracted = TokenId.extractTemplate(tokenId, sessionId, organizationId); + expect(extracted).toBeUndefined(); + }); + + it('should round-trip: sessionId + template + organizationId', () => { + const sessionId = 'sess_123'; + const template = 'custom'; + const organizationId = 'org_456'; + const tokenId = TokenId.build(sessionId, template, organizationId); + const extracted = TokenId.extractTemplate(tokenId, sessionId, organizationId); + expect(extracted).toBe(template); + }); + + it('should round-trip: template with hyphens', () => { + const sessionId = 'sess_123'; + const template = 'my-custom-template'; + const tokenId = TokenId.build(sessionId, template); + const extracted = TokenId.extractTemplate(tokenId, sessionId); + expect(extracted).toBe(template); + }); + + it('should round-trip: template with hyphens + organizationId', () => { + const sessionId = 'sess_123'; + const template = 'my-custom-template'; + const organizationId = 'org_456'; + const tokenId = TokenId.build(sessionId, template, organizationId); + const extracted = TokenId.extractTemplate(tokenId, sessionId, organizationId); + expect(extracted).toBe(template); + }); + + it('should round-trip: null organizationId behaves like undefined', () => { + const sessionId = 'sess_123'; + const template = 'custom'; + const tokenId = TokenId.build(sessionId, template, null); + const extracted = TokenId.extractTemplate(tokenId, sessionId, null); + expect(extracted).toBe(template); + }); +}); diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts index 3fe9714465a..9794c1c3b1f 100644 --- a/packages/clerk-js/src/utils/index.ts +++ b/packages/clerk-js/src/utils/index.ts @@ -23,6 +23,7 @@ export * from './props'; export * from './queryStateParams'; export * from './querystring'; export * from './runtime'; +export * from './tokenId'; export * from './url'; export * from './web3'; export * from './windowNavigate'; diff --git a/packages/clerk-js/src/utils/tokenId.ts b/packages/clerk-js/src/utils/tokenId.ts new file mode 100644 index 00000000000..73f9e1ac9cc --- /dev/null +++ b/packages/clerk-js/src/utils/tokenId.ts @@ -0,0 +1,79 @@ +export interface ParsedTokenId { + organizationId?: string | null; + sessionId: string; + template?: string; +} + +/** + * Utility for building and parsing token identifiers. + * Token IDs follow the format: sessionId[-template][-organizationId] + */ +export const TokenId = { + /** + * Builds a token identifier from session context components. + * + * @example + * ```typescript + * TokenId.build('sess_123') // 'sess_123' + * TokenId.build('sess_123', 'custom') // 'sess_123-custom' + * TokenId.build('sess_123', 'custom', 'org_456') // 'sess_123-custom-org_456' + * TokenId.build('sess_123', undefined, 'org_456') // 'sess_123-org_456' + * ``` + */ + build: (sessionId: string, template?: string, organizationId?: string | null): string => { + return [sessionId, template, organizationId].filter(Boolean).join('-'); + }, + + /** + * Parses a token identifier into its component parts. + * + * @example + * ```typescript + * TokenId.parse('sess_123', 'sess_123') + * // { sessionId: 'sess_123', template: undefined, organizationId: undefined } + * + * TokenId.parse('sess_123-custom', 'sess_123') + * // { sessionId: 'sess_123', template: 'custom', organizationId: undefined } + * + * TokenId.parse('sess_123-custom-org_456', 'sess_123', 'org_456') + * // { sessionId: 'sess_123', template: 'custom', organizationId: 'org_456' } + * ``` + */ + parse: (tokenId: string, sessionId: string, organizationId?: string | null): ParsedTokenId => { + const template = TokenId.extractTemplate(tokenId, sessionId, organizationId); + return { + organizationId, + sessionId, + template, + }; + }, + + /** + * Extracts only the template name from a token identifier. + * + * @example + * ```typescript + * TokenId.extractTemplate('sess_123', 'sess_123') // undefined + * TokenId.extractTemplate('sess_123-custom', 'sess_123') // 'custom' + * TokenId.extractTemplate('sess_123-custom-org_456', 'sess_123', 'org_456') // 'custom' + * TokenId.extractTemplate('sess_123-org_456', 'sess_123', 'org_456') // undefined + * ``` + */ + extractTemplate: (tokenId: string, sessionId: string, organizationId?: string | null): string | undefined => { + if (tokenId === sessionId) { + return undefined; + } + + if (organizationId && tokenId === `${sessionId}-${organizationId}`) { + return undefined; + } + + let remainder = tokenId.slice(sessionId.length + 1); + + if (organizationId && remainder.endsWith(`-${organizationId}`)) { + remainder = remainder.slice(0, -(organizationId.length + 1)); + } + + return remainder || undefined; + }, +}; diff --git a/packages/shared/src/localStorageBroadcastChannel.ts b/packages/shared/src/localStorageBroadcastChannel.ts index fcdbf0bc4c5..79509ed9e79 100644 --- a/packages/shared/src/localStorageBroadcastChannel.ts +++ b/packages/shared/src/localStorageBroadcastChannel.ts @@ -1,12 +1,19 @@ +import { deprecated } from './deprecated'; + type Listener = (e: MessageEvent) => void; const KEY_PREFIX = '__lsbc__'; +/** + * @deprecated This class will be completely removed in the next major version. + * Use the native BroadcastChannel API directly instead. + */ export class LocalStorageBroadcastChannel { private readonly eventTarget = window; private readonly channelKey: string; constructor(name: string) { + deprecated('LocalStorageBroadcastChannel', 'Use the native BroadcastChannel API directly instead.'); this.channelKey = KEY_PREFIX + name; this.setupLocalStorageListener(); } From 3139d591bbba0f160018f566f34b41488d33387e Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 2 Oct 2025 20:35:33 -0500 Subject: [PATCH 2/7] duration based expiration check --- .../src/core/__tests__/tokenCache.test.ts | 27 +++++++++---------- packages/clerk-js/src/core/tokenCache.ts | 23 +++++++--------- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 09223d355ca..e63c9a56c5c 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -273,18 +273,18 @@ describe('SessionTokenCache', () => { expect(cachedEntry?.tokenId).toBe('future_token'); }); - it('removes token when expiresAt is in the past', async () => { - const pastExp = Math.floor(Date.now() / 1000) - 60; // 60 seconds ago - const pastJwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa( - JSON.stringify({ iat: Math.floor(Date.now() / 1000) - 120, exp: pastExp }), - )}.signature`; + it('removes token when it has already expired based on duration', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const iat = nowSeconds - 120; + const exp = iat + 60; + const pastJwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify({ iat, exp }))}.signature`; const tokenResolver = Promise.resolve({ getRawString: () => pastJwt, - jwt: { claims: { exp: pastExp, iat: Math.floor(Date.now() / 1000) - 120 } }, + jwt: { claims: { exp, iat } }, } as any); - SessionTokenCache.set({ tokenId: 'expired_token', tokenResolver }); + SessionTokenCache.set({ createdAt: nowSeconds - 70, tokenId: 'expired_token', tokenResolver }); await tokenResolver; @@ -293,19 +293,18 @@ describe('SessionTokenCache', () => { }); it('removes token when it expires within the leeway threshold', async () => { - const soonExp = Math.floor(Date.now() / 1000) + 8; // 8 seconds from now (less than LEEWAY + SYNC_LEEWAY = 15) - const soonJwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa( - JSON.stringify({ iat: Math.floor(Date.now() / 1000) - 10, exp: soonExp }), - )}.signature`; + const nowSeconds = Math.floor(Date.now() / 1000); + const iat = nowSeconds; + const exp = iat + 20; + const soonJwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify({ iat, exp }))}.signature`; const tokenResolver = Promise.resolve({ getRawString: () => soonJwt, - jwt: { claims: { exp: soonExp, iat: Math.floor(Date.now() / 1000) - 10 } }, + jwt: { claims: { exp, iat } }, } as any); - SessionTokenCache.set({ tokenId: 'soon_expired_token', tokenResolver }); + SessionTokenCache.set({ createdAt: nowSeconds - 13, tokenId: 'soon_expired_token', tokenResolver }); - // Wait for promise to resolve await tokenResolver; const cachedEntry = SessionTokenCache.get({ tokenId: 'soon_expired_token' }); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 4cbb46563fb..46d6260e91b 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -38,7 +38,6 @@ type Seconds = number; interface TokenCacheValue { createdAt: Seconds; entry: TokenCacheEntry; - expiresAt?: Seconds; expiresIn?: Seconds; timeoutId?: ReturnType; } @@ -176,12 +175,12 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { } const nowSeconds = Math.floor(Date.now() / 1000); - const remainingSeconds = value.expiresAt !== undefined ? value.expiresAt - nowSeconds : 0; + const elapsed = nowSeconds - value.createdAt; // Include poller interval as part of the leeway to ensure the cache value // will be valid for more than the SYNC_LEEWAY or the leeway in the next poll. - const threshold = (leeway ?? 0) + SYNC_LEEWAY; - const expiresSoon = value.expiresAt !== undefined && remainingSeconds < threshold; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const expiresSoon = value.expiresIn! - elapsed < (leeway || 1) + SYNC_LEEWAY; if (expiresSoon) { if (value.timeoutId !== undefined) { @@ -300,7 +299,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const nowSeconds = Math.floor(Date.now() / 1000); const createdAt = entry.createdAt ?? nowSeconds; - const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined, expiresAt: undefined }; + const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined }; const deleteKey = () => { const cachedValue = cache.get(key); @@ -315,21 +314,17 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { entry.tokenResolver .then(newToken => { const claims = newToken.jwt?.claims; - if (!claims || typeof claims.exp !== 'number') { + if (!claims || typeof claims.exp !== 'number' || typeof claims.iat !== 'number') { return deleteKey(); } const expiresAt = claims.exp; - value.expiresAt = expiresAt; + const issuedAt = claims.iat; + const expiresIn: Seconds = expiresAt - issuedAt; - // Keep expiresIn for backward-compatible logging, but it is no longer used for validity. - if (typeof claims.iat === 'number') { - value.expiresIn = expiresAt - claims.iat; - } + value.expiresIn = expiresIn; - const now = Math.floor(Date.now() / 1000); - const remainingMs = Math.max(0, (expiresAt - now) * 1000); - const timeoutId = setTimeout(deleteKey, remainingMs); + const timeoutId = setTimeout(deleteKey, expiresIn * 1000); value.timeoutId = timeoutId; // Teach ClerkJS not to block the exit of the event loop when used in Node environments. From acf1e78e21aaf8b45c3d768342dcc597bd38b2cf Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 2 Oct 2025 22:30:15 -0500 Subject: [PATCH 3/7] do not re-broadcast when handling message --- .../src/core/__tests__/tokenCache.test.ts | 28 +++++++++++++++++++ packages/clerk-js/src/core/tokenCache.ts | 26 +++++++++++------ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index e63c9a56c5c..87525e8ee04 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -249,6 +249,34 @@ describe('SessionTokenCache', () => { expect(cachedEntry).toBeDefined(); expect(cachedEntry?.tokenId).toBe('session_123'); }); + + it('does not re-broadcast when receiving a broadcast message', async () => { + // Clear any previous postMessage calls from setup + mockBroadcastChannel.postMessage.mockClear(); + + const event: MessageEvent = { + data: { + organizationId: null, + sessionId: 'session_123', + template: undefined, + tokenId: 'session_123', + tokenRaw: mockJwt, + traceId: 'test_trace_10', + }, + } as MessageEvent; + + broadcastListener(event); + + // Flush microtasks to let the tokenResolver promise settle without advancing timers + await Promise.resolve(); + + // Verify cache was updated + const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(cachedEntry).toBeDefined(); + + // Critical: postMessage should NOT be called when handling a broadcast + expect(mockBroadcastChannel.postMessage).not.toHaveBeenCalled(); + }); }); describe('token expiration with absolute time', () => { diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 46d6260e91b..d26124364a7 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -86,6 +86,9 @@ const LEEWAY = 10; // This value should have the same value as the INTERVAL_IN_MS in SessionCookiePoller const SYNC_LEEWAY = 5; +const BROADCAST = { broadcast: true }; +const NO_BROADCAST = { broadcast: false }; + /** * Converts between cache key objects and string representations. * Format: `prefix::tokenId::audience` @@ -271,25 +274,30 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { 'tokenCache', ); - setInternal({ - createdAt: iat, - tokenId: data.tokenId, - tokenResolver: Promise.resolve(token), - }); + setInternal( + { + createdAt: iat, + tokenId: data.tokenId, + tokenResolver: Promise.resolve(token), + }, + NO_BROADCAST, + ); }; const set = (entry: TokenCacheEntry) => { ensureBroadcastChannel(); - setInternal(entry); + setInternal(entry, BROADCAST); }; /** * Internal cache setter that stores an entry and schedules expiration cleanup. * Resolves the token promise to extract expiration claims and set a deletion timeout. - * Automatically broadcasts to other tabs when the token resolves. + * + * @param entry - The token cache entry to store + * @param options - Configuration for cache behavior; broadcast controls whether to notify other tabs */ - const setInternal = (entry: TokenCacheEntry) => { + const setInternal = (entry: TokenCacheEntry, options: { broadcast: boolean } = BROADCAST) => { const cacheKey = new TokenCacheKey(prefix, { audience: entry.audience, tokenId: entry.tokenId, @@ -334,7 +342,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { } const channel = broadcastChannel; - if (channel) { + if (channel && options.broadcast) { const tokenRaw = newToken.getRawString(); if (tokenRaw && claims.sid) { const sessionId = claims.sid; From 38323d5cb69ef013432b60f140678d547ce9ce92 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 3 Oct 2025 13:00:57 -0500 Subject: [PATCH 4/7] add new build variant --- packages/clerk-js/bundlewatch.config.json | 1 + packages/clerk-js/package.json | 2 ++ packages/clerk-js/rspack.config.js | 16 +++++++++ packages/clerk-js/src/core/tokenCache.ts | 7 +++- packages/clerk-js/src/global.d.ts | 1 + .../clerk-js/src/index.channel.browser.ts | 36 +++++++++++++++++++ 6 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 packages/clerk-js/src/index.channel.browser.ts diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 362c61fd573..eb4717c9b6f 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -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" }, diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index d1afe83202b..1f8aa7beb0b 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -26,6 +26,7 @@ "module": "dist/clerk.mjs", "types": "dist/types/index.d.ts", "files": [ + "channel", "dist", "headless", "no-rhc" @@ -42,6 +43,7 @@ "bundlewatch:fix": "node bundlewatch-fix.mjs", "clean": "rimraf ./dist", "dev": "rspack serve --config rspack.config.js", + "dev:channel": "rspack serve --config rspack.config.js --env variant=\"clerk.channel.browser\"", "dev:chips": "rspack serve --config rspack.config.js --env variant=\"clerk.chips.browser\"", "dev:headless": "rspack serve --config rspack.config.js --env variant=\"clerk.headless.browser\"", "dev:origin": "rspack serve --config rspack.config.js --env devOrigin=http://localhost:${PORT:-4000}", diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index 78467f67a79..85587ee05d2 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -17,6 +17,7 @@ const variants = { clerkHeadlessBrowser: 'clerk.headless.browser', clerkLegacyBrowser: 'clerk.legacy.browser', clerkCHIPS: 'clerk.chips.browser', + clerkChannelBrowser: 'clerk.channel.browser', }; const variantToSourceFile = { @@ -27,6 +28,7 @@ const variantToSourceFile = { [variants.clerkHeadlessBrowser]: './src/index.headless.browser.ts', [variants.clerkLegacyBrowser]: './src/index.legacy.browser.ts', [variants.clerkCHIPS]: './src/index.chips.browser.ts', + [variants.clerkChannelBrowser]: './src/index.channel.browser.ts', }; /** @@ -58,6 +60,7 @@ const common = ({ mode, variant, disableRHC = false }) => { */ __BUILD_FLAG_KEYLESS_UI__: isDevelopment(mode), __BUILD_DISABLE_RHC__: JSON.stringify(disableRHC), + __BUILD_VARIANT_CHANNEL__: variant === variants.clerkChannelBrowser, __BUILD_VARIANT_CHIPS__: variant === variants.clerkCHIPS, }), new rspack.EnvironmentPlugin({ @@ -424,6 +427,13 @@ const prodConfig = ({ mode, env, analysis }) => { commonForProdChunked(), ); + const clerkChannelBrowser = merge( + entryForVariant(variants.clerkChannelBrowser), + common({ mode, variant: variants.clerkChannelBrowser }), + commonForProd(), + commonForProdChunked(), + ); + const clerkEsm = merge( entryForVariant(variants.clerk), common({ mode, variant: variants.clerk }), @@ -538,6 +548,7 @@ const prodConfig = ({ mode, env, analysis }) => { clerkHeadless, clerkHeadlessBrowser, clerkCHIPS, + clerkChannelBrowser, clerkEsm, clerkEsmNoRHC, clerkCjs, @@ -645,6 +656,11 @@ const devConfig = ({ mode, env }) => { common({ mode, variant: variants.clerkCHIPS }), commonForDev(), ), + [variants.clerkChannelBrowser]: merge( + entryForVariant(variants.clerkChannelBrowser), + common({ mode, variant: variants.clerkChannelBrowser }), + commonForDev(), + ), }; if (!entryToConfigMap[variant]) { diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index d26124364a7..6afcb9d07ec 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -132,8 +132,9 @@ interface SessionTokenEvent { } /** - * Creates an in-memory token cache with BroadcastChannel synchronization across tabs. + * Creates an in-memory token cache with optional BroadcastChannel synchronization across tabs. * Automatically manages token expiration and cleanup via scheduled timeouts. + * BroadcastChannel support is enabled only in the channel build variant. */ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const cache = new Map(); @@ -141,6 +142,10 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { let broadcastChannel: BroadcastChannel | null = null; const ensureBroadcastChannel = (): BroadcastChannel | null => { + if (!__BUILD_VARIANT_CHANNEL__) { + return null; + } + if (broadcastChannel) { return broadcastChannel; } diff --git a/packages/clerk-js/src/global.d.ts b/packages/clerk-js/src/global.d.ts index 7751ef82378..50a17aae822 100644 --- a/packages/clerk-js/src/global.d.ts +++ b/packages/clerk-js/src/global.d.ts @@ -11,6 +11,7 @@ declare const __DEV__: boolean; * Build time feature flags. */ declare const __BUILD_DISABLE_RHC__: string; +declare const __BUILD_VARIANT_CHANNEL__: boolean; declare const __BUILD_VARIANT_CHIPS__: boolean; interface Window { diff --git a/packages/clerk-js/src/index.channel.browser.ts b/packages/clerk-js/src/index.channel.browser.ts new file mode 100644 index 00000000000..82bd326c1a6 --- /dev/null +++ b/packages/clerk-js/src/index.channel.browser.ts @@ -0,0 +1,36 @@ +// It's crucial this is the first import, +// otherwise chunk loading will not work +// eslint-disable-next-line +import './utils/setWebpackChunkPublicPath'; + +import { Clerk } from './core/clerk'; + +import { mountComponentRenderer } from './ui/Components'; + +Clerk.mountComponentRenderer = mountComponentRenderer; + +const publishableKey = + document.querySelector('script[data-clerk-publishable-key]')?.getAttribute('data-clerk-publishable-key') || + window.__clerk_publishable_key || + ''; + +const proxyUrl = + document.querySelector('script[data-clerk-proxy-url]')?.getAttribute('data-clerk-proxy-url') || + window.__clerk_proxy_url || + ''; + +const domain = + document.querySelector('script[data-clerk-domain]')?.getAttribute('data-clerk-domain') || window.__clerk_domain || ''; + +// Ensure that if the script has already been injected we don't overwrite the existing Clerk instance. +if (!window.Clerk) { + window.Clerk = new Clerk(publishableKey, { + proxyUrl, + // @ts-expect-error + domain, + }); +} + +if (module.hot) { + module.hot.accept(); +} From 4f0c799f033b90c0ef92734957d20f8b1cd26249 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 3 Oct 2025 14:54:59 -0500 Subject: [PATCH 5/7] fix tests --- packages/clerk-js/src/core/tokenCache.ts | 1 - packages/clerk-js/vitest.config.mts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 6afcb9d07ec..0241ab3e265 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -178,7 +178,6 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const value = cache.get(cacheKey.toKey()); if (!value) { - debugLogger.debug('Token not found in cache', { tokenId: cacheKeyJSON.tokenId }, 'tokenCache'); return; } diff --git a/packages/clerk-js/vitest.config.mts b/packages/clerk-js/vitest.config.mts index c74923a9bfd..0c50cfefdf2 100644 --- a/packages/clerk-js/vitest.config.mts +++ b/packages/clerk-js/vitest.config.mts @@ -26,6 +26,7 @@ export default defineConfig({ plugins: [react({ jsxRuntime: 'automatic', jsxImportSource: '@emotion/react' }), viteSvgMockPlugin()], define: { __BUILD_DISABLE_RHC__: JSON.stringify(false), + __BUILD_VARIANT_CHANNEL__: JSON.stringify(true), __BUILD_VARIANT_CHIPS__: JSON.stringify(false), __PKG_NAME__: JSON.stringify('@clerk/clerk-js'), __PKG_VERSION__: JSON.stringify('test'), From 5dcac2a42ac52f9ae96e64a06c5e3af2646309b9 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 3 Oct 2025 15:12:22 -0500 Subject: [PATCH 6/7] fix e2e and consolidate clerk-js index files --- integration/presets/envs.ts | 40 ++++++++++++------- .../single-session.test.ts | 2 +- packages/clerk-js/rspack.config.js | 4 +- .../clerk-js/src/index.channel.browser.ts | 36 ----------------- packages/clerk-js/src/index.chips.browser.ts | 36 ----------------- 5 files changed, 28 insertions(+), 90 deletions(-) delete mode 100644 packages/clerk-js/src/index.channel.browser.ts delete mode 100644 packages/clerk-js/src/index.chips.browser.ts diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index c2a0770afbc..3cc80a7860f 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -42,6 +42,15 @@ const withEmailCodes = base .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk) .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key'); +const withBroadcastChannel = withEmailCodes + .clone() + .setId('withBroadcastChannel') + .setEnvVariable( + 'public', + 'CLERK_JS_URL', + constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.channel.browser.js', + ); + const sessionsProd1 = base .clone() .setId('sessionsProd1') @@ -178,28 +187,29 @@ const withAPIKeys = base export const envs = { base, - withKeyless, - withEmailCodes, - withEmailCodes_destroy_client, - withEmailLinks, - withCustomRoles, - withReverification, - withEmailCodesQuickstart, + sessionsProd1, + withAPIKeys, withAPCore1ClerkLatest, withAPCore1ClerkV4, withAPCore2ClerkLatest, withAPCore2ClerkV4, + withBilling, + withBillingJwtV2, + withBroadcastChannel, + withCustomRoles, withDynamicKeys, - withRestrictedMode, + withEmailCodes, + withEmailCodes_destroy_client, + withEmailCodesQuickstart, + withEmailLinks, + withKeyless, withLegalConsent, - withWaitlistdMode, - withSignInOrUpFlow, + withRestrictedMode, + withReverification, + withSessionTasks, withSignInOrUpEmailLinksFlow, + withSignInOrUpFlow, withSignInOrUpwithRestrictedModeFlow, - withSessionTasks, - withBillingJwtV2, - withBilling, + withWaitlistdMode, withWhatsappPhoneCode, - sessionsProd1, - withAPIKeys, } as const; diff --git a/integration/tests/session-token-cache/single-session.test.ts b/integration/tests/session-token-cache/single-session.test.ts index 03b5bd24953..793137b0471 100644 --- a/integration/tests/session-token-cache/single-session.test.ts +++ b/integration/tests/session-token-cache/single-session.test.ts @@ -11,7 +11,7 @@ import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; * token fetches in one tab are automatically broadcast and cached in other tabs, * eliminating redundant network requests. */ -testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( +testAgainstRunningApps({ withEnv: [appConfigs.envs.withBroadcastChannel] })( 'MemoryTokenCache Multi-Tab Integration @generic', ({ app }) => { test.describe.configure({ mode: 'serial' }); diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index 85587ee05d2..76c48d467cc 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -27,8 +27,8 @@ const variantToSourceFile = { [variants.clerkHeadless]: './src/index.headless.ts', [variants.clerkHeadlessBrowser]: './src/index.headless.browser.ts', [variants.clerkLegacyBrowser]: './src/index.legacy.browser.ts', - [variants.clerkCHIPS]: './src/index.chips.browser.ts', - [variants.clerkChannelBrowser]: './src/index.channel.browser.ts', + [variants.clerkCHIPS]: './src/index.browser.ts', + [variants.clerkChannelBrowser]: './src/index.browser.ts', }; /** diff --git a/packages/clerk-js/src/index.channel.browser.ts b/packages/clerk-js/src/index.channel.browser.ts deleted file mode 100644 index 82bd326c1a6..00000000000 --- a/packages/clerk-js/src/index.channel.browser.ts +++ /dev/null @@ -1,36 +0,0 @@ -// It's crucial this is the first import, -// otherwise chunk loading will not work -// eslint-disable-next-line -import './utils/setWebpackChunkPublicPath'; - -import { Clerk } from './core/clerk'; - -import { mountComponentRenderer } from './ui/Components'; - -Clerk.mountComponentRenderer = mountComponentRenderer; - -const publishableKey = - document.querySelector('script[data-clerk-publishable-key]')?.getAttribute('data-clerk-publishable-key') || - window.__clerk_publishable_key || - ''; - -const proxyUrl = - document.querySelector('script[data-clerk-proxy-url]')?.getAttribute('data-clerk-proxy-url') || - window.__clerk_proxy_url || - ''; - -const domain = - document.querySelector('script[data-clerk-domain]')?.getAttribute('data-clerk-domain') || window.__clerk_domain || ''; - -// Ensure that if the script has already been injected we don't overwrite the existing Clerk instance. -if (!window.Clerk) { - window.Clerk = new Clerk(publishableKey, { - proxyUrl, - // @ts-expect-error - domain, - }); -} - -if (module.hot) { - module.hot.accept(); -} diff --git a/packages/clerk-js/src/index.chips.browser.ts b/packages/clerk-js/src/index.chips.browser.ts deleted file mode 100644 index 82bd326c1a6..00000000000 --- a/packages/clerk-js/src/index.chips.browser.ts +++ /dev/null @@ -1,36 +0,0 @@ -// It's crucial this is the first import, -// otherwise chunk loading will not work -// eslint-disable-next-line -import './utils/setWebpackChunkPublicPath'; - -import { Clerk } from './core/clerk'; - -import { mountComponentRenderer } from './ui/Components'; - -Clerk.mountComponentRenderer = mountComponentRenderer; - -const publishableKey = - document.querySelector('script[data-clerk-publishable-key]')?.getAttribute('data-clerk-publishable-key') || - window.__clerk_publishable_key || - ''; - -const proxyUrl = - document.querySelector('script[data-clerk-proxy-url]')?.getAttribute('data-clerk-proxy-url') || - window.__clerk_proxy_url || - ''; - -const domain = - document.querySelector('script[data-clerk-domain]')?.getAttribute('data-clerk-domain') || window.__clerk_domain || ''; - -// Ensure that if the script has already been injected we don't overwrite the existing Clerk instance. -if (!window.Clerk) { - window.Clerk = new Clerk(publishableKey, { - proxyUrl, - // @ts-expect-error - domain, - }); -} - -if (module.hot) { - module.hot.accept(); -} From be2b34bcd26b1010df486244bc8ead3ef3d62134 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 3 Oct 2025 15:25:26 -0500 Subject: [PATCH 7/7] this is not needed --- packages/clerk-js/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 1f8aa7beb0b..e40638c96a8 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -26,7 +26,6 @@ "module": "dist/clerk.mjs", "types": "dist/types/index.d.ts", "files": [ - "channel", "dist", "headless", "no-rhc"