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 2e29bcd7568..93107956676 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -59,6 +59,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/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 3ddd940623a..05c1e1e4b08 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -8,6 +8,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..3ef50adafc1 --- /dev/null +++ b/packages/shared/src/__tests__/getToken.spec.ts @@ -0,0 +1,320 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +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 timeout and return null if Clerk never loads', async () => { + global.window = {} as any; + + const tokenPromise = getToken(); + + // Fast-forward past timeout (10 seconds) + await vi.advanceTimersByTimeAsync(15000); + + const token = await tokenPromise; + expect(token).toBeNull(); + }); + }); + + 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 return null when window is undefined', async () => { + global.window = undefined as any; + + const token = await getToken(); + expect(token).toBeNull(); + }); + }); + + describe('when Clerk enters error status', () => { + it('should return null', 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'); + } + + const token = await tokenPromise; + expect(token).toBeNull(); + }); + }); + + describe('when session.getToken throws', () => { + it('should return null and not propagate the error', async () => { + const mockClerk = { + status: 'ready', + session: { + getToken: vi.fn().mockRejectedValue(new Error('Token fetch failed')), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const token = await getToken(); + expect(token).toBeNull(); + }); + }); + + 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..04f6f95e903 --- /dev/null +++ b/packages/shared/src/getToken.ts @@ -0,0 +1,160 @@ +import { inBrowser } from './browser'; +import type { ClerkStatus, GetTokenOptions, LoadedClerk } from './types'; + +const POLL_INTERVAL_MS = 50; +const MAX_POLL_RETRIES = 100; // 5 seconds of polling +const TIMEOUT_MS = 10000; // 10 second absolute timeout + +type WindowClerk = LoadedClerk & { + status?: ClerkStatus; + loaded?: boolean; + on?: (event: 'status', handler: (status: ClerkStatus) => void, opts?: { notify?: boolean }) => void; + off?: (event: 'status', handler: (status: ClerkStatus) => void) => void; +}; + +function getWindowClerk(): WindowClerk | undefined { + if (inBrowser() && 'Clerk' in window) { + return (window as unknown as { Clerk?: WindowClerk }).Clerk; + } + return undefined; +} + +class ClerkNotLoadedError extends Error { + constructor() { + super('Clerk: Timeout waiting for Clerk to load. Ensure ClerkProvider is mounted.'); + this.name = 'ClerkNotLoadedError'; + } +} + +class ClerkNotAvailableError extends Error { + constructor() { + super('Clerk: getToken can only be used in browser environments.'); + this.name = 'ClerkNotAvailableError'; + } +} + +function waitForClerk(): Promise { + return new Promise((resolve, reject) => { + if (!inBrowser()) { + reject(new ClerkNotAvailableError()); + return; + } + + const clerk = getWindowClerk(); + + if (clerk && (clerk.status === 'ready' || clerk.status === 'degraded')) { + resolve(clerk as LoadedClerk); + return; + } + + if (clerk && clerk.loaded && !clerk.status) { + resolve(clerk as LoadedClerk); + return; + } + + let retries = 0; + let timeoutId: ReturnType; + let statusHandler: ((status: ClerkStatus) => void) | undefined; + let pollTimeoutId: ReturnType; + let currentClerk: WindowClerk | undefined = clerk; + + const cleanup = () => { + clearTimeout(timeoutId); + clearTimeout(pollTimeoutId); + if (statusHandler && currentClerk?.off) { + currentClerk.off('status', statusHandler); + } + }; + + timeoutId = setTimeout(() => { + cleanup(); + reject(new ClerkNotLoadedError()); + }, TIMEOUT_MS); + + const checkAndResolve = () => { + currentClerk = getWindowClerk(); + + if (!currentClerk) { + if (retries < MAX_POLL_RETRIES) { + retries++; + pollTimeoutId = setTimeout(checkAndResolve, POLL_INTERVAL_MS); + } + return; + } + + if (currentClerk.status === 'ready' || currentClerk.status === 'degraded') { + cleanup(); + resolve(currentClerk as LoadedClerk); + return; + } + + if (currentClerk.loaded && !currentClerk.status) { + cleanup(); + resolve(currentClerk as LoadedClerk); + return; + } + + if (!statusHandler && currentClerk.on) { + statusHandler = (status: ClerkStatus) => { + if (status === 'ready' || status === 'degraded') { + cleanup(); + resolve(currentClerk as LoadedClerk); + } else if (status === 'error') { + cleanup(); + reject(new ClerkNotLoadedError()); + } + }; + + currentClerk.on('status', statusHandler, { notify: true }); + } + }; + + checkAndResolve(); + }); +} + +/** + * 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 + * - Clerk failed to load + * - Called in a non-browser environment + * + * @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 { + try { + const clerk = await waitForClerk(); + + if (!clerk.session) { + return null; + } + + return await clerk.session.getToken(options); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('[Clerk] getToken failed:', error); + } + return null; + } +} 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);