diff --git a/packages/astro/src/client/index.ts b/packages/astro/src/client/index.ts index 3573d363730..20a7f7b4f0b 100644 --- a/packages/astro/src/client/index.ts +++ b/packages/astro/src/client/index.ts @@ -1,2 +1,3 @@ export { updateClerkOptions } from '../internal/create-clerk-instance'; export * from '../stores/external'; +export { getToken } from '@clerk/shared/getToken'; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index b9c24e9b7ce..23e653be582 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -60,6 +60,8 @@ export { useUser, } from './client-boundary/hooks'; +export { getToken } from '@clerk/shared/getToken'; + /** * Conditionally export components that exhibit different behavior * when used in /app vs /pages. diff --git a/packages/nuxt/src/runtime/client/index.ts b/packages/nuxt/src/runtime/client/index.ts index 631ad5718bf..424c99be18e 100644 --- a/packages/nuxt/src/runtime/client/index.ts +++ b/packages/nuxt/src/runtime/client/index.ts @@ -1,2 +1,3 @@ export { createRouteMatcher } from './routeMatcher'; export { updateClerkOptions } from '@clerk/vue'; +export { getToken } from '@clerk/shared/getToken'; diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 54b196e9899..a6fe525e496 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -55,6 +55,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "__experimental_PaymentElementProvider", "__experimental_useCheckout", "__experimental_usePaymentElement", + "getToken", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/react-router/src/index.ts b/packages/react-router/src/index.ts index 3b94d578d24..6ee02505b4c 100644 --- a/packages/react-router/src/index.ts +++ b/packages/react-router/src/index.ts @@ -3,6 +3,7 @@ if (typeof window !== 'undefined' && typeof (window as any).global === 'undefine } export * from './client'; +export { getToken } from '@clerk/shared/getToken'; // Override Clerk React error thrower to show that errors come from @clerk/react-router import { setErrorThrowerOptions } from '@clerk/react/internal'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 97d841eaf1c..3ffd47e9e7d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -9,6 +9,7 @@ export * from './components'; export * from './contexts'; export * from './hooks'; +export { getToken } from '@clerk/shared/getToken'; export type { BrowserClerk, BrowserClerkConstructor, diff --git a/packages/shared/src/__tests__/getToken.spec.ts b/packages/shared/src/__tests__/getToken.spec.ts new file mode 100644 index 00000000000..ab5ee6f6b9d --- /dev/null +++ b/packages/shared/src/__tests__/getToken.spec.ts @@ -0,0 +1,328 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ClerkRuntimeError } from '../errors/clerkRuntimeError'; +import { getToken } from '../getToken'; + +type StatusHandler = (status: string) => void; + +describe('getToken', () => { + const originalWindow = global.window; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + global.window = originalWindow; + }); + + describe('when Clerk is already ready', () => { + it('should return token immediately', async () => { + const mockToken = 'mock-jwt-token'; + const mockClerk = { + status: 'ready', + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const token = await getToken(); + expect(token).toBe(mockToken); + expect(mockClerk.session.getToken).toHaveBeenCalledWith(undefined); + }); + + it('should pass options to session.getToken', async () => { + const mockClerk = { + status: 'ready', + session: { + getToken: vi.fn().mockResolvedValue('token'), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + await getToken({ template: 'custom-template' }); + expect(mockClerk.session.getToken).toHaveBeenCalledWith({ template: 'custom-template' }); + }); + + it('should pass organizationId option to session.getToken', async () => { + const mockClerk = { + status: 'ready', + session: { + getToken: vi.fn().mockResolvedValue('token'), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + await getToken({ organizationId: 'org_123' }); + expect(mockClerk.session.getToken).toHaveBeenCalledWith({ organizationId: 'org_123' }); + }); + }); + + describe('when Clerk is loading', () => { + it('should wait for ready status via event listener', async () => { + const mockToken = 'delayed-token'; + let statusHandler: StatusHandler | null = null; + + const mockClerk = { + status: 'loading' as string, + on: vi.fn((event: string, handler: StatusHandler) => { + if (event === 'status') { + statusHandler = handler; + } + }), + off: vi.fn(), + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const tokenPromise = getToken(); + + // Simulate Clerk becoming ready + await vi.advanceTimersByTimeAsync(100); + mockClerk.status = 'ready'; + if (statusHandler) { + (statusHandler as StatusHandler)('ready'); + } + + const token = await tokenPromise; + expect(token).toBe(mockToken); + }); + + it('should resolve when status changes to degraded', async () => { + const mockToken = 'degraded-token'; + let statusHandler: StatusHandler | null = null; + + const mockClerk = { + status: 'loading' as string, + on: vi.fn((event: string, handler: StatusHandler) => { + if (event === 'status') { + statusHandler = handler; + } + }), + off: vi.fn(), + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const tokenPromise = getToken(); + + // Simulate Clerk becoming degraded + await vi.advanceTimersByTimeAsync(100); + mockClerk.status = 'degraded'; + if (statusHandler) { + (statusHandler as StatusHandler)('degraded'); + } + + const token = await tokenPromise; + expect(token).toBe(mockToken); + }); + }); + + describe('when window.Clerk does not exist', () => { + it('should poll until Clerk is available', async () => { + const mockToken = 'polled-token'; + + global.window = {} as any; + + const tokenPromise = getToken(); + + // Simulate Clerk loading after 200ms + await vi.advanceTimersByTimeAsync(200); + + (global.window as any).Clerk = { + status: 'ready', + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + await vi.advanceTimersByTimeAsync(100); + + const token = await tokenPromise; + expect(token).toBe(mockToken); + }); + + it('should throw ClerkRuntimeError if Clerk never loads', async () => { + global.window = {} as any; + + let caughtError: unknown; + const tokenPromise = getToken().catch(e => { + caughtError = e; + }); + + // Fast-forward past timeout (10 seconds) + await vi.advanceTimersByTimeAsync(15000); + await tokenPromise; + + expect(caughtError).toBeInstanceOf(ClerkRuntimeError); + expect((caughtError as ClerkRuntimeError).code).toBe('clerk_runtime_load_timeout'); + }); + }); + + describe('when user is not signed in', () => { + it('should return null when session is null', async () => { + const mockClerk = { + status: 'ready', + session: null, + }; + + global.window = { Clerk: mockClerk } as any; + + const token = await getToken(); + expect(token).toBeNull(); + }); + + it('should return null when session is undefined', async () => { + const mockClerk = { + status: 'ready', + session: undefined, + }; + + global.window = { Clerk: mockClerk } as any; + + const token = await getToken(); + expect(token).toBeNull(); + }); + }); + + describe('when Clerk status is degraded', () => { + it('should still return token', async () => { + const mockToken = 'degraded-token'; + const mockClerk = { + status: 'degraded', + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const token = await getToken(); + expect(token).toBe(mockToken); + }); + }); + + describe('in non-browser environment', () => { + it('should throw ClerkRuntimeError when window is undefined', async () => { + global.window = undefined as any; + + await expect(getToken()).rejects.toThrow(ClerkRuntimeError); + await expect(getToken()).rejects.toMatchObject({ + code: 'clerk_runtime_not_browser', + }); + }); + }); + + describe('when Clerk enters error status', () => { + it('should throw ClerkRuntimeError', async () => { + let statusHandler: StatusHandler | null = null; + + const mockClerk = { + status: 'loading' as string, + on: vi.fn((event: string, handler: StatusHandler) => { + if (event === 'status') { + statusHandler = handler; + } + }), + off: vi.fn(), + session: null, + }; + + global.window = { Clerk: mockClerk } as any; + + const tokenPromise = getToken(); + + // Simulate Clerk entering error state + await vi.advanceTimersByTimeAsync(100); + mockClerk.status = 'error'; + if (statusHandler) { + (statusHandler as StatusHandler)('error'); + } + + await expect(tokenPromise).rejects.toThrow(ClerkRuntimeError); + await expect(tokenPromise).rejects.toMatchObject({ + code: 'clerk_runtime_init_error', + }); + }); + }); + + describe('when session.getToken throws', () => { + it('should propagate the error', async () => { + const mockClerk = { + status: 'ready', + session: { + getToken: vi.fn().mockRejectedValue(new Error('Token fetch failed')), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + await expect(getToken()).rejects.toThrow('Token fetch failed'); + }); + }); + + describe('fallback for older clerk-js versions', () => { + it('should resolve when clerk.loaded is true but status is undefined', async () => { + const mockToken = 'legacy-token'; + const mockClerk = { + loaded: true, + status: undefined, + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const token = await getToken(); + expect(token).toBe(mockToken); + }); + }); + + describe('cleanup', () => { + it('should unsubscribe from status listener on success', async () => { + const mockToken = 'cleanup-token'; + let statusHandler: StatusHandler | null = null; + + const mockClerk = { + status: 'loading' as string, + on: vi.fn((event: string, handler: StatusHandler) => { + if (event === 'status') { + statusHandler = handler; + } + }), + off: vi.fn(), + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const tokenPromise = getToken(); + + await vi.advanceTimersByTimeAsync(50); + mockClerk.status = 'ready'; + if (statusHandler) { + (statusHandler as StatusHandler)('ready'); + } + + await tokenPromise; + + // Verify cleanup was called + expect(mockClerk.off).toHaveBeenCalledWith('status', statusHandler); + }); + }); +}); diff --git a/packages/shared/src/getToken.ts b/packages/shared/src/getToken.ts new file mode 100644 index 00000000000..f35b96757ef --- /dev/null +++ b/packages/shared/src/getToken.ts @@ -0,0 +1,141 @@ +import { inBrowser } from './browser'; +import { ClerkRuntimeError } from './errors/clerkRuntimeError'; +import { retry } from './retry'; +import type { Clerk, ClerkStatus, GetTokenOptions, LoadedClerk } from './types'; + +const POLL_INTERVAL_MS = 50; +const MAX_POLL_RETRIES = 100; +const STATUS_TIMEOUT_MS = 10000; // 10 second timeout for status changes + +function getWindowClerk(): Clerk | undefined { + if (inBrowser() && 'Clerk' in window) { + return (window as unknown as { Clerk?: Clerk }).Clerk; + } + return undefined; +} + +function waitForClerkStatus(clerk: Clerk): Promise { + return new Promise((resolve, reject) => { + let settled = false; + + const statusHandler = (status: ClerkStatus) => { + if (settled) { + return; + } + + if (status === 'ready' || status === 'degraded') { + settled = true; + clearTimeout(timeoutId); + clerk.off('status', statusHandler); + resolve(clerk as LoadedClerk); + } else if (status === 'error') { + settled = true; + clearTimeout(timeoutId); + clerk.off('status', statusHandler); + reject( + new ClerkRuntimeError('Clerk failed to initialize.', { + code: 'clerk_runtime_init_error', + }), + ); + } + }; + + const timeoutId = setTimeout(() => { + if (settled) { + return; + } + settled = true; + clerk.off('status', statusHandler); + reject( + new ClerkRuntimeError('Timeout waiting for Clerk to initialize.', { + code: 'clerk_runtime_load_timeout', + }), + ); + }, STATUS_TIMEOUT_MS); + + clerk.on('status', statusHandler, { notify: true }); + }); +} + +async function waitForClerk(): Promise { + if (!inBrowser()) { + throw new ClerkRuntimeError('getToken can only be used in browser environments.', { + code: 'clerk_runtime_not_browser', + }); + } + + let clerk: Clerk; + try { + clerk = await retry( + () => { + const clerk = getWindowClerk(); + if (!clerk) { + throw new Error('Clerk not found'); + } + return clerk; + }, + { + initialDelay: POLL_INTERVAL_MS, + factor: 1, + jitter: false, + shouldRetry: (_, iterations) => iterations < MAX_POLL_RETRIES, + }, + ); + } catch { + throw new ClerkRuntimeError('Timeout waiting for Clerk to load.', { + code: 'clerk_runtime_load_timeout', + }); + } + + if (clerk.status === 'ready' || clerk.status === 'degraded') { + return clerk as LoadedClerk; + } + + if (clerk.loaded && !clerk.status) { + return clerk as LoadedClerk; + } + + return waitForClerkStatus(clerk); +} + +/** + * Retrieves the current session token, waiting for Clerk to initialize if necessary. + * + * This function is safe to call from anywhere in the browser + * + * @param options - Optional configuration for token retrieval + * @param options.template - The name of a JWT template to use + * @param options.organizationId - Organization ID to include in the token + * @param options.leewayInSeconds - Number of seconds of leeway for token expiration + * @param options.skipCache - Whether to skip the token cache + * @returns A Promise that resolves to the session token, or `null` if the user is not signed in + * + * @throws {ClerkRuntimeError} When called in a non-browser environment (code: `clerk_runtime_not_browser`) + * + * @throws {ClerkRuntimeError} When Clerk fails to load within timeout (code: `clerk_runtime_load_timeout`) + * + * @throws {ClerkRuntimeError} When Clerk fails to initialize (code: `clerk_runtime_init_error`) + * + * @example + * ```typescript + * // In an Axios interceptor + * import { getToken } from '@clerk/nextjs'; + * + * axios.interceptors.request.use(async (config) => { + * const token = await getToken(); + * if (token) { + * config.headers.Authorization = `Bearer ${token}`; + * } + * return config; + * }); + * ``` + */ +export async function getToken(options?: GetTokenOptions): Promise { + const clerk = await waitForClerk(); + + if (!clerk.session) { + return null; + } + + return clerk.session.getToken(options); +} diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 3e1c592195b..13ec7be9235 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -60,6 +60,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "__experimental_PaymentElementProvider", "__experimental_useCheckout", "__experimental_usePaymentElement", + "getToken", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/tanstack-react-start/src/index.ts b/packages/tanstack-react-start/src/index.ts index 4d1e3bee830..50218d443e5 100644 --- a/packages/tanstack-react-start/src/index.ts +++ b/packages/tanstack-react-start/src/index.ts @@ -1,4 +1,5 @@ export * from './client/index'; +export { getToken } from '@clerk/shared/getToken'; // Override Clerk React error thrower to show that errors come from @clerk/tanstack-react-start import { setErrorThrowerOptions } from '@clerk/react/internal'; diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 325f66ea890..6b04b6b5ce4 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -7,6 +7,7 @@ export * from './composables'; export { clerkPlugin, type PluginOptions } from './plugin'; export { updateClerkOptions } from './utils'; +export { getToken } from '@clerk/shared/getToken'; setErrorThrowerOptions({ packageName: PACKAGE_NAME }); setClerkJsLoadingErrorPackageName(PACKAGE_NAME);