Hand the front door to Entra, and keep the keys out of the build
Auth.js replaces GoTrue. The sign-in still goes to the same Entra tenant,
but nothing sits between the app and the identity provider any more — the
code exchange, state, nonce and the session cookie are ours.
lib/auth/session.ts stays the only place that knows where a user id comes
from, which is why this was one file and not fifty. What it returns is now
app_users.id. app_upsert_user() maps the Entra `oid` onto it, and for an
address that already has a profiles row it adopts that id instead of
minting a new one — otherwise everyone would have been signed in and cut
off from their own notes, drafts and audit trail at the same time.
That upsert is the one write that cannot have a session context yet: the
id is what it produces. It runs as a SECURITY DEFINER function that may
touch app_users and nothing else, which is a far smaller lever than the
service key that used to answer this class of problem.
The proxy no longer checks HR rights. It has no database connection, and
putting role/is_active in the token would have frozen the claim until the
next sign-in. The check moved to where it can read the current truth: the
app layout on every render, requireHrUser() for the export routes, and
underneath both, RLS.
Two things only came out by running it:
- `export const proxy = auth(…)` is not a function declaration, so
Next.js never found it and every request 404'd. `next build` reported
success and listed the proxy. In the function config form auth() also
returns the handler as a promise, so it needs an await. The proxy test
now mocks it as a promise for that reason — a friendlier mock would
let the same bug back in.
- A missing AUTH_MICROSOFT_ENTRA_ID_ISSUER silently falls back to
/common/, and the redirect really did go there. That would let any
Microsoft account sign in, including a private one, and it would never
look broken. It now refuses to start in production.
Neither build nor image needs credentials any more: the pool is created on
first use, the auth config is evaluated per request, and there are no
NEXT_PUBLIC_* values left to bake in. One image now runs in every
environment.
Verified: typecheck, lint, 187 tests, build, and by hand in the browser —
/employees redirects to /login, and the sign-in button reaches the Entra
page with PKCE and the callback URL that goes into the app registration.
Not verified against a real database; there is still no DATABASE_URL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
99
lib/auth/config.ts
Normal file
99
lib/auth/config.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
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 },
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
import "server-only";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
// Der einzige Ort, an dem die Kennung der angemeldeten Person herkommt.
|
||||
//
|
||||
// Heute liefert sie GoTrue, morgen Auth.js mit Entra ID. Weil alles andere
|
||||
// nur noch `currentUserId()` aufruft und den Wert an withUser() weiterreicht,
|
||||
// ist der Wechsel des Anmeldeverfahrens eine Änderung an dieser Datei — nicht
|
||||
// an fünfzig Aufrufstellen.
|
||||
// Dass der Wechsel von GoTrue auf Auth.js eine Änderung an dieser Datei war
|
||||
// und nicht an fünfzig Aufrufstellen, lag genau an dieser Bündelung: alles
|
||||
// andere ruft `currentUserId()` auf und reicht den Wert an withUser() weiter.
|
||||
//
|
||||
// Dass das aufgeht, liegt an einer Eigenschaft des Übergangs: profiles.id ist
|
||||
// heute die auth.users.id. Die Kennung, die hier herauskommt, passt also
|
||||
// bereits auf das, was app_current_user_id() in der Datenbank erwartet.
|
||||
// Der Wert ist app_users.id — nicht die `oid` von Entra. Die Zuordnung
|
||||
// zwischen beiden macht app_upsert_user() bei der Anmeldung, und sie
|
||||
// übernimmt für eine bereits bekannte Adresse die vorhandene profiles.id.
|
||||
// Deshalb passt die Kennung weiterhin auf das, was app_current_user_id() in
|
||||
// der Datenbank erwartet, und die 58 RLS-Policies merken vom Wechsel nichts.
|
||||
|
||||
export async function currentUserId(): Promise<string | null> {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
return user?.id ?? null;
|
||||
const session = await auth();
|
||||
return session?.user?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user