// ST-1006: POST /api/auth/pair/claim — desktop ruilt zijn pre-auth cookie // (s4m_pair) in voor een echte iron-session na een succesvolle approve op de // mobiele kant. // // Auth: alleen via de HttpOnly s4m_pair-cookie. Geen body-secret nodig — het // cookie-token is het bewijs. Body bevat alleen pairingId. // // Atomicity: één UPDATE met WHERE-clausule die status én token-hash én niet- // verlopen tegelijk eist. Concurrent dubbele claims: PostgreSQL row-locking // zorgt dat exact één caller count=1 ziet, de rest count=0 → 410. import { getIronSession } from 'iron-session' import { cookies } from 'next/headers' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { hashToken } from '@/lib/auth/pairing' import { readPairCookie, clearPairCookie } from '@/lib/auth/pair-cookie' export const runtime = 'nodejs' const PAIRED_TTL_MS = 8 * 60 * 60 * 1000 // 8 uur — kortere TTL voor publieke desktops interface ClaimBody { pairingId?: unknown } export async function POST(request: Request) { const desktopToken = await readPairCookie() if (!desktopToken) { return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 }) } let body: ClaimBody try { body = (await request.json()) as ClaimBody } catch { return Response.json({ error: 'Ongeldige JSON' }, { status: 400 }) } const pairingId = typeof body?.pairingId === 'string' ? body.pairingId : null if (!pairingId) { return Response.json({ error: 'pairingId vereist' }, { status: 400 }) } const desktopTokenHash = hashToken(desktopToken) // Atomic state-transitie: alleen rij die approved is + token-hash matcht + // niet verlopen wordt geconsumeerd. const updated = await prisma.loginPairing.updateMany({ where: { id: pairingId, status: 'approved', desktop_token_hash: desktopTokenHash, expires_at: { gt: new Date() }, }, data: { status: 'consumed', consumed_at: new Date() }, }) if (updated.count !== 1) { // Disambigueer: bestaat de pairing wel met deze cookie? Zo ja → al consumed // of cancelled (410). Zo nee → cookie matcht geen pairing (401). const exists = await prisma.loginPairing.findFirst({ where: { id: pairingId, desktop_token_hash: desktopTokenHash }, select: { status: true }, }) await clearPairCookie() if (!exists) return Response.json({ error: 'Ongeldig' }, { status: 401 }) return Response.json( { error: `Pairing al ${exists.status}` }, { status: 410 }, ) } // Haal user-info op voor de iron-session payload. const pairing = await prisma.loginPairing.findUnique({ where: { id: pairingId }, select: { user_id: true, user: { select: { is_demo: true } } }, }) if (!pairing?.user_id) { await clearPairCookie() return Response.json({ error: 'Pairing zonder user' }, { status: 500 }) } if (pairing.user?.is_demo) { await clearPairCookie() return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) } const session = await getIronSession(await cookies(), sessionOptions) session.userId = pairing.user_id session.isDemo = pairing.user?.is_demo ?? false session.isAdmin = false session.paired = true session.pairedExpiresAt = Date.now() + PAIRED_TTL_MS await session.save() await clearPairCookie() if (process.env.NODE_ENV !== 'production') { console.log(`[pair/claim] consumed pairingId=${pairingId}`) } return Response.json({ ok: true }) }