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:
2026-07-31 14:57:32 +02:00
parent b3a0af2b8f
commit 2ba9b37aa7
29 changed files with 853 additions and 531 deletions

View File

@@ -14,10 +14,11 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
// und damit nebenbei auf einem einheitlichen Lesestand.
const data = await withUser(userId, async (tx) => {
// Defense in depth: proxy.ts already redirects any non-active-HR session
// away before this layout ever renders. Re-checking here means a gap in
// the proxy matcher (or a future route added outside it) still fails
// closed instead of silently granting access — see docs/security.md.
// Hier — und nicht im Proxy — fällt die Entscheidung über den Zugang.
// Der Proxy prüft nur, ob überhaupt jemand angemeldet ist; er hat keine
// Datenbankverbindung. Diese Abfrage läuft bei jedem Aufbau frisch, eine
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
// Sitzungstoken. Die eigentliche Grenze bleibt darunter RLS.
const profile = await tx
.selectFrom("profiles")
.select(["full_name", "email", "role", "is_active"])
@@ -40,7 +41,10 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
return { profile, openPositions, locations, drafts, openNotes };
});
if (!data) redirect("/login");
// `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet
// ist. Ohne den Grund in der Adresse stünde sie vor einer wortlosen
// Anmeldeseite und versuchte es endlos erneut.
if (!data) redirect("/login?error=no_hr_access");
const userLabel = data.profile.full_name || data.profile.email || "";

View File

@@ -25,7 +25,17 @@ type LoginPageProps = {
export default async function LoginPage({ searchParams }: LoginPageProps) {
const params = await searchParams;
const code = params.error && Object.hasOwn(ERROR_MESSAGES, params.error) ? (params.error as ErrorCode) : null;
// Ein unbekannter Code wird nicht verschluckt, sondern auf die allgemeine
// Meldung abgebildet: Auth.js schickt bei einem Fehlschlag seine eigenen
// Codes („Configuration", „AccessDenied", „OAuthCallbackError" …), und ohne
// diese Abbildung stünde man vor einer Anmeldeseite, die so tut, als wäre
// nichts gewesen. Angezeigt wird trotzdem nur eigener Text — der Parameter
// selbst kommt nie auf die Seite.
const code = params.error
? Object.hasOwn(ERROR_MESSAGES, params.error)
? (params.error as ErrorCode)
: "sso_failed"
: null;
const error = code ? ERROR_MESSAGES[code] : null;
return (

View File

@@ -0,0 +1,9 @@
import { handlers } from "@/auth";
// Der Rückweg aus Entra ID und die Endpunkte für An- und Abmeldung.
//
// Tritt an die Stelle von app/auth/callback/route.ts: den Tausch des
// Einmal-Codes gegen eine Sitzung, die Prüfung von `state` und `nonce` und
// das Setzen des Cookies macht jetzt Auth.js. Die Rückruf-Adresse in der
// Entra-Anwendungsregistrierung ändert sich dadurch — siehe docs/entra-sso.md.
export const { GET, POST } = handlers;

View File

@@ -1,30 +0,0 @@
import { NextResponse, type NextRequest } from "next/server";
import { createClient } from "@/lib/supabase/server";
// Rückweg aus Entra ID. @supabase/ssr benutzt PKCE, das heisst der Anbieter
// liefert einen einmaligen Code, der hier gegen eine Sitzung getauscht wird.
// Ohne diese Route landet die Anmeldung in einer Schleife: der Code steht in
// der URL, aber es entsteht nie ein Sitzungscookie, und der Proxy schickt
// zurück auf /login.
export async function GET(request: NextRequest) {
const { searchParams, origin } = request.nextUrl;
// Entra meldet abgelehnte Zustimmung oder gesperrte Konten als Fehler
// zurück. Der Text daraus wird nicht angezeigt — er ist fremdbestimmt und
// stünde sonst auf der echten, korrekt gebrandeten Anmeldeseite.
if (searchParams.get("error")) {
return NextResponse.redirect(`${origin}/login?error=sso_failed`);
}
const code = searchParams.get("code");
if (!code) return NextResponse.redirect(`${origin}/login?error=sso_failed`);
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) return NextResponse.redirect(`${origin}/login?error=sso_failed`);
// Ob die Person HR-Zugriff hat, entscheidet nicht diese Route, sondern
// proxy.ts anhand von profiles.role/is_active — und darunter, unabhängig
// davon, die RLS-Policies. Hier wird nur die Sitzung hergestellt.
return NextResponse.redirect(`${origin}/`);
}