import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { errorResponse } from '@/lib/errors'; import { PORTAL_COOKIE } from '@/lib/portal/auth'; import { signIn } from '@/lib/services/portal-auth.service'; import { enforcePublicRateLimit } from '@/lib/api/route-helpers'; const bodySchema = z.object({ email: z.string().email(), password: z.string().min(1), }); const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24; // 24h, matches createPortalToken export async function POST(req: NextRequest): Promise { let body: unknown; try { body = await req.json(); } catch { return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 }); } const parsed = bodySchema.safeParse(body); if (!parsed.success) { return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 }); } // Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so // the limiter is per-account-per-IP, not just per-IP — a NATed network // shouldn't be able to lock a single victim by burning their bucket. const limited = await enforcePublicRateLimit( req, 'portalSignIn', parsed.data.email.toLowerCase(), ); if (limited) return limited; try { const result = await signIn(parsed.data); const res = NextResponse.json({ success: true }); res.cookies.set(PORTAL_COOKIE, result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: SESSION_MAX_AGE_SECONDS, }); return res; } catch (err) { return errorResponse(err); } }