import "server-only"; import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { authConfig } from "@/lib/auth/config"; import { asSystem, sql } from "@/lib/db"; // Die vollständige Anmeldung — die Fassung, die die Datenbank kennt. // // Aufgeteilt ist sie, weil proxy.ts nur den Teil aus lib/auth/config.ts lädt. // Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der // Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung // versteht. // // Seit 08.09.2026 steht daneben ein zweiter Weg: Anmeldung mit Passwort, für // die Testphase und mit eingebautem Ende (siehe Migration 20260908120000). // Auch er gehört **hierher** und nicht nach lib/auth/config.ts, denn auch er // spricht mit der Datenbank. Der Proxy sieht ihn nicht und braucht ihn nicht: // er liest nur das Sitzungscookie, und das ist ein signiertes Token, dessen // Entschlüsselung von der Anbieterliste unabhängig ist. Welcher Weg zur // Sitzung geführt hat, steht darin nicht — und muss es auch nicht. /** * Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung, * die überall sonst als `userId` durchgereicht wird. * * Die Datenbankfunktion ist SECURITY DEFINER und darf genau dieses eine: * app_users schreiben. Für den einen Schreibvorgang, für den es noch keinen * Sitzungskontext geben kann, ist das der kleinstmögliche Hebel — früher lag * hier ein Dienstschlüssel, der jede Zeile jeder Tabelle lesen konnte. */ async function upsertAppUser(externalId: string, email: string, fullName: string | null): Promise { const row = await asSystem(async (tx) => { const result = await sql<{ id: string }>` select app_upsert_user(${externalId}, ${email}, ${fullName}) as id `.execute(tx); return result.rows[0]; }); if (!row?.id) throw new Error("app_upsert_user() lieferte keine Kennung."); return row.id; } /** * Anmeldung mit Passwort. * * Die Prüfung selbst steht vollständig in app_passwort_pruefen(): Sperre nach * fünf Fehlversuchen, Frist für das Initialpasswort, Enddatum des * Passwortpfads, Protokolleintrag. Hier bleibt nur das Weiterreichen — und * das ist Absicht. Eine zweite Fassung der Regeln im Anwendungscode wäre die * Fassung, die zuerst veraltet, und sie liesse sich umgehen, indem jemand die * Datenbankfunktion direkt aufruft. * * Die Funktion liefert eine Kennung oder null und **nie einen Grund**. Was * hier ankommt, reicht deshalb nicht aus, um zu unterscheiden, ob die Adresse * unbekannt, das Passwort falsch oder das Konto gesperrt war. Genau so soll * die Anmeldemaske antworten. */ const passwortAnbieter = Credentials({ id: "passwort", name: "Passwort", credentials: { email: { label: "E-Mail", type: "email" }, passwort: { label: "Passwort", type: "password" }, }, async authorize(eingabe) { const email = typeof eingabe?.email === "string" ? eingabe.email : ""; const passwort = typeof eingabe?.passwort === "string" ? eingabe.passwort : ""; if (!email || !passwort) return null; // asSystem, weil es noch keine angemeldete Person gibt — die entsteht ja // erst aus dem Ergebnis. Dieselbe Rolle ohne BYPASSRLS wie überall; die // Funktion ist SECURITY DEFINER und darf genau das eine. const zeile = await asSystem(async (tx) => { const ergebnis = await sql<{ id: string | null }>` select app_passwort_pruefen(${email}, ${passwort}) as id `.execute(tx); return ergebnis.rows[0]; }); if (!zeile?.id) return null; return { id: zeile.id, email, name: null }; }, }); export const { handlers, auth, signIn, signOut } = NextAuth(() => { const base = authConfig(); return { ...base, // Entra bleibt der erste Eintrag und damit der Hauptweg. Die Basisliste // wird ergänzt, nicht ersetzt: `providers: [passwortAnbieter]` hätte die // Firmenanmeldung stillschweigend entfernt. providers: [...base.providers, passwortAnbieter], callbacks: { // Die Rückrufe aus der Basis **behalten**, nicht ersetzen: dort liegt // session(), das die Kennung aus dem Token auf die Sitzung legt. Ein // schlichtes `callbacks: { jwt }` hätte es stillschweigend entfernt. ...base.callbacks, async jwt({ token, profile, user }) { // Drei Fälle, und die Reihenfolge trägt sie: // // 1. Entra, erster Durchlauf → `profile` liegt vor (und `user` auch) // 2. Passwort, erster Durchlauf → nur `user`, aus authorize() // 3. jeder weitere Aufruf → keins von beiden, Token durchreichen // // Deshalb wird `profile` zuerst geprüft: bei der Anmeldung über Entra // ist `user` ebenfalls gesetzt, und eine Prüfung auf `user` zuerst // führte den Entra-Weg an app_upsert_user() vorbei — die Kennung wäre // dann die `oid` statt app_users.id, und keine einzige Policy fände // dazu eine Zeile. if (!profile) { if (user?.id) token.uid = user.id; return token; } // `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra // vor. Danach wird das Token nur noch weitergereicht — die Datenbank // wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf. const externalId = typeof profile.oid === "string" ? profile.oid : null; const email = [profile.email, profile.preferred_username, profile.upn].find( (v): v is string => typeof v === "string" && v.length > 0 ); // Lieber abbrechen als eine Sitzung ohne Kennung ausstellen: die käme // als `null` bei withUser() an, und die Policies gäben dann konsequent // nichts zurück — was sich als „die Anwendung ist leer" zeigt statt als // Anmeldefehler. if (!externalId || !email) throw new Error("Entra lieferte weder oid noch E-Mail-Adresse."); token.uid = await upsertAppUser(externalId, email, typeof profile.name === "string" ? profile.name : null); return token; }, }, }; });