Scrum4Me/lib/auth/pairing.ts
Madhura68 b4813e6e54 feat(ST-1002): add pairing helpers, pre-auth cookie + paired-session guard
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>
2026-04-27 22:23:00 +02:00

42 lines
1.5 KiB
TypeScript

// ST-1002: Pure crypto-helpers voor de QR-pairing flow (M10).
//
// Twee gescheiden 256-bit geheimen per pairing:
// mobileSecret — bewijs dat de mobiel komt vanaf het scan-kanaal (QR-fragment → POST-body)
// desktopToken — bewijs dat de desktop is wie de pairing startte (HttpOnly cookie)
//
// In de DB staan alleen sha256-hashes van beide; de plaintext-waarden verlaten
// alleen de desktop-JS (mobileSecret via QR-fragment, desktopToken via Set-Cookie)
// en blijven nooit in URL-paden of access-logs.
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
const SECRET_BYTES = 32
export function generateMobileSecret(): string {
return randomBytes(SECRET_BYTES).toString('base64url')
}
export function generateDesktopToken(): string {
return randomBytes(SECRET_BYTES).toString('base64url')
}
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
export function verifyToken(token: string, hash: string): boolean {
const a = Buffer.from(hashToken(token), 'hex')
const b = Buffer.from(hash, 'hex')
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
// Geëxtraheerd zodat de Server Component (app/(app)/layout.tsx) Date.now() niet
// rechtstreeks in render aanroept — de React Compiler markeert dat als impure.
export function isPairedSessionExpired(session: {
paired?: boolean
pairedExpiresAt?: number
}): boolean {
if (!session.paired || !session.pairedExpiresAt) return false
return session.pairedExpiresAt < Date.now()
}