/ src / app / api / auth / route.ts
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  }