/** * Session-Management (plan.md Abschnitt 11). * * Signierte, HttpOnly-Cookies auf Basis von JWT (jose). Keine eigene * Kryptografie, keine Klartext-Sessions in der Datenbank. */ import { SignJWT, jwtVerify } from 'jose'; import { cookies, headers } from 'next/headers'; import { getConfig } from '@/lib/config'; import { provisionMpmUser } from '@/lib/auth/mpm-provisioning'; import type { AuthenticatedUser } from '@/lib/permissions/permissions'; const SESSION_COOKIE_NAME = 'calendar_session'; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 Tage /** Fehler bei ungueltiger/abgelaufener Session. */ export class UnauthorizedError extends Error { constructor() { super('Nicht angemeldet oder Sitzung abgelaufen.'); this.name = 'UnauthorizedError'; } } async function getSessionKey(): Promise { const secret = getConfig().sessionSecret; return new TextEncoder().encode(secret); } /** Erstellt ein signiertes Session-Token fuer den Benutzer. */ export async function createSessionToken(user: { id: string; email: string; role: string; }): Promise { const key = await getSessionKey(); return new SignJWT({ sub: user.id, email: user.email, role: user.role }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`) .sign(key); } /** Setzt das Session-Cookie sicher (HttpOnly, SameSite=Lax, Secure in Prod). */ export async function setSessionCookie(token: string): Promise { const cookieStore = await cookies(); cookieStore.set(SESSION_COOKIE_NAME, token, { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: SESSION_MAX_AGE_SECONDS, path: '/', }); } /** Loescht das Session-Cookie (Logout). */ export async function clearSessionCookie(): Promise { const cookieStore = await cookies(); cookieStore.delete(SESSION_COOKIE_NAME); } /** * Liest und verifiziert die aktuelle Session. * Wirft UnauthorizedError, wenn keine gueltige Session existiert. */ export async function getAuthenticatedUser(): Promise { if (process.env.MPM_AUTH_MODE === 'true') { const requestHeaders = await headers(); const userId = requestHeaders.get('x-kalendartool-mpm-user-id'); const username = requestHeaders.get('x-kalendartool-mpm-username'); const displayName = requestHeaders.get('x-kalendartool-mpm-display-name'); const role = requestHeaders.get('x-kalendartool-mpm-role'); if (!userId || !username || !role || !['ADMIN', 'USER'].includes(role)) { throw new UnauthorizedError(); } return provisionMpmUser({ userId, username, displayName, role: role as 'ADMIN' | 'USER' }); } const cookieStore = await cookies(); const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; if (!token) { throw new UnauthorizedError(); } try { const { payload } = await jwtVerify(token, await getSessionKey()); if (typeof payload.sub !== 'string' || typeof payload.role !== 'string') { throw new UnauthorizedError(); } // Alte Tokens (vor E-Mail-Erweiterung) enthalten keine E-Mail; // in dem Fall wird ein Platzhalter verwendet, requireDbUser laedt // ohnehin die frischen Daten aus der DB. const email = typeof payload.email === 'string' ? payload.email : 'unbekannt@lokal'; // Username ist nicht im JWT (kann sich aendern); requireDbUser // laedt den aktuellen Wert aus der DB. Das JWT liefert null als // Platzhalter, damit der Typ erfuellt ist. return { id: payload.sub, email, username: null, role: payload.role as AuthenticatedUser['role'], }; } catch { throw new UnauthorizedError(); } } /** Wie getAuthenticatedUser, gibt aber null statt zu werfen. */ export async function tryGetAuthenticatedUser(): Promise { try { return await getAuthenticatedUser(); } catch { return null; } }