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>
This commit is contained in:
parent
075cf28a5e
commit
b4813e6e54
5 changed files with 175 additions and 0 deletions
88
__tests__/lib/auth/pairing.test.ts
Normal file
88
__tests__/lib/auth/pairing.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
33
lib/auth/pair-cookie.ts
Normal file
33
lib/auth/pair-cookie.ts
Normal file
|
|
@ -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<void> {
|
||||
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<string | null> {
|
||||
const jar = await cookies()
|
||||
return jar.get(COOKIE_NAME)?.value ?? null
|
||||
}
|
||||
|
||||
export async function clearPairCookie(): Promise<void> {
|
||||
const jar = await cookies()
|
||||
jar.delete({ name: COOKIE_NAME, path: COOKIE_PATH })
|
||||
}
|
||||
42
lib/auth/pairing.ts
Normal file
42
lib/auth/pairing.ts
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue