Files
AOS/src/middleware.ts
T
Z User db8e365932 feat: global API rate limiting + webhook dispatcher wired to key events
Rate limiting (middleware-level):
- 120 requests/minute per IP for all /api/* routes
- Returns 429 with retry-after header when exceeded
- In-memory store (per-container, sufficient for single-instance)

Webhook dispatcher wired to events:
- license.created → fires when a new license is issued
- ticket.created → fires when a support ticket is created
- instance.provisioned → fires when a tenant instance goes live
- Each event includes relevant payload (key, product, URL, etc.)
- Fire-and-forget (doesn't block the response)
- HMAC-SHA256 signed (existing webhook.ts implementation)

Also:
- Ticket creation now sends email notification (sendTicketCreatedEmail)
2026-10-29 10:46:49 +00:00

193 lines
5.9 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.
// Also applies global rate limiting to API routes.
// --- Simple in-memory rate limiter (per-IP, per-minute) ---
const rateLimitStore = new Map<string, { count: number; resetAt: number }>()
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<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
// --- 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).*)',
],
}