Skip to content

Commit f1d257d

Browse files
authored
fix(nextjs): enforce middleware authorization during keyless bootstrap (#8369)
1 parent b2e702e commit f1d257d

4 files changed

Lines changed: 129 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/nextjs': patch
3+
---
4+
5+
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.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import type { Application } from '../models/application';
4+
import { appConfigs } from '../presets';
5+
6+
const commonSetup = appConfigs.next.appRouter.clone();
7+
8+
test.describe('Keyless mode | middleware authorization @nextjs', () => {
9+
test.describe.configure({ mode: 'serial' });
10+
11+
test.use({
12+
extraHTTPHeaders: {
13+
'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET || '',
14+
},
15+
});
16+
17+
let app: Application;
18+
19+
test.beforeAll(async () => {
20+
app = await commonSetup.commit();
21+
await app.setup();
22+
await app.withEnv(appConfigs.envs.withKeyless);
23+
await app.dev();
24+
});
25+
26+
test.afterAll(async () => {
27+
await app.teardown();
28+
});
29+
30+
test('auth.protect() in middleware redirects to sign-in during keyless bootstrap', async ({ page }) => {
31+
await page.goto(`${app.serverUrl}/protected`);
32+
await page.waitForURL(/\/sign-in/);
33+
await expect(page.getByTestId('protected')).not.toBeVisible();
34+
});
35+
});

packages/backend/src/tokens/__tests__/authStatus.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
33

44
import { mockTokens, mockVerificationResults } from '../../fixtures/machine';
55
import type { AuthenticateContext } from '../../tokens/authenticateContext';
6-
import { handshake, signedIn, signedOut } from '../authStatus';
6+
import { createBootstrapSignedOutState, handshake, signedIn, signedOut } from '../authStatus';
77

88
describe('signed-in', () => {
99
describe('session tokens', () => {
@@ -132,6 +132,48 @@ describe('signed-out', () => {
132132
});
133133
});
134134

135+
describe('createBootstrapSignedOutState', () => {
136+
it('returns a signed-out session_token state with no publishable key', () => {
137+
const state = createBootstrapSignedOutState();
138+
139+
expect(state.status).toBe('signed-out');
140+
expect(state.tokenType).toBe('session_token');
141+
expect(state.isSignedIn).toBe(false);
142+
expect(state.isAuthenticated).toBe(false);
143+
expect(state.publishableKey).toBe('');
144+
expect(state.token).toBeNull();
145+
});
146+
147+
it('applies provided signInUrl and signUpUrl', () => {
148+
const state = createBootstrapSignedOutState({
149+
signInUrl: '/sign-in',
150+
signUpUrl: '/sign-up',
151+
});
152+
153+
expect(state.signInUrl).toBe('/sign-in');
154+
expect(state.signUpUrl).toBe('/sign-up');
155+
});
156+
157+
it('toAuth() returns a signed-out auth object without throwing', () => {
158+
const authObject = createBootstrapSignedOutState().toAuth();
159+
160+
expect(authObject.userId).toBeNull();
161+
expect(authObject.sessionId).toBeNull();
162+
expect(authObject.tokenType).toBe('session_token');
163+
});
164+
165+
it('includes debug headers on the state', () => {
166+
const state = createBootstrapSignedOutState({
167+
reason: 'session-token-and-uat-missing',
168+
message: 'no keys yet',
169+
});
170+
171+
expect(state.headers.get('x-clerk-auth-status')).toBe('signed-out');
172+
expect(state.headers.get('x-clerk-auth-reason')).toBe('session-token-and-uat-missing');
173+
expect(state.headers.get('x-clerk-auth-message')).toBe('no keys yet');
174+
});
175+
});
176+
135177
describe('handshake', () => {
136178
it('includes debug headers', () => {
137179
const headers = new Headers({ location: '/' });

packages/nextjs/src/server/clerkMiddleware.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
import {
1313
AuthStatus,
1414
constants,
15+
createBootstrapSignedOutState,
1516
createClerkRequest,
1617
createRedirect,
1718
getAuthObjectForAcceptedToken,
@@ -239,6 +240,50 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
239240
});
240241
});
241242

243+
/**
244+
* Runs the user's handler against a synthetic signed-out `RequestState` during the keyless
245+
* bootstrap window, so authorization fails closed until a publishable key is provisioned.
246+
*/
247+
const bootstrapNextMiddleware: NextMiddleware = withLogger('clerkMiddleware', logger => async (request, event) => {
248+
const resolvedParams = typeof params === 'function' ? await params(request) : params;
249+
const keyless = await getKeylessCookieValue(name => request.cookies.get(name)?.value);
250+
251+
const signInUrl = resolvedParams.signInUrl || SIGN_IN_URL || '';
252+
const signUpUrl = resolvedParams.signUpUrl || SIGN_UP_URL || '';
253+
254+
const options = {
255+
publishableKey: '',
256+
secretKey: '',
257+
signInUrl,
258+
signUpUrl,
259+
...resolvedParams,
260+
};
261+
262+
clerkMiddlewareRequestDataStore.set('requestData', options);
263+
264+
if (options.debug) {
265+
logger.enable();
266+
}
267+
268+
const clerkRequest = createClerkRequest(request);
269+
logger.debug('keyless bootstrap (no publishable key)', () => ({ signInUrl, signUpUrl }));
270+
logger.debug('url', () => clerkRequest.toJSON());
271+
272+
const requestState = createBootstrapSignedOutState({ signInUrl, signUpUrl });
273+
274+
return runHandlerWithRequestState({
275+
clerkRequest,
276+
request,
277+
event,
278+
requestState,
279+
handler,
280+
options,
281+
resolvedParams,
282+
keyless,
283+
logger,
284+
});
285+
});
286+
242287
const keylessMiddleware: NextMiddleware = async (request, event) => {
243288
/**
244289
* 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.
@@ -253,15 +298,8 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
253298
const isMissingPublishableKey = !(resolvedParams.publishableKey || PUBLISHABLE_KEY || keyless?.publishableKey);
254299
const authHeader = getHeader(request, constants.Headers.Authorization)?.replace('Bearer ', '') ?? '';
255300

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

267305
return baseNextMiddleware(request, event);

0 commit comments

Comments
 (0)