import { cookies } from 'next/headers'; import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server'; import type { RegistrationResponseJSON, AuthenticationResponseJSON, AuthenticatorTransportFuture, } from '@simplewebauthn/server'; import { isKnownUser } from './auth'; import { listPasskeys, getPasskey, insertPasskey, updatePasskeyUsage } from './passkeys'; const REG_CHALLENGE_COOKIE = 'werte_pk_reg'; const AUTH_CHALLENGE_COOKIE = 'werte_pk_auth'; const CHALLENGE_TTL_S = 300; // 5 Minuten /** * Relying-Party-Konfiguration. In Produktion über Env-Vars gesetzt * (RP_ID = Host, RP_ORIGIN = volle URL). Default-Werte für lokale Entwicklung. */ export function getRpConfig() { const rpID = process.env.RP_ID || 'localhost'; const rpName = process.env.RP_NAME || 'Werte-Log'; const origin = process.env.RP_ORIGIN || 'http://localhost:3000'; return { rpID, rpName, origin }; } // --- Challenge-Cookie (kurzlebig, httpOnly) ------------------------------ async function setChallengeCookie(name: string, challenge: string): Promise { const cookieStore = await cookies(); cookieStore.set(name, challenge, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: CHALLENGE_TTL_S, }); } async function takeChallengeCookie(name: string): Promise { const cookieStore = await cookies(); const value = cookieStore.get(name)?.value ?? null; cookieStore.delete(name); return value; } // --- Registrierung (Enrollment, Session erforderlich) -------------------- export async function buildRegistrationOptions(username: string) { const { rpID, rpName } = getRpConfig(); const existing = await listPasskeys(username); const options = await generateRegistrationOptions({ rpName, rpID, userName: username, userDisplayName: username, userID: new TextEncoder().encode(username), attestationType: 'none', excludeCredentials: existing.map((pk) => ({ id: pk.credentialId, transports: pk.transports as AuthenticatorTransportFuture[], })), authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', }, }); await setChallengeCookie(REG_CHALLENGE_COOKIE, options.challenge); return options; } /** Verifiziert die Registrierungs-Antwort und speichert den Passkey für den Benutzer. */ export async function finishRegistration( username: string, response: RegistrationResponseJSON, label: string ): Promise<{ ok: true } | { ok: false; error: string }> { const expectedChallenge = await takeChallengeCookie(REG_CHALLENGE_COOKIE); if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' }; const { rpID, origin } = getRpConfig(); let verification; try { verification = await verifyRegistrationResponse({ response, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false, }); } catch { return { ok: false, error: 'Passkey konnte nicht verifiziert werden.' }; } if (!verification.verified || !verification.registrationInfo) { return { ok: false, error: 'Passkey-Registrierung fehlgeschlagen.' }; } const { credential } = verification.registrationInfo; if (await getPasskey(credential.id)) { return { ok: false, error: 'Dieser Passkey ist bereits registriert.' }; } await insertPasskey({ credentialId: credential.id, username, publicKey: credential.publicKey, counter: credential.counter, transports: credential.transports ?? [], label: label.trim().slice(0, 80) || 'Passkey', }); return { ok: true }; } // --- Anmeldung (öffentlich, keine Session) ------------------------------- export async function buildAuthenticationOptions() { const { rpID } = getRpConfig(); const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred', // allowCredentials leer lassen → erlaubt Discoverable Credentials / Browser-Auswahl. }); await setChallengeCookie(AUTH_CHALLENGE_COOKIE, options.challenge); return options; } /** * Verifiziert die Anmelde-Antwort gegen einen gespeicherten Passkey und liefert * den zugehörigen Benutzer zurück. */ export async function finishAuthentication( response: AuthenticationResponseJSON ): Promise<{ ok: true; username: string } | { ok: false; error: string }> { const expectedChallenge = await takeChallengeCookie(AUTH_CHALLENGE_COOKIE); if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' }; const passkey = await getPasskey(response.id); if (!passkey) return { ok: false, error: 'Unbekannter Passkey.' }; // Benutzer könnte inzwischen aus AUTH_USERS entfernt worden sein. if (!isKnownUser(passkey.username)) { return { ok: false, error: 'Benutzer existiert nicht mehr.' }; } const { rpID, origin } = getRpConfig(); let verification; try { verification = await verifyAuthenticationResponse({ response, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false, credential: { id: passkey.credentialId, publicKey: passkey.publicKey, counter: passkey.counter, transports: passkey.transports as AuthenticatorTransportFuture[], }, }); } catch { return { ok: false, error: 'Anmeldung mit Passkey fehlgeschlagen.' }; } if (!verification.verified) return { ok: false, error: 'Anmeldung mit Passkey fehlgeschlagen.' }; await updatePasskeyUsage(passkey.credentialId, verification.authenticationInfo.newCounter); return { ok: true, username: passkey.username }; }