From 4355bca848a54dfb6f5680737e14fdf32c3df56a 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 + .../src/tokens/__tests__/handshake.test.ts | 34 ++++ packages/backend/src/tokens/handshake.ts | 4 + .../clerk-js/src/core/resources/Session.ts | 17 +- packages/clerk-js/src/core/resources/Token.ts | 6 +- .../core/resources/__tests__/Session.test.ts | 148 ++++++++++++++++++ .../core/resources/__tests__/Token.test.ts | 41 +++++ 7 files changed, 248 insertions(+), 8 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/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..005644b0881 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.Cookies.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 7ac829ca520..2d6dfccf282 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 { ClerkAPIResponseError, ClerkWebAuthnError, is4xxError } from '@clerk/shared/error'; import { retry } from '@clerk/shared/retry'; import type { ActClaim, @@ -405,9 +405,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..dd7439441b9 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Token.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Token.test.ts @@ -153,4 +153,45 @@ describe('Token', () => { 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 c76297cb5cbcf15f20222cd71fa51047d364b102 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Fri, 21 Nov 2025 18:15:29 +0100 Subject: [PATCH 2/4] fix(clerk-js): Add fallback to get last known session token for origin outage mode --- packages/clerk-js/src/core/clerk.ts | 4 ++ .../clerk-js/src/core/resources/Session.ts | 5 +- .../core/resources/__tests__/Session.test.ts | 52 ++++++++++++++++++- packages/shared/src/types/clerk.ts | 8 +++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 6353d4c09d3..2c147dd1541 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2386,6 +2386,10 @@ export class Clerk implements ClerkInterface { } }; + __internal_getSessionCookie = (): string | undefined => { + return this.#authService?.getSessionCookie(); + }; + get __internal_last_error(): ClerkAPIError | null { const value = this.internal_last_error; this.internal_last_error = null; diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 2d6dfccf282..165081cd2d7 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -404,6 +404,7 @@ export class Session extends BaseResource implements SessionResource { // TODO: update template endpoint to accept organizationId const params: Record = template ? {} : { organizationId }; + const lastActiveToken = this.lastActiveToken?.getRawString() ?? Session.clerk.__internal_getSessionCookie?.(); const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { if ( @@ -411,9 +412,9 @@ export class Session extends BaseResource implements SessionResource { e.status === 422 && e.errors.length > 0 && e.errors[0].code === 'missing_expired_token' && - this.lastActiveToken + lastActiveToken ) { - return Token.create(path, { ...params }, { expired_token: this.lastActiveToken.getRawString() }); + return Token.create(path, { ...params }, { expired_token: lastActiveToken }); } throw e; }); 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 eb51257191d..4a1cd8746a2 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1147,7 +1147,56 @@ describe('Session', () => { }); }); - it('should not retry with expired token when lastActiveToken is not available', async () => { + it('should retry with cookie token when lastActiveToken is not available but cookie exists', async () => { + const mockGetSessionCookie = vi.fn().mockReturnValue(mockJwt); + BaseResource.clerk.__internal_getSessionCookie = mockGetSessionCookie; + + 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.mockRejectedValueOnce(errorResponse); + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken(); + + expect(mockGetSessionCookie).toHaveBeenCalled(); + 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 neither lastActiveToken nor cookie are available', async () => { + const mockGetSessionCookie = vi.fn().mockReturnValue(undefined); + BaseResource.clerk.__internal_getSessionCookie = mockGetSessionCookie; + const session = new Session({ status: 'active', id: 'session_1', @@ -1175,6 +1224,7 @@ describe('Session', () => { errors: [{ code: 'missing_expired_token' }], }); + expect(mockGetSessionCookie).toHaveBeenCalled(); expect(fetchSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index b37e7d0a8a8..aaf2d6c7754 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -921,6 +921,14 @@ export interface Clerk { */ __internal_setActiveInProgress: boolean; + /** + * Retrieves the session token from the browser cookie. + * This is useful as a fallback when the in-memory lastActiveToken is not available. + * + * @internal + */ + __internal_getSessionCookie?: () => string | undefined; + /** * API Keys Object * From 4efc21151cc74742857a4eaa782ad922dc79c2ad Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Fri, 21 Nov 2025 18:45:29 +0100 Subject: [PATCH 3/4] fixup! feat(backend,clerk-js): Support origin outage mode --- packages/clerk-js/src/core/resources/__tests__/Token.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 dd7439441b9..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,7 +147,7 @@ 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'); From 113c5c092d67cd5e708ad47dfd6b93838655328b Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Tue, 25 Nov 2025 20:21:38 +0100 Subject: [PATCH 4/4] wip --- integration/presets/envs.ts | 8 + integration/presets/longRunningApps.ts | 1 + .../outage-mode-frontend-refresh.test.ts | 95 ++++++++++++ .../tests/outage-mode-handshake.test.ts | 144 ++++++++++++++++++ package.json | 1 + packages/backend/src/tokens/request.ts | 10 +- packages/backend/src/tokens/types.ts | 4 + turbo.json | 12 ++ 8 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 integration/tests/outage-mode-frontend-refresh.test.ts create mode 100644 integration/tests/outage-mode-handshake.test.ts diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index d52dfeb8aab..177c3ceddf6 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -191,6 +191,13 @@ const withProtectService = base .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-protect-service').sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-protect-service').pk); +const withOutageMode = base + .clone() + .setId('withOutageMode') + .setEnvVariable('private', 'ORIGIN_OUTAGE_MODE_OPT_IN_SECRET', '9e890823') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-email-codes').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk); + export const envs = { base, sessionsProd1, @@ -219,4 +226,5 @@ export const envs = { withWaitlistdMode, withWhatsappPhoneCode, withProtectService, + withOutageMode, } as const; diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index ebca49ac50a..90a303445cc 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -32,6 +32,7 @@ export const createLongRunningApps = () => { { id: 'next.appRouter.withSignInOrUpEmailLinksFlow', config: next.appRouter, env: envs.withSignInOrUpEmailLinksFlow }, { id: 'next.appRouter.withSessionTasks', config: next.appRouter, env: envs.withSessionTasks }, { id: 'next.appRouter.withLegalConsent', config: next.appRouter, env: envs.withLegalConsent }, + { id: 'next.appRouter.withEmailCodesOutageMode', config: next.appRouter, env: envs.withOutageMode }, /** * Quickstart apps diff --git a/integration/tests/outage-mode-frontend-refresh.test.ts b/integration/tests/outage-mode-frontend-refresh.test.ts new file mode 100644 index 00000000000..a58c9ea008a --- /dev/null +++ b/integration/tests/outage-mode-frontend-refresh.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +const ORIGIN_OUTAGE_MODE_OPT_IN_HEADER = 'X-Clerk-Origin-Outage-Mode-Opt-In'; + +const OPT_IN_HEADER_SECRET = process.env.ORIGIN_OUTAGE_MODE_OPT_IN_SECRET; + +function isEdgeGeneratedToken(token: string): boolean { + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); + return payload._cs === 'e'; +} + +testAgainstRunningApps({ + withEnv: [appConfigs.envs.withOutageMode], +})('Frontend - Session Token Refresh (Outage Mode) @outage-mode', ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPassword: true, + }); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser?.deleteIfExists(); + }); + + test('token refresh: should get new token from origin in normal mode', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.po.expect.toBeSignedIn(); + + await page.evaluate(async () => { + const clerk = (window as any).Clerk; + if (clerk?.session?.getToken) { + await clerk.session.getToken({ skipCache: true }); + } + }); + + const refreshedToken = await page.evaluate(() => { + return (window as any).Clerk?.session?.lastActiveToken?.getRawString(); + }); + + expect(refreshedToken).toBeTruthy(); + expect(isEdgeGeneratedToken(refreshedToken as string)).toBe(false); + }); + + test('token refresh: should get new token from proxy with opt-in header', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.po.expect.toBeSignedIn(); + + await page.route('**/sessions/*/tokens*', async (route, request) => { + const headers = { + ...request.headers(), + [ORIGIN_OUTAGE_MODE_OPT_IN_HEADER]: OPT_IN_HEADER_SECRET, + }; + await route.continue({ headers }); + }); + + await page.evaluate(async () => { + const clerk = (window as any).Clerk; + if (clerk?.session?.getToken) { + await clerk.session.getToken({ skipCache: true }); + } + }); + + await page.unroute('**/sessions/*/tokens*'); + + const refreshedToken = await page.evaluate(() => { + return (window as any).Clerk?.session?.lastActiveToken?.getRawString(); + }); + + expect(refreshedToken).toBeTruthy(); + expect(isEdgeGeneratedToken(refreshedToken as string)).toBe(true); + }); +}); diff --git a/integration/tests/outage-mode-handshake.test.ts b/integration/tests/outage-mode-handshake.test.ts new file mode 100644 index 00000000000..6b9c455135a --- /dev/null +++ b/integration/tests/outage-mode-handshake.test.ts @@ -0,0 +1,144 @@ +import type { BrowserContext } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +const ORIGIN_OUTAGE_MODE_OPT_IN_HEADER = 'X-Clerk-Origin-Outage-Mode-Opt-In'; + +const OPT_IN_HEADER_SECRET = process.env.ORIGIN_OUTAGE_MODE_OPT_IN_SECRET; + +function isEdgeGeneratedToken(token: string): boolean { + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); + return payload._cs === 'e'; +} + +/** + * Clear cookies to trigger handshake flow. + * + * In DEVELOPMENT mode (pk_test_* keys), we need to clear: + * - __client_uat, __client_uat_{suffix} + * - __refresh_{suffix} + * + * In PRODUCTION mode, we would clear: + * - __client_uat, __client_uat_{suffix} + * - __session, __session_{suffix} + * - __refresh_{suffix} + * + * Since integration tests run against dev instances, we use the dev mode approach. + */ +async function clearCookiesForHandshake(context: BrowserContext): Promise { + const cookies = await context.cookies(); + let suffix = null; + + ['__client_uat', '__refresh', '__session'].forEach(cookieName => { + const cookie = cookies.find(cookie => cookie.name === cookieName); + if (cookie) { + suffix = cookie.name.match(new RegExp(`^${cookieName}_(.+)$`))?.[1] || null; + } + }); + + await context.clearCookies({ name: '__client_uat' }); + + if (suffix) { + await context.clearCookies({ name: `__client_uat_${suffix}` }); + await context.clearCookies({ name: `__refresh_${suffix}` }); + } +} + +testAgainstRunningApps({ + withEnv: [appConfigs.envs.withOutageMode], +})('Handshake Flow (Outage Mode) @outage-mode', ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPassword: true, + }); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser?.deleteIfExists(); + }); + + test('handshake: should recover session via origin in normal mode', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.po.expect.toBeSignedIn(); + + const initialSessionId = await page.evaluate(() => { + return (window as any).Clerk?.session?.id; + }); + + await clearCookiesForHandshake(context); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + + const sessionToken = await page.evaluate(() => { + return (window as any).Clerk?.session?.lastActiveToken?.getRawString(); + }); + + const sessionId = await page.evaluate(() => { + return (window as any).Clerk?.session?.id; + }); + + await u.po.expect.toBeSignedIn(); + expect(sessionToken).toBeTruthy(); + expect(isEdgeGeneratedToken(sessionToken as string)).toBe(false); + expect(sessionId).toBe(initialSessionId); + }); + + test('handshake: should recover session via proxy with opt-in header', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.po.expect.toBeSignedIn(); + + const initialSessionId = await page.evaluate(() => { + return (window as any).Clerk?.session?.id; + }); + + await clearCookiesForHandshake(context); + + await page.route('**/*', async (route, request) => { + const headers = { + ...request.headers(), + [ORIGIN_OUTAGE_MODE_OPT_IN_HEADER]: OPT_IN_HEADER_SECRET, + }; + await route.continue({ headers }); + }); + + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + + await page.unroute('**/*'); + + const sessionToken = await page.evaluate(() => { + return (window as any).Clerk?.session?.lastActiveToken?.getRawString(); + }); + + const sessionId = await page.evaluate(() => { + return (window as any).Clerk?.session?.id; + }); + + await u.po.expect.toBeSignedIn(); + expect(sessionToken).toBeTruthy(); + expect(isEdgeGeneratedToken(sessionToken as string)).toBe(true); + expect(sessionId).toBe(initialSessionId); + }); +}); diff --git a/package.json b/package.json index 9d09bb226ed..cb4e56cb4d0 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "test:integration:machine": "E2E_APP_ID=withMachine.* pnpm test:integration:base --grep @machine", "test:integration:nextjs": "E2E_APP_ID=next.appRouter.* pnpm test:integration:base --grep @nextjs", "test:integration:nuxt": "E2E_APP_ID=nuxt.node npm run test:integration:base -- --grep @nuxt", + "test:integration:outage-mode": "E2E_APP_ID='next.appRouter.withOutageMode' pnpm test:integration:base --grep @outage-mode", "test:integration:quickstart": "E2E_APP_ID=quickstart.* pnpm test:integration:base --grep @quickstart", "test:integration:react-router": "E2E_APP_ID=react-router.* pnpm test:integration:base --grep @react-router", "test:integration:sessions": "DISABLE_WEB_SECURITY=true E2E_SESSIONS_APP_1_ENV_KEY=sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=sessions-prod-2 E2E_SESSIONS_APP_1_HOST=multiple-apps-e2e.clerk.app pnpm test:integration:base --grep @sessions", diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 1d2aaaa6d1e..30c36d0cc59 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -234,6 +234,14 @@ export const authenticateRequest: AuthenticateRequest = (async ( try { // Perform the actual token refresh. + const requestHeaders = new Headers(request.headers); + if (options.headers) { + const extraHeaders = new Headers(options.headers); + extraHeaders.forEach((value, key) => { + requestHeaders.append(key, value); + }); + } + const response = await options.apiClient.sessions.refreshSession(decodeResult.payload.sid, { format: 'cookie', suffixed_cookies: authenticateContext.usesSuffixedCookies(), @@ -241,7 +249,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( refresh_token: refreshToken || '', request_origin: authenticateContext.clerkUrl.origin, // The refresh endpoint expects headers as Record, so we need to transform it. - request_headers: Object.fromEntries(Array.from(request.headers.entries()).map(([k, v]) => [k, [v]])), + request_headers: Object.fromEntries(Array.from(requestHeaders.entries()).map(([k, v]) => [k, [v]])), }); return { data: response.cookies, error: null }; } catch (err: any) { diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index dab308d6d94..dae462d6ee9 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -58,6 +58,10 @@ export type AuthenticateRequestOptions = { * If the activation can't be performed, either because an organization doesn't exist or the user lacks access, the active organization in the session won't be changed. Ultimately, it's the responsibility of the page to verify that the resources are appropriate to render given the URL and handle mismatches appropriately (e.g., by returning a 404). */ organizationSyncOptions?: OrganizationSyncOptions; + /** + * Optional headers to be passed to the backend API. + */ + headers?: HeadersInit; /** * @internal */ diff --git a/turbo.json b/turbo.json index 172abe15e43..372f04bffde 100644 --- a/turbo.json +++ b/turbo.json @@ -374,6 +374,18 @@ "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"], "inputs": ["integration/**"], "outputLogs": "new-only" + }, + "//#test:integration:outage-mode": { + "dependsOn": [ + "@clerk/testing#build", + "@clerk/clerk-js#build", + "@clerk/backend#build", + "@clerk/nextjs#build", + "@clerk/clerk-react#build" + ], + "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS", "ORIGIN_OUTAGE_MODE_OPT_IN_SECRET"], + "inputs": ["integration/**"], + "outputLogs": "new-only" } } }