route.ts
1 import { NextResponse } from 'next/server' 2 import { safeParseBody } from '@/lib/server/safe-parse-body' 3 import { log } from '@/lib/server/logger' 4 import { getAccessKey, validateAccessKey, isFirstTimeSetup, markSetupComplete, replaceAccessKey } from '@/lib/server/storage-auth' 5 import { AUTH_COOKIE_NAME, getCookieValue } from '@/lib/auth' 6 import { isProductionRuntime } from '@/lib/runtime/runtime-env' 7 import { ensureDaemonProcessRunning } from '@/lib/server/daemon/controller' 8 import { hmrSingleton } from '@/lib/shared-utils' 9 export const dynamic = 'force-dynamic' 10 11 const TAG = 'auth-route' 12 13 interface AuthAttemptEntry { 14 count: number 15 lockedUntil: number 16 } 17 18 const authRateLimitMap = hmrSingleton('__swarmclaw_auth_rate_limit__', () => new Map<string, AuthAttemptEntry>()) 19 20 const MAX_ATTEMPTS = 5 21 const LOCKOUT_MS = 15 * 60 * 1000 22 23 function isRateLimitEnabled(): boolean { 24 return isProductionRuntime() 25 } 26 27 function getClientIp(req: Request): string { 28 const forwarded = req.headers.get('x-forwarded-for') 29 if (forwarded) { 30 const first = forwarded.split(',')[0]?.trim() 31 if (first) return first 32 } 33 const realIp = req.headers.get('x-real-ip')?.trim() 34 return realIp || 'unknown' 35 } 36 37 function clearAuthCookie(response: NextResponse): NextResponse { 38 response.cookies.set(AUTH_COOKIE_NAME, '', { 39 httpOnly: true, 40 sameSite: 'lax', 41 secure: false, 42 path: '/', 43 maxAge: 0, 44 }) 45 return response 46 } 47 48 function setAuthCookie(response: NextResponse, req: Request, key: string): NextResponse { 49 response.cookies.set(AUTH_COOKIE_NAME, key, { 50 httpOnly: true, 51 sameSite: 'lax', 52 secure: new URL(req.url).protocol === 'https:', 53 path: '/', 54 maxAge: 60 * 60 * 24 * 30, 55 }) 56 return response 57 } 58 59 /** GET /api/auth — returns setup state and whether the auth cookie is currently valid. 60 * During first-time setup the generated access key is included so the UI can 61 * display it with a copy button. Once setup completes the key is never exposed 62 * over an unauthenticated endpoint again. */ 63 export async function GET(req: Request) { 64 const cookieKey = getCookieValue(req.headers.get('cookie'), AUTH_COOKIE_NAME) 65 const firstTime = isFirstTimeSetup() 66 return NextResponse.json({ 67 firstTime, 68 authenticated: !!cookieKey && validateAccessKey(cookieKey), 69 ...(firstTime ? { generatedKey: getAccessKey() } : {}), 70 }) 71 } 72 73 function pruneExpiredEntries() { 74 const now = Date.now() 75 for (const [ip, entry] of authRateLimitMap) { 76 if (entry.lockedUntil > 0 && entry.lockedUntil < now) { 77 authRateLimitMap.delete(ip) 78 } 79 } 80 } 81 82 function startDaemonAfterAuth() { 83 void ensureDaemonProcessRunning('api/auth:post') 84 .catch((err: unknown) => { 85 log.error(TAG, 'Deferred daemon start failed', err) 86 }) 87 } 88 89 /** POST /api/auth — validate an access key */ 90 export async function POST(req: Request) { 91 const rateLimitEnabled = isRateLimitEnabled() 92 if (rateLimitEnabled) pruneExpiredEntries() 93 const clientIp = getClientIp(req) 94 const entry = rateLimitEnabled ? authRateLimitMap.get(clientIp) : undefined 95 if (rateLimitEnabled && entry && entry.lockedUntil > Date.now()) { 96 const retryAfter = Math.ceil((entry.lockedUntil - Date.now()) / 1000) 97 return clearAuthCookie(NextResponse.json( 98 { error: 'Too many failed attempts. Try again later.', retryAfter }, 99 { status: 429, headers: { 'Retry-After': String(retryAfter) } }, 100 )) 101 } 102 103 const { data: body, error } = await safeParseBody<{ key: string; override?: boolean }>(req) 104 if (error) return error 105 const { key, override } = body 106 107 // During first-time setup, allow the user to replace the generated key with their own 108 if (override && isFirstTimeSetup() && typeof key === 'string' && key.trim().length >= 8) { 109 replaceAccessKey(key.trim()) 110 markSetupComplete() 111 if (rateLimitEnabled) authRateLimitMap.delete(clientIp) 112 startDaemonAfterAuth() 113 return setAuthCookie(NextResponse.json({ ok: true }), req, key.trim()) 114 } 115 116 if (!key || !validateAccessKey(key)) { 117 let remaining = MAX_ATTEMPTS 118 if (rateLimitEnabled) { 119 const current = authRateLimitMap.get(clientIp) ?? { count: 0, lockedUntil: 0 } 120 current.count += 1 121 if (current.count >= MAX_ATTEMPTS) { 122 current.lockedUntil = Date.now() + LOCKOUT_MS 123 } 124 authRateLimitMap.set(clientIp, current) 125 remaining = Math.max(0, MAX_ATTEMPTS - current.count) 126 } 127 return clearAuthCookie(NextResponse.json( 128 { error: 'Invalid access key' }, 129 { 130 status: 401, 131 headers: { 'X-RateLimit-Remaining': String(remaining) }, 132 }, 133 )) 134 } 135 136 if (rateLimitEnabled) authRateLimitMap.delete(clientIp) 137 // If this was first-time setup, mark it as claimed 138 if (isFirstTimeSetup()) { 139 markSetupComplete() 140 } 141 startDaemonAfterAuth() 142 return setAuthCookie(NextResponse.json({ ok: true }), req, key) 143 }