Scrum4Me/docs/plans/M10-qr-pairing-login.md
Janpeter Visser e10f8f81bc
Phase 2 — Normalize file naming (#59)
* docs(naming): drop scrum4me- prefix from doc filenames

Rename 10 docs/scrum4me-*.md files to unprefixed kebab-case names.
Update every internal link in docs/, CLAUDE.md, AGENTS.md, README.md.

* docs(naming): lowercase API.md and MD3 filenames

Rename docs/API.md → docs/api.md and
docs/MD3_Color_Scheme_Documentation.md → docs/md3-color-scheme.md.
Update all internal links across 7 files.

* docs(naming): rename plan file to kebab-case ASCII

Rename "docs/plans/Tweede Claude Agent — Planning Agent.md"
→ docs/plans/tweede-claude-agent-planning.md. No external links needed updating.

* docs(naming): rename middleware.md to proxy.md (next 16)

docs/patterns/middleware.md → docs/patterns/proxy.md following
the Next.js 16 proxy.ts rename. Update link in CLAUDE.md.

* docs(naming): polish CLAUDE.md doc-index after renames

Fix doubled scrum4me-scrum4me-mcp repo references (cascade from
prior sed) in CLAUDE.md, docs/architecture.md, backlog.md,
agent-instruction-audit.md, and plans/ST-1109. Update
'Middleware' label to 'Proxy middleware' in patterns table.
2026-05-03 03:00:47 +02:00

885 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 12 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 [backlog.md § M10](../backlog.md#m10-password-loze-inlog-via-qr-pairing).
Functional spec: zie [functional.md § F-01b](../functional.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/<timestamp>_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<string | null> {
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 `<NoticeToast />` 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/<id>` blijft open en print `: heartbeat` elke 25 s
- Andere terminal: `psql $DIRECT_URL -c "UPDATE login_pairings SET status='approved' WHERE id='<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 (
<main className="container mx-auto max-w-md py-12">
<h1 className="text-h2">Inloggen op desktop</h1>
<p className="text-muted-foreground mt-2">
Bevestig hieronder dat je wilt inloggen op het apparaat dat de QR-code toont.
</p>
<PairConfirmation />
</main>
)
}
```
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<State>({ 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<SessionData>(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: 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<Phase>({ kind: 'idle' })
const sseRef = useRef<EventSource | null>(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
<div className="my-4 flex items-center gap-2">
<div className="border-border h-px flex-1 border-t" />
<span className="text-muted-foreground text-sm">of</span>
<div className="border-border h-px flex-1 border-t" />
</div>
<QrLoginButton />
```
MD3-tokens uit `docs/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/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/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/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 → Branch & PR Strategy](../../CLAUDE.md#branch--pr-strategy-strict--kostenbeheersing): **één branch voor de hele milestone**, PR pas na handmatige acceptatie door de gebruiker. Reden: elke push triggert een Vercel preview-build, en op het Hobby-account zijn die schaars.
**Branch:** `feat/M10-qr-login` — afgesplitst van `main` na merge van de planning-PR (#11). Alle ST-1001..ST-1008-werk landt op deze branch.
**Commits** in chronologische volgorde, één per stap, ST-code in de titel. Voorbeeld-progressie:
```
feat(ST-1001): add LoginPairing model
feat(ST-1001): add pg_notify trigger on scrum4me_pairing channel
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
feat(ST-1003): add /api/auth/pair/start with rate-limit and pre-auth cookie
feat(ST-1004): add SSE /api/auth/pair/stream with cookie auth
feat(ST-1005): add pairing server actions
feat(ST-1005): add mobile pair confirmation page with hash-fragment client island
feat(ST-1006): add /api/auth/pair/claim with atomic consume
chore(ST-1007): add qrcode.react dependency
feat(ST-1007): add QR login button on /login with SSE listener
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
```
**Push + PR**: pas nadat ST-1008-acceptatie-scenario 1 (happy path, end-to-end op localhost) handmatig groen is bevonden door de gebruiker. Tussentijdse "klaar voor jouw test"-momenten markeren we lokaal — niet met een push.
**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 na merge
**Wanneer dit aanpassen:** zodra het Vercel-account naar Pro gaat — zie CLAUDE.md.
---
## 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.