import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; /** * Paths that do not require an authenticated session. * Checked with startsWith, so /auth/ covers /auth/callback etc. */ const PUBLIC_PATHS: string[] = [ '/login', '/reset-password', '/set-password', '/auth/', '/api/auth/', '/api/public/', '/api/health', '/api/webhooks/', '/scan', '/portal/', '/api/portal/', // Token-gated email-change endpoints. The confirm/cancel links land in // a fresh browser (the user may not be signed in on this device), so // they need to bypass the session 401 gate. The endpoints validate a // signed sha256-hashed token instead — that's the auth. '/api/v1/me/email/confirm/', '/api/v1/me/email/cancel/', ]; function isPublicPath(pathname: string): boolean { // Per-port PWA manifests sit under `//scan/manifest.webmanifest` // and need to be fetchable without a session - browsers fetch them eagerly // during install / first paint. The manifest only contains port name + // icon paths, no sensitive data, so making it public is safe. if (pathname.endsWith('/scan/manifest.webmanifest')) return true; return PUBLIC_PATHS.some((prefix) => pathname === prefix || pathname.startsWith(prefix)); } function isApiRoute(pathname: string): boolean { return pathname.startsWith('/api/'); } const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); /** * SameSite=Lax cookies block top-level cross-site POSTs in modern browsers, * but defense-in-depth: every state-changing request to a session-authed * `/api/v1/**` endpoint must originate from the same origin as the app. * Webhooks (`/api/webhooks/**`) and public posts (`/api/public/**`) are * exempt because they're called by external systems with no session * cookie. Auth flows (`/api/auth/**`) and portal (`/api/portal/**`) handle * their own origin/CSRF checks via better-auth. */ function isOriginCheckedPath(pathname: string): boolean { if (!pathname.startsWith('/api/v1/')) return false; return true; } function originAllowed(request: NextRequest): boolean { const origin = request.headers.get('origin'); const referer = request.headers.get('referer'); // Same-origin fetch from the app sends both Origin AND a matching host. // Use request.nextUrl.origin (the deployed origin) as the source of truth. const expectedOrigin = request.nextUrl.origin; if (origin) return origin === expectedOrigin; if (referer) { try { return new URL(referer).origin === expectedOrigin; } catch { return false; } } // Neither header present: most browser fetches always send Origin on // POST/PUT/PATCH/DELETE, so this likely means a same-origin server-side // call (e.g. Next.js internal fetch). Allow. return true; } export function proxy(request: NextRequest): NextResponse { const { pathname } = request.nextUrl; // CSRF defense-in-depth: state-changing requests to authed /api/v1 // endpoints must come from the app's own origin. Skipped in dev so // LAN testing (e.g. real iPhone hitting the Mac via 192.168.x.x while // a Mac browser tab is loaded from localhost) doesn't trip on the // origin mismatch. Production keeps the check. if ( process.env.NODE_ENV !== 'development' && STATE_CHANGING_METHODS.has(request.method) && isOriginCheckedPath(pathname) && !originAllowed(request) ) { return NextResponse.json( { error: 'Cross-origin state-changing request rejected' }, { status: 403 }, ); } // Always allow public paths through if (isPublicPath(pathname)) { return NextResponse.next(); } const sessionToken = request.cookies.get('pn-crm.session_token'); if (!sessionToken?.value) { if (isApiRoute(pathname)) { // API routes return 401 JSON - never redirect return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); } // Page routes redirect to /login, preserving the intended destination const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('redirect', pathname + request.nextUrl.search); return NextResponse.redirect(loginUrl); } return NextResponse.next(); } export const config = { matcher: [ /* * Match all request paths except: * - _next/static (static files) * - _next/image (Next.js image optimisation) * - favicon.ico (browser tab icon) * - /images/ (public image assets) * - manifest.json (PWA manifest — must be unauthed for installability) * - icon-*.png (PWA + apple-touch icons referenced by manifest) * - apple-touch-icon (iOS home-screen icon) */ '/((?!_next/static|_next/image|favicon\\.ico|images/|manifest\\.json|icon-|apple-touch-icon).*)', ], };