From 75c7f7b7beb2e4dce327f206c0bd669184c72f86 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Mon, 1 Sep 2025 15:13:06 +0200 Subject: [PATCH 1/4] feat(backend,clerk-js): Support origin outage mode --- .changeset/chatty-tigers-see.md | 6 + packages/backend/src/constants.ts | 1 + .../src/tokens/__tests__/handshake.test.ts | 34 ++++ packages/backend/src/tokens/handshake.ts | 4 + .../clerk-js/src/core/resources/Session.ts | 15 +- packages/clerk-js/src/core/resources/Token.ts | 6 +- .../core/resources/__tests__/Session.test.ts | 148 ++++++++++++++++++ .../core/resources/__tests__/Token.test.ts | 45 +++++- 8 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 .changeset/chatty-tigers-see.md diff --git a/.changeset/chatty-tigers-see.md b/.changeset/chatty-tigers-see.md new file mode 100644 index 00000000000..8aeca63900b --- /dev/null +++ b/.changeset/chatty-tigers-see.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/backend': minor +--- + +Adds support for origin outage mode diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index c79d0b340d3..ceaa1721d37 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -36,6 +36,7 @@ const QueryParameters = { HandshakeReason: '__clerk_hs_reason', HandshakeNonce: Cookies.HandshakeNonce, HandshakeFormat: 'format', + Session: '__session', } as const; const Headers = { diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts index f570867edba..4ee06f80a7b 100644 --- a/packages/backend/src/tokens/__tests__/handshake.test.ts +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -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', () => { diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index c19267e0506..8b0b79ee0b0 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -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); } diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ee5df2c43a1..5d16189f343 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -399,9 +399,18 @@ export class Session extends BaseResource implements SessionResource { // TODO: update template endpoint to accept organizationId const params: Record = template ? {} : { organizationId }; - const tokenResolver = Token.create(path, params, skipCache); - - // 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 ( + e instanceof ClerkAPIResponseError && + e.status === 422 && + e.errors.length > 0 && + e.errors[0].code === 'missing_expired_token' && + this.lastActiveToken + ) { + return Token.create(path, { ...params }, { expired_token: this.lastActiveToken.getRawString() }); + } + throw e; + }); SessionTokenCache.set({ tokenId, tokenResolver }); return tokenResolver.then(token => { diff --git a/packages/clerk-js/src/core/resources/Token.ts b/packages/clerk-js/src/core/resources/Token.ts index 2b75d8b3c52..dcdf885fe6c 100644 --- a/packages/clerk-js/src/core/resources/Token.ts +++ b/packages/clerk-js/src/core/resources/Token.ts @@ -9,13 +9,11 @@ export class Token extends BaseResource implements TokenResource { jwt?: JWT; - static async create(path: string, body: any = {}, skipCache = false): Promise { - const search = skipCache ? `debug=skip_cache` : undefined; - + static async create(path: string, body: any = {}, search: Record = {}): Promise { const json = (await BaseResource._fetch({ - body, method: 'POST', path, + body, search, })) as unknown as TokenJSON; 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 4de20208046..eb51257191d 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -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'; @@ -1085,4 +1086,151 @@ describe('Session', () => { expect(isAuthorized).toBe(true); }); }); + + describe('origin outage mode fallback', () => { + let dispatchSpy: ReturnType; + let fetchSpy: ReturnType; + + 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); + }); + }); }); 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 bd1830cb4e5..d4738734267 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Token.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Token.test.ts @@ -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'); @@ -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), + }); + }); + }); }); From 7e8769e9a6c5d7bf24a2d28a4a0e15e2a54289ef Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Wed, 26 Nov 2025 13:51:12 +0100 Subject: [PATCH 2/4] fix(clerk-js): Add helper subclass for missing token error --- .../clerk-js/src/core/resources/Session.ts | 14 +++--- packages/shared/src/error.ts | 1 + .../src/errors/missingExpiredTokenError.ts | 45 +++++++++++++++++++ 3 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 packages/shared/src/errors/missingExpiredTokenError.ts diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 5d16189f343..aeec7d47914 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -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, @@ -399,15 +399,11 @@ export class Session extends BaseResource implements SessionResource { // TODO: update template endpoint to accept organizationId const params: Record = template ? {} : { organizationId }; + const lastActiveToken = this.lastActiveToken?.getRawString(); + const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { - if ( - e instanceof ClerkAPIResponseError && - e.status === 422 && - e.errors.length > 0 && - e.errors[0].code === 'missing_expired_token' && - this.lastActiveToken - ) { - return Token.create(path, { ...params }, { expired_token: this.lastActiveToken.getRawString() }); + if (MissingExpiredTokenError.is(e) && lastActiveToken) { + return Token.create(path, { ...params }, { expired_token: lastActiveToken }); } throw e; }); diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 3f034067234..328a363015e 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -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'; diff --git a/packages/shared/src/errors/missingExpiredTokenError.ts b/packages/shared/src/errors/missingExpiredTokenError.ts new file mode 100644 index 00000000000..062403b2a6b --- /dev/null +++ b/packages/shared/src/errors/missingExpiredTokenError.ts @@ -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 + ); + } +} From 8b033f07df3638d25de8bbc774a62d34325b4bc5 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Fri, 19 Dec 2025 19:13:41 +0100 Subject: [PATCH 3/4] fixup! feat(backend,clerk-js): Support origin outage mode --- .changeset/chatty-tigers-see.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/chatty-tigers-see.md b/.changeset/chatty-tigers-see.md index 8aeca63900b..2b2976a03b1 100644 --- a/.changeset/chatty-tigers-see.md +++ b/.changeset/chatty-tigers-see.md @@ -3,4 +3,4 @@ '@clerk/backend': minor --- -Adds support for origin outage mode +Improves resilience by keeping users logged in when Clerk's origin is temporarily unavailable using edge-based token generation From 981749f98333ab1ec7f61c357ccd6835b2666520 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Fri, 19 Dec 2025 19:37:03 +0100 Subject: [PATCH 4/4] fixup! fix(clerk-js): Add helper subclass for missing token error --- packages/shared/src/errors/missingExpiredTokenError.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/errors/missingExpiredTokenError.ts b/packages/shared/src/errors/missingExpiredTokenError.ts index 062403b2a6b..2b3d15396e0 100644 --- a/packages/shared/src/errors/missingExpiredTokenError.ts +++ b/packages/shared/src/errors/missingExpiredTokenError.ts @@ -1,4 +1,4 @@ -import { ClerkAPIResponseError, isClerkApiResponseError } from './clerkApiResponseError'; +import { ClerkAPIResponseError, isClerkAPIResponseError } from './clerkApiResponseError'; /** * Error class representing a missing expired token error from the API. @@ -36,7 +36,7 @@ export class MissingExpiredTokenError extends ClerkAPIResponseError { */ static is(err: unknown): err is ClerkAPIResponseError { return ( - isClerkApiResponseError(err) && + isClerkAPIResponseError(err) && err.status === MissingExpiredTokenError.STATUS && err.errors.length > 0 && err.errors[0].code === MissingExpiredTokenError.ERROR_CODE