4.1 KiB
title: "QR-pairing Login Flow" status: active audience: [maintainer, contributor] language: nl last_updated: 2026-05-03 related: auth-and-sessions.md
QR-pairing flow (M10)
Password-loze inlog op een (publieke) desktop. Mobiel — al ingelogd — bevestigt
door een QR te scannen die de desktop toont. Geen wachtwoord op het publieke
toetsenbord, geen credentials op de draad, demo-accounts geblokkeerd, paired-
sessie heeft eigen kortere TTL (8 u) + paired-vlag.
Sequence
sequenceDiagram
participant D as Desktop (anon)
participant S as Server
participant M as Mobiel (ingelogd)
D->>S: POST /api/auth/pair/start
S->>S: maak LoginPairing { secret_hash, desktop_token_hash, status=pending, expires=+5min }
S-->>D: 200 { pairingId, mobileSecret, qrUrl }<br/>Set-Cookie: s4m_pair=desktopToken
D->>D: render QR met qrUrl (#id=…&s=mobileSecret)
D->>S: GET /api/auth/pair/stream/[pairingId]<br/>Cookie: s4m_pair
S->>S: LISTEN scrum4me_pairing
S-->>D: event: state { status: 'pending' }
Note over M: Gebruiker scant QR
M->>M: location.hash → mobileSecret
M->>S: getPairingForApproval(pairingId, mobileSecret)
S-->>M: { desktop_ua, desktop_ip, username }
M->>M: toont bevestigingskaart
Note over M: Tap "Bevestig"
M->>S: approvePairing(pairingId, mobileSecret)
S->>S: status pending→approved, expires +5min<br/>pg_notify scrum4me_pairing
S-->>D: data { status: 'approved' }
D->>S: POST /api/auth/pair/claim<br/>Cookie: s4m_pair, body: { pairingId }
S->>S: atomic UPDATE WHERE status=approved AND token-hash<br/>→ status=consumed
S->>S: getIronSession.save { userId, paired: true, pairedExpiresAt }
S-->>D: 200, Set-Cookie: session<br/>+ s4m_pair cleared
D->>D: redirect /dashboard
Threat-model
| Aanval | Mitigatie |
|---|---|
| Replay van een geconsumeerde pairing | Atomic updateMany WHERE status='approved' — concurrent dubbele claim ziet count=0 → 410 |
| Phishing-QR ingesloten op een vreemde site | Mobiele bevestigingspagina toont desktop-UA + IP; gebruiker moet expliciet tappen; waarschuwing onder de kaart |
| Demo-account misbruik | approvePairing early-return op session.isDemo — pairing blijft pending |
| Brute-force van pairings | Rate-limit 10 starts per IP per minuut; pairingId is CUID (lange entropy) |
| Secret-leak via DB-dump | DB bevat alleen sha256-hashes; plaintext geheimen verlaten desktop alleen via QR-fragment + POST-body (mobile) of HttpOnly cookie (desktop) |
| Long-lived sessie op publieke desktop | Paired-sessie krijgt 8u TTL i.p.v. reguliere; paired: true markeert 'm voor toekomstige remote-revoke |
TTL-rationale
- Pending: 5 min. Genoeg voor menselijke handeling (telefoon pakken, scannen, bevestigen) — kort genoeg dat een verloren QR een klein attack-window heeft.
- Approved (na bump): nogmaals 5 min. Klant claim moet binnen redelijke tijd plaatsvinden; voorkomt dat een approved-maar-onclaimed pairing eindeloos open blijft.
- Paired-sessie: 8 uur. Korter dan de reguliere wachtwoord-sessie omdat de use-case publieke apparaten zijn waar je niet wil dat de sessie 's nachts blijft hangen.
Waarom geen secret in URL
Servers loggen URL-paden en querystrings standaard — nginx, Vercel access
logs, observability-stacks (Sentry, Datadog), reverse proxies, CDN's. Een
geheim in ?s=… belandt onbedoeld in al die logs. Twee technieken voorkomen dit:
- URL-fragment voor
mobileSecret. Het deel achter de#wordt door browsers nooit naar de server gestuurd in HTTP-requests. De mobiele Client Component leestwindow.location.hashen POST't de waarde in een body — ook niet in een URL. - HttpOnly cookie voor
desktopToken. Cookie-headers worden meestal NIET in toegangslogs gelogd (in tegenstelling tot URLs). De cookie is bovendienPath=/api/auth/pair-scoped, dus verlaat die route nooit.
Twee gescheiden hashes (secret_hash voor mobiel-bewijs, desktop_token_hash
voor desktop-bewijs) zorgen dat een ene server-side compromis niet automatisch
de andere kant compromitteert.
Dit patroon is herbruikbaar — zie docs/patterns/qr-login.md.