Skip to content

Commit ede3e2a

Browse files
authored
feat(backend, clerk-js): Support origin outage mode on Core 2 (#7516)
1 parent 03dd374 commit ede3e2a

File tree

10 files changed

+292
-9
lines changed

10 files changed

+292
-9
lines changed

.changeset/chatty-tigers-see.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/backend': minor
4+
---
5+
6+
Improves resilience by keeping users logged in when Clerk's origin is temporarily unavailable using edge-based token generation

packages/backend/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const QueryParameters = {
3636
HandshakeReason: '__clerk_hs_reason',
3737
HandshakeNonce: Cookies.HandshakeNonce,
3838
HandshakeFormat: 'format',
39+
Session: '__session',
3940
} as const;
4041

4142
const Headers = {

packages/backend/src/tokens/__tests__/handshake.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,40 @@ describe('HandshakeService', () => {
431431
expect(url.searchParams.get(constants.QueryParameters.SuffixedCookies)).toMatch(/^(true|false)$/);
432432
expect(url.searchParams.get(constants.QueryParameters.HandshakeReason)).toBe('test-reason');
433433
});
434+
435+
it('should include session token in handshake URL when session token is present', () => {
436+
const contextWithSession = {
437+
...mockAuthenticateContext,
438+
sessionToken: 'test_session_token_123',
439+
} as AuthenticateContext;
440+
const serviceWithSession = new HandshakeService(contextWithSession, mockOptions, mockOrganizationMatcher);
441+
442+
const headers = serviceWithSession.buildRedirectToHandshake('test-reason');
443+
const location = headers.get(constants.Headers.Location);
444+
if (!location) {
445+
throw new Error('Location header is missing');
446+
}
447+
const url = new URL(location);
448+
449+
expect(url.searchParams.get(constants.Cookies.Session)).toBe('test_session_token_123');
450+
});
451+
452+
it('should not include session token in handshake URL when session token is absent', () => {
453+
const contextWithoutSession = {
454+
...mockAuthenticateContext,
455+
sessionToken: undefined,
456+
} as AuthenticateContext;
457+
const serviceWithoutSession = new HandshakeService(contextWithoutSession, mockOptions, mockOrganizationMatcher);
458+
459+
const headers = serviceWithoutSession.buildRedirectToHandshake('test-reason');
460+
const location = headers.get(constants.Headers.Location);
461+
if (!location) {
462+
throw new Error('Location header is missing');
463+
}
464+
const url = new URL(location);
465+
466+
expect(url.searchParams.get(constants.Cookies.Session)).toBeNull();
467+
});
434468
});
435469

