TTL: 2 min was te kort voor handmatig curl-paste-confirm-testen — gebruiker zag 'Pairing verlopen' voor hij kon bevestigen. Bumpt naar 5 min (gelijk aan approved-TTL): nog steeds tight voor security, ruim voor menselijke reactie. - app/api/auth/pair/start/route.ts: PENDING_TTL_MS 120s → 300s - lib/auth/pair-cookie.ts: MAX_AGE_SECONDS 120 → 300 - __tests__/api/pair-start.test.ts: maxAge en expires_at-window meegegroeid Kleuren: bevestigingspagina gebruikte bg-destructive/10 + text-destructive- foreground — beide lichte kleuren, te weinig contrast. Vervangen door MD3 container-tokens (zelfde patroon als components/auth/auth-form.tsx): - error-state: bg-error-container + text-error-container-foreground + border-l-4 border-error - approved-state: bg-success-container + foreground + accent-border - cancelled-state: bg-surface-container-high + neutral foreground Quality gates: lint 0 errors, tsc clean, vitest 139/139. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 lines
2.3 KiB
TypeScript
74 lines
2.3 KiB
TypeScript
// 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 = 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 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,
|
|
})
|
|
}
|