diff --git a/.changeset/keyless-ci-guard.md b/.changeset/keyless-ci-guard.md new file mode 100644 index 00000000000..f920ff36e65 --- /dev/null +++ b/.changeset/keyless-ci-guard.md @@ -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. diff --git a/integration/models/__tests__/application.test.ts b/integration/models/__tests__/application.test.ts index 6e2d52d0e2e..b3eb9f1bccc 100644 --- a/integration/models/__tests__/application.test.ts +++ b/integration/models/__tests__/application.test.ts @@ -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', () => { @@ -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', + }); + }); +}); diff --git a/integration/models/application.ts b/integration/models/application.ts index 79918bd9c29..2afb03d134b 100644 --- a/integration/models/application.ts +++ b/integration/models/application.ts @@ -34,6 +34,24 @@ export const resolveServerUrl = ( return fallbackServerUrl || `http://localhost:${port}`; }; +export const createAppRuntimeEnv = (env?: EnvironmentConfig): Record => { + if (!env?.publicVariables || !env?.privateVariables) { + return {}; + } + + const runtimeEnv: Record = {}; + // 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, @@ -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, @@ -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); @@ -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, diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 5c87b72647c..31a81470de1 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -1,5 +1,6 @@ import { resolve } from 'node:path'; +import { automatedEnvironmentVariables } from '@clerk/shared/utils'; import fs from 'fs-extra'; import { constants } from '../constants'; @@ -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 diff --git a/packages/astro/src/server/keyless/index.ts b/packages/astro/src/server/keyless/index.ts index 7c1bb31353e..15df38e46b6 100644 --- a/packages/astro/src/server/keyless/index.ts +++ b/packages/astro/src/server/keyless/index.ts @@ -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; diff --git a/packages/astro/src/utils/__tests__/feature-flags.test.ts b/packages/astro/src/utils/__tests__/feature-flags.test.ts new file mode 100644 index 00000000000..5baa1304a80 --- /dev/null +++ b/packages/astro/src/utils/__tests__/feature-flags.test.ts @@ -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); + }); +}); diff --git a/packages/astro/src/utils/feature-flags.ts b/packages/astro/src/utils/feature-flags.ts index 94421cb9937..c0131261a0f 100644 --- a/packages/astro/src/utils/feature-flags.ts +++ b/packages/astro/src/utils/feature-flags.ts @@ -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; diff --git a/packages/backend/src/api/__tests__/AccountlessApplicationsApi.test.ts b/packages/backend/src/api/__tests__/AccountlessApplicationsApi.test.ts new file mode 100644 index 00000000000..98f0e1c9c19 --- /dev/null +++ b/packages/backend/src/api/__tests__/AccountlessApplicationsApi.test.ts @@ -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'); + }); +}); diff --git a/packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts b/packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts index 1a11b532b98..37b69809753 100644 --- a/packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts +++ b/packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts @@ -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 { const headerParams = params?.requestHeaders ? Object.fromEntries(params.requestHeaders.entries()) : undefined; return this.request({ method: 'POST', path: basePath, headerParams, + queryParams: { + source: params?.source, + }, }); } - public async completeAccountlessApplicationOnboarding(params?: { requestHeaders?: Headers }) { + public async completeAccountlessApplicationOnboarding( + params?: AccountlessApplicationParams, + ): Promise { const headerParams = params?.requestHeaders ? Object.fromEntries(params.requestHeaders.entries()) : undefined; return this.request({ method: 'POST', path: joinPaths(basePath, 'complete'), headerParams, + queryParams: { + source: params?.source, + }, }); } } diff --git a/packages/nextjs/src/server/keyless-node.ts b/packages/nextjs/src/server/keyless-node.ts index eb01ef20691..5df56892ab9 100644 --- a/packages/nextjs/src/server/keyless-node.ts +++ b/packages/nextjs/src/server/keyless-node.ts @@ -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; diff --git a/packages/nextjs/src/utils/__tests__/feature-flags.test.ts b/packages/nextjs/src/utils/__tests__/feature-flags.test.ts new file mode 100644 index 00000000000..97a34b1be51 --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/feature-flags.test.ts @@ -0,0 +1,47 @@ +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('NEXT_PUBLIC_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('NEXT_PUBLIC_CLERK_KEYLESS_DISABLED', 'true'); + + await expect(loadCanUseKeyless()).resolves.toBe(false); + }); +}); diff --git a/packages/nextjs/src/utils/feature-flags.ts b/packages/nextjs/src/utils/feature-flags.ts index 86cac903a1b..026705b7c50 100644 --- a/packages/nextjs/src/utils/feature-flags.ts +++ b/packages/nextjs/src/utils/feature-flags.ts @@ -1,7 +1,8 @@ -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; +// Next.js inlines NODE_ENV on the client bundle. CI detection is reliable server-side, +// while client bundles only see automation signals that are explicitly exposed. +const canUseKeyless = isDevelopmentEnvironment() && !isAutomatedEnvironment() && !KEYLESS_DISABLED; export { canUseKeyless }; diff --git a/packages/nuxt/src/runtime/server/keyless/index.ts b/packages/nuxt/src/runtime/server/keyless/index.ts index 0ee1a4fac47..14072688105 100644 --- a/packages/nuxt/src/runtime/server/keyless/index.ts +++ b/packages/nuxt/src/runtime/server/keyless/index.ts @@ -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; diff --git a/packages/nuxt/src/runtime/utils/__tests__/feature-flags.test.ts b/packages/nuxt/src/runtime/utils/__tests__/feature-flags.test.ts new file mode 100644 index 00000000000..c24fb08fceb --- /dev/null +++ b/packages/nuxt/src/runtime/utils/__tests__/feature-flags.test.ts @@ -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('NUXT_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('NUXT_PUBLIC_CLERK_KEYLESS_DISABLED', 'true'); + + await expect(loadCanUseKeyless()).resolves.toBe(false); + }); +}); diff --git a/packages/nuxt/src/runtime/utils/feature-flags.ts b/packages/nuxt/src/runtime/utils/feature-flags.ts index 774cdc71e99..35a2a9f09fa 100644 --- a/packages/nuxt/src/runtime/utils/feature-flags.ts +++ b/packages/nuxt/src/runtime/utils/feature-flags.ts @@ -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; diff --git a/packages/react-router/src/server/keyless/index.ts b/packages/react-router/src/server/keyless/index.ts index 2874e022481..b347fa888e1 100644 --- a/packages/react-router/src/server/keyless/index.ts +++ b/packages/react-router/src/server/keyless/index.ts @@ -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; diff --git a/packages/react-router/src/utils/__tests__/feature-flags.test.ts b/packages/react-router/src/utils/__tests__/feature-flags.test.ts new file mode 100644 index 00000000000..693278f61f9 --- /dev/null +++ b/packages/react-router/src/utils/__tests__/feature-flags.test.ts @@ -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('VITE_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('VITE_CLERK_KEYLESS_DISABLED', 'true'); + + await expect(loadCanUseKeyless()).resolves.toBe(false); + }); +}); diff --git a/packages/react-router/src/utils/feature-flags.ts b/packages/react-router/src/utils/feature-flags.ts index bd40eaca25e..f28932ab26a 100644 --- a/packages/react-router/src/utils/feature-flags.ts +++ b/packages/react-router/src/utils/feature-flags.ts @@ -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; diff --git a/packages/shared/src/__tests__/getEnvVariable.spec.ts b/packages/shared/src/__tests__/getEnvVariable.spec.ts new file mode 100644 index 00000000000..918f6cf33b9 --- /dev/null +++ b/packages/shared/src/__tests__/getEnvVariable.spec.ts @@ -0,0 +1,17 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getEnvVariable } from '../getEnvVariable'; + +describe('getEnvVariable', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('preserves raw globalThis fallback values for backward compatibility', () => { + vi.stubEnv('CLERK_TEST_GLOBAL_VALUE', undefined); + vi.stubGlobal('CLERK_TEST_GLOBAL_VALUE', true); + + expect(getEnvVariable('CLERK_TEST_GLOBAL_VALUE') as unknown).toBe(true); + }); +}); diff --git a/packages/shared/src/__tests__/runtimeEnvironment.spec.ts b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts new file mode 100644 index 00000000000..e792bbb45a6 --- /dev/null +++ b/packages/shared/src/__tests__/runtimeEnvironment.spec.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { automatedEnvironmentVariables, isAutomatedEnvironment } from '../utils/runtimeEnvironment'; + +describe('isAutomatedEnvironment', () => { + beforeEach(() => { + automatedEnvironmentVariables.forEach(name => { + vi.stubEnv(name, undefined); + vi.stubGlobal(name, undefined); + }); + }); + + 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', () => { + const falseyValues = ['false', '0', 'off', 'no']; + + automatedEnvironmentVariables.forEach((name, index) => { + vi.stubEnv(name, falseyValues[index % falseyValues.length]); + }); + + 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); + }); + + it('does not treat presence-sensitive build tool variables as automation signals', () => { + vi.stubEnv('NOW_BUILDER', '1'); + + expect(isAutomatedEnvironment()).toBe(false); + }); + + it('does not treat interactive development host variables as automation signals', () => { + vi.stubEnv('CODESPACES', 'true'); + vi.stubEnv('GITPOD_WORKSPACE_ID', 'workspace-id'); + vi.stubEnv('VERCEL', '1'); + vi.stubEnv('NETLIFY', 'true'); + + expect(isAutomatedEnvironment()).toBe(false); + }); + + it('ignores non-string automation environment variables from shared runtime fallbacks', () => { + vi.stubEnv('CI', undefined); + vi.stubGlobal('CI', true); + + expect(isAutomatedEnvironment()).toBe(false); + }); +}); diff --git a/packages/shared/src/getEnvVariable.ts b/packages/shared/src/getEnvVariable.ts index 5d3904a2d81..40011125eb1 100644 --- a/packages/shared/src/getEnvVariable.ts +++ b/packages/shared/src/getEnvVariable.ts @@ -42,7 +42,7 @@ export const getEnvVariable = (name: string, context?: Record): str // Cloudflare workers try { - return globalThis[name as keyof typeof globalThis]; + return globalThis[name as keyof typeof globalThis] as string; } catch { // This will raise an error in Cloudflare Pages } diff --git a/packages/shared/src/keyless/__tests__/service.spec.ts b/packages/shared/src/keyless/__tests__/service.spec.ts new file mode 100644 index 00000000000..7a76b26cb3e --- /dev/null +++ b/packages/shared/src/keyless/__tests__/service.spec.ts @@ -0,0 +1,115 @@ +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 = ''; + }), + }; +}; + +const createApi = (overrides: Partial = {}): KeylessAPI => ({ + createAccountlessApplication: vi.fn(() => Promise.resolve(accountlessApplication)), + completeOnboarding: vi.fn(() => Promise.resolve(accountlessApplication)), + ...overrides, +}); + +describe('createKeylessService', () => { + it('passes the framework as the source when creating an accountless application', async () => { + const createAccountlessApplication = vi.fn(() => + Promise.resolve(accountlessApplication), + ); + + const service = createKeylessService({ + storage: createStorage(), + api: createApi({ createAccountlessApplication }), + 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(() => Promise.resolve(accountlessApplication)); + + const service = createKeylessService({ + storage: createStorage(), + api: createApi({ completeOnboarding }), + framework: 'nextjs', + }); + + await service.completeOnboarding(); + + const [headers, source] = completeOnboarding.mock.calls[0]; + expect(headers).toBeInstanceOf(Headers); + expect(source).toBe('nextjs'); + }); + + it('sanitizes the framework before passing it as the source', async () => { + const createAccountlessApplication = vi.fn(() => + Promise.resolve(accountlessApplication), + ); + + const service = createKeylessService({ + storage: createStorage(), + api: createApi({ createAccountlessApplication }), + framework: 'Next.js @ Canary!', + }); + + await service.getOrCreateKeys(); + + expect(createAccountlessApplication.mock.calls[0][1]).toBe('next.js-canary'); + }); + + it('falls back to javascript when framework sanitization produces an empty source', async () => { + const createAccountlessApplication = vi.fn(() => + Promise.resolve(accountlessApplication), + ); + + const service = createKeylessService({ + storage: createStorage(), + api: createApi({ createAccountlessApplication }), + framework: '!!!', + }); + + await service.getOrCreateKeys(); + + expect(createAccountlessApplication.mock.calls[0][1]).toBe('javascript'); + }); + + it('truncates the source before passing it to the accountless application API', async () => { + const createAccountlessApplication = vi.fn(() => + Promise.resolve(accountlessApplication), + ); + + const service = createKeylessService({ + storage: createStorage(), + api: createApi({ createAccountlessApplication }), + framework: 'a'.repeat(50), + }); + + await service.getOrCreateKeys(); + + expect(createAccountlessApplication.mock.calls[0][1]).toBe('a'.repeat(36)); + }); +}); diff --git a/packages/shared/src/keyless/service.ts b/packages/shared/src/keyless/service.ts index 20b989ff364..7e776a3e9ac 100644 --- a/packages/shared/src/keyless/service.ts +++ b/packages/shared/src/keyless/service.ts @@ -1,6 +1,10 @@ import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; import type { AccountlessApplication } from './types'; +const KEYLESS_SOURCE_FALLBACK = 'javascript'; +// Keep the source compact for BAPI metadata dimensions while covering common framework identifiers. +const KEYLESS_SOURCE_MAX_LENGTH = 36; + /** * Storage adapter interface for keyless mode. * Implementations can use file system, cookies, or other storage mechanisms. @@ -38,17 +42,19 @@ export interface KeylessAPI { * Creates a new accountless application. * * @param requestHeaders - Optional headers to include with the request. + * @param source - Optional source value to include with the request. * @returns The created AccountlessApplication or null if failed. */ - createAccountlessApplication(requestHeaders?: Headers): Promise; + createAccountlessApplication(requestHeaders?: Headers, source?: string): Promise; /** * Notifies the backend that onboarding is complete (instance has been claimed). * * @param requestHeaders - Optional headers to include with the request. + * @param source - Optional source value to include with the request. * @returns The updated AccountlessApplication or null if failed. */ - completeOnboarding(requestHeaders?: Headers): Promise; + completeOnboarding(requestHeaders?: Headers, source?: string): Promise; } /** @@ -145,6 +151,16 @@ function createMetadataHeaders(framework?: string, frameworkVersion?: string): H return headers; } +function createSource(framework?: string): string { + const source = (framework || KEYLESS_SOURCE_FALLBACK) + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, KEYLESS_SOURCE_MAX_LENGTH); + + return source || KEYLESS_SOURCE_FALLBACK; +} + /** * Creates a keyless service that handles accountless application creation and storage. * This provides a simple API for frameworks to integrate keyless mode. @@ -172,6 +188,7 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer const { storage, api, framework, frameworkVersion } = options; let hasLoggedKeylessMessage = false; + const source = createSource(framework); const safeParseConfig = (): AccountlessApplication | undefined => { try { @@ -197,7 +214,7 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer const headers = createMetadataHeaders(framework, frameworkVersion); // Create new keys via the API - const accountlessApplication = await api.createAccountlessApplication(headers); + const accountlessApplication = await api.createAccountlessApplication(headers, source); if (accountlessApplication) { storage.write(JSON.stringify(accountlessApplication)); @@ -216,7 +233,7 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer async completeOnboarding(): Promise { const headers = createMetadataHeaders(framework, frameworkVersion); - return api.completeOnboarding(headers); + return api.completeOnboarding(headers, source); }, logKeylessMessage(claimUrl: string): void { diff --git a/packages/shared/src/utils/runtimeEnvironment.ts b/packages/shared/src/utils/runtimeEnvironment.ts index 43331de2b05..72014f1e15c 100644 --- a/packages/shared/src/utils/runtimeEnvironment.ts +++ b/packages/shared/src/utils/runtimeEnvironment.ts @@ -1,3 +1,32 @@ +import { getEnvVariable } from '../getEnvVariable'; + +export const automatedEnvironmentVariables = [ + 'CI', + 'CONTINUOUS_INTEGRATION', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'BITBUCKET_BUILD_NUMBER', + 'APPVEYOR', + 'CODEBUILD_BUILD_ID', + 'TF_BUILD', + 'TEAMCITY_VERSION', + 'JENKINS_URL', + 'HUDSON_URL', + 'BAMBOO_BUILDKEY', + 'CF_PAGES', +] as const; + +const isTruthyEnvValue = (value: unknown): boolean => { + if (typeof value !== 'string' || !value) { + return false; + } + + return !['0', 'false', 'off', 'no'].includes(value.toLowerCase()); +}; + export const isDevelopmentEnvironment = (): boolean => { try { return process.env.NODE_ENV === 'development'; @@ -28,3 +57,7 @@ export const isProductionEnvironment = (): boolean => { // TODO: add support for import.meta.env.DEV that is being used by vite return false; }; + +export const isAutomatedEnvironment = (): boolean => { + return automatedEnvironmentVariables.some(name => isTruthyEnvValue(getEnvVariable(name))); +}; diff --git a/packages/tanstack-react-start/src/server/keyless/index.ts b/packages/tanstack-react-start/src/server/keyless/index.ts index 590edfa9d84..bb91fbedfa9 100644 --- a/packages/tanstack-react-start/src/server/keyless/index.ts +++ b/packages/tanstack-react-start/src/server/keyless/index.ts @@ -11,19 +11,21 @@ export function keyless() { keylessServiceInstance = createKeylessService({ storage: createFileStorage(), api: { - async createAccountlessApplication(requestHeaders?: Headers) { + async createAccountlessApplication(requestHeaders?: Headers, source?: string) { try { return await clerkClient().__experimental_accountlessApplications.createAccountlessApplication({ requestHeaders, + source, }); } catch { return null; } }, - async completeOnboarding(requestHeaders?: Headers) { + async completeOnboarding(requestHeaders?: Headers, source?: string) { try { return await clerkClient().__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ requestHeaders, + source, }); } catch { return null; diff --git a/packages/tanstack-react-start/src/utils/__tests__/feature-flags.test.ts b/packages/tanstack-react-start/src/utils/__tests__/feature-flags.test.ts new file mode 100644 index 00000000000..693278f61f9 --- /dev/null +++ b/packages/tanstack-react-start/src/utils/__tests__/feature-flags.test.ts @@ -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('VITE_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('VITE_CLERK_KEYLESS_DISABLED', 'true'); + + await expect(loadCanUseKeyless()).resolves.toBe(false); + }); +}); diff --git a/packages/tanstack-react-start/src/utils/feature-flags.ts b/packages/tanstack-react-start/src/utils/feature-flags.ts index a2ec57f481f..4d129d8887c 100644 --- a/packages/tanstack-react-start/src/utils/feature-flags.ts +++ b/packages/tanstack-react-start/src/utils/feature-flags.ts @@ -1,6 +1,6 @@ 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'; // Support both Vite-style and generic env var names for disabling keyless mode const KEYLESS_DISABLED = @@ -10,10 +10,11 @@ const KEYLESS_DISABLED = /** * Whether keyless mode can be used in the current environment. - * Keyless mode is only available in development and when not explicitly disabled. + * Keyless mode is only available in development, when not explicitly disabled, + * and when not running in an automated/CI environment. * * To disable keyless mode, set either: * - `VITE_CLERK_KEYLESS_DISABLED=1` (for Vite-based projects) * - `CLERK_KEYLESS_DISABLED=1` (generic) */ -export const canUseKeyless = isDevelopmentEnvironment() && !KEYLESS_DISABLED; +export const canUseKeyless = isDevelopmentEnvironment() && !isAutomatedEnvironment() && !KEYLESS_DISABLED;