Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a659eaf
feat(react-router): Keyless support
wobsoriano Feb 6, 2026
e08192c
chore: clean up
wobsoriano Feb 6, 2026
140ba30
chore: clean up var name
wobsoriano Feb 6, 2026
6bc3243
chore: remove any assertion
wobsoriano Feb 6, 2026
15d5fc0
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 6, 2026
6638d89
chore: throw only if not keyless mode
wobsoriano Feb 6, 2026
fe7577e
add integraiton test
wobsoriano Feb 6, 2026
4a6b959
chore: extract shared test utils
wobsoriano Feb 7, 2026
f79def9
chore: add changeset
wobsoriano Feb 7, 2026
9354709
chore: share main keyless fallback function
wobsoriano Feb 8, 2026
ae6c168
chore: delete md file
wobsoriano Feb 8, 2026
9e962c2
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 8, 2026
818352a
chore: extract reusable file storage create function
wobsoriano Feb 9, 2026
57a8049
Add Keyless quickstart and refactor createFileStorage
wobsoriano Feb 9, 2026
834c6eb
chore: revert
wobsoriano Feb 9, 2026
069aaef
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 9, 2026
32bec51
chore: Make resolveKeysWithKeylessFallback function a method on the k…
wobsoriano Feb 9, 2026
9fee85e
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 9, 2026
a70b8a2
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 9, 2026
1c39d21
fix(repo): Handle framework query param in keyless claim URLs integra…
wobsoriano Feb 10, 2026
6b04f9f
chore: share main keyless fallback function
wobsoriano Feb 8, 2026
567b5a5
refactor: use shared helper for Next.js keyless test
wobsoriano Feb 10, 2026
7183175
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 10, 2026
0049b7f
delete doc
wobsoriano Feb 10, 2026
2e71569
chore: add missing framework requirement
wobsoriano Feb 10, 2026
ab36fa8
chore: remove redundant export
wobsoriano Feb 10, 2026
cda1649
fix type errors
wobsoriano Feb 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions integration/testUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@ export const createTestUtils = <
};

export { testAgainstRunningApps } from './testAgainstRunningApps';
export { mockClaimedInstanceEnvironmentCall } from './keylessHelpers';
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) => {
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 });
});
};
18 changes: 1 addition & 17 deletions integration/tests/next-quickstart-keyless.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,11 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';

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

const commonSetup = appConfigs.next.appRouterQuickstart.clone();

const mockClaimedInstanceEnvironmentCall = async (page: Page) => {
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 });
});
};

test.describe('Keyless mode @quickstart', () => {
test.describe.configure({ mode: 'serial' });

Expand Down
115 changes: 115 additions & 0 deletions integration/tests/react-router/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, mockClaimedInstanceEnvironmentCall } from '../../testUtils';

const commonSetup = appConfigs.reactRouter.reactRouterNode.clone();

test.describe('Keyless mode @react-router', () => {
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 () => {
// Keep files for debugging
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 (Vite 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();
});
});
18 changes: 1 addition & 17 deletions integration/tests/tanstack-start/keyless.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,11 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';

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

const commonSetup = appConfigs.tanstack.reactStart.clone();

const mockClaimedInstanceEnvironmentCall = async (page: Page) => {
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 });
});
};

