Skip to content

Commit 21aafe3

Browse files
authored
feat(astro): Add support for keyless mode (#7812)
1 parent 875aee5 commit 21aafe3

File tree

16 files changed

+327
-35
lines changed

16 files changed

+327
-35
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/astro": minor
3+
---
4+
5+
Introduce Keyless quickstart for Astro. This allows the Clerk SDK to be used without having to sign up and paste your keys manually.
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
},
9898
"devDependencies": {
9999
"@clerk/ui": "workspace:^",
100-
"astro": "^5.15.9"
100+
"astro": "^5.17.1"
101101
},
102102
"peerDependencies": {
103103
"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: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ function createIntegration<Params extends HotloadAstroClerkIntegrationParams>()
136136

137137
function createClerkEnvSchema() {
138138
return {
139-
PUBLIC_CLERK_PUBLISHABLE_KEY: envField.string({ context: 'client', access: 'public' }),
139+
PUBLIC_CLERK_PUBLISHABLE_KEY: envField.string({ context: 'client', access: 'public', optional: true }),
140140
PUBLIC_CLERK_SIGN_IN_URL: envField.string({ context: 'client', access: 'public', optional: true }),
141141
PUBLIC_CLERK_SIGN_UP_URL: envField.string({ context: 'client', access: 'public', optional: true }),
142142
PUBLIC_CLERK_IS_SATELLITE: envField.boolean({ context: 'client', access: 'public', optional: true }),
@@ -149,7 +149,8 @@ function createClerkEnvSchema() {
149149
PUBLIC_CLERK_PREFETCH_UI: envField.string({ context: 'client', access: 'public', optional: true }),
150150
PUBLIC_CLERK_TELEMETRY_DISABLED: envField.boolean({ context: 'client', access: 'public', optional: true }),
151151
PUBLIC_CLERK_TELEMETRY_DEBUG: envField.boolean({ context: 'client', access: 'public', optional: true }),
152-
CLERK_SECRET_KEY: envField.string({ context: 'server', access: 'secret' }),
152+
PUBLIC_CLERK_KEYLESS_DISABLED: envField.boolean({ context: 'client', access: 'public', optional: true }),
153+
CLERK_SECRET_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
153154
CLERK_MACHINE_SECRET_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
154155
CLERK_JWT_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
155156
};

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { Ui } from '@clerk/ui/internal';
99

1010
import { $clerkStore } from '../stores/external';
1111
import { $clerk, $csrState } from '../stores/internal';
12-
import type { AstroClerkCreateInstanceParams, AstroClerkUpdateOptions } from '../types';
12+
import type { AstroClerkCreateInstanceParams, AstroClerkUpdateOptions, InternalRuntimeOptions } from '../types';
1313
import { invokeClerkAstroJSFunctions } from './invoke-clerk-astro-js-functions';
1414
import { mountAllClerkAstroJSComponents } from './mount-clerk-astro-js-components';
1515
import { runOnce } from './run-once';
@@ -54,12 +54,18 @@ async function createClerkInstanceInternal<TUi extends Ui = Ui>(options?: AstroC
5454
$clerk.set(clerkJSInstance);
5555
}
5656

57+
const internalOptions = options as AstroClerkCreateInstanceParams<TUi> & InternalRuntimeOptions;
58+
const keylessClaimUrl = internalOptions.__internal_keylessClaimUrl;
59+
const keylessApiKeysUrl = internalOptions.__internal_keylessApiKeysUrl;
60+
5761
const clerkOptions = {
5862
routerPush: createNavigationHandler(window.history.pushState.bind(window.history)),
5963
routerReplace: createNavigationHandler(window.history.replaceState.bind(window.history)),
6064
...options,
6165
// Pass the clerk-ui constructor promise to clerk.load()
6266
ui: { ...options?.ui, ClerkUI },
67+
...(keylessClaimUrl && { __internal_keyless_claimKeylessApplicationUrl: keylessClaimUrl }),
68+
...(keylessApiKeysUrl && { __internal_keyless_copyInstanceKeysUrl: keylessApiKeysUrl }),
6369
} as unknown as ClerkOptions;
6470

6571
initOptions = clerkOptions;

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { isTruthy } from '@clerk/shared/underscore';
22

3-
import type { AstroClerkIntegrationParams } from '../types';
3+
import type { AstroClerkIntegrationParams, InternalRuntimeOptions } from '../types';
44

55
/**
66
* Merges `prefetchUI` param with env vars.
@@ -25,7 +25,7 @@ function mergePrefetchUIConfig(paramPrefetchUI: AstroClerkIntegrationParams['pre
2525
/**
2626
* @internal
2727
*/
28-
const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publishableKey?: string }) => {
28+
const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & InternalRuntimeOptions) => {
2929
const {
3030
signInUrl: paramSignIn,
3131
signUpUrl: paramSignUp,
@@ -42,13 +42,17 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
4242
...rest
4343
} = params || {};
4444

45+
const internalOptions = params;
46+
4547
return {
4648
signInUrl: paramSignIn || import.meta.env.PUBLIC_CLERK_SIGN_IN_URL,
4749
signUpUrl: paramSignUp || import.meta.env.PUBLIC_CLERK_SIGN_UP_URL,
4850
isSatellite: paramSatellite || import.meta.env.PUBLIC_CLERK_IS_SATELLITE,
4951
proxyUrl: paramProxy || import.meta.env.PUBLIC_CLERK_PROXY_URL,
5052
domain: paramDomain || import.meta.env.PUBLIC_CLERK_DOMAIN,
51-
publishableKey: paramPublishableKey || import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY || '',
53+
// In keyless mode, use server-injected publishableKey from params
54+
publishableKey:
55+
paramPublishableKey || internalOptions?.publishableKey || import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY || '',
5256
clerkJSUrl: paramClerkJSUrl || import.meta.env.PUBLIC_CLERK_JS_URL,
5357
clerkJSVersion: paramClerkJSVersion || import.meta.env.PUBLIC_CLERK_JS_VERSION,
5458
clerkUIUrl: paramClerkUIUrl || import.meta.env.PUBLIC_CLERK_UI_URL,
@@ -58,6 +62,10 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
5862
disabled: isTruthy(import.meta.env.PUBLIC_CLERK_TELEMETRY_DISABLED),
5963
debug: isTruthy(import.meta.env.PUBLIC_CLERK_TELEMETRY_DEBUG),
6064
},
65+
// Read from params (server-injected via __CLERK_ASTRO_SAFE_VARS__)
66+
// These are dynamically resolved by middleware, not from env vars
67+
__internal_keylessClaimUrl: internalOptions?.keylessClaimUrl,
68+
__internal_keylessApiKeysUrl: internalOptions?.keylessApiKeysUrl,
6169
...rest,
6270
};
6371
};

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

