feat(ST-1006): add /api/auth/pair/claim with atomic consume + iron-session

POST /api/auth/pair/claim (cookie-auth, runtime: 'nodejs'):
- Auth via s4m_pair HttpOnly cookie alleen — body bevat enkel pairingId, geen
  secret. Het cookie-token is het bewijs.
- Atomic state-transitie via prisma.loginPairing.updateMany met composite
  WHERE (id + status='approved' + desktop_token_hash + expires_at > now);
  PostgreSQL row-locking garandeert dat concurrent dubbele claims slechts één
  count=1 zien — de rest 410.
- Bij geen rij geüpdate: tweede findFirst om te disambigueren tussen 401
  (cookie matcht geen pairing) en 410 (al consumed/cancelled). Cookie altijd
  gecleared bij faalpaden om herhaalde verwerking te voorkomen.
- Bij succes: getIronSession schrijft scrum4me-session-cookie met userId +
  isDemo (uit user-record als vangnet) + paired=true + pairedExpiresAt = now+8h
  (kortere TTL voor publieke desktops). s4m_pair wordt gecleared.
- Logging onder NODE_ENV !== 'production' alleen pairingId, nooit cookie of
  mobileSecret.

Tests __tests__/api/pair-claim.test.ts (7 cases):
- 200 happy: updateMany met juiste WHERE, iron-session payload (userId, isDemo,
  paired, pairedExpiresAt ~8h), save() called, s4m_pair cleared
- demo-vangnet: isDemo=true wordt doorgezet
- 401 zonder cookie (geen DB-call)
- 400 op malformed body
- 400 zonder pairingId
- 410 op tweede claim (al consumed, cookie cleared, geen session.save)
- 401 op cookie/hash-mismatch (cookie cleared)

Quality gates: lint 0 errors, tsc clean, vitest 139/139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-27 22:58:17 +02:00
parent 625221f9ee
commit 5c4ee150ea
2 changed files with 255 additions and 0 deletions

View file

@ -0,0 +1,97 @@
// 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 })
}
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
session.userId = pairing.user_id
session.isDemo = pairing.user?.is_demo ?? 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 })
}