Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/chatty-tigers-see.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/backend': minor
---

Improves resilience by keeping users logged in when Clerk's origin is temporarily unavailable using edge-based token generation
1 change: 1 addition & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const QueryParameters = {
HandshakeReason: '__clerk_hs_reason',
HandshakeNonce: Cookies.HandshakeNonce,
HandshakeFormat: 'format',
Session: '__session',
} as const;

const Headers = {
Expand Down
34 changes: 34 additions & 0 deletions packages/backend/src/tokens/__tests__/handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,40 @@ describe('HandshakeService', () => {
expect(url.searchParams.get(constants.QueryParameters.SuffixedCookies)).toMatch(/^(true|false)$/);
expect(url.searchParams.get(constants.QueryParameters.HandshakeReason)).toBe('test-reason');
});

it('should include session token in handshake URL when session token is present', () => {
const contextWithSession = {
...mockAuthenticateContext,
sessionToken: 'test_session_token_123',
} as AuthenticateContext;
const serviceWithSession = new HandshakeService(contextWithSession, mockOptions, mockOrganizationMatcher);

const headers = serviceWithSession.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.searchParams.get(constants.Cookies.Session)).toBe('test_session_token_123');
});

it('should not include session token in handshake URL when session token is absent', () => {
const contextWithoutSession = {
...mockAuthenticateContext,
sessionToken: undefined,
} as AuthenticateContext;
const serviceWithoutSession = new HandshakeService(contextWithoutSession, mockOptions, mockOrganizationMatcher);

const headers = serviceWithoutSession.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.searchParams.get(constants.Cookies.Session)).toBeNull();
});
});

describe('handleTokenVerificationErrorInDevelopment', () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/tokens/handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ export class HandshakeService {
url.searchParams.append(constants.QueryParameters.HandshakeReason, reason);
url.searchParams.append(constants.QueryParameters.HandshakeFormat, 'nonce');

if (this.authenticateContext.sessionToken) {
url.searchParams.append(constants.QueryParameters.Session, this.authenticateContext.sessionToken);
}

if (this.authenticateContext.instanceType === 'development' && this.authenticateContext.devBrowserToken) {
url.searchParams.append(constants.QueryParameters.DevBrowser, this.authenticateContext.devBrowserToken);
}
Expand Down
11 changes: 8 additions & 3 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createCheckAuthorization } from '@clerk/shared/authorization';
import { ClerkWebAuthnError, is4xxError } from '@clerk/shared/error';
import { ClerkWebAuthnError, is4xxError, MissingExpiredTokenError } from '@clerk/shared/error';
import { retry } from '@clerk/shared/retry';
import type {
ActClaim,
Expand Down Expand Up @@ -399,9 +399,14 @@ export class Session extends BaseResource implements SessionResource {
// TODO: update template endpoint to accept organizationId
const params: Record<string, string | null> = template ? {} : { organizationId };

const tokenResolver = Token.create(path, params, skipCache);
const lastActiveToken = this.lastActiveToken?.getRawString();

// Cache the promise immediately to prevent concurrent calls from triggering duplicate requests
const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => {
if (MissingExpiredTokenError.is(e) && lastActiveToken) {
return Token.create(path, { ...params }, { expired_token: lastActiveToken });
}
throw e;
});
SessionTokenCache.set({ tokenId, tokenResolver });

return tokenResolver.then(token => {
Expand Down
6 changes: 2 additions & 4 deletions packages/clerk-js/src/core/resources/Token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,11 @@ export class Token extends BaseResource implements TokenResource {

jwt?: JWT;

static async create(path: string, body: any = {}, skipCache = false): Promise<TokenResource> {
const search = skipCache ? `debug=skip_cache` : undefined;

static async create(path: string, body: any = {}, search: Record<string, string> = {}): Promise<TokenResource> {
const json = (await BaseResource._fetch<TokenJSON>({
body,
method: 'POST',
path,
body,
search,
})) as unknown as TokenJSON;

Expand Down
148 changes: 148 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ClerkAPIResponseError } 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 @@ -1085,4 +1086,151 @@ describe('Session', () => {
expect(isAuthorized).toBe(true);
});
});

describe('origin outage mode fallback', () => {
let dispatchSpy: ReturnType<typeof vi.spyOn>;
let fetchSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
SessionTokenCache.clear();
dispatchSpy = vi.spyOn(eventBus, 'emit');
fetchSpy = vi.spyOn(BaseResource, '_fetch' as any);
BaseResource.clerk = clerkMock() as any;
});

afterEach(() => {
dispatchSpy?.mockRestore();
fetchSpy?.mockRestore();
BaseResource.clerk = null as any;
});

it('should retry with expired token when API returns 422 with missing_expired_token error', 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);

SessionTokenCache.clear();

const errorResponse = new ClerkAPIResponseError('Missing expired token', {
data: [
{ code: 'missing_expired_token', message: 'Missing expired token', long_message: 'Missing expired token' },
],
status: 422,
});
fetchSpy.mockRejectedValueOnce(errorResponse);

fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt });

await session.getToken();

expect(fetchSpy).toHaveBeenCalledTimes(2);

expect(fetchSpy.mock.calls[0][0]).toMatchObject({
path: '/client/sessions/session_1/tokens',
method: 'POST',
body: { organizationId: null },
});

expect(fetchSpy.mock.calls[1][0]).toMatchObject({
path: '/client/sessions/session_1/tokens',
method: 'POST',
body: { organizationId: null },
search: { expired_token: mockJwt },
});
});