Lines changed: 47 additions & 2 deletions
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,
@@ -49,7 +51,7 @@ type ClerkAstroMiddlewareHandler = (
4951
next: AstroMiddlewareNextParam,
5052
) => AstroMiddlewareReturn | undefined;
5153

52-
type ClerkAstroMiddlewareOptions = AuthenticateRequestOptions;
54+
export type ClerkAstroMiddlewareOptions = AuthenticateRequestOptions;
5355

5456
/**
5557
* Middleware for Astro that handles authentication and authorization with Clerk.
@@ -79,9 +81,42 @@ 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(
96+
configuredPublishableKey,
97+
configuredSecretKey,
98+
context,
99+
);
100+
101+
keylessClaimUrl = keylessResult.claimUrl;
102+
keylessApiKeysUrl = keylessResult.apiKeysUrl;
103+
104+
// Override keys with keyless values if returned
105+
if (keylessResult.publishableKey || keylessResult.secretKey) {
106+
keylessOptions = {
107+
...options,
108+
...(keylessResult.publishableKey && { publishableKey: keylessResult.publishableKey }),
109+
...(keylessResult.secretKey && { secretKey: keylessResult.secretKey }),
110+
};
111+
}
112+
} catch {
113+
// Silently fail - continue without keyless
114+
}
115+
}
116+
82117
const requestState = await clerkClient(context).authenticateRequest(
83118
clerkRequest,
84-
createAuthenticateRequestOptions(clerkRequest, options, context),
119+
createAuthenticateRequestOptions(clerkRequest, keylessOptions, context),
85120
);
86121

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

105140
decorateAstroLocal(clerkRequest, authObjectFn, context, requestState);
106141

142+
// Store keyless data for injection into client
143+
if (keylessClaimUrl || keylessApiKeysUrl) {
144+
context.locals.keylessClaimUrl = keylessClaimUrl;
145+
context.locals.keylessApiKeysUrl = keylessApiKeysUrl;
146+
// Also store the resolved publishable key so client can use it
147+
if (keylessOptions?.publishableKey) {
148+
context.locals.keylessPublishableKey = keylessOptions.publishableKey;
149+
}
150+
}
151+
107152
/**
108153
* ALS is crucial for guaranteeing SSR in UI frameworks like React.
109154
* 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: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@ function getContextEnvVar(envVarName: keyof InternalEnv, contextOrLocals: Contex
2121
* @internal
2222
*/
2323
function getSafeEnv(context: ContextOrLocals) {
24+
const locals = 'locals' in context ? context.locals : context;
25+
2426
return {
2527
domain: getContextEnvVar('PUBLIC_CLERK_DOMAIN', context),
2628
isSatellite: getContextEnvVar('PUBLIC_CLERK_IS_SATELLITE', context) === 'true',
2729
proxyUrl: getContextEnvVar('PUBLIC_CLERK_PROXY_URL', context),
28-
pk: getContextEnvVar('PUBLIC_CLERK_PUBLISHABLE_KEY', context),
30+
// Use keyless publishable key if available, otherwise read from env
31+
pk: locals.keylessPublishableKey || getContextEnvVar('PUBLIC_CLERK_PUBLISHABLE_KEY', context),
2932
sk: getContextEnvVar('CLERK_SECRET_KEY', context),
3033
machineSecretKey: getContextEnvVar('CLERK_MACHINE_SECRET_KEY', context),
3134
signInUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_IN_URL', context),
@@ -39,6 +42,9 @@ function getSafeEnv(context: ContextOrLocals) {
3942
apiUrl: getContextEnvVar('CLERK_API_URL', context),
4043
telemetryDisabled: isTruthy(getContextEnvVar('PUBLIC_CLERK_TELEMETRY_DISABLED', context)),
4144
telemetryDebug: isTruthy(getContextEnvVar('PUBLIC_CLERK_TELEMETRY_DEBUG', context)),
45+
// Read from locals (set by middleware) instead of env vars
46+
keylessClaimUrl: locals.keylessClaimUrl,
47+
keylessApiKeysUrl: locals.keylessApiKeysUrl,
4248
};
4349
}
4450

@@ -50,12 +56,19 @@ function getSafeEnv(context: ContextOrLocals) {
5056
* This is a way to get around it.
5157
*/
5258
function getClientSafeEnv(context: ContextOrLocals) {
59+
const locals = 'locals' in context ? context.locals : context;
60+
5361
return {
5462
domain: getContextEnvVar('PUBLIC_CLERK_DOMAIN', context),
5563
isSatellite: getContextEnvVar('PUBLIC_CLERK_IS_SATELLITE', context) === 'true',
5664
proxyUrl: getContextEnvVar('PUBLIC_CLERK_PROXY_URL', context),
5765
signInUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_IN_URL', context),
5866
signUpUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_UP_URL', context),
67+
// In keyless mode, pass the resolved publishable key to client
68+
publishableKey: locals.keylessPublishableKey || getContextEnvVar('PUBLIC_CLERK_PUBLISHABLE_KEY', context),
69+
// Read from locals (set by middleware) instead of env vars
70+
keylessClaimUrl: locals.keylessClaimUrl,
71+
keylessApiKeysUrl: locals.keylessApiKeysUrl,
5972
};
6073
}
6174

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/astro',
18+
});
19+
}

0 commit comments

Comments
 (0)