Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 44 additions & 0 deletions lib/middleware-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const PROTECTED_ROUTE_PREFIXES = [
'/app',
'/admin',
'/api/stripe/checkout',
'/api/stripe/portal',
'/api/twilio/provision-number',
] as const;

const PROTECTED_MUTATION_ROUTE_PREFIXES = [
'/api/stripe/checkout',
'/api/stripe/portal',
'/api/twilio/provision-number',
] as const;

const CLERK_CONTEXT_PUBLIC_ROUTE_PREFIXES = [
'/sign-in',
'/sign-up',
'/start-free-pilot',
'/buy',
] as const;

function matchesRoutePrefix(pathname: string, prefix: string) {
return pathname === prefix || pathname.startsWith(`${prefix}/`);
}

function matchesAnyPrefix(pathname: string, prefixes: readonly string[]) {
return prefixes.some((prefix) => matchesRoutePrefix(pathname, prefix));
}

export function routeNeedsProtection(pathname: string) {
return matchesAnyPrefix(pathname, PROTECTED_ROUTE_PREFIXES);
}

export function routeNeedsProtectedMutationRateLimit(pathname: string) {
return matchesAnyPrefix(pathname, PROTECTED_MUTATION_ROUTE_PREFIXES);
}

export function routeNeedsClerkContext(pathname: string) {
return routeNeedsProtection(pathname) || matchesAnyPrefix(pathname, CLERK_CONTEXT_PUBLIC_ROUTE_PREFIXES);
}

