Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/keyless-ci-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@clerk/astro': patch
'@clerk/backend': patch
'@clerk/nextjs': patch
'@clerk/nuxt': patch
'@clerk/react-router': patch
'@clerk/shared': patch
'@clerk/tanstack-react-start': patch
---

Prevent keyless mode from activating in CI and other automated environments in framework SDKs.
16 changes: 15 additions & 1 deletion integration/models/__tests__/application.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';

import { resolveServerUrl } from '../application';
import { createAppRuntimeEnv, resolveServerUrl } from '../application';
import { environmentConfig } from '../environment';

describe('resolveServerUrl', () => {
describe('with opts.serverUrl', () => {
Expand Down Expand Up @@ -49,3 +50,16 @@ describe('resolveServerUrl', () => {
});
});
});

describe('createAppRuntimeEnv', () => {
it('passes configured falsey values through to spawned app processes', () => {
const env = environmentConfig()
.setEnvVariable('private', 'CI', 'false')
.setEnvVariable('public', 'CLERK_KEYLESS_DISABLED', false);

expect(createAppRuntimeEnv(env)).toMatchObject({
CI: 'false',
CLERK_KEYLESS_DISABLED: 'false',
});
});
});
23 changes: 21 additions & 2 deletions integration/models/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ export const resolveServerUrl = (
return fallbackServerUrl || `http://localhost:${port}`;
};

export const createAppRuntimeEnv = (env?: EnvironmentConfig): Record<string, string> => {
if (!env?.publicVariables || !env?.privateVariables) {
return {};
}

const runtimeEnv: Record<string, string> = {};
// Private variables intentionally win when the same runtime key exists in both maps.
for (const [key, value] of [...env.publicVariables, ...env.privateVariables]) {
if (value === undefined || value === null) {
continue;
}

runtimeEnv[key] = String(value);
}

return runtimeEnv;
};

export const application = (
config: ApplicationConfig,
appDirPath: string,
Expand Down Expand Up @@ -103,7 +121,7 @@ export const application = (

const proc = run(scripts.dev, {
cwd: appDirPath,
env: { PORT: port.toString() },
env: { ...createAppRuntimeEnv(state.env), PORT: port.toString() },
detached: opts.detached,
stdout: opts.detached ? fs.openSync(stdoutFilePath, 'a') : undefined,
stderr: opts.detached ? fs.openSync(stderrFilePath, 'a') : undefined,
Expand Down Expand Up @@ -158,6 +176,7 @@ export const application = (
const log = logger.child({ prefix: 'build' }).info;
await run(scripts.build, {
cwd: appDirPath,
env: createAppRuntimeEnv(state.env),
log: (msg: string) => {
buildOutput += `\n${msg}`;
log(msg);
Expand Down Expand Up @@ -200,7 +219,7 @@ export const application = (

const proc = run(scripts.serve, {
cwd: appDirPath,
env: { ...envFromFile, PORT: port.toString() },
env: { ...envFromFile, ...createAppRuntimeEnv(state.env), PORT: port.toString() },
detached: opts.detached,
stdout: opts.detached ? fs.openSync(stdoutFilePath, 'a') : undefined,
stderr: opts.detached ? fs.openSync(stderrFilePath, 'a') : undefined,
Expand Down
5 changes: 5 additions & 0 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { resolve } from 'node:path';

import { automatedEnvironmentVariables } from '@clerk/shared/utils';
import fs from 'fs-extra';

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

automatedEnvironmentVariables.forEach(name => {
withKeyless.setEnvVariable('private', name, 'false');
});

const withEmailCodes = withInstanceKeys(
'with-email-codes',
base
Expand Down
6 changes: 4 additions & 2 deletions packages/astro/src/server/keyless/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,23 @@ export function keyless(context: APIContext) {
keylessServiceInstance = createKeylessService({
storage: createFileStorage(),
api: {
async createAccountlessApplication(requestHeaders?: Headers) {
async createAccountlessApplication(requestHeaders?: Headers, source?: string) {
try {
return await clerkClient(context).__experimental_accountlessApplications.createAccountlessApplication({
requestHeaders,
source,
});
} catch {
return null;
}
},
async completeOnboarding(requestHeaders?: Headers) {
async completeOnboarding(requestHeaders?: Headers, source?: string) {
try {
return await clerkClient(
context,
).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
requestHeaders,
source,
});
} catch {
return null;
Expand Down
48 changes: 48 additions & 0 deletions packages/astro/src/utils/__tests__/feature-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { automatedEnvironmentVariables } from '@clerk/shared/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

async function loadCanUseKeyless() {
vi.resetModules();
const { canUseKeyless } = await import('../feature-flags.js');
return canUseKeyless;
}

describe('canUseKeyless', () => {
beforeEach(() => {
vi.stubEnv('NODE_ENV', 'development');
vi.stubEnv('PUBLIC_CLERK_KEYLESS_DISABLED', undefined);
vi.stubEnv('CLERK_KEYLESS_DISABLED', undefined);
automatedEnvironmentVariables.forEach(name => {
vi.stubEnv(name, undefined);
vi.stubGlobal(name, undefined);
});
});

afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.resetModules();
});

it('enables keyless in development when automation signals are absent', async () => {
await expect(loadCanUseKeyless()).resolves.toBe(true);
});

it('disables keyless in CI even when the app runs in development mode', async () => {
vi.stubEnv('CI', 'true');

await expect(loadCanUseKeyless()).resolves.toBe(false);
});

it('disables keyless outside development mode', async () => {
vi.stubEnv('NODE_ENV', 'production');

await expect(loadCanUseKeyless()).resolves.toBe(false);
});

it('disables keyless when explicitly disabled', async () => {
vi.stubEnv('PUBLIC_CLERK_KEYLESS_DISABLED', 'true');

await expect(loadCanUseKeyless()).resolves.toBe(false);
});
});
4 changes: 2 additions & 2 deletions packages/astro/src/utils/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { getEnvVariable } from '@clerk/shared/getEnvVariable';
import { isTruthy } from '@clerk/shared/underscore';
import { isDevelopmentEnvironment } from '@clerk/shared/utils';
import { isAutomatedEnvironment, isDevelopmentEnvironment } from '@clerk/shared/utils';

const KEYLESS_DISABLED =
isTruthy(getEnvVariable('PUBLIC_CLERK_KEYLESS_DISABLED')) ||
isTruthy(getEnvVariable('CLERK_KEYLESS_DISABLED')) ||
false;

export const canUseKeyless = isDevelopmentEnvironment() && !KEYLESS_DISABLED;
export const canUseKeyless = isDevelopmentEnvironment() && !isAutomatedEnvironment() && !KEYLESS_DISABLED;
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('AccountlessApplications', () => {
const mockAccountlessApplication = {
object: 'accountless_application',
publishable_key: 'pk_test_keyless',
secret_key: 'sk_test_keyless',
claim_url: 'https://dashboard.clerk.com/claim',
api_keys_url: 'https://dashboard.clerk.com/api-keys',
};

it('creates an accountless application with a source query parameter', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
});

server.use(
http.post('https://api.clerk.test/v1/accountless_applications', ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('source')).toBe('nextjs');
expect(request.headers.get('Clerk-API-Version')).toBeTruthy();
expect(request.headers.get('User-Agent')).toBe('@clerk/backend@0.0.0-test');

return HttpResponse.json(mockAccountlessApplication);
}),
);

const response = await apiClient.__experimental_accountlessApplications.createAccountlessApplication({
source: 'nextjs',
});

expect(response.publishableKey).toBe('pk_test_keyless');
});

it('creates an accountless application without a source query parameter when source is omitted', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
});

server.use(
http.post('https://api.clerk.test/v1/accountless_applications', ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.has('source')).toBe(false);

return HttpResponse.json(mockAccountlessApplication);
}),
);

const response = await apiClient.__experimental_accountlessApplications.createAccountlessApplication();

expect(response.publishableKey).toBe('pk_test_keyless');
});

it('completes accountless application onboarding with a source query parameter', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
});

server.use(
http.post('https://api.clerk.test/v1/accountless_applications/complete', ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('source')).toBe('nextjs');
expect(request.headers.get('Clerk-API-Version')).toBeTruthy();
expect(request.headers.get('User-Agent')).toBe('@clerk/backend@0.0.0-test');

return HttpResponse.json(mockAccountlessApplication);
}),
);

const response = await apiClient.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
source: 'nextjs',
});

expect(response.publishableKey).toBe('pk_test_keyless');
});

it('completes accountless application onboarding without a source query parameter when source is omitted', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
});

