"use server"; import { cookies } from "next/headers"; import { revalidatePath } from "next/cache"; import { requireSession } from "@/server/auth"; import { prisma } from "@/server/db"; import { writeAuditLog } from "@/server/audit"; import { buildRegistrationOptions, buildAuthenticationOptions, verifyReg, toB64Url, LOGIN_CHALLENGE_COOKIE } from "@/server/webauthn"; import type { RegistrationResponseJSON } from "@simplewebauthn/types"; /** * SEC3-b: Passkey-Login vorbereiten (öffentlich, pre-session). Erzeugt Authentifizierungs- * Optionen für discoverable Credentials (leere allowCredentials → der Browser wählt einen * resident Passkey) und legt die Challenge in einem kurzlebigen httpOnly-Cookie ab. */ export async function beginPasskeyLogin() { const options = await buildAuthenticationOptions([]); const jar = await cookies(); jar.set(LOGIN_CHALLENGE_COOKIE, options.challenge, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 120, }); return options; } /** * SEC3-b: Passkey-Registrierung & -Verwaltung des angemeldeten Mandanten-Nutzers * (EXEMPT vom Modul-Gating — eigene Auth über requireSession). Die Challenge wird * zwischen Options- und Verify-Schritt in einem kurzlebigen httpOnly-Cookie gehalten. */ const REG_COOKIE = "wa_reg_challenge"; export async function beginPasskeyRegistration() { const session = await requireSession(); // WS4b: Passkeys gehören der GLOBALEN Identity (stabile userID über Mandanten hinweg). const identityId = session.user.identityId; if (!identityId) throw new Error("Kein Identity-Kontext in der Session."); const creds = await prisma.webAuthnCredential.findMany({ where: { identityId }, select: { credentialId: true, transports: true } }); const options = await buildRegistrationOptions({ userId: identityId, userName: session.user.email ?? identityId, userDisplayName: session.user.name ?? session.user.email ?? "Nutzer", existing: creds, }); const jar = await cookies(); jar.set(REG_COOKIE, options.challenge, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 300, }); return options; } export async function finishPasskeyRegistration(response: RegistrationResponseJSON, deviceName: string) { const session = await requireSession(); const jar = await cookies(); const expectedChallenge = jar.get(REG_COOKIE)?.value; if (!expectedChallenge) throw new Error("Registrierung abgelaufen — bitte erneut starten."); const verification = await verifyReg(response, expectedChallenge); if (!verification.verified || !verification.registrationInfo) throw new Error("Passkey konnte nicht verifiziert werden."); const { credentialID, credentialPublicKey, counter } = verification.registrationInfo; const identityId = session.user.identityId; if (!identityId) throw new Error("Kein Identity-Kontext in der Session."); await prisma.webAuthnCredential.create({ data: { identityId, credentialId: toB64Url(credentialID), publicKey: toB64Url(credentialPublicKey), counter: BigInt(counter), transports: response.response.transports ?? [], deviceName: deviceName.trim().slice(0, 60) || null, }, }); jar.delete(REG_COOKIE); await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "webauthn_credential", entityId: session.user.id, after: { deviceName: deviceName.trim() || null } }); revalidatePath("/account"); } export async function removePasskey(credentialDbId: string) { const session = await requireSession(); const identityId = session.user.identityId; if (!identityId) throw new Error("Kein Identity-Kontext in der Session."); const cred = await prisma.webAuthnCredential.findFirst({ where: { id: credentialDbId, identityId }, select: { id: true } }); if (!cred) throw new Error("Passkey nicht gefunden."); await prisma.webAuthnCredential.delete({ where: { id: cred.id } }); await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "webauthn_credential", entityId: cred.id }); revalidatePath("/account"); }