Skip to content
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, and include a source value on SDK accountless application requests.
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
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,61 @@
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('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');
});
});
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
21 changes: 21 additions & 0 deletions packages/nextjs/src/utils/__tests__/feature-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

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

describe('canUseKeyless', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});

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

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

import { KEYLESS_DISABLED } from '../server/constants';
// Next.js will inline the value of 'development' or 'production' on the client bundle, so this is client-safe.
const canUseKeyless = isDevelopmentEnvironment() && !KEYLESS_DISABLED;
const canUseKeyless = isDevelopmentEnvironment() && !isAutomatedEnvironment() && !KEYLESS_DISABLED;

export { canUseKeyless };
6 changes: 4 additions & 2 deletions packages/nuxt/src/runtime/server/keyless/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,23 @@ export function keyless(event: H3Event) {
keylessServiceInstance = createKeylessService({
storage: createFileStorage(),
api: {
async createAccountlessApplication(requestHeaders?: Headers) {
async createAccountlessApplication(requestHeaders?: Headers, source?: string) {
try {
return await clerkClient(event).__experimental_accountlessApplications.createAccountlessApplication({
requestHeaders,
source,
});
} catch {
return null;
}
},
async completeOnboarding(requestHeaders?: Headers) {
async completeOnboarding(requestHeaders?: Headers, source?: string) {
try {
return await clerkClient(
event,
).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
requestHeaders,
source,
});
} catch {
return null;
Expand Down
4 changes: 2 additions & 2 deletions packages/nuxt/src/runtime/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('NUXT_PUBLIC_CLERK_KEYLESS_DISABLED')) ||
isTruthy(getEnvVariable('CLERK_KEYLESS_DISABLED')) ||
false;

export const canUseKeyless = isDevelopmentEnvironment() && !KEYLESS_DISABLED;
export const canUseKeyless = isDevelopmentEnvironment() && !isAutomatedEnvironment() && !KEYLESS_DISABLED;
6 changes: 4 additions & 2 deletions packages/react-router/src/server/keyless/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,26 @@ export function keyless(args: DataFunctionArgs, options?: ClerkMiddlewareOptions
keylessServiceInstance = createKeylessService({
storage: createFileStorage(),
api: {
async createAccountlessApplication(requestHeaders?: Headers) {
async createAccountlessApplication(requestHeaders?: Headers, source?: string) {
try {
return await clerkClient(args, options).__experimental_accountlessApplications.createAccountlessApplication(
{
requestHeaders,
source,
},
);
} catch {
return null;
}
},
async completeOnboarding(requestHeaders?: Headers) {
async completeOnboarding(requestHeaders?: Headers, source?: string) {
try {
return await clerkClient(
args,
options,
).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
requestHeaders,
source,
});
} catch {
return null;
Expand Down
4 changes: 2 additions & 2 deletions packages/react-router/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('VITE_CLERK_KEYLESS_DISABLED')) ||
isTruthy(getEnvVariable('CLERK_KEYLESS_DISABLED')) ||
false;

export const canUseKeyless = isDevelopmentEnvironment() && !KEYLESS_DISABLED;
export const canUseKeyless = isDevelopmentEnvironment() && !isAutomatedEnvironment() && !KEYLESS_DISABLED;
30 changes: 30 additions & 0 deletions packages/shared/src/__tests__/runtimeEnvironment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { isAutomatedEnvironment } from '../utils/runtimeEnvironment';

describe('isAutomatedEnvironment', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});

it('returns true when a CI environment variable is enabled', () => {
vi.stubEnv('CI', 'true');

expect(isAutomatedEnvironment()).toBe(true);
});

it('returns false when automation environment variables are explicitly falsey', () => {
vi.stubEnv('CI', 'false');
vi.stubEnv('GITHUB_ACTIONS', '0');

expect(isAutomatedEnvironment()).toBe(false);
});

it('detects automation environment variables from shared runtime fallbacks', () => {
vi.stubEnv('CI', undefined);
vi.stubGlobal('CI', 'true');

expect(isAutomatedEnvironment()).toBe(true);
});
});
67 changes: 67 additions & 0 deletions packages/shared/src/keyless/__tests__/service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest';

import { createKeylessService, type KeylessAPI, type KeylessStorage } from '../service';
import type { AccountlessApplication } from '../types';

const accountlessApplication: AccountlessApplication = {
publishableKey: 'pk_test_keyless',
secretKey: 'sk_test_keyless',
claimUrl: 'https://dashboard.clerk.com/claim',
apiKeysUrl: 'https://dashboard.clerk.com/api-keys',
};

const createStorage = (): KeylessStorage => {
let value = '';

return {
read: vi.fn(() => value),
write: vi.fn(data => {
value = data;
}),
remove: vi.fn(() => {
value = '';
}),
};
};

describe('createKeylessService', () => {
it('passes the framework as the source when creating an accountless application', async () => {
const createAccountlessApplication = vi.fn<KeylessAPI['createAccountlessApplication']>(() =>
Promise.resolve(accountlessApplication),
);

const service = createKeylessService({
storage: createStorage(),
api: {
createAccountlessApplication,
completeOnboarding: vi.fn(() => Promise.resolve(accountlessApplication)),
},
framework: 'nextjs',
});

await service.getOrCreateKeys();

const [headers, source] = createAccountlessApplication.mock.calls[0];
expect(headers).toBeInstanceOf(Headers);
expect(source).toBe('nextjs');
});

it('passes the framework as the source when completing accountless application onboarding', async () => {
const completeOnboarding = vi.fn<KeylessAPI['completeOnboarding']>(() => Promise.resolve(accountlessApplication));

const service = createKeylessService({
storage: createStorage(),
api: {
createAccountlessApplication: vi.fn(() => Promise.resolve(accountlessApplication)),
completeOnboarding,
},
framework: 'nextjs',
});

await service.completeOnboarding();

const [headers, source] = completeOnboarding.mock.calls[0];
expect(headers).toBeInstanceOf(Headers);
expect(source).toBe('nextjs');
});
});
Loading
Loading