diff --git a/CLAUDE.md b/CLAUDE.md index 807fb1f..3af09fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,7 @@ Lees het relevante patroon vóór je begint. Nooit uit het hoofd schrijven. | Zustand optimistische update + rollback | `docs/patterns/zustand-optimistic.md` | | Float sort_order drag-and-drop | `docs/patterns/sort-order.md` | | Middleware (route protection) | `docs/patterns/middleware.md` | +| QR-pairing (unauth-SSE + pre-auth cookie) | `docs/patterns/qr-login.md` | | Status-enum mapping (DB ↔ API) | `lib/task-status.ts` | | Client/server module-boundary | `*-server.ts` bevat DB-calls of node-only deps; `*.ts` is pure (client-safe). Nooit `import { ... } from '@/lib/foo-server'` in een client-component, anders krijg je `Module not found: 'dns'`/`'pg'`-style runtime fouten | diff --git a/__tests__/actions/pairing.test.ts b/__tests__/actions/pairing.test.ts new file mode 100644 index 0000000..da8d92f --- /dev/null +++ b/__tests__/actions/pairing.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + loginPairing: { + findUnique: vi.fn(), + update: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { hashToken } from '@/lib/auth/pairing' +import { + getPairingForApproval, + approvePairing, + cancelPairing, +} from '@/actions/pairing' + +const mockPrisma = prisma as unknown as { + loginPairing: { + findUnique: ReturnType + update: ReturnType + } + user: { findUnique: ReturnType } +} + +const VALID_PAIRING_ID = 'cmohmk0a' + 'g008bs417mzik8x9w'.padEnd(17, 'a').slice(0, 17) +const VALID_SECRET = 'A'.repeat(43) // ≥40 chars voor Zod min(40) +const SESSION_USER = { userId: 'user-1', isDemo: false } +const SESSION_DEMO = { userId: 'demo-1', isDemo: true } + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.user.findUnique.mockResolvedValue({ username: 'lars' }) +}) + +function pendingPairing(secret = VALID_SECRET) { + return { + status: 'pending' as const, + expires_at: new Date(Date.now() + 60_000), + secret_hash: hashToken(secret), + desktop_ua: 'TestUA/1.0', + desktop_ip: '198.51.100.1', + } +} + +describe('actions/pairing', () => { + describe('getPairingForApproval', () => { + it('ok-pad: pending + correct secret → desktop-info + username', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing()) + + const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ + ok: true, + desktop_ua: 'TestUA/1.0', + desktop_ip: '198.51.100.1', + username: 'lars', + }) + }) + + it('faalt zonder sessie', async () => { + mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Niet ingelogd' }) + }) + + it('faalt op al-approved pairing', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue({ + ...pendingPairing(), + status: 'approved', + }) + const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Pairing al afgehandeld' }) + }) + + it('faalt op verlopen pairing', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue({ + ...pendingPairing(), + expires_at: new Date(Date.now() - 1000), + }) + const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Pairing verlopen' }) + }) + + it('faalt op verkeerd secret', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing('echt')) + const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Ongeldig pairing-geheim' }) + }) + + it('faalt op ongeldige cuid (Zod)', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + const res = await getPairingForApproval('niet-cuid', VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Ongeldige invoer' }) + expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled() + }) + }) + + describe('approvePairing', () => { + it('happy-pad: status pending→approved, user_id gezet, expires_at +5min', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing()) + mockPrisma.loginPairing.update.mockResolvedValue({}) + + const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: true }) + expect(mockPrisma.loginPairing.update).toHaveBeenCalledTimes(1) + const arg = mockPrisma.loginPairing.update.mock.calls[0][0] + expect(arg.where).toEqual({ id: VALID_PAIRING_ID }) + expect(arg.data.status).toBe('approved') + expect(arg.data.user_id).toBe('user-1') + const dt = new Date(arg.data.expires_at).getTime() - Date.now() + expect(dt).toBeGreaterThan(295_000) + expect(dt).toBeLessThan(305_000) + }) + + it('demo-user wordt geblokkeerd, geen DB-write', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' }) + expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled() + expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled() + }) + + it('faalt op verkeerd secret zonder DB-write', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing('echt')) + const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Ongeldig pairing-geheim' }) + expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled() + }) + }) + + describe('cancelPairing', () => { + it('happy-pad: status pending→cancelled', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing()) + mockPrisma.loginPairing.update.mockResolvedValue({}) + + const res = await cancelPairing(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: true }) + const arg = mockPrisma.loginPairing.update.mock.calls[0][0] + expect(arg.data.status).toBe('cancelled') + }) + + it('demo-user wordt geblokkeerd, geen DB-write', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + const res = await cancelPairing(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' }) + expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled() + expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled() + }) + }) +}) diff --git a/__tests__/api/pair-claim.test.ts b/__tests__/api/pair-claim.test.ts new file mode 100644 index 0000000..3c594e5 --- /dev/null +++ b/__tests__/api/pair-claim.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockReadPairCookie, mockClearPairCookie, mockSession, mockGetIronSession } = vi.hoisted( + () => ({ + mockReadPairCookie: vi.fn(), + mockClearPairCookie: vi.fn(), + mockSession: { userId: '', isDemo: false, paired: false, pairedExpiresAt: 0, save: vi.fn() }, + mockGetIronSession: vi.fn(), + }), +) + +vi.mock('@/lib/auth/pair-cookie', () => ({ + readPairCookie: mockReadPairCookie, + clearPairCookie: mockClearPairCookie, + setPairCookie: vi.fn(), +})) + +vi.mock('iron-session', () => ({ + getIronSession: mockGetIronSession, +})) + +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + loginPairing: { + updateMany: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { hashToken } from '@/lib/auth/pairing' +import { POST } from '@/app/api/auth/pair/claim/route' + +const mockPrisma = prisma as unknown as { + loginPairing: { + updateMany: ReturnType + findFirst: ReturnType + findUnique: ReturnType + } +} + +const COOKIE_TOKEN = 'desktop-token-abc' +const COOKIE_HASH = hashToken(COOKIE_TOKEN) +const PAIRING_ID = 'cmohmk0qpair006c001' + +function makePost(body: unknown): Request { + return new Request('http://localhost:3000/api/auth/pair/claim', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + // Reset session-mock voor elke test + mockSession.userId = '' + mockSession.isDemo = false + mockSession.paired = false + mockSession.pairedExpiresAt = 0 + mockSession.save = vi.fn().mockResolvedValue(undefined) + mockGetIronSession.mockResolvedValue(mockSession) +}) + +describe('POST /api/auth/pair/claim', () => { + it('200: schrijft iron-session, clear s4m_pair, retourneert {ok:true}', async () => { + mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN) + mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 }) + mockPrisma.loginPairing.findUnique.mockResolvedValue({ + user_id: 'user-42', + user: { is_demo: false }, + }) + + const res = await POST(makePost({ pairingId: PAIRING_ID })) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + + // Atomic update aangeroepen met juiste WHERE + expect(mockPrisma.loginPairing.updateMany).toHaveBeenCalledTimes(1) + const where = mockPrisma.loginPairing.updateMany.mock.calls[0][0].where + expect(where).toMatchObject({ + id: PAIRING_ID, + status: 'approved', + desktop_token_hash: COOKIE_HASH, + }) + expect(where.expires_at).toMatchObject({ gt: expect.any(Date) }) + + // Iron-session payload + expect(mockSession.userId).toBe('user-42') + expect(mockSession.isDemo).toBe(false) + expect(mockSession.paired).toBe(true) + const dt = mockSession.pairedExpiresAt - Date.now() + expect(dt).toBeGreaterThan(8 * 60 * 60 * 1000 - 5_000) + expect(dt).toBeLessThan(8 * 60 * 60 * 1000 + 5_000) + expect(mockSession.save).toHaveBeenCalledTimes(1) + + expect(mockClearPairCookie).toHaveBeenCalledTimes(1) + }) + + it('demo-user: isDemo doorgezet als vangnet', async () => { + mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN) + mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 }) + mockPrisma.loginPairing.findUnique.mockResolvedValue({ + user_id: 'demo-1', + user: { is_demo: true }, + }) + + const res = await POST(makePost({ pairingId: PAIRING_ID })) + expect(res.status).toBe(200) + expect(mockSession.isDemo).toBe(true) + }) + + it('401 zonder s4m_pair-cookie', async () => { + mockReadPairCookie.mockResolvedValue(null) + const res = await POST(makePost({ pairingId: PAIRING_ID })) + expect(res.status).toBe(401) + expect(mockPrisma.loginPairing.updateMany).not.toHaveBeenCalled() + }) + + it('400 zonder body', async () => { + mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN) + const res = await POST(makePost('not-json')) + expect(res.status).toBe(400) + }) + + it('400 zonder pairingId', async () => { + mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN) + const res = await POST(makePost({})) + expect(res.status).toBe(400) + }) + + it('410 op tweede claim — pairing al consumed', async () => { + mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN) + mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 }) + mockPrisma.loginPairing.findFirst.mockResolvedValue({ status: 'consumed' }) + + const res = await POST(makePost({ pairingId: PAIRING_ID })) + expect(res.status).toBe(410) + expect(mockClearPairCookie).toHaveBeenCalledTimes(1) + expect(mockSession.save).not.toHaveBeenCalled() + }) + + it('401 op cookie/hash-mismatch (pairing bestaat niet voor deze cookie)', async () => { + mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN) + mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 }) + mockPrisma.loginPairing.findFirst.mockResolvedValue(null) + + const res = await POST(makePost({ pairingId: PAIRING_ID })) + expect(res.status).toBe(401) + expect(mockClearPairCookie).toHaveBeenCalledTimes(1) + }) +}) diff --git a/__tests__/api/pair-start.test.ts b/__tests__/api/pair-start.test.ts new file mode 100644 index 0000000..8c7207f --- /dev/null +++ b/__tests__/api/pair-start.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { cookieJar } = vi.hoisted(() => ({ + cookieJar: { set: vi.fn(), get: vi.fn(), delete: vi.fn() }, +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + loginPairing: { + create: vi.fn(), + }, + }, +})) + +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue(cookieJar), +})) + +import { prisma } from '@/lib/prisma' +import { POST } from '@/app/api/auth/pair/start/route' + +const mockPrisma = prisma as unknown as { + loginPairing: { create: ReturnType } +} + +function makePost(opts: { ip?: string; ua?: string } = {}): Request { + const headers = new Headers() + if (opts.ip !== undefined) headers.set('x-forwarded-for', opts.ip) + if (opts.ua !== undefined) headers.set('user-agent', opts.ua) + return new Request('http://localhost:3000/api/auth/pair/start', { + method: 'POST', + headers, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.loginPairing.create.mockResolvedValue({ + id: 'pair-1', + expires_at: new Date('2026-04-27T20:30:00Z'), + }) +}) + +describe('POST /api/auth/pair/start', () => { + it('200 met body { pairingId, mobileSecret, expiresAt, qrUrl met fragment }', async () => { + const res = await POST(makePost({ ip: '198.51.100.7', ua: 'TestUA/1.0' })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.pairingId).toBe('pair-1') + expect(body.mobileSecret).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(body.qrUrl).toBe( + `http://localhost:3000/m/pair#id=pair-1&s=${body.mobileSecret}`, + ) + expect(body.expiresAt).toBe('2026-04-27T20:30:00.000Z') + }) + + it('slaat alleen sha256-hashes op — geen plaintext mobileSecret of desktopToken', async () => { + const res = await POST(makePost({ ip: '198.51.100.8' })) + const body = await res.json() + const arg = mockPrisma.loginPairing.create.mock.calls[0][0].data + expect(arg.secret_hash).toMatch(/^[a-f0-9]{64}$/) + expect(arg.desktop_token_hash).toMatch(/^[a-f0-9]{64}$/) + expect(arg.secret_hash).not.toBe(body.mobileSecret) + expect(arg.status).toBe('pending') + // expires_at ~5 min in toekomst + const dt = new Date(arg.expires_at).getTime() - Date.now() + expect(dt).toBeGreaterThan(295_000) + expect(dt).toBeLessThan(305_000) + }) + + it('zet HttpOnly Path-scoped s4m_pair cookie met Max-Age 120', async () => { + await POST(makePost({ ip: '198.51.100.9' })) + expect(cookieJar.set).toHaveBeenCalledTimes(1) + const [name, value, opts] = cookieJar.set.mock.calls[0] + expect(name).toBe('s4m_pair') + expect(value).toMatch(/^[A-Za-z0-9_-]{43}$/) // desktopToken + expect(opts).toMatchObject({ + httpOnly: true, + sameSite: 'lax', + path: '/api/auth/pair', + maxAge: 300, + }) + }) + + it('slaat user-agent en IP op (afgekapt)', async () => { + const longUa = 'A'.repeat(500) + await POST(makePost({ ip: '198.51.100.10', ua: longUa })) + const arg = mockPrisma.loginPairing.create.mock.calls[0][0].data + expect(arg.desktop_ua).toBe('A'.repeat(255)) + expect(arg.desktop_ip).toBe('198.51.100.10') + }) + + it('desktop_ip = null als x-forwarded-for ontbreekt', async () => { + await POST(makePost({})) + const arg = mockPrisma.loginPairing.create.mock.calls[0][0].data + expect(arg.desktop_ip).toBeNull() + }) + + it('11e POST binnen window levert 429', async () => { + const ip = '198.51.100.99' // unieke IP zodat andere tests niet in de weg zitten + for (let i = 0; i < 10; i++) { + const ok = await POST(makePost({ ip })) + expect(ok.status).toBe(200) + } + const blocked = await POST(makePost({ ip })) + expect(blocked.status).toBe(429) + const body = await blocked.json() + expect(body.error).toMatch(/Te veel pogingen/i) + }) +}) diff --git a/__tests__/api/pair-stream.test.ts b/__tests__/api/pair-stream.test.ts new file mode 100644 index 0000000..12433fd --- /dev/null +++ b/__tests__/api/pair-stream.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock the cookie helper directly — easier than mocking next/headers + reasoning +// over the cookies() async wrapper. The actual cookie wiring is tested in +// pair-start.test.ts. +const { mockReadPairCookie } = vi.hoisted(() => ({ + mockReadPairCookie: vi.fn(), +})) + +vi.mock('@/lib/auth/pair-cookie', () => ({ + readPairCookie: mockReadPairCookie, + setPairCookie: vi.fn(), + clearPairCookie: vi.fn(), +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + loginPairing: { + findUnique: vi.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { hashToken } from '@/lib/auth/pairing' +import type { NextRequest } from 'next/server' +import { GET } from '@/app/api/auth/pair/stream/[pairingId]/route' + +const mockPrisma = prisma as unknown as { + loginPairing: { findUnique: ReturnType } +} + +function makeReq(): NextRequest { + // Minimaal NextRequest-shape voor de auth-paden — we komen niet aan signal/url toe + // omdat de auth-checks vóór de stream-setup falen. + return { signal: new AbortController().signal } as unknown as NextRequest +} + +const params = (id: string) => + ({ params: Promise.resolve({ pairingId: id }) }) as { params: Promise<{ pairingId: string }> } + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/auth/pair/stream/[pairingId]', () => { + it('401 zonder s4m_pair-cookie', async () => { + mockReadPairCookie.mockResolvedValue(null) + const res = await GET(makeReq(), params('pair-x')) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toMatch(/cookie/i) + expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled() + }) + + it('404 als pairing onbekend is', async () => { + mockReadPairCookie.mockResolvedValue('whatever-token') + mockPrisma.loginPairing.findUnique.mockResolvedValue(null) + const res = await GET(makeReq(), params('pair-onbekend')) + expect(res.status).toBe(404) + }) + + it('410 als pairing verlopen is', async () => { + mockReadPairCookie.mockResolvedValue('correct-token') + mockPrisma.loginPairing.findUnique.mockResolvedValue({ + desktop_token_hash: hashToken('correct-token'), + status: 'pending', + expires_at: new Date(Date.now() - 1000), + }) + const res = await GET(makeReq(), params('pair-verlopen')) + expect(res.status).toBe(410) + }) + + it('401 als cookie hashed naar andere desktop_token_hash', async () => { + mockReadPairCookie.mockResolvedValue('verkeerd-token') + mockPrisma.loginPairing.findUnique.mockResolvedValue({ + desktop_token_hash: hashToken('echt-token'), + status: 'pending', + expires_at: new Date(Date.now() + 60_000), + }) + const res = await GET(makeReq(), params('pair-mismatch')) + expect(res.status).toBe(401) + }) +}) diff --git a/__tests__/lib/auth/pairing.test.ts b/__tests__/lib/auth/pairing.test.ts new file mode 100644 index 0000000..0375df4 --- /dev/null +++ b/__tests__/lib/auth/pairing.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest' + +import { + generateMobileSecret, + generateDesktopToken, + hashToken, + verifyToken, + isPairedSessionExpired, +} from '@/lib/auth/pairing' + +describe('lib/auth/pairing', () => { + describe('generateMobileSecret / generateDesktopToken', () => { + it('produceert 43-karakter base64url (32 bytes)', () => { + // 32 bytes → ceil(32/3) * 4 = 44 chars zonder padding → 43 chars in base64url (geen '=') + expect(generateMobileSecret()).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(generateDesktopToken()).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) + + it('twee opeenvolgende calls leveren verschillende waardes', () => { + const a = generateMobileSecret() + const b = generateMobileSecret() + expect(a).not.toBe(b) + }) + + it('mobile en desktop generators delen geen state — paren zijn onafhankelijk', () => { + const m1 = generateMobileSecret() + const d1 = generateDesktopToken() + const m2 = generateMobileSecret() + const d2 = generateDesktopToken() + expect(new Set([m1, d1, m2, d2]).size).toBe(4) + }) + }) + + describe('hashToken', () => { + it('is deterministisch — zelfde input → zelfde hash', () => { + const t = 'voorbeeld-token' + expect(hashToken(t)).toBe(hashToken(t)) + }) + + it('produceert 64-karakter hex (sha256)', () => { + expect(hashToken('x')).toMatch(/^[a-f0-9]{64}$/) + }) + + it('verschillende inputs → verschillende hashes', () => { + expect(hashToken('a')).not.toBe(hashToken('b')) + }) + }) + + describe('verifyToken', () => { + it('true voor geldig (token, hashOf(token))', () => { + const token = generateMobileSecret() + expect(verifyToken(token, hashToken(token))).toBe(true) + }) + + it('false voor onjuist token', () => { + const realHash = hashToken('echt-token') + expect(verifyToken('verkeerd-token', realHash)).toBe(false) + }) + + it('false bij hash met afwijkende lengte', () => { + expect(verifyToken('iets', 'abc')).toBe(false) + }) + + it('false bij lege hash', () => { + expect(verifyToken('iets', '')).toBe(false) + }) + }) + + describe('isPairedSessionExpired', () => { + it('false als paired niet gezet is (reguliere wachtwoord-sessie)', () => { + expect(isPairedSessionExpired({})).toBe(false) + }) + + it('false als pairedExpiresAt ontbreekt', () => { + expect(isPairedSessionExpired({ paired: true })).toBe(false) + }) + + it('false als de paired-sessie nog niet vervallen is', () => { + const future = Date.now() + 60_000 + expect(isPairedSessionExpired({ paired: true, pairedExpiresAt: future })).toBe(false) + }) + + it('true als paired én vervaltijd in het verleden ligt', () => { + const past = Date.now() - 1_000 + expect(isPairedSessionExpired({ paired: true, pairedExpiresAt: past })).toBe(true) + }) + }) +}) diff --git a/actions/pairing.ts b/actions/pairing.ts new file mode 100644 index 0000000..6fedd3f --- /dev/null +++ b/actions/pairing.ts @@ -0,0 +1,143 @@ +'use server' + +// ST-1005: Server Actions voor de mobiele zijde van de QR-pairing-flow (M10). +// +// Aangeroepen door de Client Component op /m/pair zodra die het #id=…&s=… +// fragment uit de URL heeft geparsed. De mobiele gebruiker is hier al +// geauthenticeerd via de bestaande iron-session (de page zit achter de +// (app)/layout.tsx-guard). +// +// Volgt docs/patterns/server-action.md: getSession + Zod + demo-guard +// (uitsluitend op approvePairing — read-only en cancel mag iedereen). + +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { verifyToken } from '@/lib/auth/pairing' + +const APPROVED_TTL_MS = 5 * 60 * 1000 + +const inputSchema = z.object({ + pairingId: z.string().cuid(), + mobileSecret: z.string().min(40), // 32 bytes base64url ≈ 43 chars +}) + +type ActionFail = { ok: false; error: string } +type ApprovalView = { + ok: true + desktop_ua: string | null + desktop_ip: string | null + username: string +} + +type PendingPairing = { + status: string + expires_at: Date + secret_hash: string + desktop_ua: string | null + desktop_ip: string | null +} + +type LoadResult = + | { kind: 'error'; error: string } + | { kind: 'ok'; pairing: PendingPairing } + +async function loadPendingPairing( + pairingId: string, + mobileSecret: string, +): Promise { + 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 { kind: 'error', error: 'Pairing niet gevonden' } + if (pairing.expires_at < new Date()) return { kind: 'error', error: 'Pairing verlopen' } + if (pairing.status !== 'pending') return { kind: 'error', error: 'Pairing al afgehandeld' } + if (!verifyToken(mobileSecret, pairing.secret_hash)) { + return { kind: 'error', error: 'Ongeldig pairing-geheim' } + } + return { kind: 'ok', pairing } +} + +export async function getPairingForApproval( + pairingId: string, + mobileSecret: string, +): Promise { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd' } + + const parsed = inputSchema.safeParse({ pairingId, mobileSecret }) + if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } + + const result = await loadPendingPairing(parsed.data.pairingId, parsed.data.mobileSecret) + if (result.kind === 'error') return { ok: false, error: result.error } + + const me = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { username: true }, + }) + return { + ok: true, + desktop_ua: result.pairing.desktop_ua, + desktop_ip: result.pairing.desktop_ip, + username: me?.username ?? '', + } +} + +export async function approvePairing( + pairingId: string, + mobileSecret: string, +): Promise<{ ok: true } | ActionFail> { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd' } + if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } + + const parsed = inputSchema.safeParse({ pairingId, mobileSecret }) + if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } + + const result = await loadPendingPairing(parsed.data.pairingId, parsed.data.mobileSecret) + if (result.kind === 'error') return { ok: false, error: result.error } + + await prisma.loginPairing.update({ + where: { id: parsed.data.pairingId }, + data: { + status: 'approved', + user_id: session.userId, + approved_at: new Date(), + expires_at: new Date(Date.now() + APPROVED_TTL_MS), + }, + }) + // Postgres-trigger emit pg_notify('scrum4me_pairing', …) automatisch — de + // desktop-SSE in ST-1004 vangt het op. Geen revalidatePath nodig: deze page + // heeft geen server-state om te ververversen, en de mobiele tab gaat naar + // de "klaar"-state direct in de Client Component. + return { ok: true } +} + +export async function cancelPairing( + pairingId: string, + mobileSecret: string, +): Promise<{ ok: true } | ActionFail> { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd' } + // Cancel is een DB-write — onder de demo-write-block-regel. + if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } + + const parsed = inputSchema.safeParse({ pairingId, mobileSecret }) + if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } + + const result = await loadPendingPairing(parsed.data.pairingId, parsed.data.mobileSecret) + if (result.kind === 'error') return { ok: false, error: result.error } + + await prisma.loginPairing.update({ + where: { id: parsed.data.pairingId }, + data: { status: 'cancelled' }, + }) + return { ok: true } +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 3366094..1a5b3b9 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -2,6 +2,7 @@ import { redirect } from 'next/navigation' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' import { SessionData, sessionOptions } from '@/lib/session' +import { isPairedSessionExpired } from '@/lib/auth/pairing' import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' import { NavBar } from '@/components/shared/nav-bar' @@ -18,6 +19,13 @@ export default async function AppLayout({ children }: { children: React.ReactNod redirect('/login') } + // ST-1002 (M10): paired-sessies (via QR-pairing) hebben een eigen kortere TTL. + // Vervallen → vernietig en stuur naar /login. + if (isPairedSessionExpired(session)) { + session.destroy() + redirect('/login') + } + const [user, userRoles, accessibleProducts] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, diff --git a/app/(app)/m/pair/page.tsx b/app/(app)/m/pair/page.tsx new file mode 100644 index 0000000..ee665c2 --- /dev/null +++ b/app/(app)/m/pair/page.tsx @@ -0,0 +1,26 @@ +// ST-1005: Mobiele bevestigingspagina voor de QR-pairing-flow (M10). +// +// Server Component achter de bestaande (app)/layout.tsx auth-guard — onbekende +// mobielen worden eerst naar /login gestuurd. Bewust géén searchParams +// uitlezen: het mobileSecret zit in het URL-fragment (#id=…&s=…), wat alleen +// client-side leesbaar is. De Client Component PairConfirmation parseert +// location.hash en doet de Server Action-calls. + +import { PairConfirmation } from './pair-confirmation' + +export const metadata = { + title: 'Inloggen op desktop', +} + +export default function PairPage() { + return ( +
+

Inloggen op desktop

+

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

+ +
+ ) +} diff --git a/app/(app)/m/pair/pair-confirmation.tsx b/app/(app)/m/pair/pair-confirmation.tsx new file mode 100644 index 0000000..9d92b0d --- /dev/null +++ b/app/(app)/m/pair/pair-confirmation.tsx @@ -0,0 +1,176 @@ +'use client' + +// ST-1005: Mobiele bevestigings-island voor de QR-pairing-flow (M10). +// +// De QR-URL is /m/pair#id=…&s=… — de fragment wordt door browsers nooit naar +// de server gestuurd, dus alleen client-side leesbaar via location.hash. Hier +// halen we 'm op, doen via Server Action de bevestigings-roundtrip, en wissen +// de hash zodra de approve gelukt is zodat back/forward de secret niet meer +// onthult. + +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 + mobileSecret: string + desktop_ua: string | null + desktop_ip: string | null + username: string + } + | { kind: 'approved'; username: string } + | { kind: 'cancelled' } + +function parseHash(): { id: string; s: string } | null { + if (typeof window === 'undefined') return null + const raw = window.location.hash.replace(/^#/, '') + if (!raw) return null + const params = new URLSearchParams(raw) + const id = params.get('id') + const s = params.get('s') + return id && s ? { id, s } : null +} + +function clearHash() { + if (typeof window === 'undefined') return + window.history.replaceState(null, '', window.location.pathname + window.location.search) +} + +export function PairConfirmation() { + const [state, setState] = useState({ kind: 'loading' }) + const [pending, startTransition] = useTransition() + + useEffect(() => { + const parsed = parseHash() + if (!parsed) { + queueMicrotask(() => { + setState({ kind: 'invalid', error: 'Ongeldige of ontbrekende pairing-link' }) + }) + return + } + void getPairingForApproval(parsed.id, parsed.s).then((res) => { + if (!res.ok) { + setState({ kind: 'invalid', error: res.error }) + return + } + setState({ + kind: 'ready', + pairingId: parsed.id, + mobileSecret: parsed.s, + desktop_ua: res.desktop_ua, + desktop_ip: res.desktop_ip, + username: res.username, + }) + }) + }, []) + + function onApprove() { + if (state.kind !== 'ready') return + startTransition(async () => { + const res = await approvePairing(state.pairingId, state.mobileSecret) + if (!res.ok) { + toast.error(res.error) + return + } + clearHash() + setState({ kind: 'approved', username: state.username }) + }) + } + + function onCancel() { + if (state.kind !== 'ready') return + startTransition(async () => { + const res = await cancelPairing(state.pairingId, state.mobileSecret) + if (!res.ok) { + toast.error(res.error) + return + } + clearHash() + setState({ kind: 'cancelled' }) + }) + } + + if (state.kind === 'loading') { + return ( +
+ Pairing controleren… +
+ ) + } + + if (state.kind === 'invalid') { + return ( +
+

Kan deze QR-code niet gebruiken

+

{state.error}

+
+ ) + } + + if (state.kind === 'approved') { + return ( +
+

Klaar — je kunt deze tab sluiten.

+

+ Het apparaat met de QR-code is nu ingelogd als {state.username}. +

+
+ ) + } + + if (state.kind === 'cancelled') { + return ( +
+

Geannuleerd

+

+ Er is geen sessie aangemaakt op het andere apparaat. +

+
+ ) + } + + return ( +
+

+ Wil je inloggen als {state.username} op dit apparaat? +

+
+
+
Browser:
+
{state.desktop_ua ?? 'onbekend'}
+
+
+
IP:
+
{state.desktop_ip ?? 'onbekend'}
+
+
+

+ Bevestig alleen als je deze QR-code zelf op een eigen scherm ziet — geen + screenshot of foto van iemand anders. +

+
+ + +
+
+ ) +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index bd8650f..4a225f9 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,6 +1,7 @@ import Link from 'next/link' import { loginAction } from '@/actions/auth' import { AuthForm } from '@/components/auth/auth-form' +import { QrLoginButton } from './qr-login-button' export default function LoginPage() { return ( @@ -17,6 +18,14 @@ export default function LoginPage() {
+ {/* M10 — Inloggen via mobiel zonder wachtwoord */} +
+
+ of +
+
+ +
Nog geen account?{' '} diff --git a/app/(auth)/login/qr-login-button.tsx b/app/(auth)/login/qr-login-button.tsx new file mode 100644 index 0000000..5332899 --- /dev/null +++ b/app/(auth)/login/qr-login-button.tsx @@ -0,0 +1,209 @@ +'use client' + +// ST-1007: Desktop-UI voor de QR-pairing-flow (M10). +// +// Klikt → POST /pair/start (cookie + body) → render QR die fragment-URL bevat +// → EventSource luistert naar /pair/stream/[id] met s4m_pair-cookie → bij +// approved-event POST /pair/claim → router.push('/dashboard'). +// +// mobileSecret blijft in JS-memory en in het QR-fragment; wordt nooit naar +// de server gestuurd vanuit deze browser. desktopToken zit alleen in de +// HttpOnly s4m_pair-cookie. fetch en EventSource sturen die cookie automatisch +// mee binnen de Path=/api/auth/pair-scope. + +import { useEffect, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +import { QRCodeSVG } from 'qrcode.react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' + +type Phase = + | { kind: 'idle' } + | { kind: 'starting' } + | { kind: 'showing'; pairingId: string; qrUrl: string; expiresAt: number } + | { kind: 'expired' } + | { kind: 'claiming' } + +interface StartResponse { + pairingId: string + mobileSecret: string + expiresAt: string + qrUrl: string +} + +interface StreamMessage { + op?: 'I' | 'U' + status?: 'pending' | 'approved' | 'consumed' | 'cancelled' + pairing_id?: string +} + +export function QrLoginButton() { + const router = useRouter() + const [phase, setPhase] = useState({ kind: 'idle' }) + const sseRef = useRef(null) + const [secondsLeft, setSecondsLeft] = useState(0) + + async function start() { + setPhase({ kind: 'starting' }) + try { + const res = await fetch('/api/auth/pair/start', { + method: 'POST', + credentials: 'same-origin', + }) + if (!res.ok) throw new Error(`pair/start ${res.status}`) + const data = (await res.json()) as StartResponse + setPhase({ + kind: 'showing', + pairingId: data.pairingId, + qrUrl: data.qrUrl, + expiresAt: new Date(data.expiresAt).getTime(), + }) + } catch { + toast.error('Kon QR-code niet aanmaken — probeer opnieuw') + setPhase({ kind: 'idle' }) + } + } + + // Open SSE-stream zodra we in 'showing' zijn + useEffect(() => { + if (phase.kind !== 'showing') return + + const es = new EventSource(`/api/auth/pair/stream/${phase.pairingId}`, { + withCredentials: true, + }) + sseRef.current = es + + const onMessage = async (ev: MessageEvent) => { + let data: StreamMessage + try { + data = JSON.parse(ev.data) as StreamMessage + } catch { + return + } + if (data.status !== 'approved') return + + // Approved! Sluit SSE en claim de sessie + es.close() + sseRef.current = null + setPhase({ kind: 'claiming' }) + try { + 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) throw new Error(`pair/claim ${res.status}`) + router.push('/dashboard') + } catch { + toast.error('Inloggen mislukt — probeer opnieuw') + setPhase({ kind: 'idle' }) + } + } + + const onError = () => { + // EventSource probeert zelf opnieuw te verbinden bij netwerk-glitches. + // Geen actie nodig tenzij we definitief willen falen. + } + + // De server stuurt direct na connect een `event: state`-payload met de + // huidige pairing-status (catch-up voor de race tussen pair/start en de + // SSE-open: als de mobiel net daarvoor approvet komt de notify door + // vóórdat onze LISTEN actief is en wordt 'ie verloren). EventSource + // routeert events met `event: ` alleen naar listeners voor die + // naam — niet naar 'message'. Dezelfde handler aan beide hangen vangt + // de catch-up én reguliere notifies op. + es.addEventListener('message', onMessage) + es.addEventListener('state', onMessage as unknown as EventListener) + es.addEventListener('error', onError) + + return () => { + es.removeEventListener('message', onMessage) + es.removeEventListener('state', onMessage as unknown as EventListener) + es.removeEventListener('error', onError) + es.close() + sseRef.current = null + } + }, [phase, router]) + + // Aftellen + auto-expire + useEffect(() => { + if (phase.kind !== 'showing') return + + const tick = () => { + const remaining = Math.max(0, Math.ceil((phase.expiresAt - Date.now()) / 1000)) + setSecondsLeft(remaining) + if (remaining === 0) { + sseRef.current?.close() + sseRef.current = null + setPhase({ kind: 'expired' }) + } + } + + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, [phase]) + + if (phase.kind === 'idle' || phase.kind === 'starting') { + return ( + + ) + } + + if (phase.kind === 'expired') { + return ( +
+

+ QR-code verlopen. Maak een nieuwe aan om opnieuw te proberen. +

+ +
+ ) + } + + if (phase.kind === 'claiming') { + return ( +
+ Inloggen… +
+ ) + } + + // phase.kind === 'showing' + const minutes = Math.floor(secondsLeft / 60) + const seconds = String(secondsLeft % 60).padStart(2, '0') + + return ( +
+
+ +

+ Vervalt over {minutes}:{seconds} +

+
+
+ Werkt scannen niet? Toon link +

{phase.qrUrl}

+
+

+ Scan met een telefoon waar je al ingelogd bent. +

+
+ ) +} diff --git a/app/api/auth/pair/claim/route.ts b/app/api/auth/pair/claim/route.ts new file mode 100644 index 0000000..69b4b89 --- /dev/null +++ b/app/api/auth/pair/claim/route.ts @@ -0,0 +1,97 @@ +// ST-1006: POST /api/auth/pair/claim — desktop ruilt zijn pre-auth cookie +// (s4m_pair) in voor een echte iron-session na een succesvolle approve op de +// mobiele kant. +// +// Auth: alleen via de HttpOnly s4m_pair-cookie. Geen body-secret nodig — het +// cookie-token is het bewijs. Body bevat alleen pairingId. +// +// Atomicity: één UPDATE met WHERE-clausule die status én token-hash én niet- +// verlopen tegelijk eist. Concurrent dubbele claims: PostgreSQL row-locking +// zorgt dat exact één caller count=1 ziet, de rest count=0 → 410. + +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 — kortere TTL voor publieke desktops + +interface ClaimBody { + pairingId?: unknown +} + +export async function POST(request: Request) { + const desktopToken = await readPairCookie() + if (!desktopToken) { + return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 }) + } + + let body: ClaimBody + try { + body = (await request.json()) as ClaimBody + } catch { + return Response.json({ error: 'Ongeldige JSON' }, { status: 400 }) + } + const pairingId = typeof body?.pairingId === 'string' ? body.pairingId : null + if (!pairingId) { + return Response.json({ error: 'pairingId vereist' }, { status: 400 }) + } + + const desktopTokenHash = hashToken(desktopToken) + + // Atomic state-transitie: alleen rij die approved is + token-hash matcht + + // niet verlopen wordt geconsumeerd. + 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) { + // Disambigueer: bestaat de pairing wel met deze cookie? Zo ja → al consumed + // of cancelled (410). Zo nee → cookie matcht geen pairing (401). + const exists = await prisma.loginPairing.findFirst({ + where: { id: pairingId, desktop_token_hash: desktopTokenHash }, + select: { status: true }, + }) + await clearPairCookie() + if (!exists) return Response.json({ error: 'Ongeldig' }, { status: 401 }) + return Response.json( + { error: `Pairing al ${exists.status}` }, + { status: 410 }, + ) + } + + // Haal user-info op voor de iron-session payload. + const pairing = await prisma.loginPairing.findUnique({ + where: { id: pairingId }, + select: { user_id: true, user: { select: { is_demo: true } } }, + }) + if (!pairing?.user_id) { + await clearPairCookie() + return Response.json({ error: 'Pairing zonder user' }, { status: 500 }) + } + + const session = await getIronSession(await cookies(), sessionOptions) + session.userId = pairing.user_id + session.isDemo = pairing.user?.is_demo ?? false + session.paired = true + session.pairedExpiresAt = Date.now() + PAIRED_TTL_MS + await session.save() + + await clearPairCookie() + + if (process.env.NODE_ENV !== 'production') { + console.log(`[pair/claim] consumed pairingId=${pairingId}`) + } + + return Response.json({ ok: true }) +} diff --git a/app/api/auth/pair/start/route.ts b/app/api/auth/pair/start/route.ts new file mode 100644 index 0000000..2062887 --- /dev/null +++ b/app/api/auth/pair/start/route.ts @@ -0,0 +1,74 @@ +// ST-1003: POST /api/auth/pair/start — anonieme endpoint die een nieuwe +// LoginPairing aanmaakt voor de QR-pairing-flow (M10). +// +// Genereert twee gescheiden 256-bit geheimen: +// - mobileSecret → komt in JSON-body terug zodat de desktop het in een +// QR-fragment kan plaatsen (wordt nooit naar onze server gestuurd) +// - desktopToken → wordt als HttpOnly cookie gezet zodat alleen deze +// browser de SSE-stream en claim mag uitvoeren +// +// Rate-limit: 10 pogingen per IP per minuut (lib/rate-limit.ts → 'pair-start'). + +import { prisma } from '@/lib/prisma' +import { + generateMobileSecret, + generateDesktopToken, + hashToken, +} from '@/lib/auth/pairing' +import { setPairCookie } from '@/lib/auth/pair-cookie' +import { checkRateLimit } from '@/lib/rate-limit' + +export const runtime = 'nodejs' + +const PENDING_TTL_MS = 5 * 60 * 1000 // 5 min — komt overeen met s4m_pair Max-Age + +const UA_MAX = 255 // matcht VarChar(255) op login_pairings.desktop_ua +const IP_MAX = 45 // matcht VarChar(45) — IPv6 max length + +function getClientIp(request: Request): string { + return ( + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + request.headers.get('x-real-ip') || + 'unknown' + ) +} + +export async function POST(request: Request) { + const ip = getClientIp(request) + if (!checkRateLimit(`pair-start:${ip}`)) { + return Response.json( + { error: 'Te veel pogingen. Probeer het over een minuut opnieuw.' }, + { status: 429 }, + ) + } + + const ua = request.headers.get('user-agent')?.slice(0, UA_MAX) ?? null + const ipStored = ip === 'unknown' ? null : ip.slice(0, IP_MAX) + + 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: ipStored, + expires_at: new Date(Date.now() + PENDING_TTL_MS), + }, + select: { id: true, expires_at: true }, + }) + + await setPairCookie(desktopToken) + + const origin = new URL(request.url).origin + const qrUrl = `${origin}/m/pair#id=${pairing.id}&s=${mobileSecret}` + + return Response.json({ + pairingId: pairing.id, + mobileSecret, + expiresAt: pairing.expires_at.toISOString(), + qrUrl, + }) +} diff --git a/app/api/auth/pair/stream/[pairingId]/route.ts b/app/api/auth/pair/stream/[pairingId]/route.ts new file mode 100644 index 0000000..049044d --- /dev/null +++ b/app/api/auth/pair/stream/[pairingId]/route.ts @@ -0,0 +1,201 @@ +// ST-1004: Server-Sent Events stream voor de QR-pairing-flow (M10). +// +// De desktop opent deze stream direct na pair/start. Auth is via de HttpOnly +// `s4m_pair`-cookie die diezelfde start-call zette — geen iron-session nodig +// (de gebruiker is op dit punt nog niet geauthenticeerd) en geen secret in +// query-parameters. De pairingId in het pad is niet vertrouwelijk. +// +// Bouwt voort op het LISTEN/NOTIFY-patroon uit ST-802 +// (app/api/realtime/solo/route.ts) maar op channel `scrum4me_pairing`. +// +// Output: text/event-stream met +// - één `state`-event direct na connect met de huidige pairing-status (zo +// mist de desktop geen approve die net vóór de SSE-open landde) +// - `message`-events met de volledige notify-payload bij elke status-wijziging +// - heartbeat-comments elke 25s om proxy-timeouts te voorkomen +// +// Sluit zelf na 240s als safety-net (Vercel kapt na maxDuration), of zodra +// status `consumed` of `cancelled` doorkomt — desktop heeft dan geen reden +// meer om te luisteren. + +import { NextRequest } from 'next/server' +import { Client } from 'pg' +import { prisma } from '@/lib/prisma' +import { verifyToken } from '@/lib/auth/pairing' +import { readPairCookie } from '@/lib/auth/pair-cookie' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const CHANNEL = 'scrum4me_pairing' +const HEARTBEAT_MS = 25_000 +const HARD_CLOSE_MS = 240_000 + +interface NotifyPayload { + op: 'I' | 'U' + pairing_id: string + status: 'pending' | 'approved' | 'consumed' | 'cancelled' +} + +const TERMINAL_STATUSES = new Set(['consumed', 'cancelled']) + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ pairingId: string }> }, +) { + const { pairingId } = await params + + 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 }) + } + + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL + if (!directUrl || !directUrl.startsWith('postgres')) { + // .env.local heeft DIRECT_URL=http://localhost:3000 (placeholder); dan + // valt fallback ook nog terug op DATABASE_URL. + const fallback = process.env.DATABASE_URL + if (!fallback) { + return Response.json( + { error: 'DATABASE_URL niet geconfigureerd' }, + { status: 500 }, + ) + } + } + const connectionString = + directUrl && directUrl.startsWith('postgres') + ? directUrl + : process.env.DATABASE_URL! + + const encoder = new TextEncoder() + const pgClient = new Client({ connectionString }) + + let heartbeatTimer: ReturnType | null = null + let hardCloseTimer: ReturnType | null = null + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (chunk: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(chunk)) + } catch { + // controller al gesloten — negeren + } + } + + const cleanup = async (reason: string) => { + if (closed) return + closed = true + if (heartbeatTimer) clearInterval(heartbeatTimer) + if (hardCloseTimer) clearTimeout(hardCloseTimer) + try { + await pgClient.end() + } catch { + // ignore + } + try { + controller.close() + } catch { + // already closed + } + if (process.env.NODE_ENV !== 'production') { + console.log(`[pair/stream ${pairingId}] closed: ${reason}`) + } + } + + try { + await pgClient.connect() + await pgClient.query(`LISTEN ${CHANNEL}`) + } catch (err) { + console.error('[pair/stream] pg connect/listen failed:', err) + enqueue( + `event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`, + ) + await cleanup('pg connect failed') + return + } + + pgClient.on('notification', (msg) => { + if (!msg.payload) return + let payload: NotifyPayload + try { + payload = JSON.parse(msg.payload) as NotifyPayload + } catch { + return + } + if (payload.pairing_id !== pairingId) return + enqueue(`data: ${msg.payload}\n\n`) + if (TERMINAL_STATUSES.has(payload.status)) { + cleanup(`terminal status: ${payload.status}`) + } + }) + + pgClient.on('error', (err) => { + console.error('[pair/stream] pg client error:', err) + cleanup('pg error') + }) + + // Initial state — dicht de race tussen pair/start en SSE-open. De + // *eerste* findUnique (voor cookie-validatie) gebeurde vóór LISTEN + // actief was; als de mobiel tussen die query en LISTEN approvet is + // de pg_notify verloren (Postgres queuet niet) én is de eerder + // gelezen status stale. Lees daarom de status hier opnieuw — nu LISTEN + // wel actief is, dus alle approvals na dit punt komen via de notify- + // handler door. + const fresh = await prisma.loginPairing.findUnique({ + where: { id: pairingId }, + select: { status: true }, + }) + const currentStatus = fresh?.status ?? pairing.status + + enqueue( + `event: state\ndata: ${JSON.stringify({ + pairing_id: pairingId, + status: currentStatus, + })}\n\n`, + ) + if (TERMINAL_STATUSES.has(currentStatus)) { + await cleanup(`already-${currentStatus}`) + return + } + + heartbeatTimer = setInterval(() => { + enqueue(`: heartbeat\n\n`) + }, HEARTBEAT_MS) + + hardCloseTimer = setTimeout(() => { + cleanup('hard close 240s') + }, HARD_CLOSE_MS) + + request.signal.addEventListener('abort', () => { + cleanup('client aborted') + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/components/shared/user-menu.tsx b/components/shared/user-menu.tsx index ba615c0..b628476 100644 --- a/components/shared/user-menu.tsx +++ b/components/shared/user-menu.tsx @@ -1,6 +1,6 @@ 'use client' -import { useRef } from 'react' +import { useTransition } from 'react' import Link from 'next/link' import { Settings, Sun, Globe, LogOut } from 'lucide-react' import { logoutAction } from '@/actions/auth' @@ -33,11 +33,20 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) { const initials = username.slice(0, 2).toUpperCase() const roleLabels = roles.map((r) => ROLE_LABELS[r]).filter(Boolean) const subtitle = email?.trim() ? email.trim() : 'Lokaal account' - const logoutFormRef = useRef(null) + const [pendingLogout, startLogout] = useTransition() + + // Server Action direct aanroepen — geen form/ref-dance. Eerdere implementatie + // gebruikte een hidden form binnen DropdownMenuContent; die unmount op + // onSelect en in deze base-ui-versie kwam de submit niet door. + function handleLogout() { + startLogout(async () => { + await logoutAction() + }) + } return ( - @@ -103,13 +112,14 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) { logoutFormRef.current?.requestSubmit()} + onClick={handleLogout} + onSelect={handleLogout} + disabled={pendingLogout} className="cursor-pointer" > - Uitloggen + {pendingLogout ? 'Uitloggen…' : 'Uitloggen'} -
) diff --git a/docs/API.md b/docs/API.md index b8f9db4..a4dda0f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -323,6 +323,100 @@ source.onmessage = (e) => console.log(JSON.parse(e.data)) --- +## Auth — QR-pairing (M10) + +Drie anonieme/cookie-geauthenticeerde endpoints voor de password-loze inlog +via QR-pairing. Worden door de browser gebruikt (niet door Claude Code) — +gedocumenteerd voor volledigheid en voor handmatige curl-tests. + +**Cookie-mechaniek:** `pair/start` zet een korte `s4m_pair`-HttpOnly-cookie +(`Path=/api/auth/pair`, `Max-Age=300`, `SameSite=Lax`, `Secure` in productie). +`pair/stream` en `pair/claim` authenticeren tegen die cookie. Geheim materiaal +zit nooit in URL-paden of querystrings — `mobileSecret` reist alleen via QR- +fragment (`#s=…`) en POST-body, `desktopToken` alleen via cookie. + +### `POST /api/auth/pair/start` + +Anon. Maakt een nieuwe `LoginPairing` aan en zet de pre-auth cookie. + +**Auth:** geen. +**Body:** geen. +**Rate-limit:** 10 per IP per minuut (zelfde patroon als `/login`). + +**Response 200:** +```json +{ + "pairingId": "cmoh...", + "mobileSecret": "<43-char base64url>", + "expiresAt": "2026-04-27T20:30:00.000Z", + "qrUrl": "https://.../m/pair#id=cmoh...&s=" +} +``` +Plus `Set-Cookie: s4m_pair=; HttpOnly; Path=/api/auth/pair; Max-Age=300; SameSite=Lax`. + +**Foutcodes:** `429` bij rate-limit overschreden. + +**Voorbeeld:** +```bash +curl -i -X POST -c /tmp/jar http://localhost:3000/api/auth/pair/start +``` + +--- + +### `GET /api/auth/pair/stream/:pairingId` + +Server-Sent Events stream die de desktop opent direct na `pair/start` om op +de approve-bevestiging van de mobiel te wachten. + +**Auth:** `s4m_pair`-cookie. Werkt vanuit `EventSource` met `withCredentials: true`. +**Path:** `pairingId` is niet vertrouwelijk; cookie is het bewijs. +**Stream-duur:** maximaal 240s (Vercel-buffer onder de 300s `maxDuration`); sluit +zodra status `consumed` of `cancelled` doorkomt. + +**Events:** +- `event: state` — eenmalig direct na connect, met `{ pairing_id, status }` (status van pairing op moment van connecten — voorkomt race wanneer approve net vóór SSE-open landt). +- `data: {...}` — bij elke status-overgang. Payload: + ```json + { "op": "I" | "U", "pairing_id": "cmoh...", "status": "pending" | "approved" | "consumed" | "cancelled" } + ``` +- `: heartbeat` — SSE-comment elke 25s. + +**Foutcodes:** `401` zonder/foute cookie, `404` als pairing onbekend, `410` als pairing verlopen. + +**Voorbeeld:** +```bash +curl -N -i -b /tmp/jar http://localhost:3000/api/auth/pair/stream/ +``` + +--- + +### `POST /api/auth/pair/claim` + +Cookie-auth. Atomisch consume van een approved pairing → schrijft de echte +`scrum4me-session` cookie zodat de desktop is ingelogd. + +**Auth:** `s4m_pair`-cookie. +**Body:** `{ "pairingId": "cmoh..." }`. + +**Response 200:** `{ "ok": true }` plus +- `Set-Cookie: scrum4me-session=...; HttpOnly; SameSite=Lax` — paired-sessie met `paired: true` en `pairedExpiresAt = now + 8h` payload-velden. +- `Set-Cookie: s4m_pair=...; Max-Age=0` — pre-auth cookie wordt gewist. + +**Foutcodes:** +- `400` bij ontbrekende of malformed body +- `401` zonder cookie of bij hash-mismatch (cookie matcht geen pairing) +- `410` als pairing al consumed/cancelled is (replay) of verlopen + +**Voorbeeld:** +```bash +curl -i -X POST -b /tmp/jar -c /tmp/jar \ + -H "Content-Type: application/json" \ + -d '{"pairingId":""}' \ + http://localhost:3000/api/auth/pair/claim +``` + +--- + ## Voorbeeldworkflow voor Claude Code 1. **Probe:** `GET /api/health?db=1` — bevestig dat de service en DB bereikbaar zijn. diff --git a/docs/patterns/qr-login.md b/docs/patterns/qr-login.md new file mode 100644 index 0000000..183c749 --- /dev/null +++ b/docs/patterns/qr-login.md @@ -0,0 +1,95 @@ +# Patroon: QR-pairing via unauth-SSE + pre-auth cookie + +Het M10 QR-login-mechanisme is herbruikbaar voor elke feature die **realtime- +feedback wil tussen twee browsers/devices vóórdat de eindgebruiker is +geauthenticeerd**. De typische vorm: + +> "Apparaat A start een proces, krijgt een token. Apparaat B (bekend kanaal) +> bevestigt iets. Apparaat A wil dat realtime weten en daarna iets claimen." + +Voorbeelden waar dit zou kunnen passen: device-pairing voor 2FA-setup, login- +op-TV via QR, "claim deze export"-flow, account-overdracht tussen sessies. + +--- + +## Drie eindpunten + +| Endpoint | Auth | Doel | +|---|---|---| +| `POST /api/.../start` | anon | maakt resource aan, retourneert mobile-secret in body, zet HttpOnly device-token cookie | +| `GET /api/.../stream/[id]` | cookie | SSE die op LISTEN/NOTIFY wacht op statusverandering | +| `POST /api/.../claim` | cookie | atomic state-transitie van "approved" → "consumed", wisselt cookie in voor échte sessie | + +Plus een server-action-laag die door het tweede device wordt aangeroepen na +het scannen / klikken van een link met fragment-secret. + +--- + +## Vier security-uitgangspunten + +1. **Twee gescheiden geheimen** — een voor het kanaal richting het tweede + device (in QR-fragment), een voor het oorspronkelijke device (in HttpOnly + cookie). Beide alleen als sha256-hash in DB. +2. **Geen secret in URL.** Path en querystring lekken naar access logs, + reverse proxies, observability. Geheimen reizen alleen via: + - URL-fragment (`#…`) — browsers sturen die niet naar de server + - HttpOnly cookies — meestal niet gelogd, en alleen leesbaar door server + - POST-body — niet gelogd standaard +3. **Atomic consume.** Het claim-endpoint doet één UPDATE met een composite + WHERE op alle invarianten (status, hash, expiry). PostgreSQL row-locking + garandeert dat concurrent dubbele claims slechts één caller succes geven. +4. **Path-scoped cookie.** `Path=/api/.../...` zorgt dat de pre-auth cookie + alleen naar pairing-routes gaat — niet naar de rest van de app. + +--- + +## Sjabloon-bestanden + +Ga voor M10 specifiek? Kopieer en pas aan: + +- `lib/auth/pairing.ts` — secret/token generators + sha256 + timing-safe verify + expiry helper +- `lib/auth/pair-cookie.ts` — set/read/clear van Path-scoped HttpOnly cookie +- `app/api/auth/pair/start/route.ts` — anon POST, rate-limited, sets cookie +- `app/api/auth/pair/stream/[id]/route.ts` — SSE met cookie-auth, LISTEN op eigen channel +- `app/api/auth/pair/claim/route.ts` — atomic update + iron-session schrijven +- `actions/pairing.ts` — Server Actions voor het tweede device +- `app/(app)/m/pair/pair-confirmation.tsx` — Client island die `location.hash` parseert + +Voor het tweede device zit de auth meestal al in de bestaande `(app)`-layout +guard. De Client Component gebruikt `window.location.hash` (niet `useSearchParams`) +om het secret op te pikken. + +--- + +## TTL-richtlijn + +Drie tijden in escalerende volgorde, alle korter dan de reguliere sessie: + +- **Pending (cookie + DB-rij)** — *kort genoeg dat een verloren cookie/QR + weinig schade aanricht*. M10: 5 minuten. +- **Approved (na bevestiging)** — *kort genoeg dat een approved-maar-niet- + geclaimde pairing niet eindeloos open blijft*. M10: 5 minuten extra. +- **Resulterende sessie** — *kort genoeg voor publieke apparaten, lang genoeg + voor een werkdag*. M10: 8 uur, plus `paired: true`-vlag voor toekomstige + remote-revoke. + +--- + +## Wanneer dit patroon NIET gebruiken + +- Wanneer beide kanten al ingelogd zijn — dan is een normaal API-call met + bestaande sessie eenvoudiger. +- Wanneer realtime niet kritiek is — een korte poll (`setInterval` op een + status-endpoint) is simpeler dan een SSE-stream. +- Wanneer er één centraal apparaat is — gebruik dan een normale sessie; de + twee-device-dans is alleen nodig om credentials van het ene apparaat naar + het andere te brengen. + +--- + +## Referenties + +- Volledige flow + threat-model: `docs/scrum4me-architecture.md` § QR-pairing flow +- Endpoint-contract: `docs/API.md` § Auth — QR-pairing +- LISTEN/NOTIFY-pattern: `app/api/realtime/solo/route.ts` (M8 ST-802) — zelfde + ReadableStream + heartbeat + hard-close + abort-cleanup, alleen ander channel diff --git a/docs/scrum4me-architecture.md b/docs/scrum4me-architecture.md index d25b6c8..2d499bc 100644 --- a/docs/scrum4me-architecture.md +++ b/docs/scrum4me-architecture.md @@ -512,6 +512,85 @@ Uitloggen: --- +## QR-pairing flow (M10) + +Password-loze inlog op een (publieke) desktop. Mobiel — al ingelogd — bevestigt +door een QR te scannen die de desktop toont. Geen wachtwoord op het publieke +toetsenbord, geen credentials op de draad, demo-accounts geblokkeerd, paired- +sessie heeft eigen kortere TTL (8 u) + `paired`-vlag. + +### Sequence + +```mermaid +sequenceDiagram + participant D as Desktop (anon) + participant S as Server + participant M as Mobiel (ingelogd) + + D->>S: POST /api/auth/pair/start + S->>S: maak LoginPairing { secret_hash, desktop_token_hash, status=pending, expires=+5min } + S-->>D: 200 { pairingId, mobileSecret, qrUrl }
Set-Cookie: s4m_pair=desktopToken + D->>D: render QR met qrUrl (#id=…&s=mobileSecret) + D->>S: GET /api/auth/pair/stream/[pairingId]
Cookie: s4m_pair + S->>S: LISTEN scrum4me_pairing + S-->>D: event: state { status: 'pending' } + + Note over M: Gebruiker scant QR + M->>M: location.hash → mobileSecret + M->>S: getPairingForApproval(pairingId, mobileSecret) + S-->>M: { desktop_ua, desktop_ip, username } + M->>M: toont bevestigingskaart + Note over M: Tap "Bevestig" + M->>S: approvePairing(pairingId, mobileSecret) + S->>S: status pending→approved, expires +5min
pg_notify scrum4me_pairing + S-->>D: data { status: 'approved' } + + D->>S: POST /api/auth/pair/claim
Cookie: s4m_pair, body: { pairingId } + S->>S: atomic UPDATE WHERE status=approved AND token-hash
→ status=consumed + S->>S: getIronSession.save { userId, paired: true, pairedExpiresAt } + S-->>D: 200, Set-Cookie: scrum4me-session
+ s4m_pair cleared + D->>D: redirect /dashboard +``` + +### Threat-model + +| Aanval | Mitigatie | +|---|---| +| **Replay** van een geconsumeerde pairing | Atomic `updateMany WHERE status='approved'` — concurrent dubbele claim ziet count=0 → 410 | +| **Phishing-QR** ingesloten op een vreemde site | Mobiele bevestigingspagina toont desktop-UA + IP; gebruiker moet expliciet tappen; waarschuwing onder de kaart | +| **Demo-account misbruik** | `approvePairing` early-return op `session.isDemo` — pairing blijft `pending` | +| **Brute-force** van pairings | Rate-limit 10 starts per IP per minuut; `pairingId` is CUID (lange entropy) | +| **Secret-leak via DB-dump** | DB bevat alleen sha256-hashes; plaintext geheimen verlaten desktop alleen via QR-fragment + POST-body (mobile) of HttpOnly cookie (desktop) | +| **Long-lived sessie op publieke desktop** | Paired-sessie krijgt 8u TTL i.p.v. reguliere; `paired: true` markeert 'm voor toekomstige remote-revoke | + +### TTL-rationale + +- **Pending: 5 min.** Genoeg voor menselijke handeling (telefoon pakken, scannen, bevestigen) — kort genoeg dat een verloren QR een klein attack-window heeft. +- **Approved (na bump): nogmaals 5 min.** Klant claim moet binnen redelijke tijd plaatsvinden; voorkomt dat een approved-maar-onclaimed pairing eindeloos open blijft. +- **Paired-sessie: 8 uur.** Korter dan de reguliere wachtwoord-sessie omdat de use-case publieke apparaten zijn waar je niet wil dat de sessie 's nachts blijft hangen. + +### Waarom geen secret in URL + +Servers loggen URL-paden en querystrings standaard — `nginx`, Vercel access +logs, observability-stacks (Sentry, Datadog), reverse proxies, CDN's. Een +geheim in `?s=…` belandt onbedoeld in al die logs. Twee technieken voorkomen dit: + +1. **URL-fragment voor `mobileSecret`.** Het deel achter de `#` wordt door + browsers nooit naar de server gestuurd in HTTP-requests. De mobiele Client + Component leest `window.location.hash` en POST't de waarde in een body — + ook niet in een URL. +2. **HttpOnly cookie voor `desktopToken`.** Cookie-headers worden meestal NIET + in toegangslogs gelogd (in tegenstelling tot URLs). De cookie is bovendien + `Path=/api/auth/pair`-scoped, dus verlaat die route nooit. + +Twee gescheiden hashes (`secret_hash` voor mobiel-bewijs, `desktop_token_hash` +voor desktop-bewijs) zorgen dat een ene server-side compromis niet automatisch +de andere kant compromitteert. + +Dit patroon is herbruikbaar — zie `docs/patterns/qr-login.md`. + +--- + ## Projectstructuur ``` diff --git a/lib/auth/pair-cookie.ts b/lib/auth/pair-cookie.ts new file mode 100644 index 0000000..e977ee7 --- /dev/null +++ b/lib/auth/pair-cookie.ts @@ -0,0 +1,33 @@ +// ST-1002: HttpOnly pre-auth cookie voor de QR-pairing desktop-side. +// +// Wordt gezet door /api/auth/pair/start (ST-1003), gelezen door +// /api/auth/pair/stream/[id] (ST-1004) en /api/auth/pair/claim (ST-1006), +// en gewist op claim of cancel. Path-scoped naar /api/auth/pair zodat de +// cookie niet naar andere routes lekt. + +import { cookies } from 'next/headers' + +const COOKIE_NAME = 's4m_pair' +const MAX_AGE_SECONDS = 300 // gelijk aan pending-TTL van LoginPairing (5 min) +const COOKIE_PATH = '/api/auth/pair' + +export async function setPairCookie(desktopToken: string): Promise { + const jar = await cookies() + jar.set(COOKIE_NAME, desktopToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: COOKIE_PATH, + maxAge: MAX_AGE_SECONDS, + }) +} + +export async function readPairCookie(): Promise { + const jar = await cookies() + return jar.get(COOKIE_NAME)?.value ?? null +} + +export async function clearPairCookie(): Promise { + const jar = await cookies() + jar.delete({ name: COOKIE_NAME, path: COOKIE_PATH }) +} diff --git a/lib/auth/pairing.ts b/lib/auth/pairing.ts new file mode 100644 index 0000000..d860a53 --- /dev/null +++ b/lib/auth/pairing.ts @@ -0,0 +1,42 @@ +// ST-1002: Pure crypto-helpers voor de QR-pairing flow (M10). +// +// Twee gescheiden 256-bit geheimen per pairing: +// mobileSecret — bewijs dat de mobiel komt vanaf het scan-kanaal (QR-fragment → POST-body) +// desktopToken — bewijs dat de desktop is wie de pairing startte (HttpOnly cookie) +// +// In de DB staan alleen sha256-hashes van beide; de plaintext-waarden verlaten +// alleen de desktop-JS (mobileSecret via QR-fragment, desktopToken via Set-Cookie) +// en blijven nooit in URL-paden of access-logs. + +import { createHash, randomBytes, timingSafeEqual } from 'crypto' + +const SECRET_BYTES = 32 + +export function generateMobileSecret(): string { + return randomBytes(SECRET_BYTES).toString('base64url') +} + +export function generateDesktopToken(): string { + return randomBytes(SECRET_BYTES).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) +} + +// Geëxtraheerd zodat de Server Component (app/(app)/layout.tsx) Date.now() niet +// rechtstreeks in render aanroept — de React Compiler markeert dat als impure. +export function isPairedSessionExpired(session: { + paired?: boolean + pairedExpiresAt?: number +}): boolean { + if (!session.paired || !session.pairedExpiresAt) return false + return session.pairedExpiresAt < Date.now() +} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index a4bcc4d..ba2b052 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -8,8 +8,9 @@ interface RateLimitConfig { } const CONFIGS: Record = { - login: { windowMs: 60_000, max: 10 }, // 10 attempts per minute + login: { windowMs: 60_000, max: 10 }, // 10 attempts per minute register: { windowMs: 3_600_000, max: 5 }, // 5 attempts per hour + 'pair-start': { windowMs: 60_000, max: 10 }, // 10 QR-pairings per minute (M10) } const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 } diff --git a/lib/session.ts b/lib/session.ts index 41da259..bf1f9a9 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -3,6 +3,10 @@ import { SessionOptions } from 'iron-session' export interface SessionData { userId: string isDemo: boolean + // ST-1002 (M10) — gezet door /api/auth/pair/claim na een succesvolle QR-pairing. + // Beide velden zijn optioneel zodat bestaande wachtwoord-sessies onveranderd blijven. + paired?: boolean + pairedExpiresAt?: number // unix ms } export const sessionOptions: SessionOptions = { diff --git a/package-lock.json b/package-lock.json index bb7cc0c..ccbe18b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scrum4me", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrum4me", - "version": "0.3.1", + "version": "0.4.0", "hasInstallScript": true, "dependencies": { "@base-ui/react": "^1.4.1", @@ -27,6 +27,7 @@ "next-themes": "^0.4.6", "pg": "^8.20.0", "prisma": "^7.8.0", + "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.4.0", @@ -13763,6 +13764,15 @@ ], "license": "MIT" }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", diff --git a/package.json b/package.json index 06ba04b..a8f39df 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "postinstall": "prisma generate --generator client", "db:erd": "prisma generate", "db:erd:watch": "chokidar \"prisma/schema.prisma\" -c \"npm run db:erd\"", - "db:insert-milestone": "tsx scripts/insert-milestone.ts" + "db:insert-milestone": "tsx scripts/insert-milestone.ts", + "seed": "prisma db seed" }, "dependencies": { "@base-ui/react": "^1.4.1", @@ -35,6 +36,7 @@ "next-themes": "^0.4.6", "pg": "^8.20.0", "prisma": "^7.8.0", + "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.4.0", diff --git a/prisma/migrations/20260427200734_add_login_pairing/migration.sql b/prisma/migrations/20260427200734_add_login_pairing/migration.sql new file mode 100644 index 0000000..5af26fe --- /dev/null +++ b/prisma/migrations/20260427200734_add_login_pairing/migration.sql @@ -0,0 +1,67 @@ +-- CreateTable +CREATE TABLE "login_pairings" ( + "id" TEXT NOT NULL, + "secret_hash" TEXT NOT NULL, + "desktop_token_hash" TEXT NOT NULL, + "status" TEXT NOT NULL, + "user_id" TEXT, + "desktop_ua" VARCHAR(255), + "desktop_ip" VARCHAR(45), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "approved_at" TIMESTAMP(3), + "consumed_at" TIMESTAMP(3), + + CONSTRAINT "login_pairings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "login_pairings_expires_at_idx" ON "login_pairings"("expires_at"); + +-- CreateIndex +CREATE INDEX "login_pairings_status_expires_at_idx" ON "login_pairings"("status", "expires_at"); + +-- AddForeignKey +ALTER TABLE "login_pairings" ADD CONSTRAINT "login_pairings_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- ST-1001: Postgres LISTEN/NOTIFY voor QR-pairing flow. +-- +-- AFTER INSERT/UPDATE-trigger op login_pairings emit een JSON-payload op het +-- `scrum4me_pairing`-kanaal. De SSE-route /api/auth/pair/stream/[pairingId] +-- (ST-1004) abonneert op dit kanaal en filtert per pairing_id. +-- +-- DELETE wordt niet ondersteund — pairings gaan naar status='consumed' of +-- 'cancelled', niet weg. Een eventuele cleanup-job die rijen wel deleten zou +-- kan dat zonder dit kanaal te bereiken. +-- +-- Payload shape: +-- { op: 'I' | 'U', +-- pairing_id: text, +-- status: text } +-- +-- Channel-name is hardcoded analoog aan `scrum4me_changes` uit ST-801. Bij +-- wijziging deze migratie én app/api/auth/pair/stream/[pairingId]/route.ts +-- bijwerken. + +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' + END, + 'pairing_id', NEW.id, + 'status', NEW.status + ); + + PERFORM pg_notify('scrum4me_pairing', payload::text); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS login_pairings_notify ON login_pairings; +CREATE TRIGGER login_pairings_notify + AFTER INSERT OR UPDATE ON login_pairings + FOR EACH ROW EXECUTE FUNCTION notify_pairing_change(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e88b31..920907b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,6 +65,7 @@ model User { todos Todo[] product_members ProductMember[] assigned_stories Story[] @relation("StoryAssignee") + login_pairings LoginPairing[] @@index([active_product_id]) @@map("users") @@ -247,3 +248,22 @@ model Todo { @@index([user_id, product_id]) @@map("todos") } + +model LoginPairing { + id String @id @default(cuid()) + secret_hash String + desktop_token_hash String + status String + 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) + created_at DateTime @default(now()) + expires_at DateTime + approved_at DateTime? + consumed_at DateTime? + + @@index([expires_at]) + @@index([status, expires_at]) + @@map("login_pairings") +} diff --git a/prisma/seed-data/parse-backlog.ts b/prisma/seed-data/parse-backlog.ts index 11d26a9..6ec03d3 100644 --- a/prisma/seed-data/parse-backlog.ts +++ b/prisma/seed-data/parse-backlog.ts @@ -68,14 +68,14 @@ const MILESTONE_SPRINT_STATUS: Record M1: 'COMPLETED', M2: 'COMPLETED', M3: 'COMPLETED', - 'M3.5': 'ACTIVE', + 'M3.5': 'COMPLETED', M4: 'COMPLETED', M5: 'COMPLETED', M6: 'COMPLETED', M7: 'COMPLETED', M8: 'COMPLETED', M9: 'COMPLETED', - M10: 'COMPLETED', + M10: 'ACTIVE', } const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/ diff --git a/prisma/seed.ts b/prisma/seed.ts index ed2b58d..53b645a 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -189,93 +189,6 @@ async function main() { } } - - // Solo board demo data — claimed stories for demo user + 1 unassigned for the sheet - const activeSprint = await prisma.sprint.findFirst({ - where: { product_id: product.id, status: 'ACTIVE' }, - }) - - if (activeSprint) { - const soloPbi = await prisma.pbi.create({ - data: { - product_id: product.id, - title: 'Solo Demo', - description: 'Voorbeeldtaken voor het Solo bord.', - priority: 3, - sort_order: 99, - }, - }) - - const soloData = [ - { - title: 'Gebruikersauthenticatie opzetten', - tasks: [ - { title: 'JWT middleware schrijven', status: 'TO_DO' as const, priority: 1 }, - { title: 'Login endpoint testen', status: 'TO_DO' as const, priority: 2 }, - ], - assignee_id: demo.id, - sortOrder: 1, - }, - { - title: 'REST API endpoints implementeren', - tasks: [ - { title: 'Route handlers aanmaken', status: 'IN_PROGRESS' as const, priority: 2 }, - { title: 'Zod-validatie toevoegen', status: 'TO_DO' as const, priority: 3 }, - ], - assignee_id: demo.id, - sortOrder: 2, - }, - { - title: 'Database schema migreren', - tasks: [ - { title: 'Prisma schema bijwerken', status: 'DONE' as const, priority: 2 }, - { title: 'Migratietest uitvoeren', status: 'DONE' as const, priority: 3 }, - ], - assignee_id: demo.id, - sortOrder: 3, - }, - { - title: 'Frontend unit tests schrijven', - tasks: [ - { title: 'Vitest opzetten', status: 'TO_DO' as const, priority: 3 }, - ], - assignee_id: null, - sortOrder: 4, - }, - ] - - for (const s of soloData) { - const story = await prisma.story.create({ - data: { - pbi_id: soloPbi.id, - product_id: product.id, - sprint_id: activeSprint.id, - title: s.title, - priority: 2, - sort_order: 90 + s.sortOrder, - status: 'IN_SPRINT', - assignee_id: s.assignee_id, - }, - }) - - for (let i = 0; i < s.tasks.length; i++) { - const t = s.tasks[i] - await prisma.task.create({ - data: { - story_id: story.id, - sprint_id: activeSprint.id, - title: t.title, - priority: t.priority, - sort_order: i + 1.0, - status: t.status, - }, - }) - } - } - - console.log(' Solo demo stories created (3 claimed, 1 unassigned)') - } - console.log('\nSeeding complete!') console.log('Demo user: username=demo password=demo1234') console.log('Main user: username=lars password=scrum4me123')