Scrum4Me/docs/plans/M10-qr-pairing-login.md

39 KiB
Raw Blame History

title status audience language last_updated applies_to
M10 — Password-loze inlog via QR-pairing active
maintainer
contributor
nl 2026-05-03
M10

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. Functional spec: zie functional.md § F-01b.

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 mcp — schema-sync ná merge

Stappen

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

  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):

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

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.tsSessionData 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:

    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:

    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.tsSessionData interface:

    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'):

    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):

    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

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:

      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:

    '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:

    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:

    '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-approvedok: 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:

    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.jsonqrcode.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:

    '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:

    <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/design/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/rest-contract.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/rest-contract.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/qa/api-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 mcp gesynced ná schema-merge

Branch- en commit-strategie

Per 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 build groen op CI
  • Schema-wijziging in ST-1001 → wekelijkse drift-check trig_015FFUnxjz9WMuhhWNGBQKFD mag niet rood staan; vendor/scrum4me-submodule in 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.