Skip to content
20 changes: 20 additions & 0 deletions integration/testUtils/keylessHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Page } from '@playwright/test';

/**
* 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<void> => {
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 });
});
};
115 changes: 115 additions & 0 deletions integration/tests/astro/keyless.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../../models/application';
import { appConfigs } from '../../presets';
import { createTestUtils } from '../../testUtils';
import { mockClaimedInstanceEnvironmentCall } from '../../testUtils/keylessHelpers';

const commonSetup = appConfigs.astro.node.clone();

test.describe('Keyless mode @astro', () => {
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 () => {
await app?.teardown();
});

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 urlToReturnTo = `${dashboardUrl}apps/claim?token=`;

const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url');

const signUpForceRedirectUrlCheck =
signUpForceRedirectUrl?.startsWith(urlToReturnTo) ||
(signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) &&
signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim?token=')));

return (
url.pathname === '/apps/claim/sign-in' &&
url.searchParams.get('sign_in_force_redirect_url')?.startsWith(urlToReturnTo) &&
signUpForceRedirectUrlCheck
);
});
});

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_`);
});
});

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 (Astro 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();
});
});
3 changes: 2 additions & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@
},
"devDependencies": {
"@clerk/ui": "workspace:^",
"astro": "^5.15.9"
"astro": "^5.15.9",
"vite": "^7.1.0"
Comment on lines +99 to +100
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check vite latest version and 7.x versions
echo "=== Vite Latest Version ==="
curl -s https://registry.npmjs.org/vite/latest | jq '{name: .name, version: .version}'

echo ""
echo "=== Vite 7.x Versions (last 10) ==="
curl -s "https://registry.npmjs.org/vite" | jq -r '[.versions | keys[] | select(startswith("7."))] | sort | .[-10:][]'

echo ""
echo "=== Astro 5 Vite Dependency Specification ==="
curl -s "https://registry.npmjs.org/astro/5.15.9" | jq '.peerDependencies, .devDependencies | select(. != null) | with_entries(select(.key == "vite"))'

Repository: clerk/javascript

Length of output: 291


🏁 Script executed:

#!/bin/bash
# Check if vite 7.1.0 specifically exists
echo "=== Check vite 7.0.x and 7.1.x versions ==="
curl -s "https://registry.npmjs.org/vite" | jq -r '[.versions | keys[] | select(startswith("7.0.") or startswith("7.1."))] | sort'

echo ""
echo "=== Astro 5.15.9 ALL dependencies ==="
curl -s "https://registry.npmjs.org/astro/5.15.9" | jq '.dependencies, .devDependencies, .peerDependencies, .optionalDependencies' | grep -A 5 vite || echo "No vite found in dependencies"

echo ""
echo "=== Astro 5.15.9 full dependency keys ==="
curl -s "https://registry.npmjs.org/astro/5.15.9" | jq 'keys'

Repository: clerk/javascript

Length of output: 1426


Remove or downgrade vite to version 6.

Astro 5.15.9 specifies vite as a dependency with ^6.4.1. Setting vite@^7.1.0 as a devDependency creates a version conflict that will cause installation to fail. Either remove the vite devDependency to use Astro's pinned version, or downgrade to ^6.4.1 or higher within the 6.x range.

🤖 Prompt for AI Agents
In `@packages/astro/package.json` around lines 99 - 100, The package.json
currently sets a conflicting devDependency "vite": "^7.1.0" while "astro":
"^5.15.9" expects vite in the 6.x range; fix this by editing package.json to
either remove the "vite" devDependency so Astro's pinned vite is used, or change
the "vite" entry to a compatible 6.x version such as "^6.4.1"; ensure the
modification targets the "vite" key in package.json and keep "astro" unchanged.

},
"peerDependencies": {
"astro": "^4.15.0 || ^5.0.0"
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface InternalEnv {
readonly PUBLIC_CLERK_SIGN_UP_URL?: string;
readonly PUBLIC_CLERK_TELEMETRY_DISABLED?: string;
readonly PUBLIC_CLERK_TELEMETRY_DEBUG?: string;
readonly PUBLIC_CLERK_KEYLESS_DISABLED?: string;
}

interface ImportMeta {
Expand All @@ -29,6 +30,9 @@ interface ImportMeta {
declare namespace App {
interface Locals {
runtime: { env: InternalEnv };
keylessClaimUrl?: string;
keylessApiKeysUrl?: string;
keylessPublishableKey?: string;
}
}

Expand Down
18 changes: 15 additions & 3 deletions packages/astro/src/integration/create-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,19 @@ function createIntegration<Params extends HotloadAstroClerkIntegrationParams>()
logger.error('Missing adapter, please update your Astro config to use one.');
}

const isDev = command === 'dev';

// Read keys from process.env for vite.define injection
// Note: Keyless mode is now handled by middleware per-request, not here
const envPublishableKey = process.env.PUBLIC_CLERK_PUBLISHABLE_KEY;
const envSecretKey = process.env.CLERK_SECRET_KEY;

const internalParams: ClerkOptions = {
...params,
sdkMetadata: {
version: packageVersion,
name: packageName,
environment: command === 'dev' ? 'development' : 'production',
environment: isDev ? 'development' : 'production',
},
};

Expand All @@ -58,6 +65,9 @@ function createIntegration<Params extends HotloadAstroClerkIntegrationParams>()
...buildEnvVarFromOption(clerkJSUrl, 'PUBLIC_CLERK_JS_URL'),
...buildEnvVarFromOption(clerkJSVersion, 'PUBLIC_CLERK_JS_VERSION'),
...buildEnvVarFromOption(prefetchUI === false ? 'false' : undefined, 'PUBLIC_CLERK_PREFETCH_UI'),
...buildEnvVarFromOption(envPublishableKey, 'PUBLIC_CLERK_PUBLISHABLE_KEY'),
...buildEnvVarFromOption(envSecretKey, 'CLERK_SECRET_KEY'),
// Keyless URLs are now handled by middleware, not vite.define
},

ssr: {
Expand Down Expand Up @@ -157,7 +167,7 @@ function createIntegration<Params extends HotloadAstroClerkIntegrationParams>()

function createClerkEnvSchema() {
return {
PUBLIC_CLERK_PUBLISHABLE_KEY: envField.string({ context: 'client', access: 'public' }),
PUBLIC_CLERK_PUBLISHABLE_KEY: envField.string({ context: 'client', access: 'public', optional: true }),
PUBLIC_CLERK_SIGN_IN_URL: envField.string({ context: 'client', access: 'public', optional: true }),
PUBLIC_CLERK_SIGN_UP_URL: envField.string({ context: 'client', access: 'public', optional: true }),
PUBLIC_CLERK_IS_SATELLITE: envField.boolean({ context: 'client', access: 'public', optional: true }),
Expand All @@ -169,7 +179,9 @@ function createClerkEnvSchema() {
PUBLIC_CLERK_UI_URL: envField.string({ context: 'client', access: 'public', optional: true, url: true }),
PUBLIC_CLERK_TELEMETRY_DISABLED: envField.boolean({ context: 'client', access: 'public', optional: true }),
PUBLIC_CLERK_TELEMETRY_DEBUG: envField.boolean({ context: 'client', access: 'public', optional: true }),
CLERK_SECRET_KEY: envField.string({ context: 'server', access: 'secret' }),
// Note: KEYLESS_CLAIM_URL and KEYLESS_API_KEYS_URL are dynamically resolved by middleware, not user-configurable
PUBLIC_CLERK_KEYLESS_DISABLED: envField.boolean({ context: 'client', access: 'public', optional: true }),
CLERK_SECRET_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
CLERK_MACHINE_SECRET_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
CLERK_JWT_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
};
Expand Down
5 changes: 5 additions & 0 deletions packages/astro/src/internal/create-clerk-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,17 @@ async function createClerkInstanceInternal<TUi extends Ui = Ui>(options?: AstroC
$clerk.set(clerkJSInstance);
}

const keylessClaimUrl = (options as any)?.__internal_keylessClaimUrl;
const keylessApiKeysUrl = (options as any)?.__internal_keylessApiKeysUrl;

const clerkOptions = {
routerPush: createNavigationHandler(window.history.pushState.bind(window.history)),
routerReplace: createNavigationHandler(window.history.replaceState.bind(window.history)),
...options,
// Pass the clerk-ui constructor promise to clerk.load()
clerkUICtor,
...(keylessClaimUrl && { __internal_keyless_claimKeylessApplicationUrl: keylessClaimUrl }),
...(keylessApiKeysUrl && { __internal_keyless_copyInstanceKeysUrl: keylessApiKeysUrl }),
} as unknown as ClerkOptions;

initOptions = clerkOptions;
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/src/internal/merge-env-vars-with-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
disabled: isTruthy(import.meta.env.PUBLIC_CLERK_TELEMETRY_DISABLED),
debug: isTruthy(import.meta.env.PUBLIC_CLERK_TELEMETRY_DEBUG),
},
// Read from params (server-injected via __CLERK_ASTRO_SAFE_VARS__)
// These are dynamically resolved by middleware, not from env vars
__internal_keylessClaimUrl: (params as any)?.keylessClaimUrl,
__internal_keylessApiKeysUrl: (params as any)?.keylessApiKeysUrl,
...rest,
};
};
Expand Down
43 changes: 42 additions & 1 deletion packages/astro/src/server/clerk-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@

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

import { canUseKeyless } from '../utils/feature-flags';
import { buildClerkHotloadScript } from './build-clerk-hotload-script';
import { clerkClient } from './clerk-client';
import { createCurrentUser } from './current-user';
import { getClientSafeEnv, getSafeEnv } from './get-safe-env';
import { resolveKeysWithKeylessFallback } from './keyless/utils';
import { serverRedirectWithAuth } from './server-redirect-with-auth';
import type {
AstroMiddleware,
Expand Down Expand Up @@ -79,9 +81,38 @@

const clerkRequest = createClerkRequest(context.request);

// Resolve keyless URLs per-request in development
let keylessClaimUrl: string | undefined;
let keylessApiKeysUrl: string | undefined;
let keylessOptions = options;

if (canUseKeyless) {
try {
const env = getSafeEnv(context);
const configuredPublishableKey = options?.publishableKey || env.pk;
const configuredSecretKey = options?.secretKey || env.sk;

const keylessResult = await resolveKeysWithKeylessFallback(configuredPublishableKey, configuredSecretKey);

keylessClaimUrl = keylessResult.claimUrl;
keylessApiKeysUrl = keylessResult.apiKeysUrl;

// Override keys with keyless values if returned
if (keylessResult.publishableKey || keylessResult.secretKey) {
keylessOptions = {
...options,
...(keylessResult.publishableKey && { publishableKey: keylessResult.publishableKey }),
...(keylessResult.secretKey && { secretKey: keylessResult.secretKey }),
};
}
} catch (error) {

Check failure on line 108 in packages/astro/src/server/clerk-middleware.ts

View workflow job for this annotation

GitHub Actions / Static analysis

'error' is defined but never used
// Silently fail - continue without keyless
}
Comment on lines +108 to +110
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent error swallowing hinders debugging.

The empty catch block will make it extremely difficult to diagnose keyless mode failures. At minimum, log the error in development:

       } catch (error) {
-        // Silently fail - continue without keyless
+        // Continue without keyless, but log for debugging
+        if (import.meta.env.DEV) {
+          console.warn('[Clerk] Keyless resolution failed, falling back to configured keys:', error);
+        }
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
// Silently fail - continue without keyless
}
} catch (error) {
// Continue without keyless, but log for debugging
if (import.meta.env.DEV) {
console.warn('[Clerk] Keyless resolution failed, falling back to configured keys:', error);
}
}
🤖 Prompt for AI Agents
In `@packages/astro/src/server/clerk-middleware.ts` around lines 112 - 114, The
empty catch in packages/astro/src/server/clerk-middleware.ts is swallowing
errors from the keyless initialization try block; update that catch to at
minimum log the caught error in development (e.g., check NODE_ENV or use the
module logger) with a clear message like "keyless init failed" and include the
error object, and keep silent/quiet behavior in production if desired—modify the
catch inside the keyless init try/catch to emit this conditional log so failures
are visible during debugging.

}

const requestState = await clerkClient(context).authenticateRequest(
clerkRequest,
createAuthenticateRequestOptions(clerkRequest, options, context),
createAuthenticateRequestOptions(clerkRequest, keylessOptions, context),
);

const locationHeader = requestState.headers.get(constants.Headers.Location);
Expand All @@ -104,6 +135,16 @@

decorateAstroLocal(clerkRequest, authObjectFn, context, requestState);

// Store keyless data for injection into client
if (keylessClaimUrl || keylessApiKeysUrl) {
context.locals.keylessClaimUrl = keylessClaimUrl;
context.locals.keylessApiKeysUrl = keylessApiKeysUrl;
// Also store the resolved publishable key so client can use it
if (keylessOptions?.publishableKey) {
context.locals.keylessPublishableKey = keylessOptions.publishableKey;
}
}

/**
* ALS is crucial for guaranteeing SSR in UI frameworks like React.
* This currently powers the `useAuth()` React hook and any other hook or Component that depends on it.
Expand Down
15 changes: 14 additions & 1 deletion packages/astro/src/server/get-safe-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ function getContextEnvVar(envVarName: keyof InternalEnv, contextOrLocals: Contex
* @internal
*/
function getSafeEnv(context: ContextOrLocals) {
const locals = 'locals' in context ? context.locals : context;

return {
domain: getContextEnvVar('PUBLIC_CLERK_DOMAIN', context),
isSatellite: getContextEnvVar('PUBLIC_CLERK_IS_SATELLITE', context) === 'true',
proxyUrl: getContextEnvVar('PUBLIC_CLERK_PROXY_URL', context),
pk: getContextEnvVar('PUBLIC_CLERK_PUBLISHABLE_KEY', context),
// Use keyless publishable key if available, otherwise read from env
pk: locals.keylessPublishableKey || getContextEnvVar('PUBLIC_CLERK_PUBLISHABLE_KEY', context),
sk: getContextEnvVar('CLERK_SECRET_KEY', context),
machineSecretKey: getContextEnvVar('CLERK_MACHINE_SECRET_KEY', context),
signInUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_IN_URL', context),
Expand All @@ -38,6 +41,9 @@ function getSafeEnv(context: ContextOrLocals) {
apiUrl: getContextEnvVar('CLERK_API_URL', context),
telemetryDisabled: isTruthy(getContextEnvVar('PUBLIC_CLERK_TELEMETRY_DISABLED', context)),
telemetryDebug: isTruthy(getContextEnvVar('PUBLIC_CLERK_TELEMETRY_DEBUG', context)),
// Read from locals (set by middleware) instead of env vars
keylessClaimUrl: locals.keylessClaimUrl,
keylessApiKeysUrl: locals.keylessApiKeysUrl,
};
}

Expand All @@ -49,12 +55,19 @@ function getSafeEnv(context: ContextOrLocals) {
* This is a way to get around it.
*/
function getClientSafeEnv(context: ContextOrLocals) {
const locals = ('locals' in context ? context.locals : context) as any;

return {
domain: getContextEnvVar('PUBLIC_CLERK_DOMAIN', context),
isSatellite: getContextEnvVar('PUBLIC_CLERK_IS_SATELLITE', context) === 'true',
proxyUrl: getContextEnvVar('PUBLIC_CLERK_PROXY_URL', context),
signInUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_IN_URL', context),
signUpUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_UP_URL', context),
// In keyless mode, pass the resolved publishable key to client
publishableKey: locals.keylessPublishableKey || getContextEnvVar('PUBLIC_CLERK_PUBLISHABLE_KEY', context),
// Read from locals (set by middleware) instead of env vars
keylessClaimUrl: locals.keylessClaimUrl,
keylessApiKeysUrl: locals.keylessApiKeysUrl,
};
}

Expand Down
20 changes: 20 additions & 0 deletions packages/astro/src/server/keyless/file-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { KeylessStorage } from '@clerk/shared/keyless';

export type { KeylessStorage };

export interface FileStorageOptions {
cwd?: () => string;
}

export async function createFileStorage(options: FileStorageOptions = {}): Promise<KeylessStorage> {
const { cwd = () => process.cwd() } = options;

const [{ default: fs }, { default: path }] = await Promise.all([import('node:fs'), import('node:path')]);

const { createNodeFileStorage } = await import('@clerk/shared/keyless');

return createNodeFileStorage(fs, path, {
cwd,
frameworkPackageName: '@clerk/astro',
});
}
Loading
Loading