From e0bec8c55ca1b010bccd612907b97fe5c4cb8a4f Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 27 Apr 2026 22:34:49 +0200 Subject: [PATCH] feat(ST-1003): add /api/auth/pair/start with rate-limit + pre-auth cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/auth/pair/start (anon, runtime: 'nodejs'): - Geen authenticateApiRequest — desktop heeft nog geen sessie - Genereert los mobileSecret + desktopToken via lib/auth/pairing - Persisteert alleen sha256-hashes in login_pairings; status='pending', expires_at = now + 2 min - Slaat user-agent + best-effort IP op (afgekapt op kolom-grootte) - Set-Cookie via setPairCookie helper: HttpOnly, Path=/api/auth/pair, Max-Age=120, SameSite=Lax - Response body: { pairingId, mobileSecret, expiresAt, qrUrl } met qrUrl = origin/m/pair#id=…&s=… → secret reist alleen via fragment (#…), nooit in querystring of access logs Rate-limit: 'pair-start' expliciet aan lib/rate-limit.ts CONFIGS toegevoegd voor self-documentatie (10/min, gelijk aan login). Tests __tests__/api/pair-start.test.ts (6 cases): - 200 met body-shape (pairingId, mobileSecret 43-char base64url, qrUrl met fragment, expiresAt ISO) - alleen hashes in DB, geen plaintext - cookie set met juiste opties - UA + IP afgekapt op kolom-grootte - IP=null als x-forwarded-for ontbreekt - 11e POST levert 429 met NL foutmelding Quality gates: lint 0 errors, tsc clean (na prisma generate), vitest 117/117. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/api/pair-start.test.ts | 110 +++++++++++++++++++++++++++++++ app/api/auth/pair/start/route.ts | 74 +++++++++++++++++++++ lib/rate-limit.ts | 3 +- 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 __tests__/api/pair-start.test.ts create mode 100644 app/api/auth/pair/start/route.ts diff --git a/__tests__/api/pair-start.test.ts b/__tests__/api/pair-start.test.ts new file mode 100644 index 0000000..c29281a --- /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 ~120s in toekomst + const dt = new Date(arg.expires_at).getTime() - Date.now() + expect(dt).toBeGreaterThan(115_000) + expect(dt).toBeLessThan(125_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: 120, + }) + }) + + 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/app/api/auth/pair/start/route.ts b/app/api/auth/pair/start/route.ts new file mode 100644 index 0000000..409714d --- /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 = 2 * 60 * 1000 // 2 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/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 }