import type { NextRequest } from 'next/server'; import { toNextJsHandler } from 'better-auth/next-js'; import { auth } from '@/lib/auth'; import { createAuditLog } from '@/lib/audit'; import { logger } from '@/lib/logger'; const upstream = toNextJsHandler(auth); /** * Wrap better-auth's `[...all]` handler so we can stamp the audit log on * authentication events. Better-auth itself doesn't fire any callback we * can hook on sign-in / sign-out / failed-login — we inspect the route * + response status after the upstream handler finishes. * * Successful sign-in → action 'login' (severity info) * Failed sign-in → action 'login' (severity warning, ok=false) * Sign-out → action 'logout' (userId resolved before cookie * is cleared) * * Audit writes are fire-and-forget (createAuditLog never throws). */ interface AuthBody { user?: { id?: string; email?: string }; id?: string; email?: string; } function clientMeta(req: NextRequest): { ipAddress: string; userAgent: string } { const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? ''; return { ipAddress: ip, userAgent: req.headers.get('user-agent') ?? '' }; } function logSignIn(args: { req: NextRequest; responseBody: string; status: number; attemptedEmail: string | null; }) { const meta = clientMeta(args.req); let parsed: AuthBody | null = null; try { parsed = JSON.parse(args.responseBody) as AuthBody; } catch { /* upstream returned non-JSON */ } const userId = parsed?.user?.id ?? parsed?.id ?? null; const email = parsed?.user?.email ?? parsed?.email ?? args.attemptedEmail ?? null; const ok = args.status >= 200 && args.status < 300; // entityId is text/unbounded but indexed; truncate the attempted- // email fallback to keep the row predictably sized when the form // sends a giant value. The audit metadata still carries the full // original attempted email for forensic context. const safeAttempted = (args.attemptedEmail ?? '').slice(0, 256); void createAuditLog({ userId, portId: null, action: 'login', entityType: 'session', entityId: userId ?? safeAttempted ?? 'unknown', metadata: { ok, status: args.status, attemptedEmail: args.attemptedEmail ?? email ?? null, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, severity: ok ? 'info' : 'warning', source: 'auth', }); } async function logSignOut(req: NextRequest) { const meta = clientMeta(req); let userId: string | null = null; try { const session = await auth.api.getSession({ headers: req.headers }); userId = session?.user?.id ?? null; } catch { /* unauthenticated or expired */ } void createAuditLog({ userId, portId: null, action: 'logout', entityType: 'session', entityId: userId ?? 'unknown', metadata: {}, ipAddress: meta.ipAddress, userAgent: meta.userAgent, severity: 'info', source: 'auth', }); } async function withAuthAudit(req: NextRequest): Promise { const url = new URL(req.url); const path = url.pathname; const isSignIn = path.endsWith('/sign-in/email') || path.endsWith('/sign-in'); const isSignOut = path.endsWith('/sign-out'); // Read the request body BEFORE forwarding so we can extract the // attempted email even when the credentials are wrong (the upstream // handler will consume the body stream and we can't read it twice). let attemptedEmail: string | null = null; let forwardReq: NextRequest = req; if (isSignIn && req.method === 'POST') { try { const raw = await req.text(); try { attemptedEmail = (JSON.parse(raw) as { email?: string }).email ?? null; } catch { /* form-encoded or non-JSON */ } // Reconstruct a fresh Request so the upstream handler can read it. forwardReq = new Request(req.url, { method: req.method, headers: req.headers, body: raw, }) as unknown as NextRequest; } catch (err) { logger.warn({ err }, 'Failed to read sign-in body for audit'); } } // Capture sign-out userId BEFORE the upstream handler clears the cookie. const signOutPromise = isSignOut ? logSignOut(req) : null; const res = await upstream.POST(forwardReq); if (isSignIn) { try { const body = await res.clone().text(); logSignIn({ req, responseBody: body, status: res.status, attemptedEmail }); } catch (err) { logger.warn({ err }, 'Failed to capture sign-in response for audit'); } } if (signOutPromise) void signOutPromise; return res; } export const GET = upstream.GET; export async function POST(req: NextRequest): Promise { return withAuthAudit(req); }