Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/sdk-70-keyless-middleware-bypass-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Enforce middleware authorization during the keyless bootstrap window. `auth.protect()` and custom authorization checks now fail closed instead of being bypassed while the publishable key is being provisioned.
35 changes: 35 additions & 0 deletions integration/tests/next-middleware-keyless.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';

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

test.describe('Keyless mode | middleware authorization @nextjs', () => {
test.describe.configure({ mode: 'serial' });

test.use({
extraHTTPHeaders: {
'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET || '',
},
});

let app: Application;

test.beforeAll(async () => {
app = await commonSetup.commit();
await app.setup();
await app.withEnv(appConfigs.envs.withKeyless);
await app.dev();
});

test.afterAll(async () => {
await app.teardown();
});

test('auth.protect() in middleware redirects to sign-in during keyless bootstrap', async ({ page }) => {
await page.goto(`${app.serverUrl}/protected`);
await page.waitForURL(/\/sign-in/);
await expect(page.getByTestId('protected')).not.toBeVisible();
});
});
44 changes: 43 additions & 1 deletion packages/backend/src/tokens/__tests__/authStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';

import { mockTokens, mockVerificationResults } from '../../fixtures/machine';
import type { AuthenticateContext } from '../../tokens/authenticateContext';
import { handshake, signedIn, signedOut } from '../authStatus';
import { createBootstrapSignedOutState, handshake, signedIn, signedOut } from '../authStatus';

describe('signed-in', () => {
describe('session tokens', () => {
Expand Down Expand Up @@ -132,6 +132,48 @@ describe('signed-out', () => {
});
});

describe('createBootstrapSignedOutState', () => {
it('returns a signed-out session_token state with no publishable key', () => {
const state = createBootstrapSignedOutState();

expect(state.status).toBe('signed-out');
expect(state.tokenType).toBe('session_token');
expect(state.isSignedIn).toBe(false);
expect(state.isAuthenticated).toBe(false);
expect(state.publishableKey).toBe('');
expect(state.token).toBeNull();
});

it('applies provided signInUrl and signUpUrl', () => {
const state = createBootstrapSignedOutState({
signInUrl: '/sign-in',
signUpUrl: '/sign-up',
});

expect(state.signInUrl).toBe('/sign-in');
expect(state.signUpUrl).toBe('/sign-up');
});

it('toAuth() returns a signed-out auth object without throwing', () => {
const authObject = createBootstrapSignedOutState().toAuth();

expect(authObject.userId).toBeNull();
expect(authObject.sessionId).toBeNull();
expect(authObject.tokenType).toBe('session_token');
});

it('includes debug headers on the state', () => {
const state = createBootstrapSignedOutState({
reason: 'session-token-and-uat-missing',
message: 'no keys yet',
});

expect(state.headers.get('x-clerk-auth-status')).toBe('signed-out');
expect(state.headers.get('x-clerk-auth-reason')).toBe('session-token-and-uat-missing');
expect(state.headers.get('x-clerk-auth-message')).toBe('no keys yet');
});
});

describe('handshake', () => {
it('includes debug headers', () => {
const headers = new Headers({ location: '/' });
Expand Down
54 changes: 46 additions & 8 deletions packages/nextjs/src/server/clerkMiddleware.ts
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I believe this fix only needs to be applied in Next SDK right? Since it has a special keyless bootstrap path before the PK is available. The other SDKs resolve keyless keys before auth runs, so they likely not need the same fix

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, that is correct.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

thanks for answering

Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
import {
AuthStatus,
constants,
createBootstrapSignedOutState,
createClerkRequest,
createRedirect,
getAuthObjectForAcceptedToken,
Expand Down Expand Up @@ -239,6 +240,50 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
});
});

/**
* Runs the user's handler against a synthetic signed-out `RequestState` during the keyless
* bootstrap window, so authorization fails closed until a publishable key is provisioned.
*/
const bootstrapNextMiddleware: NextMiddleware = withLogger('clerkMiddleware', logger => async (request, event) => {
const resolvedParams = typeof params === 'function' ? await params(request) : params;
const keyless = await getKeylessCookieValue(name => request.cookies.get(name)?.value);

const signInUrl = resolvedParams.signInUrl || SIGN_IN_URL || '';
const signUpUrl = resolvedParams.signUpUrl || SIGN_UP_URL || '';

const options = {
publishableKey: '',
secretKey: '',
signInUrl,
signUpUrl,
...resolvedParams,
};

clerkMiddlewareRequestDataStore.set('requestData', options);

if (options.debug) {
logger.enable();
}

const clerkRequest = createClerkRequest(request);
logger.debug('keyless bootstrap (no publishable key)', () => ({ signInUrl, signUpUrl }));
logger.debug('url', () => clerkRequest.toJSON());

const requestState = createBootstrapSignedOutState({ signInUrl, signUpUrl });

return runHandlerWithRequestState({
clerkRequest,
request,
event,
requestState,
handler,
options,
resolvedParams,
keyless,
logger,
});
});

const keylessMiddleware: NextMiddleware = async (request, event) => {
/**
* This mechanism replaces a full-page reload. Ensures that middleware will re-run and authenticate the request properly without the secret key or publishable key to be missing.
Expand All @@ -253,15 +298,8 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
const isMissingPublishableKey = !(resolvedParams.publishableKey || PUBLISHABLE_KEY || keyless?.publishableKey);
const authHeader = getHeader(request, constants.Headers.Authorization)?.replace('Bearer ', '') ?? '';

/**
* In keyless mode, if the publishable key is missing, let the request through, to render `<ClerkProvider/>` that will resume the flow gracefully.
*/
if (isMissingPublishableKey && !isMachineTokenByPrefix(authHeader)) {
const res = NextResponse.next();
setRequestHeadersOnNextResponse(res, request, {
[constants.Headers.AuthStatus]: 'signed-out',
});
return res;
return bootstrapNextMiddleware(request, event);
}

return baseNextMiddleware(request, event);
Expand Down
Loading