diff --git a/.changeset/curvy-jobs-pump.md b/.changeset/curvy-jobs-pump.md new file mode 100644 index 00000000000..3161921deae --- /dev/null +++ b/.changeset/curvy-jobs-pump.md @@ -0,0 +1,5 @@ +--- +"@clerk/react-router": minor +--- + +Introduce Keyless quickstart for React Router. This allows the Clerk SDK to be used without having to sign up and paste your keys manually. diff --git a/integration/testUtils/keylessHelpers.ts b/integration/testUtils/keylessHelpers.ts new file mode 100644 index 00000000000..e4deaf5813f --- /dev/null +++ b/integration/testUtils/keylessHelpers.ts @@ -0,0 +1,148 @@ +import type { BrowserContext, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { createTestUtils } from './index'; + +/** + * Mocks the environment API call to return a claimed instance. + * Used in keyless mode tests to simulate an instance that has been claimed. + */ +export const mockClaimedInstanceEnvironmentCall = async (page: Page): Promise => { + await page.route('*/**/v1/environment*', async route => { + const response = await route.fetch(); + const json = await response.json(); + const newJson = { + ...json, + auth_config: { + ...json.auth_config, + claimed_at: Date.now(), + }, + }; + await route.fulfill({ response, json: newJson }); + }); +}; + +/** + * Tests that the keyless popover can be toggled and the claim link opens the dashboard. + */ +export async function testToggleCollapsePopoverAndClaim({ + page, + context, + app, + dashboardUrl, + framework, +}: { + page: Page; + context: BrowserContext; + app: Application; + dashboardUrl: string; + framework: string; +}): Promise { + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); + + await u.po.keylessPopover.waitForMounted(); + + expect(await u.po.keylessPopover.isExpanded()).toBe(false); + await u.po.keylessPopover.toggle(); + expect(await u.po.keylessPopover.isExpanded()).toBe(true); + + const claim = u.po.keylessPopover.promptsToClaim(); + + const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]); + + await newPage.waitForLoadState(); + + await newPage.waitForURL(url => { + const signInForceRedirectUrl = url.searchParams.get('sign_in_force_redirect_url'); + const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); + + const signInHasRequiredParams = + signInForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && + signInForceRedirectUrl?.includes('token=') && + signInForceRedirectUrl?.includes(`framework=${framework}`); + + const signUpRegularCase = + signUpForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && + signUpForceRedirectUrl?.includes('token=') && + signUpForceRedirectUrl?.includes(`framework=${framework}`); + + const signUpPrepareAccountCase = + signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && + signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim')) && + signUpForceRedirectUrl?.includes(encodeURIComponent('token=')) && + signUpForceRedirectUrl?.includes(encodeURIComponent(`framework=${framework}`)); + + const signUpHasRequiredParams = signUpRegularCase || signUpPrepareAccountCase; + + return url.pathname === '/apps/claim/sign-in' && signInHasRequiredParams && signUpHasRequiredParams; + }); +} + +/** + * Tests that a claimed application with missing explicit keys shows the popover expanded + * with a prompt to get keys from the dashboard. + */ +export async function testClaimedAppWithMissingKeys({ + page, + context, + app, + dashboardUrl, +}: { + page: Page; + context: BrowserContext; + app: Application; + dashboardUrl: string; +}): Promise { + await mockClaimedInstanceEnvironmentCall(page); + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + + await u.po.keylessPopover.waitForMounted(); + expect(await u.po.keylessPopover.isExpanded()).toBe(true); + await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); + + const [newPage] = await Promise.all([ + context.waitForEvent('page'), + u.po.keylessPopover.promptToUseClaimedKeys().click(), + ]); + + await newPage.waitForLoadState(); + await newPage.waitForURL(url => { + return url.href.startsWith(`${dashboardUrl}sign-in?redirect_url=${encodeURIComponent(dashboardUrl)}apps%2Fapp_`); + }); +} + +/** + * Tests that the keyless popover is removed after adding keys to .env and restarting the dev server. + */ +export async function testKeylessRemovedAfterEnvAndRestart({ + page, + context, + app, +}: { + page: Page; + context: BrowserContext; + app: Application; +}): Promise { + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + + await u.po.keylessPopover.waitForMounted(); + expect(await u.po.keylessPopover.isExpanded()).toBe(false); + + // Copy keys from keyless.json to .env + await app.keylessToEnv(); + + // Restart the dev server to pick up new env vars (Vite doesn't hot-reload .env) + await app.restart(); + + await u.page.goToAppHome(); + + // Keyless popover should no longer be present since we now have explicit keys + await u.po.keylessPopover.waitForUnmounted(); +} diff --git a/integration/tests/next-quickstart-keyless.test.ts b/integration/tests/next-quickstart-keyless.test.ts index 4007d2b8e3e..ff4fac4e5a9 100644 --- a/integration/tests/next-quickstart-keyless.test.ts +++ b/integration/tests/next-quickstart-keyless.test.ts @@ -1,27 +1,12 @@ -import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import type { Application } from '../models/application'; import { appConfigs } from '../presets'; import { createTestUtils } from '../testUtils'; +import { mockClaimedInstanceEnvironmentCall, testToggleCollapsePopoverAndClaim } from '../testUtils/keylessHelpers'; const commonSetup = appConfigs.next.appRouterQuickstart.clone(); -const mockClaimedInstanceEnvironmentCall = async (page: Page) => { - await page.route('*/**/v1/environment*', async route => { - const response = await route.fetch(); - const json = await response.json(); - const newJson = { - ...json, - auth_config: { - ...json.auth_config, - claimed_at: Date.now(), - }, - }; - await route.fulfill({ response, json: newJson }); - }); -}; - test.describe('Keyless mode @quickstart', () => { test.describe.configure({ mode: 'serial' }); @@ -71,47 +56,7 @@ test.describe('Keyless mode @quickstart', () => { }); test('Toggle collapse popover and claim.', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - await u.po.expect.toBeSignedOut(); - - await u.po.keylessPopover.waitForMounted(); - - expect(await u.po.keylessPopover.isExpanded()).toBe(false); - await u.po.keylessPopover.toggle(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - - const claim = await u.po.keylessPopover.promptsToClaim(); - - const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]); - - await newPage.waitForLoadState(); - - await newPage.waitForURL(url => { - const signInForceRedirectUrl = url.searchParams.get('sign_in_force_redirect_url'); - const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); - - const signInHasRequiredParams = - signInForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && - signInForceRedirectUrl?.includes('token=') && - signInForceRedirectUrl?.includes('framework=nextjs'); - - const signUpRegularCase = - signUpForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && - signUpForceRedirectUrl?.includes('token=') && - signUpForceRedirectUrl?.includes('framework=nextjs'); - - const signUpPrepareAccountCase = - signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && - signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim')) && - signUpForceRedirectUrl?.includes(encodeURIComponent('token=')) && - signUpForceRedirectUrl?.includes(encodeURIComponent('framework=nextjs')); - - const signUpHasRequiredParams = signUpRegularCase || signUpPrepareAccountCase; - - return url.pathname === '/apps/claim/sign-in' && signInHasRequiredParams && signUpHasRequiredParams; - }); + await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'nextjs' }); }); test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ diff --git a/integration/tests/react-router/keyless.test.ts b/integration/tests/react-router/keyless.test.ts new file mode 100644 index 00000000000..a2605806778 --- /dev/null +++ b/integration/tests/react-router/keyless.test.ts @@ -0,0 +1,55 @@ +import { test } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import { + testClaimedAppWithMissingKeys, + testKeylessRemovedAfterEnvAndRestart, + testToggleCollapsePopoverAndClaim, +} from '../../testUtils/keylessHelpers'; + +const commonSetup = appConfigs.reactRouter.reactRouterNode.clone(); + +test.describe('Keyless mode @react-router', () => { + test.describe.configure({ mode: 'serial' }); + test.setTimeout(90_000); + + test.use({ + extraHTTPHeaders: { + 'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET || '', + }, + }); + + let app: Application; + let dashboardUrl = 'https://dashboard.clerk.com/'; + + test.beforeAll(async () => { + app = await commonSetup.commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withKeyless); + if (appConfigs.envs.withKeyless.privateVariables.get('CLERK_API_URL')?.includes('clerkstage')) { + dashboardUrl = 'https://dashboard.clerkstage.dev/'; + } + await app.dev(); + }); + + test.afterAll(async () => { + // Keep files for debugging + await app?.teardown(); + }); + + test('Toggle collapse popover and claim.', async ({ page, context }) => { + await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'react-router' }); + }); + + test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ + page, + context, + }) => { + await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl }); + }); + + test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { + await testKeylessRemovedAfterEnvAndRestart({ page, context, app }); + }); +}); diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index 284aca2f3e8..a2605806778 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -1,28 +1,16 @@ -import type { Page } from '@playwright/test'; -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { createTestUtils } from '../../testUtils'; +import { + testClaimedAppWithMissingKeys, + testKeylessRemovedAfterEnvAndRestart, + testToggleCollapsePopoverAndClaim, +} from '../../testUtils/keylessHelpers'; -const commonSetup = appConfigs.tanstack.reactStart.clone(); +const commonSetup = appConfigs.reactRouter.reactRouterNode.clone(); -const mockClaimedInstanceEnvironmentCall = async (page: Page) => { - await page.route('*/**/v1/environment*', async route => { - const response = await route.fetch(); - const json = await response.json(); - const newJson = { - ...json, - auth_config: { - ...json.auth_config, - claimed_at: Date.now(), - }, - }; - await route.fulfill({ response, json: newJson }); - }); -}; - -test.describe('Keyless mode @tanstack-react-start', () => { +test.describe('Keyless mode @react-router', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(90_000); @@ -51,89 +39,17 @@ test.describe('Keyless mode @tanstack-react-start', () => { }); test('Toggle collapse popover and claim.', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - await u.po.expect.toBeSignedOut(); - - await u.po.keylessPopover.waitForMounted(); - - expect(await u.po.keylessPopover.isExpanded()).toBe(false); - await u.po.keylessPopover.toggle(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - - const claim = await u.po.keylessPopover.promptsToClaim(); - - const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]); - - await newPage.waitForLoadState(); - - await newPage.waitForURL(url => { - const signInForceRedirectUrl = url.searchParams.get('sign_in_force_redirect_url'); - const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); - - const signInHasRequiredParams = - signInForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && - signInForceRedirectUrl?.includes('token=') && - signInForceRedirectUrl?.includes('framework=tanstack-react-start'); - - const signUpRegularCase = - signUpForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && - signUpForceRedirectUrl?.includes('token=') && - signUpForceRedirectUrl?.includes('framework=tanstack-react-start'); - - const signUpPrepareAccountCase = - signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && - signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim')) && - signUpForceRedirectUrl?.includes(encodeURIComponent('token=')) && - signUpForceRedirectUrl?.includes(encodeURIComponent('framework=tanstack-react-start')); - - const signUpHasRequiredParams = signUpRegularCase || signUpPrepareAccountCase; - - return url.pathname === '/apps/claim/sign-in' && signInHasRequiredParams && signUpHasRequiredParams; - }); + await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'react-router' }); }); test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ page, context, }) => { - await mockClaimedInstanceEnvironmentCall(page); - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - - await u.po.keylessPopover.waitForMounted(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); - - const [newPage] = await Promise.all([ - context.waitForEvent('page'), - u.po.keylessPopover.promptToUseClaimedKeys().click(), - ]); - - await newPage.waitForLoadState(); - await newPage.waitForURL(url => { - return url.href.startsWith(`${dashboardUrl}sign-in?redirect_url=${encodeURIComponent(dashboardUrl)}apps%2Fapp_`); - }); + await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl }); }); test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - - await u.po.keylessPopover.waitForMounted(); - expect(await u.po.keylessPopover.isExpanded()).toBe(false); - - // Copy keys from keyless.json to .env - await app.keylessToEnv(); - - // Restart the dev server to pick up new env vars (Vite doesn't hot-reload .env) - await app.restart(); - - await u.page.goToAppHome(); - - // Keyless popover should no longer be present since we now have explicit keys - await u.po.keylessPopover.waitForUnmounted(); + await testKeylessRemovedAfterEnvAndRestart({ page, context, app }); }); }); diff --git a/packages/react-router/src/client/ReactRouterClerkProvider.tsx b/packages/react-router/src/client/ReactRouterClerkProvider.tsx index 5da08272f20..1ee21eb578b 100644 --- a/packages/react-router/src/client/ReactRouterClerkProvider.tsx +++ b/packages/react-router/src/client/ReactRouterClerkProvider.tsx @@ -67,6 +67,8 @@ function ClerkProviderBase({ children, ...rest }: ClerkProv __prefetchUI, __telemetryDisabled, __telemetryDebug, + __keylessClaimUrl, + __keylessApiKeysUrl, } = clerkState?.__internal_clerk_state || {}; React.useEffect(() => { @@ -100,6 +102,13 @@ function ClerkProviderBase({ children, ...rest }: ClerkProv }, }; + const keylessProps = __keylessClaimUrl + ? { + __internal_keyless_claimKeylessApplicationUrl: __keylessClaimUrl, + __internal_keyless_copyInstanceKeysUrl: __keylessApiKeysUrl, + } + : {}; + return ( ({ children, ...rest }: ClerkProv initialState={__clerk_ssr_state} sdkMetadata={SDK_METADATA} {...mergedProps} + {...keylessProps} {...restProps} > {children} diff --git a/packages/react-router/src/client/types.ts b/packages/react-router/src/client/types.ts index 1c7c15fcbb3..df3d942d5c8 100644 --- a/packages/react-router/src/client/types.ts +++ b/packages/react-router/src/client/types.ts @@ -24,6 +24,8 @@ export type ClerkState = { __prefetchUI: boolean | undefined; __telemetryDisabled: boolean | undefined; __telemetryDebug: boolean | undefined; + __keylessClaimUrl?: string; + __keylessApiKeysUrl?: string; }; }; diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index 19af698c118..b78f38e05c9 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -7,6 +7,7 @@ import type { MiddlewareFunction } from 'react-router'; import { createContext } from 'react-router'; import { clerkClient } from './clerkClient'; +import { resolveKeysWithKeylessFallback } from './keyless/utils'; import { loadOptions } from './loadOptions'; import type { ClerkMiddlewareOptions } from './types'; import { patchRequest } from './utils'; @@ -35,16 +36,28 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun const clerkRequest = createClerkRequest(patchRequest(args.request)); const loadedOptions = loadOptions(args, options); + const { + publishableKey, + secretKey, + claimUrl: __keylessClaimUrl, + apiKeysUrl: __keylessApiKeysUrl, + } = await resolveKeysWithKeylessFallback(loadedOptions.publishableKey, loadedOptions.secretKey, args, options); + + if (publishableKey) { + loadedOptions.publishableKey = publishableKey; + } + if (secretKey) { + loadedOptions.secretKey = secretKey; + } + // Pick only the properties needed by authenticateRequest. // Used when manually providing options to the middleware. const { apiUrl, - secretKey, jwtKey, proxyUrl, isSatellite, domain, - publishableKey, machineSecretKey, audience, authorizedParties, @@ -55,12 +68,12 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun const requestState = await clerkClient(args, options).authenticateRequest(clerkRequest, { apiUrl, - secretKey, + secretKey: loadedOptions.secretKey, jwtKey, proxyUrl, isSatellite, domain, - publishableKey, + publishableKey: loadedOptions.publishableKey, machineSecretKey, audience, authorizedParties, @@ -70,6 +83,11 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun acceptsToken: 'any', }); + Object.assign(requestState, { + __keylessClaimUrl, + __keylessApiKeysUrl, + }); + const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { handleNetlifyCacheInDevInstance({ @@ -85,7 +103,7 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun throw new Error('Clerk: handshake status without redirect'); } - args.context.set(authFnContext, (options?: PendingSessionOptions) => requestState.toAuth(options)); + args.context.set(authFnContext, (opts?: PendingSessionOptions) => requestState.toAuth(opts)); args.context.set(requestStateContext, requestState); const response = await next(); diff --git a/packages/react-router/src/server/keyless/fileStorage.ts b/packages/react-router/src/server/keyless/fileStorage.ts new file mode 100644 index 00000000000..35f2bfc2b69 --- /dev/null +++ b/packages/react-router/src/server/keyless/fileStorage.ts @@ -0,0 +1,29 @@ +import { createNodeFileStorage, type KeylessStorage } from '@clerk/shared/keyless'; + +export type { KeylessStorage }; + +export interface FileStorageOptions { + cwd?: () => string; +} + +/** + * Creates a file-based storage adapter for keyless mode. + * Uses dynamic imports to avoid bundler issues with edge runtimes. + */ +export async function createFileStorage(options: FileStorageOptions = {}): Promise { + const { cwd = () => process.cwd() } = options; + + try { + const [fs, path] = await Promise.all([import('node:fs'), import('node:path')]); + + return createNodeFileStorage(fs, path, { + cwd, + frameworkPackageName: '@clerk/react-router', + }); + } catch { + throw new Error( + 'Keyless mode requires a Node.js runtime with file system access. ' + + 'Set VITE_CLERK_KEYLESS_DISABLED=1 to disable keyless mode.', + ); + } +} diff --git a/packages/react-router/src/server/keyless/index.ts b/packages/react-router/src/server/keyless/index.ts new file mode 100644 index 00000000000..a0ec434b19a --- /dev/null +++ b/packages/react-router/src/server/keyless/index.ts @@ -0,0 +1,94 @@ +import { createKeylessService } from '@clerk/shared/keyless'; + +import { clerkClient } from '../clerkClient'; +import type { DataFunctionArgs } from '../loadOptions'; +import type { ClerkMiddlewareOptions } from '../types'; +import { createFileStorage } from './fileStorage'; + +let keylessServiceInstance: ReturnType | null = null; +let keylessInitPromise: Promise | null> | null = null; + +function canUseFileSystem(): boolean { + try { + return typeof process !== 'undefined' && typeof process.cwd === 'function'; + } catch { + return false; + } +} + +/** + * Gets or creates the keyless service singleton. + * Returns null for non-Node.js runtimes (e.g., Cloudflare Workers). + */ +export async function keyless( + args: DataFunctionArgs, + options?: ClerkMiddlewareOptions, +): Promise | null> { + if (!canUseFileSystem()) { + return null; + } + + if (keylessServiceInstance) { + return keylessServiceInstance; + } + + if (keylessInitPromise) { + return keylessInitPromise; + } + + keylessInitPromise = (async () => { + try { + const storage = await createFileStorage(); + + const service = createKeylessService({ + storage, + api: { + async createAccountlessApplication(requestHeaders?: Headers) { + try { + return await clerkClient( + args, + options, + ).__experimental_accountlessApplications.createAccountlessApplication({ + requestHeaders, + }); + } catch { + return null; + } + }, + async completeOnboarding(requestHeaders?: Headers) { + try { + return await clerkClient( + args, + options, + ).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ + requestHeaders, + }); + } catch { + return null; + } + }, + }, + framework: 'react-router', + frameworkVersion: PACKAGE_VERSION, + }); + + keylessServiceInstance = service; + return service; + } catch (error) { + console.warn('[Clerk] Failed to initialize keyless service:', error); + return null; + } finally { + keylessInitPromise = null; + } + })(); + + return keylessInitPromise; +} + +/** + * @internal + */ +export function resetKeylessService(): void { + keylessServiceInstance = null; + keylessInitPromise = null; +} diff --git a/packages/react-router/src/server/keyless/utils.ts b/packages/react-router/src/server/keyless/utils.ts new file mode 100644 index 00000000000..138a30d22cd --- /dev/null +++ b/packages/react-router/src/server/keyless/utils.ts @@ -0,0 +1,25 @@ +import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; +export type { KeylessResult } from '@clerk/shared/keyless'; + +import { canUseKeyless } from '../../utils/feature-flags'; +import type { DataFunctionArgs } from '../loadOptions'; +import type { ClerkMiddlewareOptions } from '../types'; +import { keyless } from './index'; + +/** + * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. + */ +export async function resolveKeysWithKeylessFallback( + configuredPublishableKey: string | undefined, + configuredSecretKey: string | undefined, + args: DataFunctionArgs, + options?: ClerkMiddlewareOptions, +) { + const keylessService = await keyless(args, options); + return sharedResolveKeysWithKeylessFallback( + configuredPublishableKey, + configuredSecretKey, + keylessService, + canUseKeyless, + ); +} diff --git a/packages/react-router/src/server/loadOptions.ts b/packages/react-router/src/server/loadOptions.ts index 2ae4dab747f..9832552883b 100644 --- a/packages/react-router/src/server/loadOptions.ts +++ b/packages/react-router/src/server/loadOptions.ts @@ -8,6 +8,7 @@ import type { MiddlewareFunction } from 'react-router'; import { getPublicEnvVariables } from '../utils/env'; import { noSecretKeyError, satelliteAndMissingProxyUrlAndDomain, satelliteAndMissingSignInUrl } from '../utils/errors'; +import { canUseKeyless } from '../utils/feature-flags'; import type { ClerkMiddlewareOptions } from './types'; import { patchRequest } from './utils'; @@ -55,13 +56,13 @@ export const loadOptions = (args: DataFunctionArgs, overrides: ClerkMiddlewareOp proxyUrl = relativeOrAbsoluteProxyUrl; } - if (!secretKey) { + if (!secretKey && !canUseKeyless) { throw new Error(noSecretKeyError); } if (isSatellite && !proxyUrl && !domain) { throw new Error(satelliteAndMissingProxyUrlAndDomain); } - if (isSatellite && !isHttpOrHttps(signInUrl) && isDevelopmentFromSecretKey(secretKey)) { + if (isSatellite && secretKey && !isHttpOrHttps(signInUrl) && isDevelopmentFromSecretKey(secretKey)) { throw new Error(satelliteAndMissingSignInUrl); } diff --git a/packages/react-router/src/server/types.ts b/packages/react-router/src/server/types.ts index 467afce52a9..662d100fd58 100644 --- a/packages/react-router/src/server/types.ts +++ b/packages/react-router/src/server/types.ts @@ -63,11 +63,17 @@ export type RootAuthLoaderOptions = ClerkMiddlewareOptions & { loadOrganization?: boolean; }; +export interface KeylessUrls { + __keylessClaimUrl?: string; + __keylessApiKeysUrl?: string; +} + export type RequestStateWithRedirectUrls = RequestState & SignInForceRedirectUrl & SignInFallbackRedirectUrl & SignUpForceRedirectUrl & - SignUpFallbackRedirectUrl; + SignUpFallbackRedirectUrl & + KeylessUrls; export type RootAuthLoaderCallback = ( args: LoaderFunctionArgsWithAuth, diff --git a/packages/react-router/src/server/utils.ts b/packages/react-router/src/server/utils.ts index 1c7582f18de..94a75aecb9e 100644 --- a/packages/react-router/src/server/utils.ts +++ b/packages/react-router/src/server/utils.ts @@ -3,6 +3,7 @@ import cookie from 'cookie'; import type { AppLoadContext, UNSAFE_DataWithResponseInit } from 'react-router'; import { getPublicEnvVariables } from '../utils/env'; +import { canUseKeyless } from '../utils/feature-flags'; import type { RequestStateWithRedirectUrls } from './types'; export function isResponse(value: any): value is Response { @@ -78,9 +79,10 @@ export const injectRequestStateIntoResponse = async ( * @internal */ export function getResponseClerkState(requestState: RequestStateWithRedirectUrls, context: AppLoadContext) { - const { reason, message, isSignedIn, ...rest } = requestState; + const { reason, message, isSignedIn, __keylessClaimUrl, __keylessApiKeysUrl, ...rest } = requestState; const envVars = getPublicEnvVariables(context); - const clerkState = wrapWithClerkState({ + + const baseState: Record = { __clerk_ssr_state: rest.toAuth(), __publishableKey: requestState.publishableKey, __proxyUrl: requestState.proxyUrl, @@ -99,7 +101,14 @@ export function getResponseClerkState(requestState: RequestStateWithRedirectUrls __prefetchUI: envVars.prefetchUI, __telemetryDisabled: envVars.telemetryDisabled, __telemetryDebug: envVars.telemetryDebug, - }); + }; + + if (canUseKeyless && __keylessClaimUrl) { + baseState.__keylessClaimUrl = __keylessClaimUrl; + baseState.__keylessApiKeysUrl = __keylessApiKeysUrl; + } + + const clerkState = wrapWithClerkState(baseState); return { clerkState, diff --git a/packages/react-router/src/utils/feature-flags.ts b/packages/react-router/src/utils/feature-flags.ts new file mode 100644 index 00000000000..bd40eaca25e --- /dev/null +++ b/packages/react-router/src/utils/feature-flags.ts @@ -0,0 +1,10 @@ +import { getEnvVariable } from '@clerk/shared/getEnvVariable'; +import { isTruthy } from '@clerk/shared/underscore'; +import { 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; diff --git a/packages/shared/src/keyless/index.ts b/packages/shared/src/keyless/index.ts index 42c0089949d..75e2cf16c91 100644 --- a/packages/shared/src/keyless/index.ts +++ b/packages/shared/src/keyless/index.ts @@ -12,4 +12,7 @@ export type { FileSystemAdapter, NodeFileStorageOptions, PathAdapter } from './n export { createKeylessService } from './service'; export type { KeylessAPI, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; +export { resolveKeysWithKeylessFallback } from './resolveKeysWithKeylessFallback'; +export type { KeylessResult } from './resolveKeysWithKeylessFallback'; + export type { AccountlessApplication, PublicKeylessApplication } from './types'; diff --git a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts new file mode 100644 index 00000000000..fc923ae2a2f --- /dev/null +++ b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts @@ -0,0 +1,87 @@ +import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; +import type { KeylessService } from './service'; +import type { AccountlessApplication } from './types'; + +export interface KeylessResult { + publishableKey: string | undefined; + secretKey: string | undefined; + claimUrl: string | undefined; + apiKeysUrl: string | undefined; +} + +/** + * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. + * + * @param configuredPublishableKey - The publishable key from options or environment + * @param configuredSecretKey - The secret key from options or environment + * @param keylessService - The keyless service instance (or null if unavailable) + * @param canUseKeyless - Whether keyless mode is enabled in the current environment + * @returns The resolved keys (either configured or from keyless mode) + */ +export async function resolveKeysWithKeylessFallback( + configuredPublishableKey: string | undefined, + configuredSecretKey: string | undefined, + keylessService: KeylessService | null, + canUseKeyless: boolean, +): Promise { + let publishableKey = configuredPublishableKey; + let secretKey = configuredSecretKey; + let claimUrl: string | undefined; + let apiKeysUrl: string | undefined; + + if (!canUseKeyless) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + if (!keylessService) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + try { + const locallyStoredKeys = keylessService.readKeys(); + + // Check if running with claimed keys (configured keys match locally stored keyless keys) + const runningWithClaimedKeys = + Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; + + if (runningWithClaimedKeys && locallyStoredKeys) { + // Complete onboarding when running with claimed keys + try { + await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), { + cacheKey: `${locallyStoredKeys.publishableKey}_complete`, + onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours + }); + } catch { + // noop + } + + clerkDevelopmentCache?.log({ + cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, + msg: createConfirmationMessage(), + }); + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + // In keyless mode, try to read/create keys from the file system + if (!publishableKey && !secretKey) { + const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); + + if (keylessApp) { + publishableKey = keylessApp.publishableKey; + secretKey = keylessApp.secretKey; + claimUrl = keylessApp.claimUrl; + apiKeysUrl = keylessApp.apiKeysUrl; + + clerkDevelopmentCache?.log({ + cacheKey: keylessApp.publishableKey, + msg: createKeylessModeMessage(keylessApp), + }); + } + } + } catch { + // noop - fall through to return whatever keys we have + } + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; +} diff --git a/packages/shared/src/keyless/service.ts b/packages/shared/src/keyless/service.ts index 903c8fa65b8..20b989ff364 100644 --- a/packages/shared/src/keyless/service.ts +++ b/packages/shared/src/keyless/service.ts @@ -1,3 +1,4 @@ +import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; import type { AccountlessApplication } from './types'; /** @@ -75,6 +76,16 @@ export interface KeylessServiceOptions { frameworkVersion?: string; } +/** + * Result type for key resolution. + */ +export interface KeylessResult { + publishableKey: string | undefined; + secretKey: string | undefined; + claimUrl: string | undefined; + apiKeysUrl: string | undefined; +} + /** * The keyless service interface. */ @@ -104,6 +115,18 @@ export interface KeylessService { * Logs a keyless mode message to the console (throttled to once per process). */ logKeylessMessage: (claimUrl: string) => void; + + /** + * Resolves Clerk keys, falling back to keyless mode if configured keys are missing. + * + * @param configuredPublishableKey - The publishable key from options or environment + * @param configuredSecretKey - The secret key from options or environment + * @returns The resolved keys (either configured or from keyless mode) + */ + resolveKeysWithKeylessFallback: ( + configuredPublishableKey: string | undefined, + configuredSecretKey: string | undefined, + ) => Promise; } /** @@ -202,5 +225,63 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer console.log(`[Clerk]: Running in keyless mode. Claim your keys at: ${claimUrl}`); } }, + + async resolveKeysWithKeylessFallback( + configuredPublishableKey: string | undefined, + configuredSecretKey: string | undefined, + ): Promise { + let publishableKey = configuredPublishableKey; + let secretKey = configuredSecretKey; + let claimUrl: string | undefined; + let apiKeysUrl: string | undefined; + + try { + const locallyStoredKeys = safeParseConfig(); + + // Check if running with claimed keys (configured keys match locally stored keyless keys) + const runningWithClaimedKeys = + Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; + + if (runningWithClaimedKeys && locallyStoredKeys) { + // Complete onboarding when running with claimed keys + try { + await clerkDevelopmentCache?.run(() => this.completeOnboarding(), { + cacheKey: `${locallyStoredKeys.publishableKey}_complete`, + onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours + }); + } catch { + // noop + } + + clerkDevelopmentCache?.log({ + cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, + msg: createConfirmationMessage(), + }); + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + // In keyless mode, try to read/create keys from the file system + if (!publishableKey && !secretKey) { + const keylessApp: AccountlessApplication | null = await this.getOrCreateKeys(); + + if (keylessApp) { + publishableKey = keylessApp.publishableKey; + secretKey = keylessApp.secretKey; + claimUrl = keylessApp.claimUrl; + apiKeysUrl = keylessApp.apiKeysUrl; + + clerkDevelopmentCache?.log({ + cacheKey: keylessApp.publishableKey, + msg: createKeylessModeMessage(keylessApp), + }); + } + } + } catch { + // noop - fall through to return whatever keys we have + } + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + }, }; } diff --git a/packages/tanstack-react-start/src/server/keyless/utils.ts b/packages/tanstack-react-start/src/server/keyless/utils.ts index 3a22f0aae86..ab4896cadb5 100644 --- a/packages/tanstack-react-start/src/server/keyless/utils.ts +++ b/packages/tanstack-react-start/src/server/keyless/utils.ts @@ -1,16 +1,9 @@ -import type { AccountlessApplication } from '@clerk/shared/keyless'; -import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from '@clerk/shared/keyless'; +import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; +export type { KeylessResult } from '@clerk/shared/keyless'; import { canUseKeyless } from '../../utils/feature-flags'; import { keyless } from './index'; -export interface KeylessResult { - publishableKey: string | undefined; - secretKey: string | undefined; - claimUrl: string | undefined; - apiKeysUrl: string | undefined; -} - /** * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. * @@ -18,61 +11,9 @@ export interface KeylessResult { * @param configuredSecretKey - The secret key from options or environment * @returns The resolved keys (either configured or from keyless mode) */ -export async function resolveKeysWithKeylessFallback( +export function resolveKeysWithKeylessFallback( configuredPublishableKey: string | undefined, configuredSecretKey: string | undefined, -): Promise { - let publishableKey = configuredPublishableKey; - let secretKey = configuredSecretKey; - let claimUrl: string | undefined; - let apiKeysUrl: string | undefined; - - if (!canUseKeyless) { - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - const keylessService = keyless(); - const locallyStoredKeys = keylessService.readKeys(); - - // Check if running with claimed keys (configured keys match locally stored keyless keys) - const runningWithClaimedKeys = - Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; - - if (runningWithClaimedKeys && locallyStoredKeys) { - // Complete onboarding when running with claimed keys - try { - await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), { - cacheKey: `${locallyStoredKeys.publishableKey}_complete`, - onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours - }); - } catch { - // noop - } - - clerkDevelopmentCache?.log({ - cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, - msg: createConfirmationMessage(), - }); - - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - // In keyless mode, try to read/create keys from the file system - if (!publishableKey || !secretKey) { - const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); - - if (keylessApp) { - publishableKey = publishableKey || keylessApp.publishableKey; - secretKey = secretKey || keylessApp.secretKey; - claimUrl = keylessApp.claimUrl; - apiKeysUrl = keylessApp.apiKeysUrl; - - clerkDevelopmentCache?.log({ - cacheKey: keylessApp.publishableKey, - msg: createKeylessModeMessage(keylessApp), - }); - } - } - - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; +) { + return sharedResolveKeysWithKeylessFallback(configuredPublishableKey, configuredSecretKey, keyless(), canUseKeyless); }