* 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.
38 KiB
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.
mobileSecretreist alleen via QR-fragment (#s=…) → mobilelocation.hash→ POST-bodydesktopTokenreist alleen via HttpOnly cookies4m_pairmetPath=/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. Functional spec: zie functional.md § F-01b.
Implementatie-volgorde (commit-strategy uit CLAUDE.md):
- DB-laag — ST-1001 (schema + trigger)
- Auth helpers + sessie-uitbreiding — ST-1002
- API-laag — ST-1003 (start), ST-1004 (SSE), ST-1006 (claim)
- Server actions + mobile UI — ST-1005
- Desktop UI — ST-1007
- 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 modelLoginPairing+ back-relation opUserprisma/migrations/<timestamp>_add_login_pairing/migration.sql— model + triggervendor/scrum4me-submodule in reposcrum4me-mcp— schema-sync ná merge
Stappen
-
Schema-uitbreiding:
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. -
Migratie-SQL voegt naast de tabel ook trigger toe (mirror van
notify_solo_changeinprisma/migrations/20260426230316_add_solo_realtime_triggers/migration.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(); -
npx prisma migrate dev --name add_login_pairing.
Aandachtspunten
desktop_iphoudt op 45 tekens om IPv6 te accommoderen (xxxx:xxxx:…:255.255.255.255).- Geen index op
user_idnodig 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 opmaindirect gesynced worden, anders breekt de wekelijkse drift-check (trig_015FFUnxjz9WMuhhWNGBQKFD). Dit was ook een aandachtspunt bij ST-901.
Verificatie
npx prisma migrate devslaagtnpx prisma validatezonder foutenpsql $DIRECT_URL -c "LISTEN scrum4me_pairing;"toont payload bijINSERT INTO login_pairings(...) VALUES(...)prisma studiotoont tabel met beide hash-kolommenNOT NULL
ST-1002 — Pairing-helpers + sessie-uitbreiding + pre-auth-cookie
Bestanden
lib/auth/pairing.ts— nieuw, secret/token-generatie en hash-helperslib/auth/pair-cookie.ts— nieuw, set/read/clear vans4m_pair-cookielib/session.ts—SessionDatauitbreiden metpairedenpairedExpiresAtapp/(app)/layout.tsx— extra guard op vervallen paired-sessie__tests__/lib/auth/pairing.test.ts— nieuw
Stappen
-
lib/auth/pairing.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.
-
lib/auth/pair-cookie.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/pairzorgt dat de cookie alleen naar pair-endpoints wordt gestuurd — niet naar elke route. -
lib/session.ts—SessionDatainterface: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.
-
app/(app)/layout.tsx— guard ná de bestaandeif (!session.userId) redirect('/login'):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". -
Tests —
__tests__/lib/auth/pairing.test.ts:generateMobileSecret()produceert 43-karakter base64url (32 bytes)hashTokenis deterministischverifyTokenis 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.tsherzien wordt (uit scope hier). cookies().delete({ name, path })moet dezelfde path specificeren als bij set, anders blijft de cookie staan.crypto.randomBytesis sync en blocking — voor 32 bytes ruim < 1ms; geen async-variant nodig.
Verificatie
npm run lint && npx tsc --noEmit && npm test && npm run buildgroen- Handmatig: in DevTools Application-tab is de cookie zichtbaar als HttpOnly + Path scoped
document.cookieop 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— nieuwlib/rate-limit.ts— checken of bestaand (uit ST-608); anders helper toevoegen__tests__/api/pair-start.test.ts— nieuw
Stappen
-
Route Handler (vrij van
authenticateApiRequest— dit is anon):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, }) } -
Rate-limit helper — als
lib/rate-limit.tsnog 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.
- In-memory Map keyed op
-
Tests —
__tests__/api/pair-start.test.ts:- 200 response bevat
pairingId,mobileSecret,qrUrlmet fragment-syntax (#id=…&s=…) Set-Cookie-header bevats4m_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_uaendesktop_ipworden opgeslagen bij aanwezigheid, andersnull
- 200 response bevat
Aandachtspunten
mobileSecretmag 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.qrUrlgebruikt#(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 leestwindow.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 gebruikencrypto.randomBytes.
Verificatie
curl -i -X POST http://localhost:3000/api/auth/pair/start --cookie-jar /tmp/jarretourneert JSON +Set-Cookie- Body bevat
qrUrlmet#id=…&s=… - Rij in
login_pairingsheeft 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
-
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: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
-
-
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 alapprovedis voordat de SSE opent — race-conditie). -
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
pairingIdin het URL-pad is OK — niet vertrouwelijk. De cookie is het bewijs.EventSourceop de client kan geen custom headers; cookie is daarom de enige praktische auth-methode.withCredentials: trueop de client is verplicht.- Bij browser-tab-sluiten wordt
request.signal.abortgetriggered → cleanup zoals in solo-route. - Vermijd Prisma in de notification-handler; gebruik alleen
pg.Clientzoals 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: heartbeatelke 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 -Nzonder cookie → JSON 401
ST-1005 — Server actions + mobiele bevestigingspagina
Bestanden
actions/pairing.ts— nieuw, drie Server Actionsapp/(app)/m/pair/page.tsx— nieuw, Server Componentapp/(app)/m/pair/pair-confirmation.tsx— nieuw, Client Component__tests__/actions/pairing.test.ts— nieuw
Stappen
-
actions/pairing.ts— volgtdocs/patterns/server-action.md:'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. } -
app/(app)/m/pair/page.tsx— Server Component, achter bestaande(app)/layout.tsxauth-guard: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. -
app/(app)/m/pair/pair-confirmation.tsx— Client Component:'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) } -
Tests —
__tests__/actions/pairing.test.ts:getPairingForApprovalmetpending-pairing →ok: true+ ua/ip/usernamegetPairingForApprovalmet al-approved→ok: falsegetPairingForApprovalmet verlopen →ok: falsegetPairingForApprovalmet verkeerd secret →ok: falseapprovePairingals demo-user →ok: false+ DB onveranderdapprovePairinghappy path → statusapproved,user_idgezet,expires_atbumpedcancelPairinghappy path → statuscancelled
Aandachtspunten
getPairingForApprovalmág door demo-users worden aangeroepen (om iets te zien); alleenapprovePairingblokkeert demo's.pair-confirmation.tsxgebruikt geenuseSearchParams— die kan in Next.js 16 hash niet lezen, en we willen het ook niet via routing zien.- Na approve
window.history.replaceStatezonder de#hashzorgt dat browser-back de secret niet opnieuw onthult. revalidatePathinapprovePairingis 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 buildgroen- Handmatig: log in op telefoon-emulator → bezoek
/m/pair#id=…&s=…→ kaart toont UA/IP → Bevestig → succes-state, URL is/m/pairzonder 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
-
Route Handler:
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 }) } -
Tests:
- 200 + iron-session cookie + clear
s4m_pairna succes - 410 op tweede claim met dezelfde cookie
- 401 zonder cookie
- 401 met cookie die hasht naar andere pairing
- paired-sessie bevat
paired: trueenpairedExpiresAtrondnow + 8h
- 200 + iron-session cookie + clear
Aandachtspunten
updateManygebruiken (nietupdate) want we hebben een composite WHERE metstatus+desktop_token_hash+expires_at;updatekan alleen op unique keys.- Het WHERE-criterium garandeert atomiciteit: PostgreSQL UPDATE met meerdere predicates is row-level locked; concurrent dubbele claim resulteert in
count = 1voor één caller encount = 0voor de ander. clearPairCookieook bij faalpaden, anders blijft 'm na expiry hangen (cosmetisch —Max-Age=120regelt het ook).- De
session.isDemocheck overneemt: als de approver een demo-user is — wat ST-1005 al blokkeert — komen we hier niet eens, maaris_demodoorzetten is een extra vangnet.
Verificatie
- Handmatig: na approve in mobiele tab, POST naar
/api/auth/pair/claimmet de cookie van start → 200 +Set-Cookie: session=... curl -X POSTzonder 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" toevoegenapp/login/qr-login-button.tsx— nieuw, Client Componentpackage.json—qrcode.reacttoevoegen__tests__/components/qr-login-button.test.tsx— minimale render-test
Stappen
-
Dependency:
npm install qrcode.react— direct independenciesper CLAUDE.md-conventie. -
qr-login-button.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 } -
app/login/page.tsx— knop toevoegen onder of naast het wachtwoord-formulier:<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. -
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 des4m_pair-cookie meestuurt; standaard verstuurt EventSource geen credentials.- Cleanup-volgorde:
es.close()eerst, dán fetch claim. Anders kan een tweedeapproved-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.reactexporteert zowelQRCodeSVGalsQRCodeCanvas. 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 buildgroen- Handmatige twee-browser-test: A toont QR → B (ingelogd op mobile-emulator) opent QR-URL en bevestigt → A redirect naar
/dashboardmetsession.paired === true - DevTools Network-tab: geen URL bevat
s=(alleenSet-Cookie/Cookieheaders) - Speel met een verlopen QR: na 2 min toont knop "Vernieuwen",
Vernieuwenstart nieuwe pairing
ST-1008 — Documentatie + acceptatietest
Bestanden
docs/api.md— drie nieuwe endpointsdocs/architecture.md— sectie "QR-pairing flow" + threat-modeldocs/patterns/qr-login.md— nieuw pattern-docCLAUDE.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
-
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 dats4m_paireen tijdelijke pre-auth cookie is, anders dan de iron-session cookie. -
docs/architecture.md— sectie "QR-pairing flow" met:- Sequence-diagram (mermaid of ASCII analoog aan M8)
- Threat-model:
- Replay: atomic
updateManymetstatus='approved'voorkomt dubbele claim - Phishing-QR: mobiele bevestigingspagina toont UA + IP zodat gebruiker een vreemd apparaat herkent; expliciete tap vereist
- Demo-block:
approvePairingearly return opsession.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
- Replay: atomic
-
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.tsals sjabloon - Risico's en mitigaties
-
CLAUDE.md— in de Implementatiepatronen-tabel een rij| QR-pairing (unauth-SSE + pre-auth cookie) | docs/patterns/qr-login.md |. -
Acceptatie-scenario's (handmatig, eventueel automatiseerbaar in v2):
- Happy path — twee browsers, end-to-end binnen 2 minuten ingelogd
- Demo-block — mobiel ingelogd als demo-user, scant QR → kan niet bevestigen
- Replay — claim de pairing twee keer → tweede call 410
- Expiry tijdens pending — wacht 3 min na start, scan dan → mobiel toont "Pairing verlopen"
- Expiry tussen approve en claim — approve, wacht 6 min, claim → 410
- Ontbrekende cookie op SSE/claim — verwijder
s4m_pairin DevTools, herhaal → 401 - 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 ops=-substrings; verwacht: 0 hits
Aandachtspunten
- Zorg dat de runtime-logs MCP-controle in
docs/test-plan.mdbelandt zodat hij bij elke release herhaalbaar is. docs/patterns/qr-login.mdmag refereren naar bestaande pattern-docs (iron-session, route-handler) zonder ze te dupliceren.
Verificatie
npm run lint && npx tsc --noEmit && npm test && npm run buildgroen- 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: éé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 buildgroen op CI- Schema-wijziging in ST-1001 → wekelijkse drift-check
trig_015FFUnxjz9WMuhhWNGBQKFDmag 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.