Files
alpenwerk-hr/proxy.ts
Maximilian Stubhan 2ba9b37aa7 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>
2026-07-31 14:57:32 +02:00

84 lines
3.6 KiB
TypeScript

import NextAuth from "next-auth";
import { NextResponse } from "next/server";
import { authConfig } from "@/lib/auth/config";
// Next.js 16 renamed Middleware to Proxy (same mechanism, new filename and
// export). Das hier ist die vorderste Tür: wer keine Sitzung hat, landet auf
// /login statt in der Anwendung.
//
// Gebaut wird dafür eine *zweite*, absichtlich unvollständige Auth.js-Instanz
// — nur aus lib/auth/config.ts, ohne die Rückrufe aus auth.ts. Der Grund ist
// handfest: auth.ts spricht mit der Datenbank, und der Proxy läuft je nach
// Betriebsart in einer Umgebung ohne Node-Module. Die Instanz hier liest das
// Sitzungscookie und sonst nichts.
//
// Deshalb prüft der Proxy auch nur, *ob* jemand angemeldet ist — nicht mehr,
// ob die Person HR-Rechte hat. Das ist keine Lücke, sondern eine Verschiebung
// an die Stellen, die es wahrheitsgemäss beantworten können:
//
// • app/(app)/layout.tsx fragt profiles bei jedem Aufbau frisch ab und
// leitet auf /login?error=no_hr_access um,
// • die Route Handler unter /api/export/* tun dasselbe über requireHrUser(),
// • und darunter, unabhängig von allem Anwendungscode, entscheiden die
// RLS-Policies über is_hr_user().
//
// Die Alternative — role und is_active ins Sitzungstoken schreiben — hätte
// den Proxy schneller gemacht und dafür eine Behauptung eingefroren: eine
// entzogene Freischaltung wirkte erst mit dem nächsten Token. Bei einer
// Personalanwendung ist das die falsche Richtung.
// Als Funktion übergeben, nicht als Objekt: siehe authConfig().
const { auth } = NextAuth(() => authConfig());
const gate = auth((request) => {
const { pathname } = request.nextUrl;
// Auth.js' eigene Endpunkte müssen durch, bevor es eine Sitzung gibt —
// dort entsteht sie ja erst. Ohne diese Ausnahme leitet der Proxy den
// Rückweg aus Entra nach /login um und die Anmeldung kommt nie zustande.
if (pathname.startsWith("/api/auth")) return NextResponse.next();
const isLoginRoute = pathname.startsWith("/login");
const isSignedIn = Boolean(request.auth?.user?.id);
if (!isSignedIn) {
if (isLoginRoute) return NextResponse.next();
const url = request.nextUrl.clone();
url.pathname = "/login";
url.search = "";
return NextResponse.redirect(url);
}
if (isLoginRoute) {
// Angemeldet und trotzdem auf /login: nur weiterschicken, wenn keine
// Meldung ansteht. Sonst geriete jemand ohne HR-Freischaltung in eine
// Schleife — das Layout leitet nach /login?error=no_hr_access, der Proxy
// zurück auf /, das Layout wieder … und der Grund wäre nie zu lesen.
if (request.nextUrl.searchParams.has("error")) return NextResponse.next();
const url = request.nextUrl.clone();
url.pathname = "/";
url.search = "";
return NextResponse.redirect(url);
}
return NextResponse.next();
});
// Zwei Eigenheiten auf einmal, beide erst beim Ausprobieren aufgefallen:
//
// 1. Next.js sucht hier eine *Funktionsdeklaration* namens `proxy` (oder
// einen Default-Export) und erkennt `export const proxy = auth(…)` nicht.
// Jede Anfrage lief in einen 404 — und `next build` meldete Erfolg und
// listete den Proxy sogar auf.
//
// 2. In der Funktionsform liefert `auth(handler)` den Handler erst als
// Zusage. Ohne `await` steht hier ein Promise, und der Aufruf scheitert
// mit „gate is not a function". Auf einem gewöhnlichen Funktionswert ist
// `await` wirkungslos, das `await` ist also in beiden Fällen richtig.
export async function proxy(...args: Parameters<Awaited<typeof gate>>) {
return (await gate)(...args);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};