Files
alpenwerk-hr/auth.ts
Andrei Laas c6cff9656e
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m27s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m3s
Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell
2026-09-08 16:51:14 +02:00

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;
},
},
};
});