-
Notifications
You must be signed in to change notification settings - Fork 436
feat(astro): [WIP] Add support for keyless mode #7800
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5995193
da2bc6f
69a3119
4d0ee39
490699d
e1bf0ac
f7d0ce9
d3a360a
fd376a2
458c600
238f5d3
7abc1bd
11ed1ee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }); | ||
| }); | ||
| }; |
| 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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||||||||||||||||
|
|
@@ -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) { | ||||||||||||||||||||
| // Silently fail - continue without keyless | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+108
to
+110
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const requestState = await clerkClient(context).authenticateRequest( | ||||||||||||||||||||
| clerkRequest, | ||||||||||||||||||||
| createAuthenticateRequestOptions(clerkRequest, options, context), | ||||||||||||||||||||
| createAuthenticateRequestOptions(clerkRequest, keylessOptions, context), | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const locationHeader = requestState.headers.get(constants.Headers.Location); | ||||||||||||||||||||
|
|
@@ -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. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| 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', | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: clerk/javascript
Length of output: 291
🏁 Script executed:
Repository: clerk/javascript
Length of output: 1426
Remove or downgrade vite to version 6.
Astro 5.15.9 specifies
viteas a dependency with^6.4.1. Settingvite@^7.1.0as 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.1or higher within the 6.x range.🤖 Prompt for AI Agents