it('should not retry with expired token when lastActiveToken is not available', async () => {
const session = new Session({
status: 'active',
id: 'session_1',
object: 'session',
user: createUser({}),
last_active_organization_id: null,
last_active_token: null,
actor: null,
created_at: new Date().getTime(),
updated_at: new Date().getTime(),
} as unknown as SessionJSON);

SessionTokenCache.clear();

const errorResponse = new ClerkAPIResponseError('Missing expired token', {
data: [
{ code: 'missing_expired_token', message: 'Missing expired token', long_message: 'Missing expired token' },
],
status: 422,
});
fetchSpy.mockRejectedValue(errorResponse);

await expect(session.getToken()).rejects.toMatchObject({
status: 422,
errors: [{ code: 'missing_expired_token' }],
});

expect(fetchSpy).toHaveBeenCalledTimes(1);
});

it('should not retry with expired token for non-422 errors', 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);

SessionTokenCache.clear();

const errorResponse = new ClerkAPIResponseError('Bad request', {
data: [{ code: 'bad_request', message: 'Bad request', long_message: 'Bad request' }],
status: 400,
});
fetchSpy.mockRejectedValueOnce(errorResponse);

await expect(session.getToken()).rejects.toThrow(ClerkAPIResponseError);

expect(fetchSpy).toHaveBeenCalledTimes(1);
});

it('should not retry with expired token when error code is different', 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 unknown as SessionJSON);

SessionTokenCache.clear();

const errorResponse = new ClerkAPIResponseError('Validation failed', {
data: [{ code: 'validation_error', message: 'Validation failed', long_message: 'Validation failed' }],
status: 422,
});
fetchSpy.mockRejectedValue(errorResponse);

await expect(session.getToken()).rejects.toMatchObject({
status: 422,
errors: [{ code: 'validation_error' }],
});

expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
});
45 changes: 43 additions & 2 deletions packages/clerk-js/src/core/resources/__tests__/Token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ describe('Token', () => {
mockFetch(true, 200, { jwt: mockJwt });
BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any;

await Token.create('/path/to/tokens', {}, true);
await Token.create('/path/to/tokens', {}, { debug: 'skip_cache' });

const [url] = (global.fetch as Mock).mock.calls[0];
expect(url.toString()).toContain('debug=skip_cache');
Expand All @@ -147,10 +147,51 @@ describe('Token', () => {
mockFetch(true, 200, { jwt: mockJwt });
BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any;

await Token.create('/path/to/tokens', {}, false);
await Token.create('/path/to/tokens', {});

const [url] = (global.fetch as Mock).mock.calls[0];
expect(url.toString()).not.toContain('debug=skip_cache');
});
});

describe('create with search parameters', () => {
afterEach(() => {
(global.fetch as Mock)?.mockClear();
BaseResource.clerk = null as any;
});

it('should include search parameters in the API request', async () => {
mockFetch(true, 200, { object: 'token', jwt: mockJwt });
BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any;

await Token.create('/path/to/tokens', {}, { expired_token: 'some_expired_token' });

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(url.toString()).toContain('expired_token=some_expired_token');
expect(options).toMatchObject({
method: 'POST',
credentials: 'include',
headers: expect.any(Headers),
});
});

it('should work without search parameters (backward compatibility)', async () => {
mockFetch(true, 200, { object: 'token', jwt: mockJwt });
BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any;

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),
});
});
});
});
1 change: 1 addition & 0 deletions packages/shared/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { errorToJSON, parseError, parseErrors } from './errors/parseError';
export { ClerkAPIError, isClerkAPIError } from './errors/clerkApiError';
export { ClerkAPIResponseError, isClerkAPIResponseError } from './errors/clerkApiResponseError';
export { ClerkError, isClerkError } from './errors/clerkError';
export { MissingExpiredTokenError } from './errors/missingExpiredTokenError';

export { buildErrorThrower, type ErrorThrower, type ErrorThrowerOptions } from './errors/errorThrower';

Expand Down
45 changes: 45 additions & 0 deletions packages/shared/src/errors/missingExpiredTokenError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ClerkAPIResponseError, isClerkAPIResponseError } from './clerkApiResponseError';

/**
* Error class representing a missing expired token error from the API.
* This error occurs when the server requires an expired token to mint a new session token.
*
* Use the static `is` method to check if a ClerkAPIResponseError matches this error type.
*
* @example
* ```typescript
* if (MissingExpiredTokenError.is(error)) {
* // Handle the missing expired token error
* }
* ```
*/
export class MissingExpiredTokenError extends ClerkAPIResponseError {
static kind = 'MissingExpiredTokenError';
static readonly ERROR_CODE = 'missing_expired_token' as const;
static readonly STATUS = 422 as const;

/**
* Type guard to check if an error is a MissingExpiredTokenError.
* This checks the error's properties (status and error code) rather than instanceof,
* allowing it to work with ClerkAPIResponseError instances thrown from the API layer.
*
* @example
* ```typescript
* try {
* await someApiCall();
* } catch (e) {
* if (MissingExpiredTokenError.is(e)) {
* // e is typed as ClerkAPIResponseError with the specific error properties
* }
* }
* ```
*/
static is(err: unknown): err is ClerkAPIResponseError {
return (
isClerkAPIResponseError(err) &&
err.status === MissingExpiredTokenError.STATUS &&
err.errors.length > 0 &&
err.errors[0].code === MissingExpiredTokenError.ERROR_CODE
);
}
}
Loading