import { createHmac, timingSafeEqual } from 'node:crypto' import { getEnv } from '@/lib/env' /** * Kurzlebiges Zeichen für den Druckabruf. * * Der PDF-Dienst ruft die Druckansicht selbst auf und braucht dafür die Rechte * des Abrufers. Ihm dafür das **Sitzungscookie** mitzugeben wäre der bequeme * Weg und der falsche: ein Cookie ist der volle Zugang zum Konto, und es läge * dann im Speicher eines zweiten Prozesses und in dessen Protokollen. * * Stattdessen ein signiertes Zeichen, das genau eines erlaubt: diese eine * Ansicht, für diesen einen Auftrag, für ein paar Minuten. Es taugt zu nichts * anderem. Vergleichbar mit der signierten Datei-URL aus docs/07. */ /** Lange genug für einen langsamen Rendervorgang, kurz genug zum Wegwerfen. */ const GUELTIG_MS = 10 * 60_000 export type RenderClaim = { jobId: string userId: string clearanceLevel: number expiresAt: number } function signieren(nutzlast: string): string { return createHmac('sha256', getEnv().AUTH_SECRET).update(nutzlast).digest('base64url') } export function createRenderToken(claim: Omit): string { const vollstaendig: RenderClaim = { ...claim, expiresAt: Date.now() + GUELTIG_MS } const nutzlast = Buffer.from(JSON.stringify(vollstaendig)).toString('base64url') return `${nutzlast}.${signieren(nutzlast)}` } /** * Prüft das Zeichen. Gibt `null` zurück, wenn irgendetwas nicht stimmt — der * Aufrufer macht daraus 404, nicht 403: dass es die Druckansicht gibt, geht * niemanden etwas an, der kein gültiges Zeichen hat. */ export function verifyRenderToken(token: string | null | undefined): RenderClaim | null { if (!token) return null const [nutzlast, unterschrift] = token.split('.') if (!nutzlast || !unterschrift) return null const erwartet = signieren(nutzlast) // Zeitkonstanter Vergleich: ein Längenvergleich vorweg, weil timingSafeEqual // bei ungleicher Länge wirft. if (erwartet.length !== unterschrift.length) return null if (!timingSafeEqual(Buffer.from(erwartet), Buffer.from(unterschrift))) return null try { const claim = JSON.parse(Buffer.from(nutzlast, 'base64url').toString('utf8')) as RenderClaim if (typeof claim.expiresAt !== 'number' || claim.expiresAt < Date.now()) return null if (typeof claim.userId !== 'string' || typeof claim.jobId !== 'string') return null return claim } catch { return null } }