Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/sdk-70-keyless-middleware-bypass-fix.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ import type {
import {
AuthStatus,
constants,
createBootstrapSignedOutState,
createClerkRequest,
createRedirect,
getAuthObjectForAcceptedToken,
Expand DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(nextjs): enforce middleware authorization during keyless bootstrap by jacekradko · Pull Request #8369 · clerk/javascript · GitHub
Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/sdk-70-keyless-middleware-bypass-fix.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ import type {
import {
AuthStatus,
constants,
createBootstrapSignedOutState,
createClerkRequest,
createRedirect,
getAuthObjectForAcceptedToken,
Expand DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(nextjs): enforce middleware authorization during keyless bootstrap by jacekradko · Pull Request #8369 · clerk/javascript · GitHub
Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/sdk-70-keyless-middleware-bypass-fix.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ import type {
import {
AuthStatus,
constants,
createBootstrapSignedOutState,
createClerkRequest,
createRedirect,
getAuthObjectForAcceptedToken,
Expand DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(nextjs): enforce middleware authorization during keyless bootstrap by jacekradko · Pull Request #8369 · clerk/javascript · GitHub
Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/sdk-70-keyless-middleware-bypass-fix.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ import type {
import {
AuthStatus,
constants,
createBootstrapSignedOutState,
createClerkRequest,
createRedirect,
getAuthObjectForAcceptedToken,
Expand DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(nextjs): enforce middleware authorization during keyless bootstrap by jacekradko · Pull Request #8369 · clerk/javascript · GitHub
Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/sdk-70-keyless-middleware-bypass-fix.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ import type {
import {
AuthStatus,
constants,
createBootstrapSignedOutState,
createClerkRequest,
createRedirect,
getAuthObjectForAcceptedToken,
Expand DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(nextjs): enforce middleware authorization during keyless bootstrap by jacekradko · Pull Request #8369 · clerk/javascript · GitHub
Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/sdk-70-keyless-middleware-bypass-fix.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ import type {
import {
AuthStatus,
constants,
createBootstrapSignedOutState,
createClerkRequest,
createRedirect,
getAuthObjectForAcceptedToken,
Expand DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(nextjs): enforce middleware authorization during keyless bootstrap by jacekradko · Pull Request #8369 · clerk/javascript · GitHub
Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/sdk-70-keyless-middleware-bypass-fix.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ import type {
import {
AuthStatus,
constants,
createBootstrapSignedOutState,
createClerkRequest,
createRedirect,
getAuthObjectForAcceptedToken,
Expand DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(nextjs): enforce middleware authorization during keyless bootstrap by jacekradko · Pull Request #8369 · clerk/javascript · GitHub
Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/sdk-70-keyless-middleware-bypass-fix.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ import type {
import {
AuthStatus,
constants,
createBootstrapSignedOutState,
createClerkRequest,
createRedirect,
getAuthObjectForAcceptedToken,
Expand DownExpand 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