import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; import type { NextAuthConfig } from "next-auth"; // Der Teil der Anmeldung, der **ohne Datenbank** auskommt. // // Das ist keine Stilfrage: proxy.ts läuft je nach Betriebsart in einer // Umgebung ohne Node-Module — dort gibt es kein `pg` und keine Verbindung. // Würde der Proxy die vollständige Konfiguration laden, zöge er die // Zugriffsschicht mit hinein und liesse sich nicht mehr ausliefern. Deshalb // hier nur Anbieter und Sitzungsregeln; alles, was die Datenbank berührt, // steht in auth.ts. /** Wie lange eine Anmeldung ohne erneuten Besuch bei Entra gilt. */ const SESSION_MAX_AGE_SECONDS = 60 * 60 * 9; // ein Arbeitstag /** * Der Aussteller — mit Abbruch statt Rückfall. * * Ohne diese Prüfung setzt Auth.js bei fehlender Variablen stillschweigend * `https://login.microsoftonline.com/common/v2.0` ein. Das ist beim ersten * Ausprobieren aufgefallen: die Weiterleitung ging tatsächlich nach * `/common/oauth2/v2.0/authorize`, und damit hätte sich **jedes** * Microsoft-Konto anmelden dürfen, auch ein privates. * * Aufgefallen wäre das im Betrieb sonst nicht — die Anmeldung funktioniert * ja, nur eben für zu viele. Ein vergessener Wert in der Deployment-Umgebung * muss deshalb den Start verhindern, nicht die Tür aufmachen. * * In der Entwicklung bleibt es bei einer Warnung: dort ist nichts * konfiguriert, und ein Abbruch beim Laden des Moduls nähme auch die * Anmeldeseite mit. */ function tenantIssuer(): string | undefined { const issuer = process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER; if (issuer) return issuer; const hinweis = "AUTH_MICROSOFT_ENTRA_ID_ISSUER fehlt. Ohne Mandanten-Aussteller fiele die " + "Anmeldung auf /common/ zurück und stünde jedem Microsoft-Konto offen."; if (process.env.NODE_ENV === "production") throw new Error(hinweis); console.warn(`[auth] ${hinweis}`); return undefined; } /** * Die Konfiguration als **Funktion**, nicht als Objekt. * * Auth.js wertet die Funktionsform pro Anfrage aus. Das ist hier nötig, weil * tenantIssuer() in der Produktion abbricht: als Objekt gebaut liefe die * Prüfung schon beim Import — und `next build` importiert jedes Route-Modul, * um die Seitendaten einzusammeln. Der Bau bräuchte dann die * Anmeldekonfiguration der Zielumgebung, und ein Abbild liesse sich nicht * mehr einmal bauen und überall ausliefern. */ export function authConfig(): NextAuthConfig { return { providers: [ MicrosoftEntraID({ clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID, clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET, issuer: tenantIssuer(), // Der eingebaute Anbieter fordert zusätzlich `User.Read` an und holt // damit das Profilbild aus dem Graph. Beides ist hier unerwünscht: eine // Berechtigung, die niemand braucht, muss die Mandantenverwaltung // trotzdem genehmigen — und das Bild landete base64-kodiert im // Sitzungscookie, das dann in Teile zerfällt. authorization: { params: { scope: "openid profile email" } }, profile(profile) { return { // Die `oid`, nicht `sub`: `sub` ist pro Anwendung verschieden, die // `oid` identifiziert dieselbe Person über Anwendungen hinweg und // überlebt Namens- und Adressänderungen. id: profile.oid, name: profile.name ?? null, // `email` ist im Token ein optionaler Anspruch — je nach Mandant // fehlt er. `preferred_username` bzw. `upn` tragen dann dieselbe // Adresse. Ohne diesen Rückfall scheitert die Anmeldung in genau // den Mandanten, die den Anspruch nicht ausdrücklich konfiguriert // haben. email: profile.email ?? profile.preferred_username ?? profile.upn ?? null, image: null, }; }, }), ], // Eigene Seite statt der von Auth.js mitgelieferten: die Anmeldung ist die // erste Seite, die jemand sieht, und soll aussehen wie die Anwendung. pages: { signIn: "/login", error: "/login" }, session: { strategy: "jwt", maxAge: SESSION_MAX_AGE_SECONDS }, callbacks: { // Muss **hier** stehen und nicht in auth.ts, obwohl es nur eine // Zuweisung ist. // // proxy.ts baut eine eigene Auth.js-Instanz aus genau dieser Datei. Lag // die Zuordnung in auth.ts, bekäme der Proxy die Standard-Sitzung ohne // `id`, hielte jede angemeldete Person für nicht angemeldet und // schickte sie zurück auf /login — obwohl das Cookie längst gesetzt // ist. Genau so ist die erste echte Anmeldung in einer Schleife // gelandet: Konto angelegt, Sitzung gültig, und trotzdem kam man nicht // hinein. // // Datenbank braucht das nicht, es liest nur aus dem entschlüsselten // Token — deshalb darf es am Rand laufen. session({ session, token }) { if (token.uid) session.user.id = token.uid; return session; }, }, // Hinter Reverse Proxy und Container-Netzwerk kommt der Host aus dem // Header. Ohne das verweigert Auth.js in der Produktion den Dienst, weil // es die Herkunft nicht bestätigen kann. trustHost: true, // Auf der Anmeldeseite steht bewusst nur eine allgemeine Meldung — der // Grund ist fremdbestimmt und gehört nicht auf eine Seite, die echt // aussieht. Im Serverprotokoll gehört er dagegen hin, und zwar // vollständig: ohne das scheitert die Anmeldung lautlos, der Browser // springt auf /login zurück, und es gibt nichts zu lesen ausser einer // 302. Genau so ist die erste Anmeldung hier fehlgeschlagen. logger: { error(err) { console.error("[auth] Fehler:", err); const cause = (err as { cause?: unknown }).cause; // Der eigentliche Grund steckt oft eine Ebene tiefer — bei einem // Fehler in einem Rückruf ist die äussere Meldung nur „Read more at // …/errors#callback-route-error". if (cause) console.error("[auth] Ursache:", cause); }, warn(code) { console.warn("[auth] Warnung:", code); }, }, }; }