Skip to content

Commit 68d1d8d

Browse files
authored
feat(nuxt): Add support for keyless mode (#7844)
1 parent 3fd586d commit 68d1d8d

16 files changed

Lines changed: 319 additions & 168 deletions

File tree

.changeset/lazy-turkeys-switch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/nuxt": minor
3+
---
4+
5+
Introduce Keyless quickstart for Nuxt. This allows the Clerk SDK to be used without having to sign up and paste your keys manually.

.changeset/tiny-papayas-hope.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clerk/astro": patch
3+
"@clerk/react-router": patch
4+
---
5+
6+
Simplified keyless service initialization.

integration/templates/nuxt-node/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
"preview": "nuxt preview --port $PORT"
1111
},
1212
"dependencies": {
13-
"nuxt": "4.1.2",
14-
"vue": "^3.5.13",
15-
"vue-router": "^4.4.5"
13+
"nuxt": "4.4.2",
14+
"vue": "^3.5.30",
15+
"vue-router": "^5.0.3"
1616
}
1717
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { test } from '@playwright/test';
2+
3+
import type { Application } from '../../models/application';
4+
import { appConfigs } from '../../presets';
5+
import {
6+
testClaimedAppWithMissingKeys,
7+
testKeylessRemovedAfterEnvAndRestart,
8+
testToggleCollapsePopoverAndClaim,
9+
} from '../../testUtils/keylessHelpers';
10+
11+
const commonSetup = appConfigs.nuxt.node.clone();
12+
13+
test.describe('Keyless mode @nuxt', () => {
14+
test.describe.configure({ mode: 'serial' });
15+
test.setTimeout(90_000);
16+
17+
test.use({
18+
extraHTTPHeaders: {
19+
'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET || '',
20+
},
21+
});
22+
23+
let app: Application;
24+
let dashboardUrl = 'https://dashboard.clerk.com/';
25+
26+
test.beforeAll(async () => {
27+
app = await commonSetup.commit();
28+
await app.setup();
29+
await app.withEnv(appConfigs.envs.withKeyless);
30+
if (appConfigs.envs.withKeyless.privateVariables.get('CLERK_API_URL')?.includes('clerkstage')) {
31+
dashboardUrl = 'https://dashboard.clerkstage.dev/';
32+
}
33+
await app.dev();
34+
});
35+
36+
test.afterAll(async () => {
37+
// Keep files for debugging
38+
await app?.teardown();
39+
});
40+
41+
test('Toggle collapse popover and claim.', async ({ page, context }) => {
42+
await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'nuxt' });
43+
});
44+
45+
test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({
46+
page,
47+
context,
48+
}) => {
49+
await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl });
50+
});
51+
52+
test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => {
53+
await testKeylessRemovedAfterEnvAndRestart({ page, context, app });
54+
});
55+
});

packages/astro/src/server/keyless/index.ts

Lines changed: 30 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -4,83 +4,37 @@ import type { APIContext } from 'astro';
44
import { clerkClient } from '../clerk-client';
55
import { createFileStorage } from './file-storage.js';
66

7+
// Lazily initialized keyless service singleton
78
let keylessServiceInstance: ReturnType<typeof createKeylessService> | null = null;
8-
let keylessInitPromise: Promise<ReturnType<typeof createKeylessService> | null> | null = null;
99

10-
function canUseFileSystem(): boolean {
11-
try {
12-
return typeof process !== 'undefined' && typeof process.cwd === 'function';
13-
} catch {
14-
return false;
15-
}
16-
}
17-
18-
/**
19-
* Gets or creates the keyless service singleton.
20-
* Returns null for non-Node.js runtimes (e.g., Cloudflare Workers).
21-
*/
22-
export async function keyless(context: APIContext): Promise<ReturnType<typeof createKeylessService> | null> {
23-
if (!canUseFileSystem()) {
24-
return null;
25-
}
26-
27-
if (keylessServiceInstance) {
28-
return keylessServiceInstance;
29-
}
30-
31-
if (keylessInitPromise) {
32-
return keylessInitPromise;
33-
}
34-
35-
keylessInitPromise = (async () => {
36-
try {
37-
const storage = await createFileStorage();
38-
39-
const service = createKeylessService({
40-
storage,
41-
api: {
42-
async createAccountlessApplication(requestHeaders?: Headers) {
43-
try {
44-
return await clerkClient(context).__experimental_accountlessApplications.createAccountlessApplication({
45-
requestHeaders,
46-
});
47-
} catch {
48-
return null;
49-
}
50-
},
51-
async completeOnboarding(requestHeaders?: Headers) {
52-
try {
53-
return await clerkClient(
54-
context,
55-
).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
56-
requestHeaders,
57-
});
58-
} catch {
59-
return null;
60-
}
61-
},
10+
export function keyless(context: APIContext) {
11+
if (!keylessServiceInstance) {
12+
keylessServiceInstance = createKeylessService({
13+
storage: createFileStorage(),
14+
api: {
15+
async createAccountlessApplication(requestHeaders?: Headers) {
16+
try {
17+
return await clerkClient(context).__experimental_accountlessApplications.createAccountlessApplication({
18+
requestHeaders,
19+
});
20+
} catch {
21+
return null;
22+
}
6223
},
63-
framework: 'astro',
64-
frameworkVersion: PACKAGE_VERSION,
65-
});
66-
67-
keylessServiceInstance = service;
68-
return service;
69-
} catch (error) {
70-
console.warn('[Clerk] Failed to initialize keyless service:', error);
71-
return null;
72-
} finally {
73-
keylessInitPromise = null;
74-
}
75-
})();
76-
77-
return keylessInitPromise;
78-
}
79-
80-
/**
81-
* @internal
82-
*/
83-
export function resetKeylessService(): void {
84-
keylessServiceInstance = null;
85-
keylessInitPromise = null;
24+
async completeOnboarding(requestHeaders?: Headers) {
25+
try {
26+
return await clerkClient(
27+
context,
28+
).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
29+
requestHeaders,
30+
});
31+
} catch {
32+
return null;
33+
}
34+
},
35+
},
36+
framework: 'astro',
37+
});
38+
}
39+
return keylessServiceInstance;
8640
}

