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:
116
proxy.ts
116
proxy.ts
@@ -1,73 +1,81 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
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/export).
|
||||
// This is the app's single entry-point gate (spec
|
||||
// §2.2): unauthenticated users are sent to /login, and — this is the part
|
||||
// that used to be missing — authenticated users who are NOT an active,
|
||||
// explicitly-provisioned HR user are sent to /login too, with an error
|
||||
// message, instead of being let through. Previously this only checked for
|
||||
// a Supabase Auth session at all, which meant any signed-in user (even one
|
||||
// with no profile row, or the old "manager" role) could open the app.
|
||||
// This UI-layer gate is defense in depth, not the real boundary — every
|
||||
// table is independently RLS-gated on is_hr_user() regardless of what this
|
||||
// proxy does.
|
||||
export async function proxy(request: NextRequest) {
|
||||
let response = NextResponse.next({ request });
|
||||
// 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 supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
|
||||
response = NextResponse.next({ request });
|
||||
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
const gate = auth((request) => {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
// 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 = request.nextUrl.pathname.startsWith("/login");
|
||||
const isLoginRoute = pathname.startsWith("/login");
|
||||
const isSignedIn = Boolean(request.auth?.user?.id);
|
||||
|
||||
// Der Rückweg aus Entra muss durch, bevor es eine Sitzung gibt — dort wird
|
||||
// sie ja erst hergestellt. Ohne diese Ausnahme leitet der Gate den Code
|
||||
// nach /login um und die Anmeldung kommt nie zustande.
|
||||
if (request.nextUrl.pathname.startsWith("/auth/callback")) return response;
|
||||
|
||||
if (!user) {
|
||||
if (isLoginRoute) return response;
|
||||
if (!isSignedIn) {
|
||||
if (isLoginRoute) return NextResponse.next();
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
|
||||
const isActiveHr = profile?.role === "hr" && profile?.is_active === true;
|
||||
|
||||
if (!isActiveHr) {
|
||||
if (isLoginRoute) return response;
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
url.searchParams.set("error", "no_hr_access");
|
||||
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 response;
|
||||
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 = {
|
||||
|
||||
Reference in New Issue
Block a user