Scrum4Me/docs/patterns/qr-login.md

4.3 KiB

title status audience language last_updated when_to_read
QR-pairing via unauth-SSE + pre-auth cookie active
ai-agent
contributor
nl 2026-05-03 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 sessiekort 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/scrum4me-architecture.md § QR-pairing flow
  • Endpoint-contract: docs/API.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