packages/nuxt/src/module.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ export default defineNuxtModule<ModuleOptions>({
7474
// Backend specific variables that are safe to share.
7575
// We want them to be overridable like the other public keys (e.g NUXT_PUBLIC_CLERK_PROXY_URL)
7676
proxyUrl: options.proxyUrl,
77-
apiUrl: '',
78-
apiVersion: 'v1',
77+
// Deprecated: use NUXT_CLERK_API_URL and NUXT_CLERK_API_VERSION instead.
78+
// Kept for backwards compatibility with NUXT_PUBLIC_CLERK_API_URL / NUXT_PUBLIC_CLERK_API_VERSION.
79+
apiUrl: undefined,
80+
apiVersion: undefined,
7981
},
8082
},
8183
// Private keys available only on within server-side
@@ -84,6 +86,8 @@ export default defineNuxtModule<ModuleOptions>({
8486
machineSecretKey: undefined,
8587
jwtKey: undefined,
8688
webhookSigningSecret: undefined,
89+
apiUrl: undefined,
90+
apiVersion: undefined,
8791
},
8892
});
8993

packages/nuxt/src/runtime/plugin.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@ import { clerkPlugin } from '@clerk/vue';
44
import { setErrorThrowerOptions } from '@clerk/vue/internal';
55
import { defineNuxtPlugin, navigateTo, useRuntimeConfig, useState } from 'nuxt/app';
66

7+
import type { ClerkKeylessContext } from './server/types';
8+
79
setErrorThrowerOptions({ packageName: PACKAGE_NAME });
810
setClerkJSLoadingErrorPackageName(PACKAGE_NAME);
911

1012
export default defineNuxtPlugin(nuxtApp => {
1113
// SSR-friendly shared state
1214
const initialState = useState<InitialState | undefined>('clerk-initial-state', () => undefined);
15+
const keylessContext = useState<ClerkKeylessContext | undefined>('clerk-keyless-context', () => undefined);
1316

1417
if (import.meta.server) {
1518
// Save the initial state from server and pass it to the plugin
1619
initialState.value = nuxtApp.ssrContext?.event.context.__clerk_initial_state;
20+
keylessContext.value = nuxtApp.ssrContext?.event.context.__clerk_keyless;
1721
}
1822

1923
const runtimeConfig = useRuntimeConfig();
@@ -34,5 +38,12 @@ export default defineNuxtPlugin(nuxtApp => {
3438
routerPush: (to: string) => navigateTo(to),
3539
routerReplace: (to: string) => navigateTo(to, { replace: true }),
3640
initialState: initialState.value,
41+
// Add keyless mode props if present
42+
...(keylessContext.value
43+
? {
44+
__internal_keyless_claimKeylessApplicationUrl: keylessContext.value.claimUrl,
45+
__internal_keyless_copyInstanceKeysUrl: keylessContext.value.apiKeysUrl,
46+
}
47+
: {}),
3748
});
3849
});

packages/nuxt/src/runtime/server/clerkClient.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,41 @@
11
import { createClerkClient } from '@clerk/backend';
22
import { apiUrlFromPublishableKey } from '@clerk/shared/apiUrlFromPublishableKey';
3+
import { deprecated } from '@clerk/shared/deprecated';
34
import { isTruthy } from '@clerk/shared/underscore';
45
import type { H3Event } from 'h3';
56

67
// @ts-expect-error: Nitro import. Handled by Nuxt.
78
import { useRuntimeConfig } from '#imports';
89

