Files
alpenwerk-hr/lib/auth/config.ts
Maximilian Stubhan 4d8e3f7154 Restore the shapes the application was written against
The app runs against a real database for the first time since the port, and
two things were broken. Both were invisible to typecheck, lint, 192 tests
and the build.

Sign-in looped. Auth.js created the session and app_upsert_user() adopted
the existing profiles id correctly — the row was in app_users, right id and
all — but the proxy builds its own Auth.js instance from lib/auth/config.ts
alone, and the session callback that copies token.uid onto session.user.id
lived in auth.ts. So the proxy saw a session without an id, treated every
signed-in user as signed out, and sent them back to /login. Click, flash,
login page: from the outside it looked like the button did nothing.

The callback moves to the config both instances share. auth.ts now spreads
the base callbacks instead of replacing them, which is the mistake that
would reintroduce this.

The proxy test did not catch it because its fixture hands the handler a
session that already has user.id — it tested the routing, not the shape
Auth.js actually produces.

Then the dashboard crashed on a.date.localeCompare. PostgREST returned JSON:
a `date` arrived as "2026-08-03", a `numeric` as a number, and that is what
lib/supabase/types.ts declares and what every sort, every date comparison
and every status derivation assumes. The pg driver does the opposite — Date
object and string respectively. The declarations stayed true to what the
code believes; only the runtime value changed, which is why nothing flagged
it.

The driver is configured back to the declared shapes in lib/db/pool.ts,
rather than rewriting 49 call sites. That also removes a timezone hazard:
`date` is a calendar day, and as a Date object it acquires midnight in the
server's zone — a birth date would shift by a day in Austria, always. The
same class of bug as in the seed.

int8 stays a string on purpose: it only comes from count() and is read
through Number() everywhere; parsed as a number it would quietly lose
precision past 2^53.

A missing sign-in error now reaches the server log. Auth.js was failing
silently — a 302 back to /login and nothing to read. That was its own
defect, and it is the reason the first diagnosis took as long as it did.

Verified against the live database: all six pages render, 797 active of
852 records, 744.4 FTE, and a detail page shows birth date 15.08.1968
against SV number 7960 150868 — the digits agree, so no day has shifted.
Both new tests were checked by mutation: remove the fix and they fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:11:34 +02:00

140 lines
6.1 KiB
TypeScript

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