feat(ST-1006): add /api/auth/pair/claim with atomic consume + iron-session
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) <noreply@anthropic.com>
This commit is contained in:
parent
625221f9ee
commit
5c4ee150ea
2 changed files with 255 additions and 0 deletions
158
__tests__/api/pair-claim.test.ts
Normal file
158
__tests__/api/pair-claim.test.ts
Normal file
|
|
@ -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<typeof vi.fn>
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
97
app/api/auth/pair/claim/route.ts
Normal file
97
app/api/auth/pair/claim/route.ts
Normal file
|
|
@ -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<SessionData>(await cookies(), sessionOptions)
|
||||
session.userId = pairing.user_id
|
||||
session.isDemo = pairing.user?.is_demo ?? false
|
||||
session.paired = true
|
||||
session.pairedExpiresAt = Date.now() + PAIRED_TTL_MS
|
||||
await session.save()
|
||||
|
||||
await clearPairCookie()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`[pair/claim] consumed pairingId=${pairingId}`)
|
||||
}
|
||||
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue