Skip to content

Commit 113c5c0

Browse files
committed
wip
1 parent 4efc211 commit 113c5c0

File tree

8 files changed

+274
-1
lines changed

8 files changed

+274
-1
lines changed

integration/presets/envs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,13 @@ const withProtectService = base
191191
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-protect-service').sk)
192192
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-protect-service').pk);
193193

194+
const withOutageMode = base
195+
.clone()
196+
.setId('withOutageMode')
197+
.setEnvVariable('private', 'ORIGIN_OUTAGE_MODE_OPT_IN_SECRET', '9e890823')
198+
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-email-codes').sk)
199+
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk);
200+
194201
export const envs = {
195202
base,
196203
sessionsProd1,
@@ -219,4 +226,5 @@ export const envs = {
219226
withWaitlistdMode,
220227
withWhatsappPhoneCode,
221228
withProtectService,
229+
withOutageMode,
222230
} as const;

integration/presets/longRunningApps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const createLongRunningApps = () => {
3232
{ id: 'next.appRouter.withSignInOrUpEmailLinksFlow', config: next.appRouter, env: envs.withSignInOrUpEmailLinksFlow },
3333
{ id: 'next.appRouter.withSessionTasks', config: next.appRouter, env: envs.withSessionTasks },
3434
{ id: 'next.appRouter.withLegalConsent', config: next.appRouter, env: envs.withLegalConsent },
35+
{ id: 'next.appRouter.withEmailCodesOutageMode', config: next.appRouter, env: envs.withOutageMode },
3536

3637
/**
3738
* Quickstart apps
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { appConfigs } from '../presets';
4+
import type { FakeUser } from '../testUtils';
5+
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
6+
7+
const ORIGIN_OUTAGE_MODE_OPT_IN_HEADER = 'X-Clerk-Origin-Outage-Mode-Opt-In';
8+
9+
const OPT_IN_HEADER_SECRET = process.env.ORIGIN_OUTAGE_MODE_OPT_IN_SECRET;
10+
11+
function isEdgeGeneratedToken(token: string): boolean {
12+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
13+
return payload._cs === 'e';
14+
}
15+
16+
testAgainstRunningApps({
17+
withEnv: [appConfigs.envs.withOutageMode],
18+
})('Frontend - Session Token Refresh (Outage Mode) @outage-mode', ({ app }) => {
19+
test.describe.configure({ mode: 'parallel' });
20+
21+
let fakeUser: FakeUser;
22+
23+
test.beforeAll(async () => {
24+
const u = createTestUtils({ app });
25+
fakeUser = u.services.users.createFakeUser({
26+
fictionalEmail: true,
27+
withPassword: true,
28+
});
29+
await u.services.users.createBapiUser(fakeUser);
30+
});
31+
32+
test.afterAll(async () => {
33+
await fakeUser?.deleteIfExists();
34+
});
35+
36+
test('token refresh: should get new token from origin in normal mode', async ({ page, context }) => {
37+
const u = createTestUtils({ app, page, context });
38+
39+
await u.po.signIn.goTo();
40+
await u.po.signIn.signInWithEmailAndInstantPassword({
41+
email: fakeUser.email,
42+
password: fakeUser.password,
43+
});
44+
await u.po.expect.toBeSignedIn();
45+
46+
await page.evaluate(async () => {
47+
const clerk = (window as any).Clerk;
48+
if (clerk?.session?.getToken) {
49+
await clerk.session.getToken({ skipCache: true });
50+
}
51+
});
52+
53+
const refreshedToken = await page.evaluate(() => {
54+
return (window as any).Clerk?.session?.lastActiveToken?.getRawString();
55+
});
56+
57+
expect(refreshedToken).toBeTruthy();
58+
expect(isEdgeGeneratedToken(refreshedToken as string)).toBe(false);
59+
});
60+
61+
test('token refresh: should get new token from proxy with opt-in header', async ({ page, context }) => {
62+
const u = createTestUtils({ app, page, context });
63+
64+
await u.po.signIn.goTo();
65+
await u.po.signIn.signInWithEmailAndInstantPassword({
66+
email: fakeUser.email,
67+
password: fakeUser.password,
68+
});
69+
await u.po.expect.toBeSignedIn();
70+
71+
await page.route('**/sessions/*/tokens*', async (route, request) => {
72+
const headers = {
73+
...request.headers(),
74+
[ORIGIN_OUTAGE_MODE_OPT_IN_HEADER]: OPT_IN_HEADER_SECRET,
75+
};
76+
await route.continue({ headers });
77+
});
78+
79+
await page.evaluate(async () => {
80+
const clerk = (window as any).Clerk;
81+
if (clerk?.session?.getToken) {
82+
await clerk.session.getToken({ skipCache: true });
83+
}
84+
});
85+
86+
await page.unroute('**/sessions/*/tokens*');
87+
88+
const refreshedToken = await page.evaluate(() => {
89+
return (window as any).Clerk?.session?.lastActiveToken?.getRawString();
90+
});
91+
92+
expect(refreshedToken).toBeTruthy();
93+
expect(isEdgeGeneratedToken(refreshedToken as string)).toBe(true);
94+
});
95+
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { BrowserContext } from '@playwright/test';
2+
import { expect, test } from '@playwright/test';
3+
4+
import { appConfigs } from '../presets';
5+
import type { FakeUser } from '../testUtils';
6+
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
7+
8+
const ORIGIN_OUTAGE_MODE_OPT_IN_HEADER = 'X-Clerk-Origin-Outage-Mode-Opt-In';
9+
10+
const OPT_IN_HEADER_SECRET = process.env.ORIGIN_OUTAGE_MODE_OPT_IN_SECRET;
11+
12+
function isEdgeGeneratedToken(token: string): boolean {
13+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
14+
return payload._cs === 'e';
15+
}
16+
17+
/**
18+
* Clear cookies to trigger handshake flow.
19+
*
20+
* In DEVELOPMENT mode (pk_test_* keys), we need to clear:
21+
* - __client_uat, __client_uat_{suffix}
22+
* - __refresh_{suffix}
23+
*
24+
* In PRODUCTION mode, we would clear:
25+
* - __client_uat, __client_uat_{suffix}
26+
* - __session, __session_{suffix}
27+
* - __refresh_{suffix}
28+
*
29+
* Since integration tests run against dev instances, we use the dev mode approach.
30+
*/
31+
async function clearCookiesForHandshake(context: BrowserContext): Promise<void> {
32+
const cookies = await context.cookies();
33+
let suffix = null;
34+
35+
['__client_uat', '__refresh', '__session'].forEach(cookieName => {
36+
const cookie = cookies.find(cookie => cookie.name === cookieName);
37+
if (cookie) {
38+
suffix = cookie.name.match(new RegExp(`^${cookieName}_(.+)$`))?.[1] || null;
39+
}
40+
});
41+
42+
await context.clearCookies({ name: '__client_uat' });
43+
44+
if (suffix) {
45+
await context.clearCookies({ name: `__client_uat_${suffix}` });
46+
await context.clearCookies({ name: `__refresh_${suffix}` });
47+
}
48+
}
49+
50+
testAgainstRunningApps({
51+
withEnv: [appConfigs.envs.withOutageMode],
52+
})('Handshake Flow (Outage Mode) @outage-mode', ({ app }) => {
53+
test.describe.configure({ mode: 'parallel' });
54+
55+
let fakeUser: FakeUser;
56+
57+
test.beforeAll(async () => {
58+
const u = createTestUtils({ app });
59+
fakeUser = u.services.users.createFakeUser({
60+
fictionalEmail: true,
61+
withPassword: true,
62+
});
63+
await u.services.users.createBapiUser(fakeUser);
64+
});
65+
66+
test.afterAll(async () => {
67+
await fakeUser?.deleteIfExists();
68+
});
69+
70+
test('handshake: should recover session via origin in normal mode', async ({ page, context }) => {
71+
const u = createTestUtils({ app, page, context });
72+
73+
await u.po.signIn.goTo();
74+
await u.po.signIn.signInWithEmailAndInstantPassword({
75+
email: fakeUser.email,
76+
password: fakeUser.password,
77+
});
78+
await u.po.expect.toBeSignedIn();
79+
80+
const initialSessionId = await page.evaluate(() => {
81+
return (window as any).Clerk?.session?.id;
82+
});
83+
84+
await clearCookiesForHandshake(context);
85+
await u.page.goToAppHome();
86+
await u.page.waitForClerkJsLoaded();
87+
88+
const sessionToken = await page.evaluate(() => {
89+
return (window as any).Clerk?.session?.lastActiveToken?.getRawString();
90+
});
91+
92+
const sessionId = await page.evaluate(() => {
93+
return (window as any).Clerk?.session?.id;
94+
});
95+
96+
await u.po.expect.toBeSignedIn();
97+
expect(sessionToken).toBeTruthy();
98+
expect(isEdgeGeneratedToken(sessionToken as string)).toBe(false);
99+
expect(sessionId).toBe(initialSessionId);
100+
});
101+
102+
test('handshake: should recover session via proxy with opt-in header', async ({ page, context }) => {
103+
const u = createTestUtils({ app, page, context });
104+
105+
await u.po.signIn.goTo();
106+
await u.po.signIn.signInWithEmailAndInstantPassword({
107+
email: fakeUser.email,
108+
password: fakeUser.password,
109+
});
110+
await u.po.expect.toBeSignedIn();
111+
112+
const initialSessionId = await page.evaluate(() => {
113+
return (window as any).Clerk?.session?.id;
114+
});
115+
116+
await clearCookiesForHandshake(context);
117+
118+
await page.route('**/*', async (route, request) => {
119+
const headers = {
120+
...request.headers(),
121+
[ORIGIN_OUTAGE_MODE_OPT_IN_HEADER]: OPT_IN_HEADER_SECRET,
122+
};
123+
await route.continue({ headers });
124+
});
125+
126+
await u.page.goToAppHome();
127+
await u.page.waitForClerkJsLoaded();
128+
129+
await page.unroute('**/*');
130+
131+
const sessionToken = await page.evaluate(() => {
132+
return (window as any).Clerk?.session?.lastActiveToken?.getRawString();
133+
});
134+
135+
const sessionId = await page.evaluate(() => {
136+
return (window as any).Clerk?.session?.id;
137+
});
138+
139+
await u.po.expect.toBeSignedIn();
140+
expect(sessionToken).toBeTruthy();
141+
expect(isEdgeGeneratedToken(sessionToken as string)).toBe(true);
142+
expect(sessionId).toBe(initialSessionId);
143+
});
144+
});

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"test:integration:machine": "E2E_APP_ID=withMachine.* pnpm test:integration:base --grep @machine",
4848
"test:integration:nextjs": "E2E_APP_ID=next.appRouter.* pnpm test:integration:base --grep @nextjs",
4949
"test:integration:nuxt": "E2E_APP_ID=nuxt.node npm run test:integration:base -- --grep @nuxt",
50+
"test:integration:outage-mode": "E2E_APP_ID='next.appRouter.withOutageMode' pnpm test:integration:base --grep @outage-mode",
5051
"test:integration:quickstart": "E2E_APP_ID=quickstart.* pnpm test:integration:base --grep @quickstart",
5152
"test:integration:react-router": "E2E_APP_ID=react-router.* pnpm test:integration:base --grep @react-router",
5253
"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",

packages/backend/src/tokens/request.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,14 +234,22 @@ export const authenticateRequest: AuthenticateRequest = (async (
234234

235235
try {
236236
// Perform the actual token refresh.
237+
const requestHeaders = new Headers(request.headers);
238+
if (options.headers) {
239+
const extraHeaders = new Headers(options.headers);
240+
extraHeaders.forEach((value, key) => {
241+
requestHeaders.append(key, value);
242+
});
243+
}
244+
237245
const response = await options.apiClient.sessions.refreshSession(decodeResult.payload.sid, {
238246
format: 'cookie',
239247
suffixed_cookies: authenticateContext.usesSuffixedCookies(),
240248
expired_token: expiredSessionToken || '',
241249
refresh_token: refreshToken || '',
242250
request_origin: authenticateContext.clerkUrl.origin,
243251
// The refresh endpoint expects headers as Record<string, string[]>, so we need to transform it.
244-
request_headers: Object.fromEntries(Array.from(request.headers.entries()).map(([k, v]) => [k, [v]])),
252+
request_headers: Object.fromEntries(Array.from(requestHeaders.entries()).map(([k, v]) => [k, [v]])),
245253
});
246254
return { data: response.cookies, error: null };
247255
} catch (err: any) {

packages/backend/src/tokens/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ export type AuthenticateRequestOptions = {
5858
* 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).
5959
*/
6060
organizationSyncOptions?: OrganizationSyncOptions;
61+
/**
62+
* Optional headers to be passed to the backend API.
63+
*/
64+
headers?: HeadersInit;
6165
/**
6266
* @internal
6367
*/

turbo.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,18 @@
374374
"env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"],
375375
"inputs": ["integration/**"],
376376
"outputLogs": "new-only"
377+
},
378+
"//#test:integration:outage-mode": {
379+
"dependsOn": [
380+
"@clerk/testing#build",
381+
"@clerk/clerk-js#build",
382+
"@clerk/backend#build",
383+
"@clerk/nextjs#build",
384+
"@clerk/clerk-react#build"
385+
],
386+
"env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS", "ORIGIN_OUTAGE_MODE_OPT_IN_SECRET"],
387+
"inputs": ["integration/**"],
388+
"outputLogs": "new-only"
377389
}
378390
}
379391
}

0 commit comments

Comments
 (0)