Skip to content

Commit fc1064b

Browse files
committed
feat(astro): Add support for keyless mode
- Add keyless service with file storage adapter - Middleware resolves keyless URLs per-request (runtime, not compile-time) - Store keyless data in context.locals - Inject keyless URLs via __CLERK_ASTRO_SAFE_VARS__ script tag - Client reads from params (server-injected) instead of import.meta.env - Add feature flag for keyless mode - Add integration test using shared keyless helpers - Add @clerk/shared as dependency for shared keyless utilities Follows runtime pattern from React Router and TanStack Start: - No browser cache issues - Instant updates when switching modes - Clean separation: Integration for build, Middleware for runtime
1 parent 0cca492 commit fc1064b

File tree

13 files changed

+312
-7
lines changed

13 files changed

+312
-7
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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.astro.node.clone();
12+
13+
test.describe('Keyless mode @astro', () => {
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+
await app?.teardown();
38+
});
39+
40+
test('Toggle collapse popover and claim.', async ({ page, context }) => {
41+
await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'astro' });
42+
});
43+
44+
test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({
45+
page,
46+
context,
47+
}) => {
48+
await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl });
49+
});
50+
51+
test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => {
52+
await testKeylessRemovedAfterEnvAndRestart({ page, context, app });
53+
});
54+
});

