135 lines
6.0 KiB
TypeScript
135 lines
6.0 KiB
TypeScript
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<string> {
|
|
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;
|
|
},
|
|
},
|
|
};
|
|
});
|