diff --git a/CLAUDE.md b/CLAUDE.md index 807fb1f..3af09fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/docs/API.md b/docs/API.md index b8f9db4..a4dda0f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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=" +} +``` +Plus `Set-Cookie: s4m_pair=; 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/ +``` + +--- + +### `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":""}' \ + 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. diff --git a/docs/patterns/qr-login.md b/docs/patterns/qr-login.md new file mode 100644 index 0000000..183c749 --- /dev/null +++ b/docs/patterns/qr-login.md @@ -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 diff --git a/docs/scrum4me-architecture.md b/docs/scrum4me-architecture.md index d25b6c8..2d499bc 100644 --- a/docs/scrum4me-architecture.md +++ b/docs/scrum4me-architecture.md @@ -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 }
Set-Cookie: s4m_pair=desktopToken + D->>D: render QR met qrUrl (#id=…&s=mobileSecret) + D->>S: GET /api/auth/pair/stream/[pairingId]
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
pg_notify scrum4me_pairing + S-->>D: data { status: 'approved' } + + D->>S: POST /api/auth/pair/claim
Cookie: s4m_pair, body: { pairingId } + S->>S: atomic UPDATE WHERE status=approved AND token-hash
→ status=consumed + S->>S: getIronSession.save { userId, paired: true, pairedExpiresAt } + S-->>D: 200, Set-Cookie: scrum4me-session
+ 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 ```