diff --git a/__tests__/lib/auth/pairing.test.ts b/__tests__/lib/auth/pairing.test.ts new file mode 100644 index 0000000..0375df4 --- /dev/null +++ b/__tests__/lib/auth/pairing.test.ts @@ -0,0 +1,88 @@ +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) + }) + }) +}) diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 3366094..1a5b3b9 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -2,6 +2,7 @@ import { redirect } from 'next/navigation' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' import { SessionData, sessionOptions } from '@/lib/session' +import { isPairedSessionExpired } from '@/lib/auth/pairing' import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' import { NavBar } from '@/components/shared/nav-bar' @@ -18,6 +19,13 @@ export default async function AppLayout({ children }: { children: React.ReactNod redirect('/login') } + // ST-1002 (M10): paired-sessies (via QR-pairing) hebben een eigen kortere TTL. + // Vervallen → vernietig en stuur naar /login. + if (isPairedSessionExpired(session)) { + session.destroy() + redirect('/login') + } + const [user, userRoles, accessibleProducts] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, diff --git a/lib/auth/pair-cookie.ts b/lib/auth/pair-cookie.ts new file mode 100644 index 0000000..a782f51 --- /dev/null +++ b/lib/auth/pair-cookie.ts @@ -0,0 +1,33 @@ +// ST-1002: HttpOnly pre-auth cookie voor de QR-pairing desktop-side. +// +// Wordt gezet door /api/auth/pair/start (ST-1003), gelezen door +// /api/auth/pair/stream/[id] (ST-1004) en /api/auth/pair/claim (ST-1006), +// en gewist op claim of cancel. Path-scoped naar /api/auth/pair zodat de +// cookie niet naar andere routes lekt. + +import { cookies } from 'next/headers' + +const COOKIE_NAME = 's4m_pair' +const MAX_AGE_SECONDS = 120 // gelijk aan pending-TTL van LoginPairing +const COOKIE_PATH = '/api/auth/pair' + +export async function setPairCookie(desktopToken: string): Promise { + const jar = await cookies() + jar.set(COOKIE_NAME, desktopToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: COOKIE_PATH, + maxAge: MAX_AGE_SECONDS, + }) +} + +export async function readPairCookie(): Promise { + const jar = await cookies() + return jar.get(COOKIE_NAME)?.value ?? null +} + +export async function clearPairCookie(): Promise { + const jar = await cookies() + jar.delete({ name: COOKIE_NAME, path: COOKIE_PATH }) +} diff --git a/lib/auth/pairing.ts b/lib/auth/pairing.ts new file mode 100644 index 0000000..d860a53 --- /dev/null +++ b/lib/auth/pairing.ts @@ -0,0 +1,42 @@ +// 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() +} diff --git a/lib/session.ts b/lib/session.ts index 41da259..bf1f9a9 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -3,6 +3,10 @@ import { SessionOptions } from 'iron-session' export interface SessionData { userId: string isDemo: boolean + // ST-1002 (M10) — gezet door /api/auth/pair/claim na een succesvolle QR-pairing. + // Beide velden zijn optioneel zodat bestaande wachtwoord-sessies onveranderd blijven. + paired?: boolean + pairedExpiresAt?: number // unix ms } export const sessionOptions: SessionOptions = {