10+
function resolveApiUrl(runtimeConfig: ReturnType<typeof useRuntimeConfig>): string {
11+
if (runtimeConfig.clerk.apiUrl) {
12+
return runtimeConfig.clerk.apiUrl;
13+
}
14+
if (runtimeConfig.public.clerk.apiUrl) {
15+
deprecated('NUXT_PUBLIC_CLERK_API_URL', 'Use `NUXT_CLERK_API_URL` instead.');
16+
return runtimeConfig.public.clerk.apiUrl;
17+
}
18+
return apiUrlFromPublishableKey(runtimeConfig.public.clerk.publishableKey);
19+
}
20+
21+
function resolveApiVersion(runtimeConfig: ReturnType<typeof useRuntimeConfig>): string {
22+
if (runtimeConfig.clerk.apiVersion) {
23+
return runtimeConfig.clerk.apiVersion;
24+
}
25+
if (runtimeConfig.public.clerk.apiVersion) {
26+
deprecated('NUXT_PUBLIC_CLERK_API_VERSION', 'Use `NUXT_CLERK_API_VERSION` instead.');
27+
return runtimeConfig.public.clerk.apiVersion;
28+
}
29+
return 'v1';
30+
}
31+
932
export function clerkClient(event: H3Event) {
1033
const runtimeConfig = useRuntimeConfig(event);
11-
const publishableKey = runtimeConfig.public.clerk.publishableKey;
12-
const apiUrl = runtimeConfig.public.clerk.apiUrl || apiUrlFromPublishableKey(publishableKey);
1334

1435
return createClerkClient({
15-
publishableKey,
16-
apiUrl,
17-
apiVersion: runtimeConfig.public.clerk.apiVersion,
36+
publishableKey: runtimeConfig.public.clerk.publishableKey,
37+
apiUrl: resolveApiUrl(runtimeConfig),
38+
apiVersion: resolveApiVersion(runtimeConfig),
1839
proxyUrl: runtimeConfig.public.clerk.proxyUrl,
1940
domain: runtimeConfig.public.clerk.domain,
2041
isSatellite: runtimeConfig.public.clerk.isSatellite,

packages/nuxt/src/runtime/server/clerkMiddleware.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import type { PendingSessionOptions } from '@clerk/shared/types';
55
import type { EventHandler } from 'h3';
66
import { createError, eventHandler, setResponseHeader } from 'h3';
77

8+
// @ts-expect-error: Nitro import. Handled by Nuxt.
9+
import { useRuntimeConfig } from '#imports';
10+
11+
import { canUseKeyless } from '../utils/feature-flags';
812
import { clerkClient } from './clerkClient';
13+
import { resolveKeysWithKeylessFallback } from './keyless/utils';
914
import type { AuthFn, AuthOptions } from './types';
1015
import { createInitialState, toWebRequest } from './utils';
1116

@@ -82,6 +87,35 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => {
8287
return eventHandler(async event => {
8388
const clerkRequest = toWebRequest(event);
8489

90+
// Resolve keyless in development if keys are missing
91+
let keylessClaimUrl: string | undefined;
92+
let keylessApiKeysUrl: string | undefined;
93+
94+
if (canUseKeyless) {
95+
try {
96+
const runtimeConfig = useRuntimeConfig(event);
97+
98+
const { publishableKey, secretKey, claimUrl, apiKeysUrl } = await resolveKeysWithKeylessFallback(
99+
runtimeConfig.public.clerk.publishableKey,
100+
runtimeConfig.clerk.secretKey,
101+
event,
102+
);
103+
104+
keylessClaimUrl = claimUrl;
105+
keylessApiKeysUrl = apiKeysUrl;
106+
107+
// Override runtime config with keyless values if returned
108+
if (publishableKey) {
109+
runtimeConfig.public.clerk.publishableKey = publishableKey;
110+
}
111+
if (secretKey) {
112+
runtimeConfig.clerk.secretKey = secretKey;
113+
}
114+
} catch {
115+
// Silently fail - continue without keyless
116+
}
117+
}
118+
85119
const requestState = await clerkClient(event).authenticateRequest(clerkRequest, {
86120
...options,
87121
acceptsToken: 'any',
@@ -117,6 +151,14 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => {
117151
// Internal serializable state that will be passed to the client
118152
event.context.__clerk_initial_state = createInitialState(authObjectFn());
119153

154+
// Store keyless mode URLs in separate context property
155+
if (canUseKeyless && keylessClaimUrl) {
156+
event.context.__clerk_keyless = {
157+
claimUrl: keylessClaimUrl,
158+
apiKeysUrl: keylessApiKeysUrl,
159+
};
160+
}
161+
120162
await handler?.(event);
121163
});
122164
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
4+
import { createNodeFileStorage, type KeylessStorage } from '@clerk/shared/keyless';
5+
6+
export type { KeylessStorage };
7+
8+
export interface FileStorageOptions {
9+
cwd?: () => string;
10+
}
11+
12+
export function createFileStorage(options: FileStorageOptions = {}): KeylessStorage {
13+
const { cwd = () => process.cwd() } = options;
14+
15+
return createNodeFileStorage(fs, path, {
16+
cwd,
17+
frameworkPackageName: '@clerk/nuxt',
18+
});
19+
}

0 commit comments

Comments
 (0)