packages/astro/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@
9797
},
9898
"devDependencies": {
9999
"@clerk/ui": "workspace:^",
100-
"astro": "^5.15.9"
100+
"astro": "^5.15.9",
101+
"vite": "^7.1.0"
101102
},
102103
"peerDependencies": {
103104
"astro": "^4.15.0 || ^5.0.0"

packages/astro/src/env.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface InternalEnv {
2121
readonly PUBLIC_CLERK_SIGN_UP_URL?: string;
2222
readonly PUBLIC_CLERK_TELEMETRY_DISABLED?: string;
2323
readonly PUBLIC_CLERK_TELEMETRY_DEBUG?: string;
24+
readonly PUBLIC_CLERK_KEYLESS_DISABLED?: string;
2425
}
2526

2627
interface ImportMeta {
@@ -30,6 +31,9 @@ interface ImportMeta {
3031
declare namespace App {
3132
interface Locals {
3233
runtime: { env: InternalEnv };
34+
keylessClaimUrl?: string;
35+
keylessApiKeysUrl?: string;
36+
keylessPublishableKey?: string;
3337
}
3438
}
3539

packages/astro/src/integration/create-integration.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,24 @@ function createIntegration<Params extends HotloadAstroClerkIntegrationParams>()
2828
return {
2929
name: '@clerk/astro/integration',
3030
hooks: {
31-
'astro:config:setup': ({ config, injectScript, updateConfig, logger, command }) => {
31+
'astro:config:setup': async ({ config, injectScript, updateConfig, logger, command }) => {
3232
if (['server', 'hybrid'].includes(config.output) && !config.adapter) {
3333
logger.error('Missing adapter, please update your Astro config to use one.');
3434
}
3535

36+
const isDev = command === 'dev';
37+
38+
// Read keys from process.env for vite.define injection
39+
// Note: Keyless mode is now handled by middleware per-request, not here
40+
const envPublishableKey = process.env.PUBLIC_CLERK_PUBLISHABLE_KEY;
41+
const envSecretKey = process.env.CLERK_SECRET_KEY;
42+
3643
const internalParams: ClerkOptions = {
3744
...params,
3845
sdkMetadata: {
3946
version: packageVersion,
4047
name: packageName,
41-
environment: command === 'dev' ? 'development' : 'production',
48+
environment: isDev ? 'development' : 'production',
4249
},
4350
};
4451

@@ -64,6 +71,9 @@ function createIntegration<Params extends HotloadAstroClerkIntegrationParams>()
6471
prefetchUI === false || hasUI ? 'false' : undefined,
6572
'PUBLIC_CLERK_PREFETCH_UI',
6673
),
74+
...buildEnvVarFromOption(envPublishableKey, 'PUBLIC_CLERK_PUBLISHABLE_KEY'),
75+
...buildEnvVarFromOption(envSecretKey, 'CLERK_SECRET_KEY'),
76+
// Keyless URLs are now handled by middleware, not vite.define
6777
},
6878

6979
ssr: {
@@ -166,7 +176,7 @@ function createIntegration<Params extends HotloadAstroClerkIntegrationParams>()
166176

167177
function createClerkEnvSchema() {
168178
return {
169-
PUBLIC_CLERK_PUBLISHABLE_KEY: envField.string({ context: 'client', access: 'public' }),
179+
PUBLIC_CLERK_PUBLISHABLE_KEY: envField.string({ context: 'client', access: 'public', optional: true }),
170180
PUBLIC_CLERK_SIGN_IN_URL: envField.string({ context: 'client', access: 'public', optional: true }),
171181
PUBLIC_CLERK_SIGN_UP_URL: envField.string({ context: 'client', access: 'public', optional: true }),
172182
PUBLIC_CLERK_IS_SATELLITE: envField.boolean({ context: 'client', access: 'public', optional: true }),
@@ -179,7 +189,20 @@ function createClerkEnvSchema() {
179189
PUBLIC_CLERK_UI_URL: envField.string({ context: 'client', access: 'public', optional: true, url: true }),
180190
PUBLIC_CLERK_TELEMETRY_DISABLED: envField.boolean({ context: 'client', access: 'public', optional: true }),
181191
PUBLIC_CLERK_TELEMETRY_DEBUG: envField.boolean({ context: 'client', access: 'public', optional: true }),
182-
CLERK_SECRET_KEY: envField.string({ context: 'server', access: 'secret' }),
192+
PUBLIC_CLERK_KEYLESS_CLAIM_URL: envField.string({
193+
context: 'client',
194+
access: 'public',
195+
optional: true,
196+
url: true,
197+
}),
198+
PUBLIC_CLERK_KEYLESS_API_KEYS_URL: envField.string({
199+
context: 'client',
200+
access: 'public',
201+
optional: true,
202+
url: true,
203+
}),
204+
PUBLIC_CLERK_KEYLESS_DISABLED: envField.boolean({ context: 'client', access: 'public', optional: true }),
205+
CLERK_SECRET_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
183206
CLERK_MACHINE_SECRET_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
184207
CLERK_JWT_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
185208
};

packages/astro/src/internal/create-clerk-instance.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,17 @@ async function createClerkInstanceInternal<TUi extends Ui = Ui>(options?: AstroC
5454
$clerk.set(clerkJSInstance);
5555
}
5656

57+
const keylessClaimUrl = (options as any)?.__internal_keylessClaimUrl;
58+
const keylessApiKeysUrl = (options as any)?.__internal_keylessApiKeysUrl;
59+
5760
const clerkOptions = {
5861
routerPush: createNavigationHandler(window.history.pushState.bind(window.history)),
5962
routerReplace: createNavigationHandler(window.history.replaceState.bind(window.history)),
6063
...options,
6164
// Pass the clerk-ui constructor promise to clerk.load()
6265
ui: { ...options?.ui, ClerkUI },
66+
...(keylessClaimUrl && { __internal_keyless_claimKeylessApplicationUrl: keylessClaimUrl }),
67+
...(keylessApiKeysUrl && { __internal_keyless_copyInstanceKeysUrl: keylessApiKeysUrl }),
6368
} as unknown as ClerkOptions;
6469

6570
initOptions = clerkOptions;

packages/astro/src/internal/merge-env-vars-with-params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
5858
disabled: isTruthy(import.meta.env.PUBLIC_CLERK_TELEMETRY_DISABLED),
5959
debug: isTruthy(import.meta.env.PUBLIC_CLERK_TELEMETRY_DEBUG),
6060
},
61+
__internal_keylessClaimUrl: import.meta.env.PUBLIC_CLERK_KEYLESS_CLAIM_URL,
62+
__internal_keylessApiKeysUrl: import.meta.env.PUBLIC_CLERK_KEYLESS_API_KEYS_URL,
6163
...rest,
6264
};
6365
};

packages/astro/src/server/clerk-middleware.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ import type { APIContext } from 'astro';
2424

2525
import { authAsyncStorage } from '#async-local-storage';
2626

27+
import { canUseKeyless } from '../utils/feature-flags';
2728
import { buildClerkHotloadScript } from './build-clerk-hotload-script';
2829
import { clerkClient } from './clerk-client';
2930
import { createCurrentUser } from './current-user';
3031
import { getClientSafeEnv, getSafeEnv } from './get-safe-env';
32+
import { resolveKeysWithKeylessFallback } from './keyless/utils';
3133
import { serverRedirectWithAuth } from './server-redirect-with-auth';
3234
import type {
3335
AstroMiddleware,
@@ -79,9 +81,38 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]): any => {
7981

8082
const clerkRequest = createClerkRequest(context.request);
8183

84+
// Resolve keyless URLs per-request in development
85+
let keylessClaimUrl: string | undefined;
86+
let keylessApiKeysUrl: string | undefined;
87+
let keylessOptions = options;
88+
89+
if (canUseKeyless) {
90+
try {
91+
const env = getSafeEnv(context);
92+
const configuredPublishableKey = options?.publishableKey || env.pk;
93+
const configuredSecretKey = options?.secretKey || env.sk;
94+
95+
const keylessResult = await resolveKeysWithKeylessFallback(configuredPublishableKey, configuredSecretKey);
96+
97+
keylessClaimUrl = keylessResult.claimUrl;
98+
keylessApiKeysUrl = keylessResult.apiKeysUrl;
99+
100+
// Override keys with keyless values if returned
101+
if (keylessResult.publishableKey || keylessResult.secretKey) {
102+
keylessOptions = {
103+
...options,
104+
...(keylessResult.publishableKey && { publishableKey: keylessResult.publishableKey }),
105+
...(keylessResult.secretKey && { secretKey: keylessResult.secretKey }),
106+
};
107+
}
108+
} catch (error) {
109+
// Silently fail - continue without keyless
110+
}
111+
}
112+
82113
const requestState = await clerkClient(context).authenticateRequest(
83114
clerkRequest,
84-
createAuthenticateRequestOptions(clerkRequest, options, context),
115+
createAuthenticateRequestOptions(clerkRequest, keylessOptions, context),
85116
);
86117

87118
const locationHeader = requestState.headers.get(constants.Headers.Location);
@@ -104,6 +135,16 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]): any => {
104135

105136
decorateAstroLocal(clerkRequest, authObjectFn, context, requestState);
106137

138+
// Store keyless data for injection into client
139+
if (keylessClaimUrl || keylessApiKeysUrl) {
140+
context.locals.keylessClaimUrl = keylessClaimUrl;
141+
context.locals.keylessApiKeysUrl = keylessApiKeysUrl;
142+
// Also store the resolved publishable key so client can use it
143+
if (keylessOptions?.publishableKey) {
144+
context.locals.keylessPublishableKey = keylessOptions.publishableKey;
145+
}
146+
}
147+
107148
/**
108149
* ALS is crucial for guaranteeing SSR in UI frameworks like React.
109150
* This currently powers the `useAuth()` React hook and any other hook or Component that depends on it.

packages/astro/src/server/get-safe-env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ function getSafeEnv(context: ContextOrLocals) {
3939
apiUrl: getContextEnvVar('CLERK_API_URL', context),
4040
telemetryDisabled: isTruthy(getContextEnvVar('PUBLIC_CLERK_TELEMETRY_DISABLED', context)),
4141
telemetryDebug: isTruthy(getContextEnvVar('PUBLIC_CLERK_TELEMETRY_DEBUG', context)),
42+
keylessClaimUrl: getContextEnvVar('PUBLIC_CLERK_KEYLESS_CLAIM_URL', context),
43+
keylessApiKeysUrl: getContextEnvVar('PUBLIC_CLERK_KEYLESS_API_KEYS_URL', context),
4244
};
4345
}
4446

@@ -56,6 +58,8 @@ function getClientSafeEnv(context: ContextOrLocals) {
5658
proxyUrl: getContextEnvVar('PUBLIC_CLERK_PROXY_URL', context),
5759
signInUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_IN_URL', context),
5860
signUpUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_UP_URL', context),
61+
keylessClaimUrl: getContextEnvVar('PUBLIC_CLERK_KEYLESS_CLAIM_URL', context),
62+
keylessApiKeysUrl: getContextEnvVar('PUBLIC_CLERK_KEYLESS_API_KEYS_URL', context),
5963
};
6064
}
6165

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { KeylessStorage } from '@clerk/shared/keyless';
2+
3+
export type { KeylessStorage };
4+
5+
export interface FileStorageOptions {
6+
cwd?: () => string;
7+
}
8+
9+
export async function createFileStorage(options: FileStorageOptions = {}): Promise<KeylessStorage> {
10+
const { cwd = () => process.cwd() } = options;
11+
12+
const [{ default: fs }, { default: path }] = await Promise.all([import('node:fs'), import('node:path')]);
13+
14+
const { createNodeFileStorage } = await import('@clerk/shared/keyless');
15+
16+
return createNodeFileStorage(fs, path, {
17+
cwd,
18+
frameworkPackageName: '@clerk/astro',
19+
});
20+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { createClerkClient } from '@clerk/backend';
2+
import { createKeylessService } from '@clerk/shared/keyless';
3+
4+
import { createFileStorage } from './file-storage.js';
5+
6+
let keylessServiceInstance: ReturnType<typeof createKeylessService> | null = null;
7+
8+
export async function keyless() {
9+
if (!keylessServiceInstance) {
10+
const storage = await createFileStorage();
11+
12+
keylessServiceInstance = createKeylessService({
13+
storage,
14+
api: {
15+
async createAccountlessApplication(requestHeaders?: Headers) {
16+
try {
17+
const client = createClerkClient({
18+
secretKey: process.env.CLERK_SECRET_KEY,
19+
publishableKey: process.env.PUBLIC_CLERK_PUBLISHABLE_KEY,
20+
apiUrl: process.env.CLERK_API_URL,
21+
});
22+
return await client.__experimental_accountlessApplications.createAccountlessApplication({
23+
requestHeaders,
24+
});
25+
} catch {
26+
return null;
27+
}
28+
},
29+
async completeOnboarding(requestHeaders?: Headers) {
30+
try {
31+
const client = createClerkClient({
32+
secretKey: process.env.CLERK_SECRET_KEY,
33+
publishableKey: process.env.PUBLIC_CLERK_PUBLISHABLE_KEY,
34+
apiUrl: process.env.CLERK_API_URL,
35+
});
36+
return await client.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
37+
requestHeaders,
38+
});
39+
} catch {
40+
return null;
41+
}
42+
},
43+
},
44+
framework: 'astro',
45+
frameworkVersion: PACKAGE_VERSION,
46+
});
47+
}
48+
49+
return keylessServiceInstance;
50+
}

0 commit comments

Comments
 (0)