Skip to content

Commit 86fd38f

Browse files
authored
fix(repo): harden keyless accountless requests (#8676)
1 parent 219b5f9 commit 86fd38f

27 files changed

Lines changed: 688 additions & 32 deletions

File tree

.changeset/keyless-ci-guard.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@clerk/astro': patch
3+
'@clerk/backend': patch
4+
'@clerk/nextjs': patch
5+
'@clerk/nuxt': patch
6+
'@clerk/react-router': patch
7+
'@clerk/shared': patch
8+
'@clerk/tanstack-react-start': patch
9+
---
10+
11+
Prevent keyless mode from activating in CI and other automated environments in framework SDKs.

integration/models/__tests__/application.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { resolveServerUrl } from '../application';
3+
import { createAppRuntimeEnv, resolveServerUrl } from '../application';
4+
import { environmentConfig } from '../environment';
45

56
describe('resolveServerUrl', () => {
67
describe('with opts.serverUrl', () => {
@@ -49,3 +50,16 @@ describe('resolveServerUrl', () => {
4950
});
5051
});
5152
});
53+
54+
describe('createAppRuntimeEnv', () => {
55+
it('passes configured falsey values through to spawned app processes', () => {
56+
const env = environmentConfig()
57+
.setEnvVariable('private', 'CI', 'false')
58+
.setEnvVariable('public', 'CLERK_KEYLESS_DISABLED', false);
59+
60+
expect(createAppRuntimeEnv(env)).toMatchObject({
61+
CI: 'false',
62+
CLERK_KEYLESS_DISABLED: 'false',
63+
});
64+
});
65+
});

integration/models/application.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ export const resolveServerUrl = (
3434
return fallbackServerUrl || `http://localhost:${port}`;
3535
};
3636

37+
export const createAppRuntimeEnv = (env?: EnvironmentConfig): Record<string, string> => {
38+
if (!env?.publicVariables || !env?.privateVariables) {
39+
return {};
40+
}
41+
42+
const runtimeEnv: Record<string, string> = {};
43+
// Private variables intentionally win when the same runtime key exists in both maps.
44+
for (const [key, value] of [...env.publicVariables, ...env.privateVariables]) {
45+
if (value === undefined || value === null) {
46+
continue;
47+
}
48+
49+
runtimeEnv[key] = String(value);
50+
}
51+
52+
return runtimeEnv;
53+
};
54+
3755
export const application = (
3856
config: ApplicationConfig,
3957
appDirPath: string,
@@ -103,7 +121,7 @@ export const application = (
103121

104122
const proc = run(scripts.dev, {
105123
cwd: appDirPath,
106-
env: { PORT: port.toString() },
124+
env: { ...createAppRuntimeEnv(state.env), PORT: port.toString() },
107125
detached: opts.detached,
108126
stdout: opts.detached ? fs.openSync(stdoutFilePath, 'a') : undefined,
109127
stderr: opts.detached ? fs.openSync(stderrFilePath, 'a') : undefined,
@@ -158,6 +176,7 @@ export const application = (
158176
const log = logger.child({ prefix: 'build' }).info;
159177
await run(scripts.build, {
160178
cwd: appDirPath,
179+
env: createAppRuntimeEnv(state.env),
161180
log: (msg: string) => {
162181
buildOutput += `\n${msg}`;
163182
log(msg);
@@ -200,7 +219,7 @@ export const application = (
200219

201220
const proc = run(scripts.serve, {
202221
cwd: appDirPath,
203-
env: { ...envFromFile, PORT: port.toString() },
222+
env: { ...envFromFile, ...createAppRuntimeEnv(state.env), PORT: port.toString() },
204223
detached: opts.detached,
205224
stdout: opts.detached ? fs.openSync(stdoutFilePath, 'a') : undefined,
206225
stderr: opts.detached ? fs.openSync(stderrFilePath, 'a') : undefined,

integration/presets/envs.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { resolve } from 'node:path';
22

3+
import { automatedEnvironmentVariables } from '@clerk/shared/utils';
34
import fs from 'fs-extra';
45

56
import { constants } from '../constants';
@@ -91,6 +92,10 @@ const withKeyless = base
9192
.setEnvVariable('private', 'CLERK_API_URL', 'https://api.clerkstage.dev')
9293
.setEnvVariable('public', 'CLERK_KEYLESS_DISABLED', false);
9394

95+
automatedEnvironmentVariables.forEach(name => {
96+
withKeyless.setEnvVariable('private', name, 'false');
97+
});
98+
9499
const withEmailCodes = withInstanceKeys(
95100
'with-email-codes',
96101
base

packages/astro/src/server/keyless/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,23 @@ export function keyless(context: APIContext) {
1212
keylessServiceInstance = createKeylessService({
1313
storage: createFileStorage(),
1414
api: {
15-
async createAccountlessApplication(requestHeaders?: Headers) {
15+
async createAccountlessApplication(requestHeaders?: Headers, source?: string) {
1616
try {
1717
return await clerkClient(context).__experimental_accountlessApplications.createAccountlessApplication({
1818
requestHeaders,
19+
source,
1920
});
2021
} catch {
2122
return null;
2223
}
2324
},
24-
async completeOnboarding(requestHeaders?: Headers) {
25+
async completeOnboarding(requestHeaders?: Headers, source?: string) {
2526
try {
2627
return await clerkClient(
2728
context,
2829
).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
2930
requestHeaders,
31+
source,
3032
});
3133
} catch {
3234
return null;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { automatedEnvironmentVariables } from '@clerk/shared/utils';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
async function loadCanUseKeyless() {
5+
vi.resetModules();
6+
const { canUseKeyless } = await import('../feature-flags.js');
7+
return canUseKeyless;
8+
}
9+
10+
describe('canUseKeyless', () => {
11+
beforeEach(() => {
12+
vi.stubEnv('NODE_ENV', 'development');
13+
vi.stubEnv('PUBLIC_CLERK_KEYLESS_DISABLED', undefined);
14+
vi.stubEnv('CLERK_KEYLESS_DISABLED', undefined);
15+
automatedEnvironmentVariables.forEach(name => {
16+
vi.stubEnv(name, undefined);
17+
vi.stubGlobal(name, undefined);
18+
});
19+
});
20+
21+
afterEach(() => {
22+
vi.unstubAllEnvs();
23+
vi.unstubAllGlobals();
24+
vi.resetModules();
25+
});
26+
27+
it('enables keyless in development when automation signals are absent', async () => {
28+
await expect(loadCanUseKeyless()).resolves.toBe(true);
29+
});
30+
31+
it('disables keyless in CI even when the app runs in development mode', async () => {
32+
vi.stubEnv('CI', 'true');
33+
34+
await expect(loadCanUseKeyless()).resolves.toBe(false);
35+
});
36+
37+
it('disables keyless outside development mode', async () => {
38+
vi.stubEnv('NODE_ENV', 'production');
39+
40+
await expect(loadCanUseKeyless()).resolves.toBe(false);
41+
});
42+
43+
it('disables keyless when explicitly disabled', async () => {
44+
vi.stubEnv('PUBLIC_CLERK_KEYLESS_DISABLED', 'true');
45+
46+
await expect(loadCanUseKeyless()).resolves.toBe(false);
47+
});
48+
});
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { getEnvVariable } from '@clerk/shared/getEnvVariable';
22
import { isTruthy } from '@clerk/shared/underscore';
3-
import { isDevelopmentEnvironment } from '@clerk/shared/utils';
3+
import { isAutomatedEnvironment, isDevelopmentEnvironment } from '@clerk/shared/utils';
44

55
const KEYLESS_DISABLED =
66
isTruthy(getEnvVariable('PUBLIC_CLERK_KEYLESS_DISABLED')) ||
77
isTruthy(getEnvVariable('CLERK_KEYLESS_DISABLED')) ||
88
false;
99

10-
export const canUseKeyless = isDevelopmentEnvironment() && !KEYLESS_DISABLED;
10+
export const canUseKeyless = isDevelopmentEnvironment() && !isAutomatedEnvironment() && !KEYLESS_DISABLED;
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { http, HttpResponse } from 'msw';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { server } from '../../mock-server';
5+
import { createBackendApiClient } from '../factory';
6+
7+
describe('AccountlessApplications', () => {
8+
const mockAccountlessApplication = {
9+
object: 'accountless_application',
10+
publishable_key: 'pk_test_keyless',
11+
secret_key: 'sk_test_keyless',
12+
claim_url: 'https://dashboard.clerk.com/claim',
13+
api_keys_url: 'https://dashboard.clerk.com/api-keys',
14+
};
15+
16+
it('creates an accountless application with a source query parameter', async () => {
17+
const apiClient = createBackendApiClient({
18+
apiUrl: 'https://api.clerk.test',
19+
});
20+
21+
server.use(
22+
http.post('https://api.clerk.test/v1/accountless_applications', ({ request }) => {
23+
const url = new URL(request.url);
24+
expect(url.searchParams.get('source')).toBe('nextjs');
25+
expect(request.headers.get('Clerk-API-Version')).toBeTruthy();
26+
expect(request.headers.get('User-Agent')).toBe('@clerk/backend@0.0.0-test');
27+
28+
return HttpResponse.json(mockAccountlessApplication);
29+
}),
30+
);
31+
32+
const response = await apiClient.__experimental_accountlessApplications.createAccountlessApplication({
33+
source: 'nextjs',
34+
});
35+
36+
expect(response.publishableKey).toBe('pk_test_keyless');
37+
});
38+
39+
it('creates an accountless application without a source query parameter when source is omitted', async () => {
40+
const apiClient = createBackendApiClient({
41+
apiUrl: 'https://api.clerk.test',
42+
});
43+
44+
server.use(
45+
http.post('https://api.clerk.test/v1/accountless_applications', ({ request }) => {
46+
const url = new URL(request.url);
47+
expect(url.searchParams.has('source')).toBe(false);
48+
49+
return HttpResponse.json(mockAccountlessApplication);
50+
}),
51+
);
52+
53+
const response = await apiClient.__experimental_accountlessApplications.createAccountlessApplication();
54+
55+
expect(response.publishableKey).toBe('pk_test_keyless');
56+
});
57+
58+
it('completes accountless application onboarding with a source query parameter', async () => {
59+
const apiClient = createBackendApiClient({
60+
apiUrl: 'https://api.clerk.test',
61+
});
62+
63+
server.use(
64+
http.post('https://api.clerk.test/v1/accountless_applications/complete', ({ request }) => {
65+
const url = new URL(request.url);
66+
expect(url.searchParams.get('source')).toBe('nextjs');
67+
expect(request.headers.get('Clerk-API-Version')).toBeTruthy();
68+
expect(request.headers.get('User-Agent')).toBe('@clerk/backend@0.0.0-test');
69+
70+
return HttpResponse.json(mockAccountlessApplication);
71+
}),
72+
);
73+
74+
const response = await apiClient.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
75+
source: 'nextjs',
76+
});
77+
78+
expect(response.publishableKey).toBe('pk_test_keyless');
79+
});
80+
81+
it('completes accountless application onboarding without a source query parameter when source is omitted', async () => {
82+
const apiClient = createBackendApiClient({
83+
apiUrl: 'https://api.clerk.test',
84+
});
85+
86+
server.use(
87+
http.post('https://api.clerk.test/v1/accountless_applications/complete', ({ request }) => {
88+
const url = new URL(request.url);
89+
expect(url.searchParams.has('source')).toBe(false);
90+
91+
return HttpResponse.json(mockAccountlessApplication);
92+
}),
93+
);
94+
95+
const response = await apiClient.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding();
96+
97+
expect(response.publishableKey).toBe('pk_test_keyless');
98+
});
99+
});

packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,35 @@ import { AbstractAPI } from './AbstractApi';
44

55
const basePath = '/accountless_applications';
66

7+
type AccountlessApplicationParams = {
8+
requestHeaders?: Headers;
9+
source?: string;
10+
};
11+
712
export class AccountlessApplicationAPI extends AbstractAPI {
8-
public async createAccountlessApplication(params?: { requestHeaders?: Headers }) {
13+
public async createAccountlessApplication(params?: AccountlessApplicationParams): Promise<AccountlessApplication> {
914
const headerParams = params?.requestHeaders ? Object.fromEntries(params.requestHeaders.entries()) : undefined;
1015
return this.request<AccountlessApplication>({
1116
method: 'POST',
1217
path: basePath,
1318
headerParams,
19+
queryParams: {
20+
source: params?.source,
21+
},
1422
});
1523
}
1624

17-
public async completeAccountlessApplicationOnboarding(params?: { requestHeaders?: Headers }) {
25+
public async completeAccountlessApplicationOnboarding(
26+
params?: AccountlessApplicationParams,
27+
): Promise<AccountlessApplication> {
1828
const headerParams = params?.requestHeaders ? Object.fromEntries(params.requestHeaders.entries()) : undefined;
1929
return this.request<AccountlessApplication>({
2030
method: 'POST',
2131
path: joinPaths(basePath, 'complete'),
2232
headerParams,
33+
queryParams: {
34+
source: params?.source,
35+
},
2336
});
2437
}
2538
}

packages/nextjs/src/server/keyless-node.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,21 @@ export function keyless() {
2424
keylessServiceInstance = createKeylessService({
2525
storage: createFileStorage(),
2626
api: {
27-
async createAccountlessApplication(requestHeaders?: Headers) {
27+
async createAccountlessApplication(requestHeaders?: Headers, source?: string) {
2828
try {
2929
return await client.__experimental_accountlessApplications.createAccountlessApplication({
3030
requestHeaders,
31+
source,
3132
});
3233
} catch {
3334
return null;
3435
}
3536
},
36-
async completeOnboarding(requestHeaders?: Headers) {
37+
async completeOnboarding(requestHeaders?: Headers, source?: string) {
3738
try {
3839
return await client.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
3940
requestHeaders,
41+
source,
4042
});
4143
} catch {
4244
return null;

0 commit comments

Comments
 (0)