feat(ST-1003): add /api/auth/pair/start with rate-limit + pre-auth cookie

POST /api/auth/pair/start (anon, runtime: 'nodejs'):
- Geen authenticateApiRequest — desktop heeft nog geen sessie
- Genereert los mobileSecret + desktopToken via lib/auth/pairing
- Persisteert alleen sha256-hashes in login_pairings; status='pending', expires_at = now + 2 min
- Slaat user-agent + best-effort IP op (afgekapt op kolom-grootte)
- Set-Cookie via setPairCookie helper: HttpOnly, Path=/api/auth/pair, Max-Age=120, SameSite=Lax
- Response body: { pairingId, mobileSecret, expiresAt, qrUrl } met qrUrl = origin/m/pair#id=…&s=…
  → secret reist alleen via fragment (#…), nooit in querystring of access logs

Rate-limit: 'pair-start' expliciet aan lib/rate-limit.ts CONFIGS toegevoegd
voor self-documentatie (10/min, gelijk aan login).

Tests __tests__/api/pair-start.test.ts (6 cases):
- 200 met body-shape (pairingId, mobileSecret 43-char base64url, qrUrl met
  fragment, expiresAt ISO)
- alleen hashes in DB, geen plaintext
- cookie set met juiste opties
- UA + IP afgekapt op kolom-grootte
- IP=null als x-forwarded-for ontbreekt
- 11e POST levert 429 met NL foutmelding

Quality gates: lint 0 errors, tsc clean (na prisma generate), vitest 117/117.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-27 22:34:49 +02:00
parent b4813e6e54
commit e0bec8c55c
3 changed files with 186 additions and 1 deletions

View file

@ -0,0 +1,74 @@
// 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 { 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'
export const runtime = 'nodejs'
const PENDING_TTL_MS = 2 * 60 * 1000 // 2 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 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,
})
}