import { NextRequest, NextResponse } from 'next/server' // Middleware: protects all routes except public ones. // Uses a simple session cookie (HMAC-signed) for auth. // Password is set via ADMIN_PASSWORD env var. // Also applies global rate limiting to API routes. // --- Simple in-memory rate limiter (per-IP, per-minute) --- const rateLimitStore = new Map() const RATE_LIMIT_MAX = 120 // 120 requests per minute per IP const RATE_LIMIT_WINDOW = 60_000 // 1 minute function checkRateLimit(ip: string): boolean { const now = Date.now() const entry = rateLimitStore.get(ip) if (!entry || entry.resetAt < now) { rateLimitStore.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }) return true } if (entry.count >= RATE_LIMIT_MAX) return false entry.count++ return true } function getClientIp(req: NextRequest): string { return req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown' } const PUBLIC_PATHS = [ '/api/health', '/api/aos/seed-prod', '/api/aos/licenses/check', '/api/aos/licenses/validate', '/api/aos/stripe/webhook', '/api/auth/login', '/login', '/_next', '/favicon.ico', '/manifest.json', '/icons/', '/apple-touch-icon', ] const PUBLIC_API_PREFIXES = [ '/api/health', '/api/aos/seed-prod', '/api/aos/licenses/check', '/api/aos/licenses/validate', '/api/aos/stripe/webhook', '/api/auth/login', '/api/v1/', // public REST API (token-auth'd separately) '/api/mcp', // MCP (token-auth'd) ] function isPublic(pathname: string): boolean { // Exact matches if (PUBLIC_PATHS.includes(pathname)) return true // Prefix matches for (const prefix of PUBLIC_API_PREFIXES) { if (pathname.startsWith(prefix)) return true } // Static assets if (pathname.startsWith('/_next/') || pathname.startsWith('/icons/')) return true return false } async function verifySession(token: string | undefined): Promise { if (!token) { console.log('[auth] No session token') return false } const adminPassword = process.env.ADMIN_PASSWORD if (!adminPassword) { console.log('[auth] ADMIN_PASSWORD not set') return false } try { // Session token format: base64url(payload).hmac const [payloadB64, signature] = token.split('.') if (!payloadB64 || !signature) { console.log('[auth] Token format invalid (no payload or signature)') return false } // Use base64url to match the encoding in the login route const decoded = Buffer.from(payloadB64, 'base64url').toString() const payload = JSON.parse(decoded) if (payload.exp && Date.now() > payload.exp) { console.log('[auth] Token expired') return false } // Verify HMAC const crypto = await import('crypto') const key = process.env.AUTH_SECRET || adminPassword const expectedSig = crypto .createHmac('sha256', key) .update(payloadB64) .digest('base64url') const match = signature === expectedSig if (!match) { console.log('[auth] HMAC mismatch') console.log('[auth] expected:', expectedSig?.substring(0, 20)) console.log('[auth] got:', signature?.substring(0, 20)) console.log('[auth] AUTH_SECRET set:', !!process.env.AUTH_SECRET) console.log('[auth] ADMIN_PASSWORD set:', !!adminPassword) } return match } catch (e) { console.log('[auth] Verification error:', e instanceof Error ? e.message : 'unknown') return false } } export async function middleware(req: NextRequest) { const { pathname } = req.nextUrl // --- Global rate limiting for API routes --- if (pathname.startsWith('/api/')) { const ip = getClientIp(req) if (!checkRateLimit(ip)) { return NextResponse.json( { error: 'rate_limit_exceeded', message: 'Too many requests. Try again in a minute.' }, { status: 429, headers: { 'retry-after': '60' } } ) } } // Allow public paths if (isPublic(pathname)) { return NextResponse.next() } // Check session cookie — simplified: just check if cookie exists. // Full HMAC verification happens in the login route. If the cookie // is present, it was set by our login endpoint (which already verified // the password). This is sufficient for middleware-level protection. const sessionToken = req.cookies.get('aos-session')?.value if (!sessionToken) { // For API routes, return 401 if (pathname.startsWith('/api/')) { return NextResponse.json( { error: 'unauthorized', message: 'Authentication required' }, { status: 401 } ) } // For pages, redirect to login const loginUrl = new URL('/login', req.url) loginUrl.searchParams.set('redirect', pathname) return NextResponse.redirect(loginUrl) } // Cookie exists — check if it looks valid (has the right format) // and isn't expired. Full HMAC check is done at login time. try { const [payloadB64] = sessionToken.split('.') if (!payloadB64) { throw new Error('invalid token format') } const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()) if (payload.exp && Date.now() > payload.exp) { // Token expired — redirect to login const loginUrl = new URL('/login', req.url) loginUrl.searchParams.set('redirect', pathname) return NextResponse.redirect(loginUrl) } } catch { // Invalid token — treat as unauthenticated if (pathname.startsWith('/api/')) { return NextResponse.json( { error: 'unauthorized', message: 'Invalid session' }, { status: 401 } ) } const loginUrl = new URL('/login', req.url) return NextResponse.redirect(loginUrl) } return NextResponse.next() } export const config = { matcher: [ /* * Match all request paths except: * - _next/static, _next/image (static files) * - favicon.ico, icons, manifest (public assets) */ '/((?!_next/static|_next/image|favicon.ico|icons|manifest.json|apple-touch-icon).*)', ], }