diff --git a/docs/plans/M10-qr-pairing-login.md b/docs/plans/M10-qr-pairing-login.md new file mode 100644 index 0000000..d5c715e --- /dev/null +++ b/docs/plans/M10-qr-pairing-login.md @@ -0,0 +1,872 @@ +# M10 — Password-loze inlog via QR-pairing + +Inloggen op een (publieke) desktop zonder wachtwoord: desktop toont QR, telefoon (al-ingelogd) scant en bevestigt expliciet, desktop is binnen 1–2 s ingelogd. Bouwt voort op M8 LISTEN/NOTIFY-infra met eigen channel `scrum4me_pairing`. + +**Beveiligingsuitgangspunt:** geheim materiaal nooit in URL-paden, querystrings, access logs of browsergeschiedenis. +- `mobileSecret` reist alleen via QR-fragment (`#s=…`) → mobile `location.hash` → POST-body +- `desktopToken` reist alleen via HttpOnly cookie `s4m_pair` met `Path=/api/auth/pair`, `Max-Age=120`, `SameSite=Lax` +- Twee gescheiden hashes in DB scheiden mobiel-bewijs (`secret_hash`) van desktop-bewijs (`desktop_token_hash`) + +Backlog-entries: zie [scrum4me-backlog.md § M10](../scrum4me-backlog.md#m10-password-loze-inlog-via-qr-pairing). +Functional spec: zie [scrum4me-functional-spec.md § F-01b](../scrum4me-functional-spec.md#f-01b-inloggen-via-mobiel-qr-pairing). + +**Implementatie-volgorde** (commit-strategy uit CLAUDE.md): + +1. **DB-laag** — ST-1001 (schema + trigger) +2. **Auth helpers + sessie-uitbreiding** — ST-1002 +3. **API-laag** — ST-1003 (start), ST-1004 (SSE), ST-1006 (claim) +4. **Server actions + mobile UI** — ST-1005 +5. **Desktop UI** — ST-1007 +6. **Documentatie + acceptatietest** — ST-1008 + +ST-1006 staat bij de API-laag (niet bij UI) omdat het een Route Handler is; ST-1005 levert tegelijk de server actions en de mobiele bevestigingspagina omdat die strak gekoppeld zijn. + +--- + +## ST-1001 — LoginPairing schema + Postgres-trigger + +**Bestanden** +- `prisma/schema.prisma` — nieuw model `LoginPairing` + back-relation op `User` +- `prisma/migrations/_add_login_pairing/migration.sql` — model + trigger +- `vendor/scrum4me`-submodule in repo `scrum4me-mcp` — schema-sync ná merge + +**Stappen** + +1. **Schema-uitbreiding**: + + ```prisma + model LoginPairing { + id String @id @default(cuid()) + secret_hash String // sha256 hex van mobileSecret + desktop_token_hash String // sha256 hex van desktopToken (HttpOnly cookie) + status String // 'pending' | 'approved' | 'consumed' | 'cancelled' + user_id String? + user User? @relation(fields: [user_id], references: [id], onDelete: SetNull) + desktop_ua String? @db.VarChar(255) + desktop_ip String? @db.VarChar(45) // IPv6 max + created_at DateTime @default(now()) + expires_at DateTime + approved_at DateTime? + consumed_at DateTime? + + @@index([expires_at]) + @@index([status, expires_at]) + @@map("login_pairings") + } + ``` + + Op `User`: `login_pairings LoginPairing[]` toevoegen. + +2. **Migratie-SQL** voegt naast de tabel ook trigger toe (mirror van `notify_solo_change` in `prisma/migrations/20260426230316_add_solo_realtime_triggers/migration.sql`): + + ```sql + CREATE OR REPLACE FUNCTION notify_pairing_change() RETURNS trigger AS $$ + DECLARE payload jsonb; + BEGIN + payload := jsonb_build_object( + 'op', CASE TG_OP WHEN 'INSERT' THEN 'I' WHEN 'UPDATE' THEN 'U' ELSE 'D' END, + 'pairing_id', COALESCE(NEW.id, OLD.id), + 'status', COALESCE(NEW.status, OLD.status) + ); + PERFORM pg_notify('scrum4me_pairing', payload::text); + RETURN COALESCE(NEW, OLD); + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER login_pairings_notify + AFTER INSERT OR UPDATE ON login_pairings + FOR EACH ROW EXECUTE FUNCTION notify_pairing_change(); + ``` + +3. `npx prisma migrate dev --name add_login_pairing`. + +**Aandachtspunten** +- `desktop_ip` houdt op 45 tekens om IPv6 te accommoderen (`xxxx:xxxx:…:255.255.255.255`). +- Geen index op `user_id` nodig voor v1 — er is geen lookup-pad "geef alle pairings van user X" (komt pas bij remote-revoke in M+1). +- Trigger emit ook bij DELETE niet nodig — pairings worden niet gedelete'd, ze gaan naar `consumed`/`cancelled`. +- `vendor/scrum4me`-submodule in scrum4me-mcp moet ná merge op `main` direct gesynced worden, anders breekt de wekelijkse drift-check (`trig_015FFUnxjz9WMuhhWNGBQKFD`). Dit was ook een aandachtspunt bij ST-901. + +**Verificatie** +- `npx prisma migrate dev` slaagt +- `npx prisma validate` zonder fouten +- `psql $DIRECT_URL -c "LISTEN scrum4me_pairing;"` toont payload bij `INSERT INTO login_pairings(...) VALUES(...)` +- `prisma studio` toont tabel met beide hash-kolommen `NOT NULL` + +--- + +## ST-1002 — Pairing-helpers + sessie-uitbreiding + pre-auth-cookie + +**Bestanden** +- `lib/auth/pairing.ts` — nieuw, secret/token-generatie en hash-helpers +- `lib/auth/pair-cookie.ts` — nieuw, set/read/clear van `s4m_pair`-cookie +- `lib/session.ts` — `SessionData` uitbreiden met `paired` en `pairedExpiresAt` +- `app/(app)/layout.tsx` — extra guard op vervallen paired-sessie +- `__tests__/lib/auth/pairing.test.ts` — nieuw + +**Stappen** + +1. **`lib/auth/pairing.ts`**: + + ```ts + import { createHash, randomBytes, timingSafeEqual } from 'crypto' + + export function generateMobileSecret(): string { + return randomBytes(32).toString('base64url') + } + + export function generateDesktopToken(): string { + return randomBytes(32).toString('base64url') + } + + export function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex') + } + + export function verifyToken(token: string, hash: string): boolean { + const a = Buffer.from(hashToken(token), 'hex') + const b = Buffer.from(hash, 'hex') + if (a.length !== b.length) return false + return timingSafeEqual(a, b) + } + ``` + + Twee aparte generators (niet één functie met arg) voorkomen dat dezelfde geheim per ongeluk twee keer wordt gebruikt. + +2. **`lib/auth/pair-cookie.ts`**: + + ```ts + import { cookies } from 'next/headers' + + const COOKIE_NAME = 's4m_pair' + const MAX_AGE = 120 // 2 min, gelijk aan pending-TTL van pairing + + export async function setPairCookie(desktopToken: string) { + const jar = await cookies() + jar.set(COOKIE_NAME, desktopToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/api/auth/pair', + maxAge: MAX_AGE, + }) + } + + export async function readPairCookie(): Promise { + const jar = await cookies() + return jar.get(COOKIE_NAME)?.value ?? null + } + + export async function clearPairCookie() { + const jar = await cookies() + jar.delete({ name: COOKIE_NAME, path: '/api/auth/pair' }) + } + ``` + + `Path=/api/auth/pair` zorgt dat de cookie alleen naar pair-endpoints wordt gestuurd — niet naar elke route. + +3. **`lib/session.ts`** — `SessionData` interface: + + ```ts + export interface SessionData { + userId: string + isDemo: boolean + paired?: boolean // true als sessie is aangemaakt via QR-pairing + pairedExpiresAt?: number // unix ms + } + ``` + + Bestaande sessies blijven werken — beide velden zijn optioneel. + +4. **`app/(app)/layout.tsx`** — guard ná de bestaande `if (!session.userId) redirect('/login')`: + + ```ts + if (session.paired && session.pairedExpiresAt && session.pairedExpiresAt < Date.now()) { + session.destroy() + redirect('/login?notice=paired-expired') + } + ``` + + Hergebruikt het bestaande `notice`-querystring-patroon van M9's `` voor de melding "Je sessie is verlopen, log opnieuw in". + +5. **Tests** — `__tests__/lib/auth/pairing.test.ts`: + - `generateMobileSecret()` produceert 43-karakter base64url (32 bytes) + - `hashToken` is deterministisch + - `verifyToken` is true voor geldig paar, false voor ongeldig + - Twee verschillende `generateMobileSecret()`-calls geven verschillende waardes + - Cookie helpers: HttpOnly bit gezet (via Next.js cookie-store mock) + +**Aandachtspunten** +- Geen middleware nodig voor de paired-expiry-check; layout-guard is voldoende. Middleware komt pas in beeld als `proxy.ts` herzien wordt (uit scope hier). +- `cookies().delete({ name, path })` moet **dezelfde path** specificeren als bij set, anders blijft de cookie staan. +- `crypto.randomBytes` is sync en blocking — voor 32 bytes ruim < 1ms; geen async-variant nodig. + +**Verificatie** +- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen +- Handmatig: in DevTools Application-tab is de cookie zichtbaar als HttpOnly + Path scoped +- `document.cookie` op de pagina laat de cookie *niet* zien + +--- + +## ST-1003 — `POST /api/auth/pair/start` (anon, sets pre-auth cookie) + +**Bestanden** +- `app/api/auth/pair/start/route.ts` — nieuw +- `lib/rate-limit.ts` — checken of bestaand (uit ST-608); anders helper toevoegen +- `__tests__/api/pair-start.test.ts` — nieuw + +**Stappen** + +1. Route Handler (vrij van `authenticateApiRequest` — dit is anon): + + ```ts + import { NextRequest } from 'next/server' + import { prisma } from '@/lib/prisma' + import { + generateMobileSecret, generateDesktopToken, hashToken, + } from '@/lib/auth/pairing' + import { setPairCookie } from '@/lib/auth/pair-cookie' + import { rateLimit } from '@/lib/rate-limit' + + export const runtime = 'nodejs' + + const PENDING_TTL_MS = 2 * 60 * 1000 + + export async function POST(request: NextRequest) { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null + const ua = request.headers.get('user-agent')?.slice(0, 255) ?? null + + // Rate-limit per IP — 10/min (zelfde patroon als ST-608) + const limited = await rateLimit(`pair-start:${ip ?? 'anon'}`, 10, 60_000) + if (limited) { + return Response.json({ error: 'Te veel verzoeken' }, { status: 429 }) + } + + const mobileSecret = generateMobileSecret() + const desktopToken = generateDesktopToken() + + const pairing = await prisma.loginPairing.create({ + data: { + secret_hash: hashToken(mobileSecret), + desktop_token_hash: hashToken(desktopToken), + status: 'pending', + desktop_ua: ua, + desktop_ip: ip, + expires_at: new Date(Date.now() + PENDING_TTL_MS), + }, + select: { id: true, expires_at: true }, + }) + + await setPairCookie(desktopToken) + + const origin = request.nextUrl.origin + const qrUrl = `${origin}/m/pair#id=${pairing.id}&s=${mobileSecret}` + + return Response.json({ + pairingId: pairing.id, + mobileSecret, + expiresAt: pairing.expires_at.toISOString(), + qrUrl, + }) + } + ``` + +2. **Rate-limit helper** — als `lib/rate-limit.ts` nog niet bestaat: + - In-memory Map keyed op `key`, met sliding-window van timestamps; thread-safe genoeg voor v1 single-instance Vercel Functions. + - Wordt ook door `actions/auth.ts` (login) gebruikt; verifieer met grep of die al bestaat — zo ja, hergebruik exact. + +3. **Tests** — `__tests__/api/pair-start.test.ts`: + - 200 response bevat `pairingId`, `mobileSecret`, `qrUrl` met fragment-syntax (`#id=…&s=…`) + - `Set-Cookie`-header bevat `s4m_pair=...; HttpOnly; SameSite=Lax; Path=/api/auth/pair; Max-Age=120` + - Database-rij heeft `secret_hash`, `desktop_token_hash`, geen plaintext + - 11e call binnen 60s → 429 + - `desktop_ua` en `desktop_ip` worden opgeslagen bij aanwezigheid, anders `null` + +**Aandachtspunten** +- `mobileSecret` mag in de JSON-response — die is HTTPS-encrypted en wordt niet door browsers naar logs geschreven. Kritisch is dat het niet in **request**-URLs of **server**-toegangslogs belandt. +- `qrUrl` gebruikt `#` (fragment), niet `?`. Browsers strippen het fragment voordat ze de mobile pair-page ophalen — maar dat is uitvoerig getest in ST-1005 server-side: de page leest de URL niet, alleen de client component leest `window.location.hash`. +- Geen idempotency-key nodig: een tweede start vanuit dezelfde tab maakt simpelweg een nieuwe pairing en cookie aan; oude cookie wordt overschreven. +- Vercel-edge-of-fluid: `runtime: 'nodejs'` expliciet (niet 'edge') want we gebruiken `crypto.randomBytes`. + +**Verificatie** +- `curl -i -X POST http://localhost:3000/api/auth/pair/start --cookie-jar /tmp/jar` retourneert JSON + `Set-Cookie` +- Body bevat `qrUrl` met `#id=…&s=…` +- Rij in `login_pairings` heeft beide hashes ingevuld +- Tests groen + +--- + +## ST-1004 — SSE-route `/api/auth/pair/stream/[pairingId]` (cookie-auth) + +**Bestanden** +- `app/api/auth/pair/stream/[pairingId]/route.ts` — nieuw +- `__tests__/api/pair-stream.test.ts` — nieuw (auth-test, geen full SSE-test) + +**Stappen** + +1. **Routebestand** — exacte structuur uit `app/api/realtime/solo/route.ts` (incl. heartbeat, hard-close, abort-handler), maar: + - Geen iron-session check; auth via `s4m_pair`-cookie: + + ```ts + const desktopToken = await readPairCookie() + if (!desktopToken) return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 }) + + const pairing = await prisma.loginPairing.findUnique({ + where: { id: pairingId }, + select: { desktop_token_hash: true, status: true, expires_at: true }, + }) + if (!pairing) return Response.json({ error: 'Pairing niet gevonden' }, { status: 404 }) + if (pairing.expires_at < new Date()) return Response.json({ error: 'Pairing verlopen' }, { status: 410 }) + if (!verifyToken(desktopToken, pairing.desktop_token_hash)) { + return Response.json({ error: 'Ongeldige cookie' }, { status: 401 }) + } + ``` + + - Channel = `'scrum4me_pairing'` + - Filter `shouldEmit`: `payload.pairing_id === pairingId` + - Auto-close ook bij payload `status` ∈ `{'consumed', 'cancelled'}` — niet alleen na 240 s + - Geen sprint-resolve, geen userId-filter — eenvoudiger dan solo-route + +2. **Initial-state-event** vlak na verbinden: query de pairing-status één keer uit DB en stuur als `event: state\ndata: {"status":"pending"}` zodat de desktop niet hoeft te wachten op het eerste pg_notify (handig als pairing al `approved` is voordat de SSE opent — race-conditie). + +3. **Tests**: + - GET zonder cookie → 401 + - GET met cookie maar onbekende `pairingId` → 404 + - GET met verlopen pairing → 410 + - GET met cookie die hasht naar een andere `desktop_token_hash` → 401 + + Geen full-stream-test — vereist een Postgres-event-mock die het niet waard is voor v1. Manuele test dekt dit (zie verificatie). + +**Aandachtspunten** +- `pairingId` in het URL-pad is OK — niet vertrouwelijk. De cookie is het bewijs. +- `EventSource` op de client kan geen custom headers; cookie is daarom de enige praktische auth-methode. `withCredentials: true` op de client is verplicht. +- Bij browser-tab-sluiten wordt `request.signal.abort` getriggered → cleanup zoals in solo-route. +- Vermijd Prisma in de notification-handler; gebruik alleen `pg.Client` zoals solo-route. Status-check hierboven is de enige Prisma-call (vóór de stream start). + +**Verificatie** +- `curl -N --cookie /tmp/jar http://localhost:3000/api/auth/pair/stream/` blijft open en print `: heartbeat` elke 25 s +- Andere terminal: `psql $DIRECT_URL -c "UPDATE login_pairings SET status='approved' WHERE id=''"` → curl-uitvoer toont event binnen 1 s +- Manuele 401-test: `curl -N` zonder cookie → JSON 401 + +--- + +## ST-1005 — Server actions + mobiele bevestigingspagina + +**Bestanden** +- `actions/pairing.ts` — nieuw, drie Server Actions +- `app/(app)/m/pair/page.tsx` — nieuw, Server Component +- `app/(app)/m/pair/pair-confirmation.tsx` — nieuw, Client Component +- `__tests__/actions/pairing.test.ts` — nieuw + +**Stappen** + +1. **`actions/pairing.ts`** — volgt `docs/patterns/server-action.md`: + + ```ts + 'use server' + + import { revalidatePath } from 'next/cache' + import { z } from 'zod' + import { prisma } from '@/lib/prisma' + import { getSession } from '@/lib/auth' + import { verifyToken } from '@/lib/auth/pairing' + + const inputSchema = z.object({ + pairingId: z.string().cuid(), + mobileSecret: z.string().min(40), // base64url van 32 bytes ≈ 43 chars + }) + + export async function getPairingForApproval(pairingId: string, mobileSecret: string) { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd' } as const + + const parsed = inputSchema.safeParse({ pairingId, mobileSecret }) + if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } as const + + const pairing = await prisma.loginPairing.findUnique({ + where: { id: pairingId }, + select: { + status: true, expires_at: true, secret_hash: true, + desktop_ua: true, desktop_ip: true, + }, + }) + if (!pairing) return { ok: false, error: 'Pairing niet gevonden' } as const + if (pairing.expires_at < new Date()) return { ok: false, error: 'Pairing verlopen' } as const + if (pairing.status !== 'pending') return { ok: false, error: 'Pairing al afgehandeld' } as const + if (!verifyToken(mobileSecret, pairing.secret_hash)) { + return { ok: false, error: 'Ongeldig pairing-geheim' } as const + } + + const me = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { username: true }, + }) + return { + ok: true, + desktop_ua: pairing.desktop_ua, + desktop_ip: pairing.desktop_ip, + username: me?.username ?? '', + } as const + } + + export async function approvePairing(pairingId: string, mobileSecret: string) { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd' } as const + if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } as const + + const parsed = inputSchema.safeParse({ pairingId, mobileSecret }) + if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } as const + + const pairing = await prisma.loginPairing.findUnique({ + where: { id: pairingId }, + select: { status: true, expires_at: true, secret_hash: true }, + }) + if (!pairing) return { ok: false, error: 'Pairing niet gevonden' } as const + if (pairing.expires_at < new Date()) return { ok: false, error: 'Pairing verlopen' } as const + if (pairing.status !== 'pending') return { ok: false, error: 'Pairing al afgehandeld' } as const + if (!verifyToken(mobileSecret, pairing.secret_hash)) { + return { ok: false, error: 'Ongeldig pairing-geheim' } as const + } + + const APPROVED_TTL_MS = 5 * 60 * 1000 + await prisma.loginPairing.update({ + where: { id: pairingId }, + data: { + status: 'approved', + user_id: session.userId, + approved_at: new Date(), + expires_at: new Date(Date.now() + APPROVED_TTL_MS), + }, + }) + // Trigger emit pg_notify automatisch — geen revalidatePath nodig + return { ok: true } as const + } + + export async function cancelPairing(pairingId: string, mobileSecret: string) { + // Vergelijkbaar met approvePairing maar status='cancelled', geen user_id; demo mag annuleren. + } + ``` + +2. **`app/(app)/m/pair/page.tsx`** — Server Component, achter bestaande `(app)/layout.tsx` auth-guard: + + ```tsx + import { PairConfirmation } from './pair-confirmation' + + export default function PairPage() { + return ( +
+

