`curl -sI` auf /brand/manner-logo.png antwortete mit **307**. Die Datei lag die ganze Zeit da, ausgeliefert wurde sie nie: der Abgleich in proxy.ts nimmt alles ausser _next/static, _next/image und favicon.ico, und public/ stand nicht darin. Eine Anfrage nach dem Logo lief also in den Gate, fand keine Sitzung und bekam eine Umleitung nach /login. Auf ein <img> antwortet der Server damit mit HTML, und der Browser zeigt den Ersatztext. Das erklaert auch, warum es so sprunghaft aussah. Auffallen konnte es nur an einer einzigen Stelle: die Anmeldeseite ist die einzige Seite, die jemand ohne Sitzung zu sehen bekommt — und ausgerechnet dort steht das Logo. Ueberall sonst ist man angemeldet, der Gate laesst das Bild durch. Einmal so geladen liegt es im Zwischenspeicher und erscheint auf /login weiter, bis jemand hart neu laedt. Und es erklaert, warum keine der Aenderungen am Bild geholfen hat: weder das Ausmisten des SVG noch der Wechsel auf ein PNG konnte etwas ausrichten, weil die Datei den Browser gar nicht erreichte. Dass es „seit 8016d31" auftrat, war eine Verwechslung von Ursache und Gelegenheit — dort bekam die Anmeldeseite ihre rosa Flaeche und damit ueberhaupt erst ein sichtbares Logo an einer Stelle ohne Sitzung. Aufgenommen wird jetzt, was eine nicht angemeldete Person auf der Anmeldeseite braucht: brand/ sowie icon.svg, apple-icon.png und manifest.webmanifest, die der Browser von sich aus holt. Einzeln aufgezaehlt und nicht als Regel ueber Dateiendungen — „alles mit einem Punkt darin" haette auch Routen durchgelassen, die keine Datei sind. In public/ liegt nichts Personenbezogenes und darf auch nie etwas liegen; alles, was Daten fuehrt, geht durch withUser() und die Policies. Fuenf Tests auf den Abgleich selbst. Die vorhandenen rufen proxy() unmittelbar auf und gehen damit am matcher vorbei — ein Fehler dort war von ihnen nicht zu sehen. Geprueft wird beides: dass die Dateien vorbeikommen, und dass sonst nichts aufgeht, auch nicht ueber einen aehnlich aussehenden Pfad wie /brandneu. Lint, Typen, Schemaabgleich, 567 Tests und der Build sind sauber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
119 lines
5.4 KiB
TypeScript
119 lines
5.4 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);
|
|
}
|
|
|
|
// ═══ Was der Proxy gar nicht erst sieht ═══
|
|
//
|
|
// Der Abgleich ist eine Verneinung: alles ausser dem hier Aufgezaehlten laeuft
|
|
// durch den Gate. Das ist die richtige Richtung — eine vergessene Route ist
|
|
// dann geschuetzt und nicht offen.
|
|
//
|
|
// Es fehlten allerdings die statischen Dateien aus public/. Eine Anfrage nach
|
|
// /brand/manner-logo.png lief damit in den Gate, fand keine Sitzung und
|
|
// bekam eine Umleitung nach /login. Der Browser bekommt auf ein <img> also
|
|
// HTML statt eines Bildes und zeigt den Ersatztext.
|
|
//
|
|
// Aufgefallen ist es nur an einer Stelle, und zwar an der einzigen, an der es
|
|
// auffallen konnte: die Anmeldeseite ist die einzige Seite, die jemand ohne
|
|
// Sitzung zu sehen bekommt — und ausgerechnet dort steht das Logo. Ueberall
|
|
// sonst ist man angemeldet, der Gate laesst das Bild durch, und es erscheint.
|
|
// Daher auch das Sprunghafte: einmal angemeldet geladen, liegt es im
|
|
// Zwischenspeicher des Browsers und erscheint auf /login weiter, bis jemand
|
|
// hart neu laedt.
|
|
//
|
|
// Aufgenommen wird nur, was eine nicht angemeldete Person auf der
|
|
// Anmeldeseite braucht, und jeder Eintrag einzeln statt einer Regel ueber
|
|
// Dateiendungen: „alles mit einem Punkt darin" haette auch Routen
|
|
// durchgelassen, die keine Datei sind.
|
|
//
|
|
// _next/static, _next/image die Ausgabe des Bauwerkzeugs
|
|
// favicon.ico, icon.svg, die Symbole, die der Browser von sich aus holt
|
|
// apple-icon.png
|
|
// manifest.webmanifest dito, fuer die Installation als App
|
|
// brand/ Logo und App-Symbole aus public/brand
|
|
//
|
|
// In public/ liegt sonst nichts; personenbezogen ist dort nichts und darf
|
|
// dort auch nie etwas liegen — alles, was Daten fuehrt, geht durch withUser()
|
|
// und die Policies.
|
|
export const config = {
|
|
matcher: [
|
|
"/((?!_next/static|_next/image|favicon\\.ico|icon\\.svg|apple-icon\\.png|manifest\\.webmanifest|brand/).*)",
|
|
],
|
|
};
|