From 5c4ee150eaa9ab14a943fcde2bd6276fa6febbc2 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 27 Apr 2026 22:58:17 +0200 Subject: [PATCH] feat(ST-1006): add /api/auth/pair/claim with atomic consume + iron-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/auth/pair/claim (cookie-auth, runtime: 'nodejs'): - Auth via s4m_pair HttpOnly cookie alleen — body bevat enkel pairingId, geen secret. Het cookie-token is het bewijs. - Atomic state-transitie via prisma.loginPairing.updateMany met composite WHERE (id + status='approved' + desktop_token_hash + expires_at > now); PostgreSQL row-locking garandeert dat concurrent dubbele claims slechts één count=1 zien — de rest 410. - Bij geen rij geüpdate: tweede findFirst om te disambigueren tussen 401 (cookie matcht geen pairing) en 410 (al consumed/cancelled). Cookie altijd gecleared bij faalpaden om herhaalde verwerking te voorkomen. - Bij succes: getIronSession schrijft scrum4me-session-cookie met userId + isDemo (uit user-record als vangnet) + paired=true + pairedExpiresAt = now+8h (kortere TTL voor publieke desktops). s4m_pair wordt gecleared. - Logging onder NODE_ENV !== 'production' alleen pairingId, nooit cookie of mobileSecret. Tests __tests__/api/pair-claim.test.ts (7 cases): - 200 happy: updateMany met juiste WHERE, iron-session payload (userId, isDemo, paired, pairedExpiresAt ~8h), save() called, s4m_pair cleared - demo-vangnet: isDemo=true wordt doorgezet - 401 zonder cookie (geen DB-call) - 400 op malformed body - 400 zonder pairingId - 410 op tweede claim (al consumed, cookie cleared, geen session.save) - 401 op cookie/hash-mismatch (cookie cleared) Quality gates: lint 0 errors, tsc clean, vitest 139/139. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/api/pair-claim.test.ts | 158 +++++++++++++++++++++++++++++++ app/api/auth/pair/claim/route.ts | 97 +++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 __tests__/api/pair-claim.test.ts create mode 100644 app/api/auth/pair/claim/route.ts 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/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 }) +}