Inloggen op desktop

+

+ Bevestig hieronder dat je wilt inloggen op het apparaat dat de QR-code toont. +

+ +
+ ) + } + ``` + + Geen searchParams! De page leest de URL überhaupt niet — alleen het client-island doet dat client-side via `window.location.hash`. + +3. **`app/(app)/m/pair/pair-confirmation.tsx`** — Client Component: + + ```tsx + 'use client' + + import { useEffect, useState, useTransition } from 'react' + import { Button } from '@/components/ui/button' + import { toast } from 'sonner' + import { getPairingForApproval, approvePairing, cancelPairing } from '@/actions/pairing' + + type State = + | { kind: 'loading' } + | { kind: 'invalid'; error: string } + | { kind: 'ready'; pairingId: string; secret: string; ua: string | null; ip: string | null; username: string } + | { kind: 'success' } + | { kind: 'error'; error: string } + + function parseHash(): { id: string; s: string } | null { + if (typeof window === 'undefined') return null + const hash = window.location.hash.replace(/^#/, '') + const params = new URLSearchParams(hash) + const id = params.get('id'); const s = params.get('s') + return id && s ? { id, s } : null + } + + export function PairConfirmation() { + const [state, setState] = useState({ kind: 'loading' }) + const [pending, startTransition] = useTransition() + + useEffect(() => { + const parsed = parseHash() + if (!parsed) { + setState({ kind: 'invalid', error: 'Ongeldige pairing-link' }) + return + } + getPairingForApproval(parsed.id, parsed.s).then((res) => { + if (!res.ok) setState({ kind: 'invalid', error: res.error }) + else setState({ + kind: 'ready', + pairingId: parsed.id, secret: parsed.s, + ua: res.desktop_ua, ip: res.desktop_ip, username: res.username, + }) + }) + }, []) + + function onApprove() { + if (state.kind !== 'ready') return + startTransition(async () => { + const res = await approvePairing(state.pairingId, state.secret) + if (!res.ok) { toast.error(res.error); return } + // Wist secret uit URL zodat back/forward 'm niet onthult + if (typeof window !== 'undefined') { + window.history.replaceState(null, '', window.location.pathname) + } + setState({ kind: 'success' }) + }) + } + + function onCancel() { + if (state.kind !== 'ready') return + startTransition(async () => { + await cancelPairing(state.pairingId, state.secret) + setState({ kind: 'invalid', error: 'Pairing geannuleerd' }) + }) + } + + // Render-logica per state — kort: + // loading → spinner + // invalid → foutmelding + link "Terug naar dashboard" + // ready → kaart met UA/IP/username + Bevestig/Annuleer + // success → "Klaar — je kunt deze tab sluiten" + // … (volledige JSX in implementation) + } + ``` + +4. **Tests** — `__tests__/actions/pairing.test.ts`: + - `getPairingForApproval` met `pending`-pairing → `ok: true` + ua/ip/username + - `getPairingForApproval` met al-`approved` → `ok: false` + - `getPairingForApproval` met verlopen → `ok: false` + - `getPairingForApproval` met verkeerd secret → `ok: false` + - `approvePairing` als demo-user → `ok: false` + DB onveranderd + - `approvePairing` happy path → status `approved`, `user_id` gezet, `expires_at` bumped + - `cancelPairing` happy path → status `cancelled` + +**Aandachtspunten** +- `getPairingForApproval` mág door demo-users worden aangeroepen (om iets te zien); alleen `approvePairing` blokkeert demo's. +- `pair-confirmation.tsx` gebruikt **geen** `useSearchParams` — die kan in Next.js 16 hash niet lezen, en we willen het ook niet via routing zien. +- Na approve `window.history.replaceState` zonder de `#hash` zorgt dat browser-back de secret niet opnieuw onthult. +- `revalidatePath` in `approvePairing` is niet nodig — de desktop hoort het via SSE, en de mobiele page heeft geen server-state om te ververversen. + +**Verificatie** +- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen +- Handmatig: log in op telefoon-emulator → bezoek `/m/pair#id=…&s=…` → kaart toont UA/IP → Bevestig → succes-state, URL is `/m/pair` zonder hash + +--- + +## ST-1006 — `POST /api/auth/pair/claim` (cookie-auth, schrijft iron-session) + +**Bestanden** +- `app/api/auth/pair/claim/route.ts` — nieuw +- `__tests__/api/pair-claim.test.ts` — nieuw + +**Stappen** + +1. Route Handler: + + ```ts + import { NextRequest } from 'next/server' + import { getIronSession } from 'iron-session' + import { cookies } from 'next/headers' + import { prisma } from '@/lib/prisma' + import { SessionData, sessionOptions } from '@/lib/session' + import { hashToken } from '@/lib/auth/pairing' + import { readPairCookie, clearPairCookie } from '@/lib/auth/pair-cookie' + + export const runtime = 'nodejs' + + const PAIRED_TTL_MS = 8 * 60 * 60 * 1000 // 8 uur + + export async function POST(request: NextRequest) { + const desktopToken = await readPairCookie() + if (!desktopToken) return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 }) + + const body = await request.json().catch(() => null) as { pairingId?: string } | null + const pairingId = body?.pairingId + if (!pairingId) return Response.json({ error: 'pairingId vereist' }, { status: 400 }) + + const desktopTokenHash = hashToken(desktopToken) + + // Atomic: WHERE status='approved' AND token-hash + expiry → consumed, RETURNING user_id + const updated = await prisma.loginPairing.updateMany({ + where: { + id: pairingId, + status: 'approved', + desktop_token_hash: desktopTokenHash, + expires_at: { gt: new Date() }, + }, + data: { status: 'consumed', consumed_at: new Date() }, + }) + if (updated.count !== 1) { + // Was het wél een geldige cookie maar al consumed? → 410. Anders → 401. + const exists = await prisma.loginPairing.findFirst({ + where: { id: pairingId, desktop_token_hash: desktopTokenHash }, + select: { status: true, expires_at: true }, + }) + await clearPairCookie() + if (!exists) return Response.json({ error: 'Ongeldig' }, { status: 401 }) + if (exists.status === 'consumed') return Response.json({ error: 'Al gebruikt' }, { status: 410 }) + return Response.json({ error: 'Niet beschikbaar' }, { status: 410 }) + } + + const pairing = await prisma.loginPairing.findUnique({ + where: { id: pairingId }, + select: { user_id: true, user: { select: { is_demo: true } } }, + }) + if (!pairing?.user_id) { + await clearPairCookie() + return Response.json({ error: 'Pairing zonder user' }, { status: 500 }) + } + + const session = await getIronSession(await cookies(), sessionOptions) + session.userId = pairing.user_id + session.isDemo = pairing.user?.is_demo ?? false + session.paired = true + session.pairedExpiresAt = Date.now() + PAIRED_TTL_MS + await session.save() + + await clearPairCookie() + return Response.json({ ok: true }) + } + ``` + +2. **Tests**: + - 200 + iron-session cookie + clear `s4m_pair` na succes + - 410 op tweede claim met dezelfde cookie + - 401 zonder cookie + - 401 met cookie die hasht naar andere pairing + - paired-sessie bevat `paired: true` en `pairedExpiresAt` rond `now + 8h` + +**Aandachtspunten** +- `updateMany` gebruiken (niet `update`) want we hebben een composite WHERE met `status` + `desktop_token_hash` + `expires_at`; `update` kan alleen op unique keys. +- Het WHERE-criterium garandeert atomiciteit: PostgreSQL UPDATE met meerdere predicates is row-level locked; concurrent dubbele claim resulteert in `count = 1` voor één caller en `count = 0` voor de ander. +- `clearPairCookie` ook bij faalpaden, anders blijft 'm na expiry hangen (cosmetisch — `Max-Age=120` regelt het ook). +- De `session.isDemo` check overneemt: als de approver een demo-user is — wat ST-1005 al blokkeert — komen we hier niet eens, maar `is_demo` doorzetten is een extra vangnet. + +**Verificatie** +- Handmatig: na approve in mobiele tab, POST naar `/api/auth/pair/claim` met de cookie van start → 200 + `Set-Cookie: scrum4me-session=...` +- `curl -X POST` zonder cookie → 401 +- Tweede claim → 410 + +--- + +## ST-1007 — Desktop UI: QR-render + SSE-listener op `/login` + +**Bestanden** +- `app/login/page.tsx` — bestaand, knop "Inloggen via mobiel" toevoegen +- `app/login/qr-login-button.tsx` — nieuw, Client Component +- `package.json` — `qrcode.react` toevoegen +- `__tests__/components/qr-login-button.test.tsx` — minimale render-test + +**Stappen** + +1. **Dependency**: `npm install qrcode.react` — direct in `dependencies` per CLAUDE.md-conventie. + +2. **`qr-login-button.tsx`**: + + ```tsx + 'use client' + + import { useEffect, useRef, useState } from 'react' + import { useRouter } from 'next/navigation' + import { QRCodeSVG } from 'qrcode.react' + import { Button } from '@/components/ui/button' + import { toast } from 'sonner' + + type Phase = + | { kind: 'idle' } + | { kind: 'starting' } + | { kind: 'showing'; pairingId: string; qrUrl: string; expiresAt: number } + | { kind: 'expired'; pairingId: string } + | { kind: 'claiming' } + + export function QrLoginButton() { + const router = useRouter() + const [phase, setPhase] = useState({ kind: 'idle' }) + const sseRef = useRef(null) + + async function start() { + setPhase({ kind: 'starting' }) + const res = await fetch('/api/auth/pair/start', { + method: 'POST', credentials: 'same-origin', + }) + if (!res.ok) { toast.error('Kon QR-code niet aanmaken'); setPhase({ kind: 'idle' }); return } + const data = await res.json() as { pairingId: string; qrUrl: string; expiresAt: string } + setPhase({ + kind: 'showing', + pairingId: data.pairingId, + qrUrl: data.qrUrl, + expiresAt: new Date(data.expiresAt).getTime(), + }) + } + + // SSE-koppeling + useEffect(() => { + if (phase.kind !== 'showing') return + const es = new EventSource(`/api/auth/pair/stream/${phase.pairingId}`, { + withCredentials: true, + }) + sseRef.current = es + + es.addEventListener('message', async (ev) => { + const data = JSON.parse(ev.data) as { status?: string } + if (data.status === 'approved') { + es.close() + setPhase({ kind: 'claiming' }) + const res = await fetch('/api/auth/pair/claim', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pairingId: phase.pairingId }), + }) + if (!res.ok) { + toast.error('Inloggen mislukt') + setPhase({ kind: 'idle' }); return + } + router.push('/dashboard') + } + }) + es.addEventListener('error', () => { /* silent — laat reconnecten */ }) + + return () => { es.close() } + }, [phase, router]) + + // Aftellende timer + auto-expire + useEffect(() => { + if (phase.kind !== 'showing') return + const t = setInterval(() => { + if (Date.now() > phase.expiresAt) { + sseRef.current?.close() + setPhase({ kind: 'expired', pairingId: phase.pairingId }) + clearInterval(t) + } + }, 1000) + return () => clearInterval(t) + }, [phase]) + + // Render: knop / QR + countdown / "Vernieuwen" — JSX hier weggelaten voor brevity + } + ``` + +3. **`app/login/page.tsx`** — knop toevoegen onder of naast het wachtwoord-formulier: + + ```tsx +
+
+ of +
+
+ + ``` + + MD3-tokens uit `docs/scrum4me-styling.md`; geen willekeurige Tailwind-kleuren. + +4. **A11y**: QR-component krijgt `aria-label="QR-code voor mobiel inloggen"` en de URL wordt visueel als kopieer-bare tekst onder de QR getoond zodat screenreaders en gebruikers met cameraproblemen de URL handmatig kunnen openen. + +**Aandachtspunten** +- `EventSource({ withCredentials: true })` is verplicht zodat de browser de `s4m_pair`-cookie meestuurt; standaard verstuurt EventSource geen credentials. +- Cleanup-volgorde: `es.close()` eerst, dán fetch claim. Anders kan een tweede `approved`-event tussen close-en-claim binnenkomen en een dubbele claim triggeren (server vangt het op met 410, maar netter is het netjes te sluiten). +- `qrcode.react` exporteert zowel `QRCodeSVG` als `QRCodeCanvas`. Kies SVG: schaalbaarder voor printen/screenshots en kleinere bundle. +- Geen `next/image` — QR is dynamisch en client-side. + +**Verificatie** +- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen +- Handmatige twee-browser-test: A toont QR → B (ingelogd op mobile-emulator) opent QR-URL en bevestigt → A redirect naar `/dashboard` met `session.paired === true` +- DevTools Network-tab: geen URL bevat `s=` (alleen `Set-Cookie`/`Cookie` headers) +- Speel met een verlopen QR: na 2 min toont knop "Vernieuwen", `Vernieuwen` start nieuwe pairing + +--- + +## ST-1008 — Documentatie + acceptatietest + +**Bestanden** +- `docs/API.md` — drie nieuwe endpoints +- `docs/scrum4me-architecture.md` — sectie "QR-pairing flow" + threat-model +- `docs/patterns/qr-login.md` — nieuw pattern-doc +- `CLAUDE.md` — verwijzing naar het pattern-doc in de patterns-tabel +- `__tests__/integration/qr-pairing-e2e.test.ts` — optioneel, alleen als de test-infra het toelaat + +**Stappen** + +1. **`docs/API.md`** — drie endpoints documenteren met request/response, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief `--cookie-jar`. Voeg een sectie *"Cookie-mechaniek"* toe die uitlegt dat `s4m_pair` een tijdelijke pre-auth cookie is, anders dan de iron-session cookie. + +2. **`docs/scrum4me-architecture.md`** — sectie *"QR-pairing flow"* met: + - Sequence-diagram (mermaid of ASCII analoog aan M8) + - Threat-model: + - **Replay**: atomic `updateMany` met `status='approved'` voorkomt dubbele claim + - **Phishing-QR**: mobiele bevestigingspagina toont UA + IP zodat gebruiker een vreemd apparaat herkent; expliciete tap vereist + - **Demo-block**: `approvePairing` early return op `session.isDemo` + - **Rate-limit**: 10 starts per IP per minuut + - **Secret-hashing**: alleen sha256-hashes in DB; secrets verlaten desktop alleen via QR-fragment + POST-body + - **TTL-rationale**: 2 min pending vs. 5 min approved vs. 8 u paired-sessie — verschillen verklaren + - **Subsectie "Waarom geen secret in URL"**: fragment-eigenschap (browsers sturen `#…` niet naar server); HttpOnly cookie voor desktop-bewijs; geen secret in access logs / reverse-proxy logs / observability / browsergeschiedenis + +3. **`docs/patterns/qr-login.md`** — herbruikbaar patroon: "unauth-SSE-via-pre-auth-cookie". Toekomstige features die een pre-auth flow met realtime-updates willen kunnen dit kopiëren (bv. "ontvang webhook live", "long-running export"). Inclusief: + - Wanneer dit patroon te gebruiken (er moet realtime-feedback zijn vóór de gebruiker is geauthenticeerd) + - Verwijzingen naar `lib/auth/pair-cookie.ts` als sjabloon + - Risico's en mitigaties + +4. **`CLAUDE.md`** — in de *Implementatiepatronen*-tabel een rij `| QR-pairing (unauth-SSE + pre-auth cookie) | docs/patterns/qr-login.md |`. + +5. **Acceptatie-scenario's** (handmatig, eventueel automatiseerbaar in v2): + 1. Happy path — twee browsers, end-to-end binnen 2 minuten ingelogd + 2. Demo-block — mobiel ingelogd als demo-user, scant QR → kan niet bevestigen + 3. Replay — claim de pairing twee keer → tweede call 410 + 4. Expiry tijdens pending — wacht 3 min na start, scan dan → mobiel toont "Pairing verlopen" + 5. Expiry tussen approve en claim — approve, wacht 6 min, claim → 410 + 6. Ontbrekende cookie op SSE/claim — verwijder `s4m_pair` in DevTools, herhaal → 401 + 7. **Secret niet in access logs** — controleer Vercel runtime-logs (via `mcp__a1fa0fcf-…__get_runtime_logs`) en lokale dev-logs; zoek op de secret-string en op `s=`-substrings; verwacht: 0 hits + +**Aandachtspunten** +- Zorg dat de runtime-logs MCP-controle in `docs/scrum4me-test-plan.md` belandt zodat hij bij elke release herhaalbaar is. +- `docs/patterns/qr-login.md` mag refereren naar bestaande pattern-docs (iron-session, route-handler) zonder ze te dupliceren. + +**Verificatie** +- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen +- Alle zeven scenario's handmatig groen, beschreven in een test-rapport-sectie +- `vendor/scrum4me`-submodule in scrum4me-mcp gesynced ná schema-merge + +--- + +## Branch- en commit-strategie + +Per CLAUDE.md één commit per laag, één story per branch (zo veel mogelijk). Voorstel: + +| # | Branch | Stories | Commit-titles | +|---|---|---|---| +| 1 | `feat/ST-1001-login-pairing-schema` | ST-1001 | `feat(ST-1001): add LoginPairing model` + `feat(ST-1001): add pg_notify trigger on scrum4me_pairing channel` | +| 2 | `feat/ST-1002-pairing-helpers` | ST-1002 | `feat(ST-1002): add pairing helpers and pre-auth cookie` + `feat(ST-1002): extend SessionData with paired flag` + `feat(ST-1002): guard expired paired sessions in app layout` | +| 3 | `feat/ST-1003-pair-start-endpoint` | ST-1003 | `feat(ST-1003): add /api/auth/pair/start with rate-limit and pre-auth cookie` | +| 4 | `feat/ST-1004-pair-stream-sse` | ST-1004 | `feat(ST-1004): add SSE /api/auth/pair/stream with cookie auth` | +| 5 | `feat/ST-1005-mobile-confirmation` | ST-1005 | `feat(ST-1005): add pairing server actions` + `feat(ST-1005): add mobile pair confirmation page with hash-fragment client island` | +| 6 | `feat/ST-1006-pair-claim-endpoint` | ST-1006 | `feat(ST-1006): add /api/auth/pair/claim with atomic consume` | +| 7 | `feat/ST-1007-desktop-qr-login` | ST-1007 | `chore(ST-1007): add qrcode.react dependency` + `feat(ST-1007): add QR login button on /login with SSE listener` | +| 8 | `feat/ST-1008-qr-login-docs` | ST-1008 | `docs(ST-1008): document QR-pairing endpoints in API.md` + `docs(ST-1008): add QR-pairing flow and threat-model to architecture` + `docs(ST-1008): add qr-login pattern doc` | + +Elke story → eigen PR. Reviewbaar in volgorde; ST-1007 hangt af van ST-1003 t/m ST-1006 (kan niet runtime-getest zonder), maar landen in stuk-en-stuk PR's blijft mogelijk omdat de UI alleen rendert achter de "Inloggen via mobiel"-knop. + +**Pre-merge gates** (uit CLAUDE.md DoD): +- `npm run lint && npm test && npm run build` groen op CI +- Schema-wijziging in ST-1001 → wekelijkse drift-check `trig_015FFUnxjz9WMuhhWNGBQKFD` mag niet rood staan; `vendor/scrum4me` submodule in scrum4me-mcp meebewegen + +--- + +## Reseed-stap (eenmalig vóór ST-1001-implementatie) + +De backlog-markdown bevat ST-1001..1008, maar de live database heeft die rijen nog niet. Voordat `mcp__scrum4me__get_claude_context` ze als next-story kan teruggeven: + +``` +npx prisma db seed +``` + +Verifieer dat M10 en zijn 8 stories als `status=OPEN` in de DB staan. Daarna geeft `mcp__scrum4me__implement_next_story product_id=cmohfotr70000jwrt0hw4q020` automatisch ST-1001 als startpunt. + +> **Let op:** seed kan bestaande dev-data overschrijven. Doe dit op een dev-DB, niet productie. Voor productie volstaat het om de stories handmatig of via een eenmalige migratie-script in te voegen — buiten scope hier. diff --git a/docs/scrum4me-backlog.md b/docs/scrum4me-backlog.md index 5308972..59b3444 100644 --- a/docs/scrum4me-backlog.md +++ b/docs/scrum4me-backlog.md @@ -589,6 +589,8 @@ Eén "actief Product Backlog" per gebruiker — persistent in DB. De NavBar word ### M10: Password-loze inlog via QR-pairing +**Implementatieplan:** [docs/plans/M10-qr-pairing-login.md](plans/M10-qr-pairing-login.md) + Inloggen op een (publieke) desktop zonder wachtwoord: de desktop toont een QR-code, de gebruiker scant met een telefoon waar hij al ingelogd is, bevestigt expliciet, en de desktop is binnen 1–2 seconden ingelogd. Bouwt voort op de Postgres LISTEN/NOTIFY-infra van M8 (eigen kanaal `scrum4me_pairing`). Geen wachtwoord ingetypt op het publieke apparaat, geen credentials op de draad, demo-accounts geblokkeerd, paired-sessie heeft eigen kortere TTL (8 u) + `paired`-vlag voor toekomstige remote-revoke. **Beveiligingsuitgangspunt:** `mobileSecret` reist alleen via QR-fragment (`#s=…`) → `location.hash` op de mobiel → POST-body. Desktop-SSE en claim authenticeren via een **HttpOnly pre-auth cookie** (`s4m_pair`, `Path=/api/auth/pair`, `Max-Age=120`, `SameSite=Lax`). Twee gescheiden hashes in DB (`secret_hash` voor mobiel-bewijs, `desktop_token_hash` voor desktop-bewijs) zodat geheim materiaal niet in URL-paden, querystrings, access logs, reverse-proxy logs, observability of browsergeschiedenis kan belanden.