// ST-1003: POST /api/auth/pair/start — anonieme endpoint die een nieuwe // LoginPairing aanmaakt voor de QR-pairing-flow (M10). // // Genereert twee gescheiden 256-bit geheimen: // - mobileSecret → komt in JSON-body terug zodat de desktop het in een // QR-fragment kan plaatsen (wordt nooit naar onze server gestuurd) // - desktopToken → wordt als HttpOnly cookie gezet zodat alleen deze // browser de SSE-stream en claim mag uitvoeren // // Rate-limit: 10 pogingen per IP per minuut (lib/rate-limit.ts → 'pair-start'). import { getIronSession } from 'iron-session' import { cookies } from 'next/headers' import { prisma } from '@/lib/prisma' import { generateMobileSecret, generateDesktopToken, hashToken, } from '@/lib/auth/pairing' import { setPairCookie } from '@/lib/auth/pair-cookie' import { checkRateLimit } from '@/lib/rate-limit' import { SessionData, sessionOptions } from '@/lib/session' export const runtime = 'nodejs' const PENDING_TTL_MS = 5 * 60 * 1000 // 5 min — komt overeen met s4m_pair Max-Age const UA_MAX = 255 // matcht VarChar(255) op login_pairings.desktop_ua const IP_MAX = 45 // matcht VarChar(45) — IPv6 max length function getClientIp(request: Request): string { return ( request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || 'unknown' ) } export async function POST(request: Request) { const session = await getIronSession(await cookies(), sessionOptions) if (session.isDemo) { return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) } const ip = getClientIp(request) if (!checkRateLimit(`pair-start:${ip}`)) { return Response.json( { error: 'Te veel pogingen. Probeer het over een minuut opnieuw.' }, { status: 429 }, ) } const ua = request.headers.get('user-agent')?.slice(0, UA_MAX) ?? null const ipStored = ip === 'unknown' ? null : ip.slice(0, IP_MAX) const mobileSecret = generateMobileSecret() const desktopToken = generateDesktopToken() const pairing = await prisma.loginPairing.create({ data: { secret_hash: hashToken(mobileSecret), desktop_token_hash: hashToken(desktopToken), status: 'pending', desktop_ua: ua, desktop_ip: ipStored, expires_at: new Date(Date.now() + PENDING_TTL_MS), }, select: { id: true, expires_at: true }, }) await setPairCookie(desktopToken) const origin = new URL(request.url).origin const qrUrl = `${origin}/m/pair#id=${pairing.id}&s=${mobileSecret}` return Response.json({ pairingId: pairing.id, mobileSecret, expiresAt: pairing.expires_at.toISOString(), qrUrl, }) }