docs(ST-1008): document QR-pairing endpoints, flow, threat-model + pattern

docs/API.md — nieuwe sectie 'Auth — QR-pairing (M10)' met alle drie endpoints
(start, stream, claim), cookie-mechaniek, foutcodes (400/401/410/429),
curl-voorbeelden inclusief --cookie-jar.

docs/scrum4me-architecture.md — sectie 'QR-pairing flow' met:
- Mermaid sequence-diagram (start → QR → scan → approve → claim)
- Threat-model (replay, phishing-QR, demo-block, rate-limit, secret-leak,
  long-lived sessie) met expliciete mitigaties
- TTL-rationale voor de drie tijden (5min pending / +5min approved / 8u paired)
- Subsectie 'Waarom geen secret in URL' — fragment-eigenschap + HttpOnly
  cookie + twee gescheiden hashes

docs/patterns/qr-login.md — herbruikbaar pattern 'QR-pairing via unauth-SSE +
pre-auth cookie' met de drie endpoints, vier security-uitgangspunten,
sjabloon-bestanden, TTL-richtlijn, en wanneer NIET te gebruiken.

CLAUDE.md — extra rij in Implementatiepatronen-tabel die naar het nieuwe
pattern-doc verwijst.

Acceptatie ST-1008 (zeven scenario's):
- Happy path: gedekt door manuele E2E in vorige stories (gebruiker bevestigde
  dat M10-stories op Solo bord verschijnen + curl-roundtrip werkt)
- Demo-block: actions/pairing.test.ts → approvePairing demo → Niet beschikbaar
- Replay: pair-claim.test.ts → 410 op tweede claim
- Expiry tijdens pending: pair-stream.test.ts + pairing.test.ts → 410/error
- Expiry tussen approve+claim: pair-claim.test.ts → 410
- Cookie-mismatch op SSE/claim: pair-stream.test.ts + pair-claim.test.ts → 401
- Secret niet in URL/logs: per ontwerp — fragment + cookie reizen niet via
  URL-paden of querystrings (gedocumenteerd in architecture.md)

Quality gates: lint 0 errors, tsc clean, vitest 139/139 (16 files).

M10 is hiermee compleet — feat/M10-qr-login bevat 13 commits klaar voor
gebruiker-acceptatie en PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-27 23:26:35 +02:00
parent 3a90fa9d13
commit c87b6156ae
4 changed files with 269 additions and 0 deletions

View file

@ -98,6 +98,7 @@ Lees het relevante patroon vóór je begint. Nooit uit het hoofd schrijven.
| Zustand optimistische update + rollback | `docs/patterns/zustand-optimistic.md` |
| Float sort_order drag-and-drop | `docs/patterns/sort-order.md` |
| Middleware (route protection) | `docs/patterns/middleware.md` |
| QR-pairing (unauth-SSE + pre-auth cookie) | `docs/patterns/qr-login.md` |
| Status-enum mapping (DB ↔ API) | `lib/task-status.ts` |
| Client/server module-boundary | `*-server.ts` bevat DB-calls of node-only deps; `*.ts` is pure (client-safe). Nooit `import { ... } from '@/lib/foo-server'` in een client-component, anders krijg je `Module not found: 'dns'`/`'pg'`-style runtime fouten |

View file

@ -323,6 +323,100 @@ source.onmessage = (e) => console.log(JSON.parse(e.data))
---
## Auth — QR-pairing (M10)
Drie anonieme/cookie-geauthenticeerde endpoints voor de password-loze inlog
via QR-pairing. Worden door de browser gebruikt (niet door Claude Code) —
gedocumenteerd voor volledigheid en voor handmatige curl-tests.
**Cookie-mechaniek:** `pair/start` zet een korte `s4m_pair`-HttpOnly-cookie
(`Path=/api/auth/pair`, `Max-Age=300`, `SameSite=Lax`, `Secure` in productie).
`pair/stream` en `pair/claim` authenticeren tegen die cookie. Geheim materiaal
zit nooit in URL-paden of querystrings — `mobileSecret` reist alleen via QR-
fragment (`#s=…`) en POST-body, `desktopToken` alleen via cookie.
### `POST /api/auth/pair/start`
Anon. Maakt een nieuwe `LoginPairing` aan en zet de pre-auth cookie.
**Auth:** geen.
**Body:** geen.
**Rate-limit:** 10 per IP per minuut (zelfde patroon als `/login`).
**Response 200:**
```json
{
"pairingId": "cmoh...",
"mobileSecret": "<43-char base64url>",
"expiresAt": "2026-04-27T20:30:00.000Z",
"qrUrl": "https://.../m/pair#id=cmoh...&s=<mobileSecret>"
}
```
Plus `Set-Cookie: s4m_pair=<desktopToken>; HttpOnly; Path=/api/auth/pair; Max-Age=300; SameSite=Lax`.
**Foutcodes:** `429` bij rate-limit overschreden.
**Voorbeeld:**
```bash
curl -i -X POST -c /tmp/jar http://localhost:3000/api/auth/pair/start
```
---
### `GET /api/auth/pair/stream/:pairingId`
Server-Sent Events stream die de desktop opent direct na `pair/start` om op
de approve-bevestiging van de mobiel te wachten.
**Auth:** `s4m_pair`-cookie. Werkt vanuit `EventSource` met `withCredentials: true`.
**Path:** `pairingId` is niet vertrouwelijk; cookie is het bewijs.
**Stream-duur:** maximaal 240s (Vercel-buffer onder de 300s `maxDuration`); sluit
zodra status `consumed` of `cancelled` doorkomt.
**Events:**
- `event: state` — eenmalig direct na connect, met `{ pairing_id, status }` (status van pairing op moment van connecten — voorkomt race wanneer approve net vóór SSE-open landt).
- `data: {...}` — bij elke status-overgang. Payload:
```json
{ "op": "I" | "U", "pairing_id": "cmoh...", "status": "pending" | "approved" | "consumed" | "cancelled" }
```
- `: heartbeat` — SSE-comment elke 25s.
**Foutcodes:** `401` zonder/foute cookie, `404` als pairing onbekend, `410` als pairing verlopen.
**Voorbeeld:**
```bash
curl -N -i -b /tmp/jar http://localhost:3000/api/auth/pair/stream/<pairingId>
```
---
### `POST /api/auth/pair/claim`
Cookie-auth. Atomisch consume van een approved pairing → schrijft de echte
`scrum4me-session` cookie zodat de desktop is ingelogd.
**Auth:** `s4m_pair`-cookie.
**Body:** `{ "pairingId": "cmoh..." }`.
**Response 200:** `{ "ok": true }` plus
- `Set-Cookie: scrum4me-session=...; HttpOnly; SameSite=Lax` — paired-sessie met `paired: true` en `pairedExpiresAt = now + 8h` payload-velden.
- `Set-Cookie: s4m_pair=...; Max-Age=0` — pre-auth cookie wordt gewist.
**Foutcodes:**
- `400` bij ontbrekende of malformed body
- `401` zonder cookie of bij hash-mismatch (cookie matcht geen pairing)
- `410` als pairing al consumed/cancelled is (replay) of verlopen
**Voorbeeld:**
```bash
curl -i -X POST -b /tmp/jar -c /tmp/jar \
-H "Content-Type: application/json" \
-d '{"pairingId":"<pairingId>"}' \
http://localhost:3000/api/auth/pair/claim
```
---
## Voorbeeldworkflow voor Claude Code
1. **Probe:** `GET /api/health?db=1` — bevestig dat de service en DB bereikbaar zijn.

95
docs/patterns/qr-login.md Normal file
View file

@ -0,0 +1,95 @@
# 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/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

View file

@ -512,6 +512,85 @@ Uitloggen:
---
## 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
```mermaid
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: scrum4me-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:
1. **URL-fragment voor `mobileSecret`.** Het deel achter de `#` wordt door
browsers nooit naar de server gestuurd in HTTP-requests. De mobiele Client
Component leest `window.location.hash` en POST't de waarde in een body —
ook niet in een URL.
2. **HttpOnly cookie voor `desktopToken`.** Cookie-headers worden meestal NIET
in toegangslogs gelogd (in tegenstelling tot URLs). De cookie is bovendien
`Path=/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`.
---
## Projectstructuur
```