--- title: "QR-pairing via unauth-SSE + pre-auth cookie" status: active audience: [ai-agent, contributor] language: nl last_updated: 2026-05-03 when_to_read: "When working on QR-code login flow or unauth SSE endpoints." --- # Patroon: QR-pairing via unauth-SSE + pre-auth cookie Het M10 QR-login-mechanisme is herbruikbaar voor elke feature die **realtime- feedback wil tussen twee browsers/devices vóórdat de eindgebruiker is geauthenticeerd**. De typische vorm: > "Apparaat A start een proces, krijgt een token. Apparaat B (bekend kanaal) > bevestigt iets. Apparaat A wil dat realtime weten en daarna iets claimen." Voorbeelden waar dit zou kunnen passen: device-pairing voor 2FA-setup, login- op-TV via QR, "claim deze export"-flow, account-overdracht tussen sessies. --- ## Drie eindpunten | Endpoint | Auth | Doel | |---|---|---| | `POST /api/.../start` | anon | maakt resource aan, retourneert mobile-secret in body, zet HttpOnly device-token cookie | | `GET /api/.../stream/[id]` | cookie | SSE die op LISTEN/NOTIFY wacht op statusverandering | | `POST /api/.../claim` | cookie | atomic state-transitie van "approved" → "consumed", wisselt cookie in voor échte sessie | Plus een server-action-laag die door het tweede device wordt aangeroepen na het scannen / klikken van een link met fragment-secret. --- ## Vier security-uitgangspunten 1. **Twee gescheiden geheimen** — een voor het kanaal richting het tweede device (in QR-fragment), een voor het oorspronkelijke device (in HttpOnly cookie). Beide alleen als sha256-hash in DB. 2. **Geen secret in URL.** Path en querystring lekken naar access logs, reverse proxies, observability. Geheimen reizen alleen via: - URL-fragment (`#…`) — browsers sturen die niet naar de server - HttpOnly cookies — meestal niet gelogd, en alleen leesbaar door server - POST-body — niet gelogd standaard 3. **Atomic consume.** Het claim-endpoint doet één UPDATE met een composite WHERE op alle invarianten (status, hash, expiry). PostgreSQL row-locking garandeert dat concurrent dubbele claims slechts één caller succes geven. 4. **Path-scoped cookie.** `Path=/api/.../...` zorgt dat de pre-auth cookie alleen naar pairing-routes gaat — niet naar de rest van de app. --- ## Sjabloon-bestanden Ga voor M10 specifiek? Kopieer en pas aan: - `lib/auth/pairing.ts` — secret/token generators + sha256 + timing-safe verify + expiry helper - `lib/auth/pair-cookie.ts` — set/read/clear van Path-scoped HttpOnly cookie - `app/api/auth/pair/start/route.ts` — anon POST, rate-limited, sets cookie - `app/api/auth/pair/stream/[id]/route.ts` — SSE met cookie-auth, LISTEN op eigen channel - `app/api/auth/pair/claim/route.ts` — atomic update + iron-session schrijven - `actions/pairing.ts` — Server Actions voor het tweede device - `app/(app)/m/pair/pair-confirmation.tsx` — Client island die `location.hash` parseert Voor het tweede device zit de auth meestal al in de bestaande `(app)`-layout guard. De Client Component gebruikt `window.location.hash` (niet `useSearchParams`) om het secret op te pikken. --- ## TTL-richtlijn Drie tijden in escalerende volgorde, alle korter dan de reguliere sessie: - **Pending (cookie + DB-rij)** — *kort genoeg dat een verloren cookie/QR weinig schade aanricht*. M10: 5 minuten. - **Approved (na bevestiging)** — *kort genoeg dat een approved-maar-niet- geclaimde pairing niet eindeloos open blijft*. M10: 5 minuten extra. - **Resulterende sessie** — *kort genoeg voor publieke apparaten, lang genoeg voor een werkdag*. M10: 8 uur, plus `paired: true`-vlag voor toekomstige remote-revoke. --- ## Wanneer dit patroon NIET gebruiken - Wanneer beide kanten al ingelogd zijn — dan is een normaal API-call met bestaande sessie eenvoudiger. - Wanneer realtime niet kritiek is — een korte poll (`setInterval` op een status-endpoint) is simpeler dan een SSE-stream. - Wanneer er één centraal apparaat is — gebruik dan een normale sessie; de twee-device-dans is alleen nodig om credentials van het ene apparaat naar het andere te brengen. --- ## Referenties - Volledige flow + threat-model: `docs/architecture.md` § QR-pairing flow - Endpoint-contract: `docs/api/rest-contract.md` § Auth — QR-pairing - LISTEN/NOTIFY-pattern: `app/api/realtime/solo/route.ts` (M8 ST-802) — zelfde ReadableStream + heartbeat + hard-close + abort-cleanup, alleen ander channel