server.use(
http.post('https://api.clerk.test/v1/accountless_applications/complete', ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.has('source')).toBe(false);

return HttpResponse.json(mockAccountlessApplication);
}),
);

const response = await apiClient.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding();

expect(response.publishableKey).toBe('pk_test_keyless');
});
});
17 changes: 15 additions & 2 deletions packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,35 @@ import { AbstractAPI } from './AbstractApi';

const basePath = '/accountless_applications';

type AccountlessApplicationParams = {
requestHeaders?: Headers;
source?: string;
};

export class AccountlessApplicationAPI extends AbstractAPI {
public async createAccountlessApplication(params?: { requestHeaders?: Headers }) {
public async createAccountlessApplication(params?: AccountlessApplicationParams): Promise<AccountlessApplication> {
const headerParams = params?.requestHeaders ? Object.fromEntries(params.requestHeaders.entries()) : undefined;
return this.request<AccountlessApplication>({
method: 'POST',
path: basePath,
headerParams,
queryParams: {
source: params?.source,
},
});
}

public async completeAccountlessApplicationOnboarding(params?: { requestHeaders?: Headers }) {
public async completeAccountlessApplicationOnboarding(
params?: AccountlessApplicationParams,
): Promise<AccountlessApplication> {
const headerParams = params?.requestHeaders ? Object.fromEntries(params.requestHeaders.entries()) : undefined;
return this.request<AccountlessApplication>({
method: 'POST',
path: joinPaths(basePath, 'complete'),
headerParams,
queryParams: {
source: params?.source,
},
});
}
}
6 changes: 4 additions & 2 deletions packages/nextjs/src/server/keyless-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,21 @@ export function keyless() {
keylessServiceInstance = createKeylessService({
storage: createFileStorage(),
api: {
async createAccountlessApplication(requestHeaders?: Headers) {
async createAccountlessApplication(requestHeaders?: Headers, source?: string) {
try {
return await client.__experimental_accountlessApplications.createAccountlessApplication({
requestHeaders,
source,
});
} catch {
return null;
}
},
async completeOnboarding(requestHeaders?: Headers) {
async completeOnboarding(requestHeaders?: Headers, source?: string) {
try {
return await client.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
requestHeaders,
source,
});
} catch {
return null;
Expand Down
Loading
Loading