Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/slimy-hotels-give.md
Original file line number Diff line number Diff line change
@@ -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;
});
1 change: 1 addition & 0 deletions packages/astro/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { updateClerkOptions } from '../internal/create-clerk-instance';
export * from '../stores/external';
export { getToken } from '@clerk/shared/getToken';
22 changes: 22 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import type {
InstanceType,
JoinWaitlistParams,
ListenerCallback,
LoadedClerk,
NavigateOptions,
OrganizationListProps,
OrganizationProfileProps,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -3117,4 +3135,8 @@ export class Clerk implements ClerkInterface {

return allowedProtocols;
}

#isLoaded(): this is LoadedClerk {
return this.client !== undefined;
}
}
17 changes: 14 additions & 3 deletions packages/clerk-js/src/core/fraudProtection.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
25 changes: 12 additions & 13 deletions packages/clerk-js/src/core/resources/Base.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -93,22 +91,23 @@ export abstract class BaseResource {
try {
fapiResponse = await BaseResource.fapiClient.request<J>(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,
});
}
Comment on lines +94 to +102
Copy link
Member

Choose a reason for hiding this comment

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

Is this something we want to happen for all resources, or just the session resource?


// 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;
}
Expand Down
47 changes: 45 additions & 2 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
},
});
Expand Down Expand Up @@ -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 });
Expand Down
119 changes: 111 additions & 8 deletions packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -269,24 +269,21 @@ 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(() => {
Object.defineProperty(window.navigator, 'onLine', {
writable: true,
value: true,
});
warnSpy.mockRestore();
});

it('returns null', async () => {
it('throws ClerkOfflineError', async () => {
const session = new Session({
status: 'active',
id: 'session_1',
Expand All @@ -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);
});
});

Expand Down
Loading
Loading