436470
describe('handleTokenVerificationErrorInDevelopment', () => {

packages/backend/src/tokens/handshake.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ export class HandshakeService {
149149
url.searchParams.append(constants.QueryParameters.HandshakeReason, reason);
150150
url.searchParams.append(constants.QueryParameters.HandshakeFormat, 'nonce');
151151

152+
if (this.authenticateContext.sessionToken) {
153+
url.searchParams.append(constants.QueryParameters.Session, this.authenticateContext.sessionToken);
154+
}
155+
152156
if (this.authenticateContext.instanceType === 'development' && this.authenticateContext.devBrowserToken) {
153157
url.searchParams.append(constants.QueryParameters.DevBrowser, this.authenticateContext.devBrowserToken);
154158
}

packages/clerk-js/src/core/resources/Session.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createCheckAuthorization } from '@clerk/shared/authorization';
2-
import { ClerkWebAuthnError, is4xxError } from '@clerk/shared/error';
2+
import { ClerkWebAuthnError, is4xxError, MissingExpiredTokenError } from '@clerk/shared/error';
33
import { retry } from '@clerk/shared/retry';
44
import type {
55
ActClaim,
@@ -399,9 +399,14 @@ export class Session extends BaseResource implements SessionResource {
399399
// TODO: update template endpoint to accept organizationId
400400
const params: Record<string, string | null> = template ? {} : { organizationId };
401401

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

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

407412
return tokenResolver.then(token => {

packages/clerk-js/src/core/resources/Token.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,11 @@ export class Token extends BaseResource implements TokenResource {
99

1010
jwt?: JWT;
1111

12-
static async create(path: string, body: any = {}, skipCache = false): Promise<TokenResource> {
13-
const search = skipCache ? `debug=skip_cache` : undefined;
14-
12+
static async create(path: string, body: any = {}, search: Record<string, string> = {}): Promise<TokenResource> {
1513
const json = (await BaseResource._fetch<TokenJSON>({
16-
body,
1714
method: 'POST',
1815
path,
16+
body,
1917
search,
2018
})) as unknown as TokenJSON;
2119

packages/clerk-js/src/core/resources/__tests__/Session.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ClerkAPIResponseError } from '@clerk/shared/error';
12
import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/shared/types';
23
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
34

@@ -1085,4 +1086,151 @@ describe('Session', () => {
10851086
expect(isAuthorized).toBe(true);
10861087
});
10871088
});
1089+
1090+
describe('origin outage mode fallback', () => {
1091+
let dispatchSpy: ReturnType<typeof vi.spyOn>;
1092+
let fetchSpy: ReturnType<typeof vi.spyOn>;
1093+
1094+
beforeEach(() => {
1095+
SessionTokenCache.clear();
1096+
dispatchSpy = vi.spyOn(eventBus, 'emit');
1097+
fetchSpy = vi.spyOn(BaseResource, '_fetch' as any);
1098+
BaseResource.clerk = clerkMock() as any;
1099+
});
1100+
1101+
afterEach(() => {
1102+
dispatchSpy?.mockRestore();
1103+
fetchSpy?.mockRestore();
1104+
BaseResource.clerk = null as any;
1105+
});
1106+
1107+
it('should retry with expired token when API returns 422 with missing_expired_token error', async () => {
1108+
const session = new Session({
1109+
status: 'active',
1110+
id: 'session_1',
1111+
object: 'session',
1112+
user: createUser({}),
1113+
last_active_organization_id: null,
1114+
last_active_token: { object: 'token', jwt: mockJwt },
1115+
actor: null,
1116+
created_at: new Date().getTime(),
1117+
updated_at: new Date().getTime(),
1118+
} as SessionJSON);
1119+
1120+
SessionTokenCache.clear();
1121+
1122+
const errorResponse = new ClerkAPIResponseError('Missing expired token', {
1123+
data: [
1124+
{ code: 'missing_expired_token', message: 'Missing expired token', long_message: 'Missing expired token' },
1125+
],
1126+
status: 422,
1127+
});
1128+
fetchSpy.mockRejectedValueOnce(errorResponse);
1129+
1130+
fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt });
1131+
1132+
await session.getToken();
1133+
1134+
expect(fetchSpy).toHaveBeenCalledTimes(2);
1135+
1136+
expect(fetchSpy.mock.calls[0][0]).toMatchObject({
1137+
path: '/client/sessions/session_1/tokens',
1138+
method: 'POST',
1139+
body: { organizationId: null },
1140+
});
1141+
1142+
expect(fetchSpy.mock.calls[1][0]).toMatchObject({
1143+
path: '/client/sessions/session_1/tokens',
1144+
method: 'POST',
1145+
body: { organizationId: null },
1146+
search: { expired_token: mockJwt },
1147+
});
1148+
});
1149+
1150+
it('should not retry with expired token when lastActiveToken is not available', async () => {
1151+
const session = new Session({
1152+
status: 'active',
1153+
id: 'session_1',
1154+
object: 'session',
1155+
user: createUser({}),
1156+
last_active_organization_id: null,
1157+
last_active_token: null,
1158+
actor: null,
1159+
created_at: new Date().getTime(),
1160+
updated_at: new Date().getTime(),
1161+
} as unknown as SessionJSON);
1162+
1163+
SessionTokenCache.clear();
1164+
1165+
const errorResponse = new ClerkAPIResponseError('Missing expired token', {
1166+
data: [
1167+
{ code: 'missing_expired_token', message: 'Missing expired token', long_message: 'Missing expired token' },
1168+
],
1169+
status: 422,
1170+
});
1171+
fetchSpy.mockRejectedValue(errorResponse);
1172+
1173+
await expect(session.getToken()).rejects.toMatchObject({
1174+
status: 422,
1175+
errors: [{ code: 'missing_expired_token' }],
1176+
});
1177+
1178+
expect(fetchSpy).toHaveBeenCalledTimes(1);
1179+
});
1180+
1181+
it('should not retry with expired token for non-422 errors', async () => {
1182+
const session = new Session({
1183+
status: 'active',
1184+
id: 'session_1',
1185+
object: 'session',
1186+
user: createUser({}),
1187+
last_active_organization_id: null,
1188+
last_active_token: { object: 'token', jwt: mockJwt },
1189+
actor: null,
1190+
created_at: new Date().getTime(),
1191+
updated_at: new Date().getTime(),
1192+
} as SessionJSON);
1193+
1194+
SessionTokenCache.clear();
1195+
1196+
const errorResponse = new ClerkAPIResponseError('Bad request', {
1197+
data: [{ code: 'bad_request', message: 'Bad request', long_message: 'Bad request' }],
1198+
status: 400,
1199+
});
1200+
fetchSpy.mockRejectedValueOnce(errorResponse);
1201+
1202+
await expect(session.getToken()).rejects.toThrow(ClerkAPIResponseError);
1203+
1204+
expect(fetchSpy).toHaveBeenCalledTimes(1);
1205+
});
1206+
1207+
it('should not retry with expired token when error code is different', async () => {
1208+
const session = new Session({
1209+
status: 'active',
1210+
id: 'session_1',
1211+
object: 'session',
1212+
user: createUser({}),
1213+
last_active_organization_id: null,
1214+
last_active_token: { object: 'token', jwt: mockJwt },
1215+
actor: null,
1216+
created_at: new Date().getTime(),
1217+
updated_at: new Date().getTime(),
1218+
} as unknown as SessionJSON);
1219+
1220+
SessionTokenCache.clear();
1221+
1222+
const errorResponse = new ClerkAPIResponseError('Validation failed', {
1223+
data: [{ code: 'validation_error', message: 'Validation failed', long_message: 'Validation failed' }],
1224+
status: 422,
1225+
});
1226+
fetchSpy.mockRejectedValue(errorResponse);
1227+
1228+
await expect(session.getToken()).rejects.toMatchObject({
1229+
status: 422,
1230+
errors: [{ code: 'validation_error' }],
1231+
});
1232+
1233+
expect(fetchSpy).toHaveBeenCalledTimes(1);
1234+
});
1235+
});
10881236
});

packages/clerk-js/src/core/resources/__tests__/Token.test.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ describe('Token', () => {
137137
mockFetch(true, 200, { jwt: mockJwt });
138138
BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any;
139139

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

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

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

152152
const [url] = (global.fetch as Mock).mock.calls[0];
153153
expect(url.toString()).not.toContain('debug=skip_cache');
154154
});
155155
});
156+
157+
describe('create with search parameters', () => {
158+
afterEach(() => {
159+
(global.fetch as Mock)?.mockClear();
160+
BaseResource.clerk = null as any;
161+
});
162+
163+
it('should include search parameters in the API request', async () => {
164+
mockFetch(true, 200, { object: 'token', jwt: mockJwt });
165+
BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any;
166+
167+
await Token.create('/path/to/tokens', {}, { expired_token: 'some_expired_token' });
168+
169+
expect(global.fetch).toHaveBeenCalledTimes(1);
170+
const [url, options] = (global.fetch as Mock).mock.calls[0];
171+
expect(url.toString()).toContain('https://clerk.example.com/v1/path/to/tokens');
172+
expect(url.toString()).toContain('expired_token=some_expired_token');
173+
expect(options).toMatchObject({
174+
method: 'POST',
175+
credentials: 'include',
176+
headers: expect.any(Headers),
177+
});
178+
});
179+
180+
it('should work without search parameters (backward compatibility)', async () => {
181+
mockFetch(true, 200, { object: 'token', jwt: mockJwt });
182+
BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any;
183+
184+
await Token.create('/path/to/tokens');
185+
186+
expect(global.fetch).toHaveBeenCalledTimes(1);
187+
const [url, options] = (global.fetch as Mock).mock.calls[0];
188+
expect(url.toString()).toContain('https://clerk.example.com/v1/path/to/tokens');
189+
expect(options).toMatchObject({
190+
method: 'POST',
191+
body: '',
192+
credentials: 'include',
193+
headers: expect.any(Headers),
194+
});
195+
});
196+
});
156197
});

