diff --git a/.changeset/slimy-hotels-give.md b/.changeset/slimy-hotels-give.md new file mode 100644 index 00000000000..e789aae85cc --- /dev/null +++ b/.changeset/slimy-hotels-give.md @@ -0,0 +1,26 @@ +--- +'@clerk/tanstack-react-start': minor +'@clerk/react-router': minor +'@clerk/clerk-js': minor +'@clerk/nextjs': minor +'@clerk/shared': minor +'@clerk/astro': minor +'@clerk/react': minor +'@clerk/nuxt': minor +'@clerk/vue': minor +--- + +Add standalone `getToken()` function for retrieving session tokens outside of framework component trees. + +This function is safe to call from anywhere in the browser, such as API interceptors, data fetching layers (e.g., React Query, SWR), or vanilla JavaScript code. It automatically waits for Clerk to initialize before returning the token. + +import { getToken } from '@clerk/nextjs'; // or any framework package + +// Example: Axios interceptor +axios.interceptors.request.use(async (config) => { + const token = await getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); 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/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 48d108389ce..74f61cb62de 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -82,6 +82,7 @@ import type { InstanceType, JoinWaitlistParams, ListenerCallback, + LoadedClerk, NavigateOptions, OrganizationListProps, OrganizationProfileProps, @@ -437,6 +438,23 @@ export class Clerk implements ClerkInterface { this.#publicEventBus.emit(clerkEvents.Status, 'loading'); this.#publicEventBus.prioritizedOn(clerkEvents.Status, s => (this.#status = s)); + this.#publicEventBus.on(clerkEvents.Status, status => { + if (!inBrowser()) { + return; + } + if (status === 'ready' || status === 'degraded') { + if (window.__clerk_internal_ready?.__resolve && this.#isLoaded()) { + window.__clerk_internal_ready.__resolve(this); + } + } else if (status === 'error') { + if (window.__clerk_internal_ready?.__reject) { + window.__clerk_internal_ready.__reject( + new ClerkRuntimeError('Clerk failed to initialize.', { code: 'clerk_init_failed' }), + ); + } + } + }); + // This line is used for the piggy-backing mechanism BaseResource.clerk = this; this.#protect = new Protect(); @@ -3117,4 +3135,8 @@ export class Clerk implements ClerkInterface { return allowedProtocols; } + + #isLoaded(): this is LoadedClerk { + return this.client !== undefined; + } } diff --git a/packages/clerk-js/src/core/fraudProtection.ts b/packages/clerk-js/src/core/fraudProtection.ts index 8dcac4aebed..1d73e1d15b7 100644 --- a/packages/clerk-js/src/core/fraudProtection.ts +++ b/packages/clerk-js/src/core/fraudProtection.ts @@ -1,4 +1,9 @@ -import { ClerkRuntimeError, isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; +import { + ClerkOfflineError, + ClerkRuntimeError, + isClerkAPIResponseError, + isClerkRuntimeError, +} from '@clerk/shared/error'; import { CaptchaChallenge } from '../utils/captcha/CaptchaChallenge'; import type { Clerk } from './resources/internal'; @@ -41,16 +46,22 @@ export class FraudProtection { return await cb(); } catch (e) { - if (!isClerkAPIResponseError(e)) { + // Offline errors should bypass captcha logic and be re-thrown immediately + // so cache fallback can be triggered + if (ClerkOfflineError.is(e)) { throw e; } // Network errors should bypass captcha logic and be re-thrown immediately - // so cache fallback can be triggered + // so higher layers can apply offline/cache handling. if (isClerkRuntimeError(e) && e.code === 'network_error') { throw e; } + if (!isClerkAPIResponseError(e)) { + throw e; + } + if (e.errors[0]?.code !== 'requires_captcha') { throw e; } diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index cc1b5db9819..61ec83ecb67 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -1,5 +1,5 @@ import { isValidBrowserOnline } from '@clerk/shared/browser'; -import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error'; +import { ClerkAPIResponseError, ClerkOfflineError, ClerkRuntimeError, isNetworkError } from '@clerk/shared/error'; import { isProductionFromPublishableKey } from '@clerk/shared/keys'; import type { ClerkAPIErrorJSON, @@ -8,8 +8,6 @@ import type { DeletedObjectJSON, } from '@clerk/shared/types'; -import { debugLogger } from '@/utils/debug'; - import { clerkMissingFapiClientInResources } from '../errors'; import type { FapiClient, FapiRequestInit, FapiResponse, FapiResponseJSON, HTTPMethod } from '../fapiClient'; import { FraudProtection } from '../fraudProtection'; @@ -93,22 +91,23 @@ export abstract class BaseResource { try { fapiResponse = await BaseResource.fapiClient.request(requestInit, { fetchMaxTries }); } catch (e) { + if (ClerkOfflineError.is(e)) { + throw e; + } + + if (isNetworkError(e) || !isValidBrowserOnline()) { + throw new ClerkOfflineError('Network request failed', { + cause: e instanceof Error ? e : undefined, + }); + } + // TODO: This should be the default behavior in the next major version, as long as we have a way to handle the requests more gracefully when offline if (this.shouldRethrowOfflineNetworkErrors()) { // TODO @userland-errors: throw new ClerkRuntimeError(e?.message || e, { code: 'network_error', + cause: e instanceof Error ? e : undefined, }); - } else if (!isValidBrowserOnline()) { - debugLogger.warn( - 'Network request failed while offline, returning null', - { - method: requestInit.method, - path: requestInit.path, - }, - 'baseResource', - ); - return null; } else { throw e; } diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index b6fd29c81ed..e34cfaee343 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -1,5 +1,12 @@ import { createCheckAuthorization } from '@clerk/shared/authorization'; -import { ClerkWebAuthnError, is4xxError, MissingExpiredTokenError } from '@clerk/shared/error'; +import { isValidBrowserOnline } from '@clerk/shared/browser'; +import { + ClerkOfflineError, + ClerkWebAuthnError, + is4xxError, + isNetworkError, + MissingExpiredTokenError, +} from '@clerk/shared/error'; import { convertJSONToPublicKeyRequestOptions, serializePublicKeyCredentialAssertion, @@ -111,6 +118,14 @@ export class Session extends BaseResource implements SessionResource { maxDelayBetweenRetries: 50 * 1_000, jitter: false, shouldRetry: (error, iterationsCount) => { + // Don't retry offline errors - fapiClient already retried with a short window. + // Let the error propagate so the user can handle the offline state. + // Use code check for robustness across module boundaries. + const isOffline = + ClerkOfflineError.is(error) || (error as { code?: string })?.code === ClerkOfflineError.ERROR_CODE; + if (isOffline) { + return false; + } return !is4xxError(error) && iterationsCount <= 8; }, }); @@ -401,10 +416,38 @@ export class Session extends BaseResource implements SessionResource { const lastActiveToken = this.lastActiveToken?.getRawString(); - const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { + const tokenResolver = retry(() => Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined), { + retryImmediately: true, + initialDelay: 500, + maxDelayBetweenRetries: 2_000, + factor: 2, + jitter: false, + shouldRetry: (error, iterations) => { + return (ClerkOfflineError.is(error) || isNetworkError(error)) && iterations < 5; + }, + }).catch(e => { if (MissingExpiredTokenError.is(e) && lastActiveToken) { return Token.create(path, { ...params }, { expired_token: lastActiveToken }); } + // Detect offline/network errors and throw ClerkOfflineError + // Check both current online status AND if error is a network error. + // This handles the race condition where browser goes offline, request fails, + // then browser comes back online before we reach this catch block. + const networkError = isNetworkError(e); + const browserOnline = isValidBrowserOnline(); + + if (networkError || !browserOnline) { + const resolvedToken = SessionTokenCache.getResolvedToken({ tokenId }); + const hasCachedTokenForId = !!resolvedToken?.getRawString(); + + const offlineError = new ClerkOfflineError('Network request failed while offline', { + cause: e instanceof Error ? e : undefined, + }); + // Attach safe, non-sensitive context for consumers without leaking raw tokens. + (offlineError as any).hasCachedToken = hasCachedTokenForId; + (offlineError as any).tokenId = tokenId; + throw offlineError; + } throw e; }); SessionTokenCache.set({ tokenId, tokenResolver }); 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 eb51257191d..599067622ea 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1,4 +1,4 @@ -import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { ClerkAPIResponseError, ClerkOfflineError } from '@clerk/shared/error'; import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/shared/types'; import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; @@ -269,13 +269,11 @@ describe('Session', () => { }); describe('with offline browser and network failure', () => { - let warnSpy; beforeEach(() => { Object.defineProperty(window.navigator, 'onLine', { writable: true, value: false, }); - warnSpy = vi.spyOn(console, 'warn').mockReturnValue(); }); afterEach(() => { @@ -283,10 +281,9 @@ describe('Session', () => { writable: true, value: true, }); - warnSpy.mockRestore(); }); - it('returns null', async () => { + it('throws ClerkOfflineError', async () => { const session = new Session({ status: 'active', id: 'session_1', @@ -301,11 +298,117 @@ describe('Session', () => { mockNetworkFailedFetch(); BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; - const token = await session.getToken(); + const p = session.getToken(); + await vi.runAllTimersAsync(); + await expect(p).rejects.toSatisfy((error: unknown) => { + return ClerkOfflineError.is(error); + }); expect(global.fetch).toHaveBeenCalled(); - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(token).toEqual(null); + }); + + it('includes cached token in offline error when available', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + mockNetworkFailedFetch(); + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + + // First call should use cached token successfully + const token1 = await session.getToken(); + expect(token1).toEqual(mockJwt); + + // Second call should throw offline error (refresh attempt) and indicate a cached token exists. + try { + const p = session.getToken({ skipCache: true }); + await vi.runAllTimersAsync(); + await p; + expect.fail('Expected getToken to throw'); + } catch (error) { + expect(ClerkOfflineError.is(error)).toBe(true); + if (ClerkOfflineError.is(error)) { + expect((error as any).hasCachedToken).toBe(true); + } + } + }); + + it('does not poison cache on offline failure - preserves previous valid token', async () => { + BaseResource.clerk = clerkMock(); + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + const token1 = await session.getToken(); + expect(token1).toEqual(mockJwt); + + mockNetworkFailedFetch(); + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + + try { + const p = session.getToken({ skipCache: true }); + await vi.runAllTimersAsync(); + await p; + expect.fail('Expected getToken to throw'); + } catch (error) { + expect(ClerkOfflineError.is(error)).toBe(true); + } + + BaseResource.clerk = clerkMock(); + + const token2 = await session.getToken(); + expect(token2).toEqual(mockJwt); + }); + + it('recovers successfully after coming back online', async () => { + 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); + + mockNetworkFailedFetch(); + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + + const p = session.getToken(); + await vi.runAllTimersAsync(); + await expect(p).rejects.toSatisfy((error: unknown) => { + return ClerkOfflineError.is(error); + }); + + // Come back online + Object.defineProperty(window.navigator, 'onLine', { + writable: true, + value: true, + }); + BaseResource.clerk = clerkMock(); + + SessionTokenCache.clear(); + + const token = await session.getToken(); + expect(token).toEqual(mockJwt); }); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/Token.test.ts b/packages/clerk-js/src/core/resources/__tests__/Token.test.ts index d4738734267..4408f7d75d2 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Token.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Token.test.ts @@ -1,10 +1,9 @@ +import { ClerkOfflineError } from '@clerk/shared/error'; import type { InstanceType } from '@clerk/shared/types'; -import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, type Mock } from 'vitest'; import { mockFetch, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures'; -import { debugLogger } from '@/utils/debug'; -import { SUPPORTED_FAPI_VERSION } from '../../constants'; import { createFapiClient } from '../../fapiClient'; import { BaseResource } from '../internal'; import { Token } from '../Token'; @@ -44,14 +43,11 @@ describe('Token', () => { }); describe('with offline browser and network failure', () => { - let warnSpy: ReturnType; - beforeEach(() => { Object.defineProperty(window.navigator, 'onLine', { writable: true, value: false, }); - warnSpy = vi.spyOn(debugLogger, 'warn').mockReturnValue(); }); afterEach(() => { @@ -59,27 +55,18 @@ describe('Token', () => { writable: true, value: true, }); - warnSpy.mockRestore(); }); - it('create returns empty raw string', async () => { + it('throws network error', async () => { mockNetworkFailedFetch(); BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; - const token = await Token.create('/path/to/tokens'); - - expect(global.fetch).toHaveBeenCalledTimes(1); - const [url, options] = (global.fetch as Mock).mock.calls[0]; - expect(url.toString()).toContain('https://clerk.example.com/v1/path/to/tokens'); - expect(options).toMatchObject({ - method: 'POST', - body: '', - credentials: 'include', - headers: expect.any(Headers), + await expect(Token.create('/path/to/tokens')).rejects.toSatisfy((error: unknown) => { + return ClerkOfflineError.is(error); }); - expect(token.getRawString()).toEqual(''); - expect(warnSpy).toBeCalled(); + // Should not retry when offline + expect(global.fetch).toHaveBeenCalledTimes(1); }); }); @@ -88,19 +75,9 @@ describe('Token', () => { mockNetworkFailedFetch(); BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; - await expect(Token.create('/path/to/tokens')).rejects.toThrow( - `ClerkJS: Network error at "https://clerk.example.com/v1/path/to/tokens?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test" - TypeError: Failed to fetch. Please try again.`, - ); + await expect(Token.create('/path/to/tokens')).rejects.toThrow(/ClerkJS: Network error/); expect(global.fetch).toHaveBeenCalledTimes(1); - const [url, options] = (global.fetch as Mock).mock.calls[0]; - expect(url.toString()).toContain('https://clerk.example.com/v1/path/to/tokens'); - expect(options).toMatchObject({ - method: 'POST', - body: '', - credentials: 'include', - headers: expect.any(Headers), - }); }); }); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 74697b80c63..26ecab846f6 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -1,3 +1,4 @@ +import { ClerkOfflineError } from '@clerk/shared/error'; import type { TokenResource } from '@clerk/shared/types'; import { debugLogger } from '@/utils/debug'; @@ -39,6 +40,11 @@ interface TokenCacheValue { createdAt: Seconds; entry: TokenCacheEntry; expiresIn?: Seconds; + /** + * The last successfully resolved token for this cache key. + * Used to serve a valid token while a refresh is in-flight. + */ + resolvedToken?: TokenResource; timeoutId?: ReturnType; } @@ -64,6 +70,16 @@ export interface TokenCache { */ get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheEntry | undefined; + /** + * Retrieves the last successfully resolved token for a cache key. + * This is useful for getting a valid token while a refresh is in-flight, + * or for attaching to error context. + * + * @param cacheKeyJSON - Object containing tokenId and optional audience + * @returns The resolved TokenResource if available, undefined otherwise + */ + getResolvedToken(cacheKeyJSON: TokenCacheKeyJSON): TokenResource | undefined; + /** * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. * @@ -203,6 +219,18 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { return value.entry; }; + /** + * Returns the last successfully resolved token for a cache key. + * Useful for serving a valid token while a refresh is in-flight, + * or for attaching to error context. + */ + const getResolvedToken = (cacheKeyJSON: TokenCacheKeyJSON): TokenResource | undefined => { + const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); + const value = cache.get(cacheKey.toKey()); + + return value?.resolvedToken; + }; + /** * Processes token updates from other tabs via BroadcastChannel. * Validates token ID, parses JWT, and updates cache if token is newer than existing entry. @@ -314,9 +342,18 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const key = cacheKey.toKey(); + // Store previous entry for potential rollback on offline failure + const previousValue = cache.get(key); + const nowSeconds = Math.floor(Date.now() / 1000); const createdAt = entry.createdAt ?? nowSeconds; - const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined }; + // Preserve the previous resolved token while the new request is in-flight + const value: TokenCacheValue = { + createdAt, + entry, + expiresIn: undefined, + resolvedToken: previousValue?.resolvedToken, + }; const deleteKey = () => { const cachedValue = cache.get(key); @@ -328,6 +365,42 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { } }; + const rollbackToPrevious = () => { + // Only rollback if current entry is still the one we're handling + const cachedValue = cache.get(key); + if (cachedValue !== value) { + debugLogger.debug('Rollback skipped (entry changed)', { tokenId: entry.tokenId }, 'tokenCache'); + return; + } + + // Restore previous entry if it had resolved successfully. + // Use `resolvedToken` as the source of truth (expiresIn may be missing in edge cases). + if (previousValue?.resolvedToken) { + // Ensure the restored entry will still be cleaned up when it expires. + // (Its original timer might have been cleared or already fired in rare cases.) + if (previousValue.timeoutId === undefined && previousValue.expiresIn !== undefined) { + const nowSeconds = Math.floor(Date.now() / 1000); + const elapsed = nowSeconds - previousValue.createdAt; + const remainingSeconds = previousValue.expiresIn - elapsed; + if (remainingSeconds > 0) { + const timeoutId = setTimeout(() => { + const current = cache.get(key); + if (current === previousValue) { + cache.delete(key); + } + }, remainingSeconds * 1000); + previousValue.timeoutId = timeoutId; + if (typeof (timeoutId as any).unref === 'function') { + (timeoutId as any).unref(); + } + } + } + + cache.set(key, previousValue); + } else { + } + }; + entry.tokenResolver .then(newToken => { const claims = newToken.jwt?.claims; @@ -340,6 +413,8 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const expiresIn: Seconds = expiresAt - issuedAt; value.expiresIn = expiresIn; + // Store the successfully resolved token for use while future refreshes are in-flight + value.resolvedToken = newToken; const timeoutId = setTimeout(deleteKey, expiresIn * 1000); value.timeoutId = timeoutId; @@ -389,8 +464,16 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { } } }) - .catch(() => { - deleteKey(); + .catch(error => { + const isOffline = ClerkOfflineError.is(error); + + if (isOffline) { + // Rollback to previous valid entry on offline failure + // This prevents cache poisoning where a rejected promise blocks recovery + rollbackToPrevious(); + } else { + deleteKey(); + } }); cache.set(key, value); @@ -407,7 +490,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { return cache.size; }; - return { clear, close, get, set, size }; + return { clear, close, get, getResolvedToken, set, size }; }; export const SessionTokenCache = MemoryTokenCache(); diff --git a/packages/clerk-js/src/global.d.ts b/packages/clerk-js/src/global.d.ts index 8ecd6ad7635..52a15e8dee6 100644 --- a/packages/clerk-js/src/global.d.ts +++ b/packages/clerk-js/src/global.d.ts @@ -19,4 +19,15 @@ interface Window { __internal_onAfterSetActive: () => Promise | void; // eslint-disable-next-line @typescript-eslint/consistent-type-imports __internal_ClerkUiCtor?: import('@clerk/shared/types').ClerkUiConstructor; + /** + * Promise used for coordination between standalone getToken() from @clerk/shared and clerk-js. + * When getToken() is called before Clerk loads, it creates this promise with __resolve/__reject callbacks. + * When Clerk reaches ready/degraded/error status, it resolves/rejects this promise. + */ + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + __clerk_internal_ready?: Promise & { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + __resolve?: (clerk: import('@clerk/shared/types').LoadedClerk) => void; + __reject?: (error: Error) => void; + }; } diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index c4123f6729c..20e5daa87db 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 e8f2ed3c6c2..8ea72969a9e 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -53,6 +53,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__/error.spec.ts b/packages/shared/src/__tests__/error.spec.ts index d04a312977a..1eeaadedfc3 100644 --- a/packages/shared/src/__tests__/error.spec.ts +++ b/packages/shared/src/__tests__/error.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { ErrorThrowerOptions } from '../error'; -import { buildErrorThrower, ClerkRuntimeError, isClerkRuntimeError } from '../error'; +import { buildErrorThrower, ClerkOfflineError, ClerkRuntimeError, isClerkRuntimeError } from '../error'; describe('ErrorThrower', () => { const errorThrower = buildErrorThrower({ packageName: '@clerk/test-package' }); @@ -62,3 +62,57 @@ describe('ClerkRuntimeError', () => { expect(isClerkRuntimeError(clerkRuntimeError)).toEqual(true); }); }); + +describe('ClerkOfflineError', () => { + it('has the correct error code constant', () => { + expect(ClerkOfflineError.ERROR_CODE).toEqual('clerk_offline'); + }); + + it('can be instantiated with message', () => { + const offlineError = new ClerkOfflineError('Network request failed while offline'); + expect(offlineError.message).toContain('Network request failed while offline'); + expect(offlineError.message).toContain('clerk_offline'); + expect(offlineError.code).toBe('clerk_offline'); + }); + + it('identifies ClerkOfflineError instances', () => { + const offlineError = new ClerkOfflineError('Network request failed while offline'); + expect(ClerkOfflineError.is(offlineError)).toBe(true); + }); + + it('does not identify ClerkRuntimeError with different code', () => { + const otherError = new ClerkRuntimeError('Some other error', { + code: 'other_error', + }); + expect(ClerkOfflineError.is(otherError)).toBe(false); + }); + + it('does not identify non-ClerkOfflineError', () => { + expect(ClerkOfflineError.is(new Error('regular error'))).toBe(false); + expect(ClerkOfflineError.is({ code: 'clerk_offline' })).toBe(false); + expect(ClerkOfflineError.is(null)).toBe(false); + expect(ClerkOfflineError.is(undefined)).toBe(false); + }); + + it('supports non-sensitive context properties', () => { + const offlineError = new ClerkOfflineError('Network request failed while offline', { + hasCachedToken: true, + tokenId: 'sess_123', + }); + + expect(ClerkOfflineError.is(offlineError)).toBe(true); + expect(offlineError.hasCachedToken).toBe(true); + expect(offlineError.tokenId).toBe('sess_123'); + expect(offlineError.code).toEqual('clerk_offline'); + }); + + it('preserves cause from original error', () => { + const originalError = new Error('Original network error'); + const offlineError = new ClerkOfflineError('Network request failed while offline', { + cause: originalError, + }); + + expect(ClerkOfflineError.is(offlineError)).toBe(true); + expect(offlineError.cause).toBe(originalError); + }); +}); diff --git a/packages/shared/src/__tests__/getToken.spec.ts b/packages/shared/src/__tests__/getToken.spec.ts new file mode 100644 index 00000000000..5682c27d256 --- /dev/null +++ b/packages/shared/src/__tests__/getToken.spec.ts @@ -0,0 +1,263 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ClerkRuntimeError } from '../errors/clerkRuntimeError'; +import { getToken } from '../getToken'; + +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 not yet ready', () => { + it('should wait for promise resolution when clerk-js resolves the global promise', async () => { + const mockToken = 'delayed-token'; + const mockClerk = { + status: 'ready', + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + // Start with empty window (no Clerk) + global.window = {} as any; + + const tokenPromise = getToken(); + + // Simulate clerk-js loading and resolving the promise + await vi.advanceTimersByTimeAsync(100); + + // Resolve the promise that getToken created + const readyPromise = (global.window as any).__clerk_internal_ready; + expect(readyPromise).toBeDefined(); + expect(readyPromise.__resolve).toBeDefined(); + + // Simulate clerk-js calling __resolve + readyPromise.__resolve(mockClerk); + + const token = await tokenPromise; + expect(token).toBe(mockToken); + }); + + it('should resolve when clerk-js resolves with degraded status', async () => { + const mockToken = 'degraded-token'; + const mockClerk = { + status: 'degraded', + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = {} as any; + + const tokenPromise = getToken(); + + await vi.advanceTimersByTimeAsync(100); + + const readyPromise = (global.window as any).__clerk_internal_ready; + readyPromise.__resolve(mockClerk); + + const token = await tokenPromise; + expect(token).toBe(mockToken); + }); + + it('should reject when clerk-js rejects the global promise', async () => { + global.window = {} as any; + + const tokenPromise = getToken(); + + await vi.advanceTimersByTimeAsync(100); + + const readyPromise = (global.window as any).__clerk_internal_ready; + readyPromise.__reject(new Error('Clerk failed to initialize')); + + await expect(tokenPromise).rejects.toThrow('Clerk failed to initialize'); + }); + + it('should throw ClerkRuntimeError if promise is never resolved (timeout)', 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('multiple concurrent getToken calls', () => { + it('should share the same promise for concurrent calls', async () => { + const mockToken = 'shared-token'; + const mockClerk = { + status: 'ready', + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = {} as any; + + const tokenPromise1 = getToken(); + const tokenPromise2 = getToken(); + const tokenPromise3 = getToken(); + + await vi.advanceTimersByTimeAsync(100); + + const readyPromise = (global.window as any).__clerk_internal_ready; + readyPromise.__resolve(mockClerk); + + const [token1, token2, token3] = await Promise.all([tokenPromise1, tokenPromise2, tokenPromise3]); + + expect(token1).toBe(mockToken); + expect(token2).toBe(mockToken); + expect(token3).toBe(mockToken); + expect(mockClerk.session.getToken).toHaveBeenCalledTimes(3); + }); + }); + + 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 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); + }); + }); +}); diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 328a363015e..b75e569a5db 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -4,6 +4,7 @@ export { ClerkAPIError, isClerkAPIError } from './errors/clerkApiError'; export { ClerkAPIResponseError, isClerkAPIResponseError } from './errors/clerkApiResponseError'; export { ClerkError, isClerkError } from './errors/clerkError'; export { MissingExpiredTokenError } from './errors/missingExpiredTokenError'; +export { ClerkOfflineError } from './errors/clerkOfflineError'; export { buildErrorThrower, type ErrorThrower, type ErrorThrowerOptions } from './errors/errorThrower'; diff --git a/packages/shared/src/errors/clerkOfflineError.ts b/packages/shared/src/errors/clerkOfflineError.ts new file mode 100644 index 00000000000..a82c243d9ad --- /dev/null +++ b/packages/shared/src/errors/clerkOfflineError.ts @@ -0,0 +1,85 @@ +import type { ClerkErrorParams } from './clerkError'; +import { ClerkError } from './clerkError'; +import { createErrorTypeGuard } from './createErrorTypeGuard'; + +type ClerkOfflineErrorOptions = Omit & { + /** + * Whether a cached token exists for the requested tokenId. + * This avoids leaking raw tokens into error objects (which are often logged/serialized). + */ + hasCachedToken?: boolean; + /** + * The tokenId that was being requested when the offline/connectivity failure happened. + */ + tokenId?: string; + /** + * @deprecated Avoid attaching raw tokens to errors. Prefer `hasCachedToken` + re-calling `getToken()`. + */ + cachedToken?: string; +}; + +/** + * Error class for offline/network failure scenarios. + * + * Thrown when a network request fails due to the user being offline or + * experiencing network connectivity issues. This allows applications to + * distinguish between API errors and connectivity problems. + * + * @example + * ```typescript + * try { + * const token = await session.getToken(); + * } catch (e) { + * if (ClerkOfflineError.is(e)) { + * // Handle offline scenario + * showOfflineMessage(); + * // Access cached token if available + * if (e.cachedToken) { + * useFallbackToken(e.cachedToken); + * } + * } + * } + * ``` + */ +export class ClerkOfflineError extends ClerkError { + static kind = 'ClerkOfflineError'; + static readonly ERROR_CODE = 'clerk_offline' as const; + + /** + * Whether a cached token exists for the requested tokenId. + */ + readonly hasCachedToken?: boolean; + /** + * The tokenId that was being requested when the offline/connectivity failure happened. + */ + readonly tokenId?: string; + /** + * @deprecated Avoid attaching raw tokens to errors. Prefer `hasCachedToken` + re-calling `getToken()`. + */ + readonly cachedToken?: string; + + constructor(message: string, options?: ClerkOfflineErrorOptions) { + super({ + ...options, + message, + code: ClerkOfflineError.ERROR_CODE, + }); + this.hasCachedToken = options?.hasCachedToken; + this.tokenId = options?.tokenId; + this.cachedToken = options?.cachedToken; + Object.setPrototypeOf(this, ClerkOfflineError.prototype); + } + + /** + * Type guard to check if an error is a ClerkOfflineError. + * + * @example + * ```typescript + * if (ClerkOfflineError.is(error)) { + * // error is typed as ClerkOfflineError + * console.log(error.cachedToken); + * } + * ``` + */ + static is = createErrorTypeGuard(ClerkOfflineError); +} diff --git a/packages/shared/src/errors/helpers.ts b/packages/shared/src/errors/helpers.ts index b95f55d5a8c..953002b3777 100644 --- a/packages/shared/src/errors/helpers.ts +++ b/packages/shared/src/errors/helpers.ts @@ -43,9 +43,33 @@ export function is4xxError(e: any): boolean { * @internal */ export function isNetworkError(e: any): boolean { - // TODO: revise during error handling epic - const message = (`${e.message}${e.name}` || '').toLowerCase().replace(/\s+/g, ''); - return message.includes('networkerror'); + if (!e) { + return false; + } + + const name = String(e.name || ''); + if (name === 'AbortError') { + return false; + } + + const message = String(e.message || ''); + const haystack = `${name} ${message}`.toLowerCase(); + + if (haystack.includes('clerkjs: network error at')) { + return true; + } + + if (haystack.includes('failed to fetch')) { + return true; + } + if (haystack.includes('networkerror when attempting to fetch resource')) { + return true; + } + if (haystack.includes('load failed')) { + return true; + } + + return false; } /** diff --git a/packages/shared/src/getToken.ts b/packages/shared/src/getToken.ts new file mode 100644 index 00000000000..af2ad28d9ce --- /dev/null +++ b/packages/shared/src/getToken.ts @@ -0,0 +1,126 @@ +import { inBrowser } from './browser'; +import { ClerkRuntimeError } from './errors/clerkRuntimeError'; +import type { GetTokenOptions, LoadedClerk } from './types'; + +const TIMEOUT_MS = 10000; // 10 second timeout for Clerk to load + +/** + * A promise that includes resolve/reject callbacks for external resolution. + * Used for coordination between getToken() and clerk-js initialization. + */ +type ClerkReadyPromise = Promise & { + __resolve?: (clerk: LoadedClerk) => void; + __reject?: (error: Error) => void; +}; + +/** + * Local Window type extension for __clerk_internal_ready coordination. + * Avoids global augmentation to prevent declaration collisions for consumers. + */ +interface ClerkWindow extends Window { + __clerk_internal_ready?: ClerkReadyPromise; +} + +function getWindowClerk(): LoadedClerk | undefined { + if (inBrowser() && 'Clerk' in window) { + const clerk = (window as unknown as { Clerk?: LoadedClerk }).Clerk; + if (clerk && (clerk.status === 'ready' || clerk.status === 'degraded')) { + return clerk; + } + // Legacy fallback for older clerk-js versions without status + if (clerk?.loaded && !clerk.status) { + return clerk; + } + } + return undefined; +} + +async function waitForClerk(): Promise { + if (!inBrowser()) { + throw new ClerkRuntimeError('getToken can only be used in browser environments.', { + code: 'clerk_runtime_not_browser', + }); + } + + const clerk = getWindowClerk(); + if (clerk) { + return clerk; + } + + const clerkWindow = window as ClerkWindow; + + // Get or create the coordination promise + if (!clerkWindow.__clerk_internal_ready) { + let resolve!: (clerk: LoadedClerk) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }) as ClerkReadyPromise; + promise.__resolve = resolve; + promise.__reject = reject; + clerkWindow.__clerk_internal_ready = promise; + } + + const readyPromise = clerkWindow.__clerk_internal_ready; + + return Promise.race([ + readyPromise, + new Promise((_, reject) => + setTimeout( + () => + reject( + new ClerkRuntimeError('Timeout waiting for Clerk to load.', { + code: 'clerk_runtime_load_timeout', + }), + ), + TIMEOUT_MS, + ), + ), + ]); +} + +/** + * Retrieves the current session token, waiting for Clerk to initialize if necessary. + * + * This function is safe to call from anywhere in the browser, such as API interceptors, + * data fetching layers, or vanilla JavaScript code. + * + * **Note:** In frameworks with concurrent rendering (e.g., React 18+), a global token read + * may not correspond to the currently committed UI during transitions. This is a coherence + * consideration, not an auth safety issue. + * + * @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`) + * + * @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 21c0511015a..c354a0bef83 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 @@ -58,6 +58,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);