140 lines
4.1 KiB
TypeScript
140 lines
4.1 KiB
TypeScript
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.
|
|
|
|
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<boolean> {
|
|
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
|
|
|
|
// Allow public paths
|
|
if (isPublic(pathname)) {
|
|
return NextResponse.next()
|
|
}
|
|
|
|
// Check session cookie
|
|
const sessionToken = req.cookies.get('aos-session')?.value
|
|
const adminPassword = process.env.ADMIN_PASSWORD
|
|
|
|
// Debug logging (remove in production)
|
|
console.log('[middleware]', pathname, '| hasCookie:', !!sessionToken, '| hasAdminPw:', !!adminPassword, '| AUTH_SECRET:', !!process.env.AUTH_SECRET)
|
|
|
|
if (!adminPassword) {
|
|
// No ADMIN_PASSWORD configured — allow access (will be fixed when env is set)
|
|
return NextResponse.next()
|
|
}
|
|
|
|
const isAuthenticated = await verifySession(sessionToken)
|
|
|
|
if (!isAuthenticated) {
|
|
// 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)
|
|
}
|
|
|
|
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).*)',
|
|
],
|
|
}
|