diff --git a/.changeset/chatty-tigers-see.md b/.changeset/chatty-tigers-see.md new file mode 100644 index 00000000000..2b2976a03b1 --- /dev/null +++ b/.changeset/chatty-tigers-see.md @@ -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 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..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,9 +399,14 @@ 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); + 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 => { 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), + }); + }); + }); }); 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..2b3d15396e0 --- /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 + ); + } +}