Skip to content
Draft
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
---

Adds support for origin outage mode
8 changes: 8 additions & 0 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -219,4 +226,5 @@ export const envs = {
withWaitlistdMode,
withWhatsappPhoneCode,
withProtectService,
withOutageMode,
} as const;
1 change: 1 addition & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions integration/tests/outage-mode-frontend-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
144 changes: 144 additions & 0 deletions integration/tests/outage-mode-handshake.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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.Cookies.Session, this.authenticateContext.sessionToken);
}

if (this.authenticateContext.instanceType === 'development' && this.authenticateContext.devBrowserToken) {
url.searchParams.append(constants.QueryParameters.DevBrowser, this.authenticateContext.devBrowserToken);
}
Expand Down
10 changes: 9 additions & 1 deletion packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,14 +234,22 @@ 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(),
expired_token: expiredSessionToken || '',
refresh_token: refreshToken || '',
request_origin: authenticateContext.clerkUrl.origin,
// The refresh endpoint expects headers as Record<string, string[]>, 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) {
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/tokens/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading