Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/keyless-ci-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@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.

Framework SDKs now include a source value on accountless application requests, and `@clerk/backend` forwards that source value to the Backend API.
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