export function routeCanRenderWithoutClerk(pathname: string) {
return !routeNeedsClerkContext(pathname);
}
71 changes: 36 additions & 35 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import type { NextFetchEvent, NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
import { clerkMiddleware } from '@clerk/nextjs/server';

import { hasRequiredValidClerkEnv } from '@/lib/clerk-config';
import {
routeCanRenderWithoutClerk,
routeNeedsClerkContext,
routeNeedsProtectedMutationRateLimit,
routeNeedsProtection,
} from '@/lib/middleware-access';
import {
getPortfolioDemoGuardrailErrorMessage,
isPortfolioDemoModeBlockedInProduction,
Expand All @@ -13,18 +19,6 @@ import { RATE_LIMIT_PROTECTED_API_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-l
import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit';
import { withSecurityHeaders } from '@/lib/security-headers';

const isProtectedRoute = createRouteMatcher([
'/app(.*)',
'/admin(.*)',
'/api/stripe/checkout(.*)',
'/api/stripe/portal(.*)',
'/api/twilio/provision-number(.*)',
]);
const isProtectedApiMutationRoute = createRouteMatcher([
'/api/stripe/checkout',
'/api/stripe/portal',
'/api/twilio/provision-number',
]);
let productionDemoGuardrailLogged = false;
let productionDemoOverrideLogged = false;
let missingClerkEnvLogged = false;
Expand All @@ -43,22 +37,26 @@ function buildAuthUnavailableResponse(request: NextRequest) {
return new NextResponse('Authentication is temporarily unavailable.', { status: 503 });
}

const protectedMiddleware = clerkMiddleware(async (auth, req) => {
await auth.protect();
const appMiddleware = clerkMiddleware(async (auth, req) => {
const pathname = req.nextUrl.pathname;

if (req.method === 'POST' && isProtectedApiMutationRoute(req)) {
const clientIp = getClientIpAddress(req);
const rateLimit = consumeRateLimit({
key: `middleware:protected-api:${clientIp}`,
limit: RATE_LIMIT_PROTECTED_API_MAX,
windowMs: RATE_LIMIT_WINDOW_MS,
});
if (routeNeedsProtection(pathname)) {
await auth.protect();

if (req.method === 'POST' && routeNeedsProtectedMutationRateLimit(pathname)) {
const clientIp = getClientIpAddress(req);
const rateLimit = consumeRateLimit({
key: `middleware:protected-api:${clientIp}`,
limit: RATE_LIMIT_PROTECTED_API_MAX,
windowMs: RATE_LIMIT_WINDOW_MS,
});

if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429, headers: buildRateLimitHeaders(rateLimit) }
);
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429, headers: buildRateLimitHeaders(rateLimit) }
);
}
}
}

Expand Down Expand Up @@ -88,14 +86,13 @@ export default async function middleware(req: NextRequest, event: NextFetchEvent
return withSecurityHeaders(NextResponse.next());
}

if (!isProtectedRoute(req)) {
return withSecurityHeaders(NextResponse.next());
}
const pathname = req.nextUrl.pathname;
const needsClerkContext = routeNeedsClerkContext(pathname);

if (!hasRequiredClerkMiddlewareEnv(process.env)) {
if (!missingClerkEnvLogged) {
missingClerkEnvLogged = true;
console.error('Clerk middleware env is incomplete; protected routes will return 503 until keys are configured.', {
console.error('Clerk middleware env is incomplete; Clerk-backed routes will return 503 until keys are configured.', {
clerkSecretKeyPresent: Boolean(process.env.CLERK_SECRET_KEY?.trim()),
clerkPublishableKeyPresent: Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY?.trim()),
clerkKeysLookValid: hasRequiredValidClerkEnv(process.env),
Expand All @@ -104,18 +101,22 @@ export default async function middleware(req: NextRequest, event: NextFetchEvent
});
}

return withSecurityHeaders(buildAuthUnavailableResponse(req));
return withSecurityHeaders(
needsClerkContext ? buildAuthUnavailableResponse(req) : NextResponse.next()
);
}

try {
const response = await protectedMiddleware(req, event);
const response = await appMiddleware(req, event);
return withSecurityHeaders(response ?? NextResponse.next());
} catch (error) {
console.error('Protected middleware invocation failed.', {
console.error('Clerk middleware invocation failed.', {
path: req.nextUrl.pathname,
message: error instanceof Error ? error.message : 'unknown_error',
});
return withSecurityHeaders(buildAuthUnavailableResponse(req));
return withSecurityHeaders(
routeCanRenderWithoutClerk(pathname) ? NextResponse.next() : buildAuthUnavailableResponse(req)
);
}
}

Expand Down
68 changes: 68 additions & 0 deletions tests/middleware-auth-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import test from 'node:test';

import {
routeCanRenderWithoutClerk,
routeNeedsClerkContext,
routeNeedsProtectedMutationRateLimit,
routeNeedsProtection,
} from '../lib/middleware-access.ts';

function read(relativePath: string) {
return readFileSync(path.join(process.cwd(), relativePath), 'utf8');
}

test('public auth routes get Clerk context without becoming protected', () => {
assert.equal(routeNeedsClerkContext('/sign-up'), true);
assert.equal(routeNeedsClerkContext('/sign-in'), true);
assert.equal(routeNeedsClerkContext('/start-free-pilot'), true);
assert.equal(routeNeedsClerkContext('/buy'), true);

assert.equal(routeNeedsProtection('/sign-up'), false);
assert.equal(routeNeedsProtection('/sign-in'), false);
assert.equal(routeNeedsProtection('/start-free-pilot'), false);
assert.equal(routeNeedsProtection('/buy'), false);
});

test('marketing pages remain public when Clerk is unavailable', () => {
assert.equal(routeCanRenderWithoutClerk('/'), true);
assert.equal(routeCanRenderWithoutClerk('/pricing'), true);
assert.equal(routeCanRenderWithoutClerk('/demo'), true);
assert.equal(routeCanRenderWithoutClerk('/contact'), true);
});

test('protected owner and admin routes still require auth', () => {
assert.equal(routeNeedsProtection('/app'), true);
assert.equal(routeNeedsProtection('/app/leads'), true);
assert.equal(routeNeedsProtection('/admin'), true);
assert.equal(routeNeedsProtection('/admin/abc123'), true);
});

test('protected API mutation routes remain rate limited', () => {
assert.equal(routeNeedsProtection('/api/stripe/checkout'), true);
assert.equal(routeNeedsProtection('/api/stripe/portal'), true);
assert.equal(routeNeedsProtection('/api/twilio/provision-number'), true);

assert.equal(routeNeedsProtectedMutationRateLimit('/api/stripe/checkout'), true);
assert.equal(routeNeedsProtectedMutationRateLimit('/api/stripe/portal'), true);
assert.equal(routeNeedsProtectedMutationRateLimit('/api/twilio/provision-number'), true);
assert.equal(routeNeedsProtectedMutationRateLimit('/app'), false);
});

test('middleware uses one clerkMiddleware path and only protects the protected subset', () => {
const middleware = read('middleware.ts');
const signInPage = read('app/(auth)/sign-in/[[...sign-in]]/page.tsx');
const signUpPage = read('app/(auth)/sign-up/[[...sign-up]]/page.tsx');

assert.match(middleware, /const appMiddleware = clerkMiddleware\(async \(auth, req\) =>/);
assert.match(middleware, /if \(routeNeedsProtection\(pathname\)\) {\s*await auth\.protect\(\);/s);
assert.match(middleware, /if \(req\.method === 'POST' && routeNeedsProtectedMutationRateLimit\(pathname\)\)/);
assert.match(middleware, /const needsClerkContext = routeNeedsClerkContext\(pathname\)/);
assert.match(middleware, /needsClerkContext \? buildAuthUnavailableResponse\(req\) : NextResponse\.next\(\)/);
assert.doesNotMatch(middleware, /if \(!isProtectedRoute\(req\)\)/);
assert.doesNotMatch(middleware, /const protectedMiddleware = clerkMiddleware/);
assert.match(signInPage, /const \{ userId \} = await auth\(\);/);
assert.match(signUpPage, /const \{ userId \} = await auth\(\);/);
});
13 changes: 7 additions & 6 deletions tests/tenant-isolation-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,15 @@ test('protected app surfaces use shared tenant-scoped access helpers', () => {

test('middleware protects owner and admin app surfaces plus sensitive authenticated mutations', () => {
const middleware = read('middleware.ts');
const middlewareAccess = read('lib/middleware-access.ts');
const twilioProvisionRoute = read('app/api/twilio/provision-number/route.ts');

assert.match(middleware, /'\/app\(\.\*\)'/);
assert.match(middleware, /'\/admin\(\.\*\)'/);
assert.match(middleware, /'\/api\/stripe\/checkout\(\.\*\)'/);
assert.match(middleware, /'\/api\/stripe\/portal\(\.\*\)'/);
assert.match(middleware, /'\/api\/twilio\/provision-number\(\.\*\)'/);
assert.match(middleware, /'\/api\/twilio\/provision-number'/);
assert.match(middleware, /routeNeedsProtection/);
assert.match(middlewareAccess, /'\/app'/);
assert.match(middlewareAccess, /'\/admin'/);
assert.match(middlewareAccess, /'\/api\/stripe\/checkout'/);
assert.match(middlewareAccess, /'\/api\/stripe\/portal'/);
assert.match(middlewareAccess, /'\/api\/twilio\/provision-number'/);
assert.match(twilioProvisionRoute, /auth\(\)/);
assert.match(twilioProvisionRoute, /isAllowedRequestOrigin/);
assert.match(twilioProvisionRoute, /Invalid request origin/);
Expand Down
Loading