test.describe('Keyless mode @tanstack-react-start', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(90_000);
Expand Down
10 changes: 10 additions & 0 deletions packages/react-router/src/client/ReactRouterClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ function ClerkProviderBase<TUi extends Ui = Ui>({ children, ...rest }: ClerkProv
__prefetchUI,
__telemetryDisabled,
__telemetryDebug,
__keylessClaimUrl,
__keylessApiKeysUrl,
} = clerkState?.__internal_clerk_state || {};

React.useEffect(() => {
Expand Down Expand Up @@ -100,6 +102,13 @@ function ClerkProviderBase<TUi extends Ui = Ui>({ children, ...rest }: ClerkProv
},
};

const keylessProps = __keylessClaimUrl
? {
__internal_keyless_claimKeylessApplicationUrl: __keylessClaimUrl,
__internal_keyless_copyInstanceKeysUrl: __keylessApiKeysUrl,
}
: {};

return (
<ClerkReactRouterOptionsProvider options={mergedProps}>
<ReactClerkProvider
Expand All @@ -108,6 +117,7 @@ function ClerkProviderBase<TUi extends Ui = Ui>({ children, ...rest }: ClerkProv
initialState={__clerk_ssr_state}
sdkMetadata={SDK_METADATA}
{...mergedProps}
{...keylessProps}
{...restProps}
>
{children}
Expand Down
2 changes: 2 additions & 0 deletions packages/react-router/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type ClerkState = {
__prefetchUI: boolean | undefined;
__telemetryDisabled: boolean | undefined;
__telemetryDebug: boolean | undefined;
__keylessClaimUrl?: string;
__keylessApiKeysUrl?: string;
};
};

Expand Down
28 changes: 23 additions & 5 deletions packages/react-router/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { MiddlewareFunction } from 'react-router';
import { createContext } from 'react-router';

import { clerkClient } from './clerkClient';
import { resolveKeysWithKeylessFallback } from './keyless/utils';
import { loadOptions } from './loadOptions';
import type { ClerkMiddlewareOptions } from './types';
import { patchRequest } from './utils';
Expand Down Expand Up @@ -35,16 +36,28 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun
const clerkRequest = createClerkRequest(patchRequest(args.request));
const loadedOptions = loadOptions(args, options);

const {
publishableKey,
secretKey,
claimUrl: __keylessClaimUrl,
apiKeysUrl: __keylessApiKeysUrl,
} = await resolveKeysWithKeylessFallback(loadedOptions.publishableKey, loadedOptions.secretKey, args, options);

if (publishableKey) {
loadedOptions.publishableKey = publishableKey;
}
if (secretKey) {
loadedOptions.secretKey = secretKey;
}

// Pick only the properties needed by authenticateRequest.
// Used when manually providing options to the middleware.
const {
apiUrl,
secretKey,
jwtKey,
proxyUrl,
isSatellite,
domain,
publishableKey,
machineSecretKey,
audience,
authorizedParties,
Expand All @@ -55,12 +68,12 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun

const requestState = await clerkClient(args, options).authenticateRequest(clerkRequest, {
apiUrl,
secretKey,
secretKey: loadedOptions.secretKey,
jwtKey,
proxyUrl,
isSatellite,
domain,
publishableKey,
publishableKey: loadedOptions.publishableKey,
machineSecretKey,
audience,
authorizedParties,
Expand All @@ -70,6 +83,11 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun
acceptsToken: 'any',
});

Object.assign(requestState, {
__keylessClaimUrl,
__keylessApiKeysUrl,
});

const locationHeader = requestState.headers.get(constants.Headers.Location);
if (locationHeader) {
handleNetlifyCacheInDevInstance({
Expand All @@ -85,7 +103,7 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun
throw new Error('Clerk: handshake status without redirect');
}

args.context.set(authFnContext, (options?: PendingSessionOptions) => requestState.toAuth(options));
args.context.set(authFnContext, (opts?: PendingSessionOptions) => requestState.toAuth(opts));
args.context.set(requestStateContext, requestState);

const response = await next();
Expand Down
29 changes: 29 additions & 0 deletions packages/react-router/src/server/keyless/fileStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createNodeFileStorage, type KeylessStorage } from '@clerk/shared/keyless';

export type { KeylessStorage };

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

/**
* Creates a file-based storage adapter for keyless mode.
* Uses dynamic imports to avoid bundler issues with edge runtimes.
*/
export async function createFileStorage(options: FileStorageOptions = {}): Promise<KeylessStorage> {
const { cwd = () => process.cwd() } = options;

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

return createNodeFileStorage(fs, path, {
cwd,
frameworkPackageName: '@clerk/react-router',
});
} catch {
throw new Error(
'Keyless mode requires a Node.js runtime with file system access. ' +
'Set VITE_CLERK_KEYLESS_DISABLED=1 to disable keyless mode.',
);
}
}
Comment on lines +13 to +29
Copy link
Member Author

Choose a reason for hiding this comment

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

This can be extracted as well, but will plan to do so while implementing keyless for other SDKs.

Having these node imports in the shared @clerk/shared/keyless barrel export would break Next.js at edge runtime, since packages/nextjs/src/server/keyless-node.ts imports from that barrel (correct me if Im wrong!)

Loading
Loading