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

@ -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.