packages/shared/src/error.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { errorToJSON, parseError, parseErrors } from './errors/parseError';
33
export { ClerkAPIError, isClerkAPIError } from './errors/clerkApiError';
44
export { ClerkAPIResponseError, isClerkAPIResponseError } from './errors/clerkApiResponseError';
55
export { ClerkError, isClerkError } from './errors/clerkError';
6+
export { MissingExpiredTokenError } from './errors/missingExpiredTokenError';
67

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { ClerkAPIResponseError, isClerkAPIResponseError } from './clerkApiResponseError';
2+
3+
/**
4+
* Error class representing a missing expired token error from the API.
5+
* This error occurs when the server requires an expired token to mint a new session token.
6+
*
7+
* Use the static `is` method to check if a ClerkAPIResponseError matches this error type.
8+
*
9+
* @example
10+
* ```typescript
11+
* if (MissingExpiredTokenError.is(error)) {
12+
* // Handle the missing expired token error
13+
* }
14+
* ```
15+
*/
16+
export class MissingExpiredTokenError extends ClerkAPIResponseError {
17+
static kind = 'MissingExpiredTokenError';
18+
static readonly ERROR_CODE = 'missing_expired_token' as const;
19+
static readonly STATUS = 422 as const;
20+
21+
/**
22+
* Type guard to check if an error is a MissingExpiredTokenError.
23+
* This checks the error's properties (status and error code) rather than instanceof,
24+
* allowing it to work with ClerkAPIResponseError instances thrown from the API layer.
25+
*
26+
* @example
27+
* ```typescript
28+
* try {
29+
* await someApiCall();
30+
* } catch (e) {
31+
* if (MissingExpiredTokenError.is(e)) {
32+
* // e is typed as ClerkAPIResponseError with the specific error properties
33+
* }
34+
* }
35+
* ```
36+
*/
37+
static is(err: unknown): err is ClerkAPIResponseError {
38+
return (
39+
isClerkAPIResponseError(err) &&
40+
err.status === MissingExpiredTokenError.STATUS &&
41+
err.errors.length > 0 &&
42+
err.errors[0].code === MissingExpiredTokenError.ERROR_CODE
43+
);
44+
}
45+
}

0 commit comments

Comments
 (0)