diff --git a/lib/middleware-access.ts b/lib/middleware-access.ts new file mode 100644 index 0000000..b1a17a5 --- /dev/null +++ b/lib/middleware-access.ts @@ -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); +} diff --git a/middleware.ts b/middleware.ts index 62940dd..cc44263 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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, @@ -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; @@ -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) } + ); + } } } @@ -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), @@ -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) + ); } } diff --git a/tests/middleware-auth-routing.test.ts b/tests/middleware-auth-routing.test.ts new file mode 100644 index 0000000..3384da8 --- /dev/null +++ b/tests/middleware-auth-routing.test.ts @@ -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\(\);/); +}); diff --git a/tests/tenant-isolation-wiring.test.ts b/tests/tenant-isolation-wiring.test.ts index a53c394..2b7a2c0 100644 --- a/tests/tenant-isolation-wiring.test.ts +++ b/tests/tenant-isolation-wiring.test.ts @@ -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/);