diff --git a/.changeset/lazy-turkeys-switch.md b/.changeset/lazy-turkeys-switch.md new file mode 100644 index 00000000000..dcd5a400652 --- /dev/null +++ b/.changeset/lazy-turkeys-switch.md @@ -0,0 +1,5 @@ +--- +"@clerk/nuxt": minor +--- + +Introduce Keyless quickstart for Nuxt. This allows the Clerk SDK to be used without having to sign up and paste your keys manually. diff --git a/integration/tests/nuxt/keyless.test.ts b/integration/tests/nuxt/keyless.test.ts new file mode 100644 index 00000000000..6a2cad13033 --- /dev/null +++ b/integration/tests/nuxt/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.nuxt.node.clone(); + +test.describe('Keyless mode @nuxt', () => { + 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: 'nuxt' }); + }); + + 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/packages/astro/src/server/keyless/index.ts b/packages/astro/src/server/keyless/index.ts index 641fb961510..7c1bb31353e 100644 --- a/packages/astro/src/server/keyless/index.ts +++ b/packages/astro/src/server/keyless/index.ts @@ -4,83 +4,37 @@ import type { APIContext } from 'astro'; import { clerkClient } from '../clerk-client'; import { createFileStorage } from './file-storage.js'; +// Lazily initialized keyless service singleton 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(context: APIContext): 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(context).__experimental_accountlessApplications.createAccountlessApplication({ - requestHeaders, - }); - } catch { - return null; - } - }, - async completeOnboarding(requestHeaders?: Headers) { - try { - return await clerkClient( - context, - ).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ - requestHeaders, - }); - } catch { - return null; - } - }, +export function keyless(context: APIContext) { + if (!keylessServiceInstance) { + keylessServiceInstance = createKeylessService({ + storage: createFileStorage(), + api: { + async createAccountlessApplication(requestHeaders?: Headers) { + try { + return await clerkClient(context).__experimental_accountlessApplications.createAccountlessApplication({ + requestHeaders, + }); + } catch { + return null; + } }, - framework: 'astro', - 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; + async completeOnboarding(requestHeaders?: Headers) { + try { + return await clerkClient( + context, + ).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ + requestHeaders, + }); + } catch { + return null; + } + }, + }, + framework: 'astro', + }); + } + return keylessServiceInstance; } diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index c4ee151a95a..d8b574c7f4a 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -116,6 +116,7 @@ export default defineNuxtModule({ { filename: 'types/clerk.d.ts', getContents: () => `import type { AuthFn } from '@clerk/nuxt/server'; + import type { InitialState } from '@clerk/shared/types'; declare module 'h3' { interface H3EventContext { diff --git a/packages/nuxt/src/runtime/plugin.ts b/packages/nuxt/src/runtime/plugin.ts index 03fdeb75d25..0e2a344c205 100644 --- a/packages/nuxt/src/runtime/plugin.ts +++ b/packages/nuxt/src/runtime/plugin.ts @@ -10,10 +10,15 @@ setClerkJSLoadingErrorPackageName(PACKAGE_NAME); export default defineNuxtPlugin(nuxtApp => { // SSR-friendly shared state const initialState = useState('clerk-initial-state', () => undefined); + const keylessContext = useState<{ claimUrl?: string; apiKeysUrl?: string } | undefined>( + 'clerk-keyless-context', + () => undefined, + ); if (import.meta.server) { // Save the initial state from server and pass it to the plugin initialState.value = nuxtApp.ssrContext?.event.context.__clerk_initial_state; + keylessContext.value = nuxtApp.ssrContext?.event.context.__clerk_keyless; } const runtimeConfig = useRuntimeConfig(); @@ -32,5 +37,12 @@ export default defineNuxtPlugin(nuxtApp => { routerPush: (to: string) => navigateTo(to), routerReplace: (to: string) => navigateTo(to, { replace: true }), initialState: initialState.value, + // Add keyless mode props if present + ...(keylessContext.value + ? { + __internal_keyless_claimKeylessApplicationUrl: keylessContext.value.claimUrl, + __internal_keyless_copyInstanceKeysUrl: keylessContext.value.apiKeysUrl, + } + : {}), }); }); diff --git a/packages/nuxt/src/runtime/server/clerkMiddleware.ts b/packages/nuxt/src/runtime/server/clerkMiddleware.ts index 555999938b5..7a62ef69dbd 100644 --- a/packages/nuxt/src/runtime/server/clerkMiddleware.ts +++ b/packages/nuxt/src/runtime/server/clerkMiddleware.ts @@ -5,7 +5,9 @@ import type { PendingSessionOptions } from '@clerk/shared/types'; import type { EventHandler } from 'h3'; import { createError, eventHandler, setResponseHeader } from 'h3'; +import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; +import { resolveKeysWithKeylessFallback } from './keyless/utils'; import type { AuthFn, AuthOptions } from './types'; import { createInitialState, toWebRequest } from './utils'; @@ -82,6 +84,38 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { return eventHandler(async event => { const clerkRequest = toWebRequest(event); + // Resolve keyless in development if keys are missing + let keylessClaimUrl: string | undefined; + let keylessApiKeysUrl: string | undefined; + + if (canUseKeyless) { + try { + // Get runtime config to access configured keys + // @ts-expect-error: Nitro import. Handled by Nuxt. + const { useRuntimeConfig } = await import('#imports'); + const runtimeConfig = useRuntimeConfig(event); + + const { publishableKey, secretKey, claimUrl, apiKeysUrl } = await resolveKeysWithKeylessFallback( + runtimeConfig.public.clerk.publishableKey, + runtimeConfig.clerk.secretKey, + event, + ); + + keylessClaimUrl = claimUrl; + keylessApiKeysUrl = apiKeysUrl; + + // Override runtime config with keyless values if returned + if (publishableKey) { + runtimeConfig.public.clerk.publishableKey = publishableKey; + } + if (secretKey) { + runtimeConfig.clerk.secretKey = secretKey; + } + } catch { + // Silently fail - continue without keyless + } + } + const requestState = await clerkClient(event).authenticateRequest(clerkRequest, { ...options, acceptsToken: 'any', @@ -117,6 +151,14 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { // Internal serializable state that will be passed to the client event.context.__clerk_initial_state = createInitialState(authObjectFn()); + // Store keyless mode URLs in separate context property + if (canUseKeyless && keylessClaimUrl) { + event.context.__clerk_keyless = { + claimUrl: keylessClaimUrl, + apiKeysUrl: keylessApiKeysUrl, + }; + } + await handler?.(event); }); }; diff --git a/packages/nuxt/src/runtime/server/keyless/fileStorage.ts b/packages/nuxt/src/runtime/server/keyless/fileStorage.ts new file mode 100644 index 00000000000..340b011dcef --- /dev/null +++ b/packages/nuxt/src/runtime/server/keyless/fileStorage.ts @@ -0,0 +1,19 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { createNodeFileStorage, type KeylessStorage } from '@clerk/shared/keyless'; + +export type { KeylessStorage }; + +export interface FileStorageOptions { + cwd?: () => string; +} + +export function createFileStorage(options: FileStorageOptions = {}): KeylessStorage { + const { cwd = () => process.cwd() } = options; + + return createNodeFileStorage(fs, path, { + cwd, + frameworkPackageName: '@clerk/nuxt', + }); +} diff --git a/packages/nuxt/src/runtime/server/keyless/index.ts b/packages/nuxt/src/runtime/server/keyless/index.ts new file mode 100644 index 00000000000..0ee1a4fac47 --- /dev/null +++ b/packages/nuxt/src/runtime/server/keyless/index.ts @@ -0,0 +1,40 @@ +import { createKeylessService } from '@clerk/shared/keyless'; +import type { H3Event } from 'h3'; + +import { clerkClient } from '../clerkClient'; +import { createFileStorage } from './fileStorage'; + +// Lazily initialized keyless service singleton +let keylessServiceInstance: ReturnType | null = null; + +export function keyless(event: H3Event) { + if (!keylessServiceInstance) { + keylessServiceInstance = createKeylessService({ + storage: createFileStorage(), + api: { + async createAccountlessApplication(requestHeaders?: Headers) { + try { + return await clerkClient(event).__experimental_accountlessApplications.createAccountlessApplication({ + requestHeaders, + }); + } catch { + return null; + } + }, + async completeOnboarding(requestHeaders?: Headers) { + try { + return await clerkClient( + event, + ).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ + requestHeaders, + }); + } catch { + return null; + } + }, + }, + framework: 'nuxt', + }); + } + return keylessServiceInstance; +} diff --git a/packages/nuxt/src/runtime/server/keyless/utils.ts b/packages/nuxt/src/runtime/server/keyless/utils.ts new file mode 100644 index 00000000000..3943211df9f --- /dev/null +++ b/packages/nuxt/src/runtime/server/keyless/utils.ts @@ -0,0 +1,24 @@ +import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; +import type { H3Event } from 'h3'; + +import { canUseKeyless } from '../../utils/feature-flags'; +import { keyless } from './index'; + +export type { KeylessResult } from '@clerk/shared/keyless'; + +/** + * 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, + event: H3Event, +) { + const keylessService = await keyless(event); + return sharedResolveKeysWithKeylessFallback( + configuredPublishableKey, + configuredSecretKey, + keylessService, + canUseKeyless, + ); +} diff --git a/packages/nuxt/src/runtime/server/types.ts b/packages/nuxt/src/runtime/server/types.ts index c4369d57e23..0700b3531f3 100644 --- a/packages/nuxt/src/runtime/server/types.ts +++ b/packages/nuxt/src/runtime/server/types.ts @@ -7,3 +7,11 @@ export type AuthOptions = PendingSessionOptions & Pick | 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({ +export function keyless(args: DataFunctionArgs, options?: ClerkMiddlewareOptions) { + if (!keylessServiceInstance) { + keylessServiceInstance = createKeylessService({ + storage: createFileStorage(), + api: { + async createAccountlessApplication(requestHeaders?: Headers) { + try { + return await clerkClient(args, options).__experimental_accountlessApplications.createAccountlessApplication( + { requestHeaders, - }); - } catch { - return null; - } - }, + }, + ); + } 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; + async completeOnboarding(requestHeaders?: Headers) { + try { + return await clerkClient( + args, + options, + ).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ + requestHeaders, + }); + } catch { + return null; + } + }, + }, + framework: 'react-router', + }); + } + return keylessServiceInstance; }