lib/auth/pairing.ts: pure crypto-helpers voor de QR-pairing flow. - generateMobileSecret() / generateDesktopToken() — beide 32 bytes base64url, los zodat ze elkaar niet onthullen - hashToken(t) — sha256-hex - verifyToken(t, hash) — timingSafeEqual met length-guard - isPairedSessionExpired(session) — geëxtraheerde helper zodat de Server- Component-render Date.now() niet rechtstreeks aanroept (React Compiler-flag) lib/auth/pair-cookie.ts: HttpOnly pre-auth cookie helpers (s4m_pair). - Path=/api/auth/pair, Max-Age=120s (gelijk aan pending-TTL pairing), SameSite=Lax, Secure in productie lib/session.ts: SessionData uitgebreid met optionele paired + pairedExpiresAt. app/(app)/layout.tsx: guard die paired-sessies vernietigt zodra pairedExpiresAt verstreken is en redirect naar /login. Tests: 14 unit-tests in __tests__/lib/auth/pairing.test.ts dekken hash- determinisme, timing-safe verify (true/false/length-mismatch), generator- uniciteit en vier expiry-scenario's voor isPairedSessionExpired. Quality gates: npm run lint (0 errors), tsc --noEmit clean, vitest 111/111. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
88 lines
2.8 KiB
TypeScript
88 lines
2.8 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|