feat(ST-1003): add /api/auth/pair/start with rate-limit + pre-auth cookie
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) <noreply@anthropic.com>
This commit is contained in:
parent
b4813e6e54
commit
e0bec8c55c
3 changed files with 186 additions and 1 deletions
110
__tests__/api/pair-start.test.ts
Normal file
110
__tests__/api/pair-start.test.ts
Normal file
|
|
@ -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<typeof vi.fn> }
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
74
app/api/auth/pair/start/route.ts
Normal file
74
app/api/auth/pair/start/route.ts
Normal file
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -8,8 +8,9 @@ interface RateLimitConfig {
|
|||
}
|
||||
|
||||
const CONFIGS: Record<string, RateLimitConfig> = {
|
||||
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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue