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:
26
.env.example
26
.env.example
@@ -10,15 +10,25 @@ DATABASE_SSL=
|
||||
# Verbindungen im Pool; Vorgabe 10.
|
||||
DATABASE_POOL_MAX=
|
||||
|
||||
# Public: safe to expose to the browser (inlined into the client bundle at
|
||||
# build time). Anon-key access is still fully gated by RLS server-side.
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
# ── Anmeldung (Auth.js + Microsoft Entra ID) ─────────────────────────
|
||||
# Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird.
|
||||
# Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel
|
||||
# meldet alle ab — was im Ernstfall genau das gewünschte Mittel ist.
|
||||
AUTH_SECRET=
|
||||
|
||||
# Server-only: bypasses Row Level Security entirely. Never prefix with
|
||||
# NEXT_PUBLIC_, never import outside lib/supabase/admin.ts (guarded by
|
||||
# `import "server-only"`), never log or return in an API response.
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
# Aus der Anwendungsregistrierung im Entra-Portal: Anwendungs-ID (Client),
|
||||
# ein Geheimnis daraus, und der Aussteller mit der Verzeichnis-ID (Mandant).
|
||||
#
|
||||
# Der Aussteller darf NICHT auf /common/ stehen bleiben — sonst könnte sich
|
||||
# jedes Microsoft-Konto anmelden, auch ein privates.
|
||||
AUTH_MICROSOFT_ENTRA_ID_ID=
|
||||
AUTH_MICROSOFT_ENTRA_ID_SECRET=
|
||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/<verzeichnis-id>/v2.0
|
||||
|
||||
# Nur nötig, wenn die Anwendung hinter einem Reverse Proxy unter einer
|
||||
# anderen Adresse erreichbar ist, als sie selbst sieht. Ohne diesen Wert baut
|
||||
# Auth.js die Rückruf-Adresse aus den Request-Headern.
|
||||
AUTH_URL=
|
||||
|
||||
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
|
||||
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
|
||||
|
||||
@@ -37,21 +37,22 @@ Werte eintragen:
|
||||
|
||||
| Variable | Woher |
|
||||
|---|---|
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase-Projekt → Settings → API |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase-Projekt → Settings → API |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase-Projekt → Settings → API (geheim!) |
|
||||
| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben |
|
||||
| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft |
|
||||
| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<verzeichnis-id>/v2.0` |
|
||||
| `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` |
|
||||
|
||||
Details zur Entra-Registrierung: [`docs/entra-sso.md`](docs/entra-sso.md).
|
||||
|
||||
Wichtig zum Verständnis:
|
||||
|
||||
- `NEXT_PUBLIC_*`-Variablen werden **beim Build** in das Browser-Bundle
|
||||
eingebacken (Next.js-Verhalten, nicht Docker-spezifisch). Ändern sich
|
||||
diese Werte, muss das Image **neu gebaut** werden – ein reiner Container-
|
||||
Neustart reicht nicht.
|
||||
- `SUPABASE_SERVICE_ROLE_KEY` und `CRON_SECRET` sind Server-only-Secrets.
|
||||
Sie werden bewusst **nicht** als Build-Arg übergeben (das würde sie im
|
||||
Image-Layer-History sichtbar machen), sondern erst zur Laufzeit über
|
||||
`env_file` injiziert.
|
||||
- **Nichts davon wird in das Image eingebacken.** Es gibt keine
|
||||
`NEXT_PUBLIC_*`-Variablen mehr; alle Werte liest die Anwendung zur Laufzeit
|
||||
über `env_file`. Eine Änderung braucht deshalb nur einen Neustart, keinen
|
||||
neuen Build — und dasselbe Image läuft in Test und Produktion.
|
||||
- `.env` steht schon in `.gitignore` – nicht committen.
|
||||
|
||||
## 2. Bauen und lokal testen
|
||||
@@ -145,12 +146,19 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Login-Redirect-Loop / `proxy.ts` verhält sich falsch:** meist falsche
|
||||
`NEXT_PUBLIC_SUPABASE_URL`/`ANON_KEY` – Image neu bauen (siehe oben, diese
|
||||
Werte sind eingebacken).
|
||||
- **Anmeldung endet auf `/login?error=…`:** die Umleitungs-URI in der
|
||||
Entra-Registrierung muss exakt
|
||||
`https://<host>/api/auth/callback/microsoft-entra-id` lauten. Steht die
|
||||
Anwendung hinter einem Reverse Proxy unter einer anderen Adresse, als sie
|
||||
selbst sieht, zusätzlich `AUTH_URL` setzen.
|
||||
- **Angemeldet, aber sofort zurück auf `/login?error=no_hr_access`:** die
|
||||
Anmeldung hat funktioniert, es fehlt die Freischaltung. Es braucht eine
|
||||
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` auf derselben
|
||||
Kennung, die in `app_users` steht.
|
||||
- **Cron läuft nicht:** `docker compose logs cron` – prüft, ob
|
||||
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
|
||||
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
|
||||
- **Healthcheck rot:** `docker compose logs app` – meist fehlende/falsche
|
||||
Supabase-Env-Variablen zur Laufzeit (`SUPABASE_SERVICE_ROLE_KEY`,
|
||||
Server-Komponenten).
|
||||
- **Healthcheck rot:** `docker compose logs app` – meist `DATABASE_URL`
|
||||
fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim
|
||||
ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im
|
||||
Start-Log.
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -12,13 +12,13 @@ WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Public env vars are inlined into the client bundle at build time, so they
|
||||
# must be available here, not just at runtime. Values are passed in via
|
||||
# --build-arg (see DEPLOYMENT.md).
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
# Keine Build-Argumente mehr: es gibt keine NEXT_PUBLIC_*-Werte mehr, die in
|
||||
# das Browser-Bundle eingebacken würden. Datenbank und Anmeldung sprechen
|
||||
# ausschliesslich den Server an, und dessen Zugangsdaten kommen zur Laufzeit.
|
||||
#
|
||||
# Dadurch ist dieses Abbild umgebungsneutral: einmal gebaut, in Test und
|
||||
# Produktion dasselbe. Vorher hätte jede Umgebung ihr eigenes gebraucht — und
|
||||
# eine Baustrecke, die die Zugangsdaten schon zum Bauen kennt.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npm run build
|
||||
|
||||
29
README.md
29
README.md
@@ -54,14 +54,18 @@ Liste. Kurzfassung:
|
||||
|
||||
| Variable | Sichtbarkeit | Zweck |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Browser + Server | Supabase-Projekt-URL |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser + Server | Anon-Key, RLS-gebunden |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | **Nur Server** | Umgeht RLS vollständig — niemals im Browser-Bundle, niemals loggen |
|
||||
| `DATABASE_URL` | Nur Server | PostgreSQL-Verbindung. Die Rolle darf **kein** `BYPASSRLS` haben |
|
||||
| `DATABASE_SSL` | Nur Server | `false` für lokal/CI ohne TLS |
|
||||
| `AUTH_SECRET` | Nur Server | Signiert und verschlüsselt das Sitzungscookie |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Nur Server | Anwendungs-ID der Entra-Registrierung |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Nur Server | Client-Geheimnis dazu |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | Nur Server | Aussteller mit Mandanten-ID — nicht `common` |
|
||||
| `CRON_SECRET` | Nur Server | Schützt `/api/cron/apply-pending-changes` |
|
||||
|
||||
`NEXT_PUBLIC_*`-Werte werden beim Build in das Client-Bundle eingebacken —
|
||||
eine Änderung erfordert einen Rebuild, nicht nur einen Neustart (relevant
|
||||
für Docker-Deployments, siehe unten).
|
||||
**Es gibt keine `NEXT_PUBLIC_*`-Variablen mehr.** Nichts wird in das
|
||||
Browser-Bundle eingebacken, weil der Browser mit nichts ausser der Anwendung
|
||||
selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
|
||||
überall dasselbe — vorher brauchte jede Umgebung ihr eigenes.
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -85,10 +89,15 @@ für Docker-Deployments, siehe unten).
|
||||
- **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`,
|
||||
geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem —
|
||||
siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell).
|
||||
- **Service-Role-Key ist server-only.** Einzige Verwendung:
|
||||
`lib/supabase/admin.ts`, geschützt durch `import "server-only"` (macht
|
||||
einen versehentlichen Client-Import zu einem Build-Fehler statt einem
|
||||
Laufzeitproblem).
|
||||
- **Es gibt keinen privilegierten Zugang mehr.** Der Dienstschlüssel, der RLS
|
||||
aushebelte, ist ersatzlos entfallen; auch der nächtliche Lauf benutzt
|
||||
dieselbe Rolle ohne `BYPASSRLS`. Was ohne angemeldete Person laufen muss,
|
||||
steht als `SECURITY DEFINER`-Funktion in der Datenbank und prüft dort
|
||||
selbst, was es tut.
|
||||
- **Jede Abfrage läuft in einer Transaktion mit gesetztem Sitzungskontext.**
|
||||
Die Kysely-Instanz wird nicht exportiert — der einzige Weg an die Datenbank
|
||||
ist `withUser()` (`lib/db/index.ts`), und eine ESLint-Regel verbietet den
|
||||
Import von `pg` ausserhalb von `lib/db/`.
|
||||
- **Audit-Log ist transaktional in der Datenbank**, nicht im App-Code: jede
|
||||
mutierende SQL-Funktion schreibt ihren `audit_log`-Eintrag in derselben
|
||||
Transaktion wie die Änderung selbst. Details und Prüfung siehe
|
||||
|
||||
@@ -1,43 +1,22 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { signIn, signOut } from "@/auth";
|
||||
|
||||
// Anmeldung ausschliesslich über Entra ID (in Supabase heisst der Anbieter
|
||||
// „Azure"). Es gibt bewusst keinen Passwort-Pfad mehr: ein zweiter Anmeldeweg
|
||||
// neben dem Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor,
|
||||
// bedingten Zugriff, Sperrung beim Austritt.
|
||||
// Anmeldung ausschliesslich über Entra ID. Es gibt bewusst keinen
|
||||
// Passwort-Pfad: ein zweiter Anmeldeweg neben dem Firmenkonto hebelt jede
|
||||
// Vorgabe des Mandanten aus — Mehrfaktor, bedingten Zugriff, Sperrung beim
|
||||
// Austritt.
|
||||
//
|
||||
// Für die Datenbank ändert sich dadurch nichts. auth.uid() liefert weiterhin
|
||||
// eine UUID, profiles.id trägt weiterhin role und is_active, und damit bleiben
|
||||
// is_hr_user() und alle darauf gebauten RLS-Policies unverändert gültig.
|
||||
// Die Herkunft muss hier nicht mehr aus dem Request geholt werden: Auth.js
|
||||
// baut die Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben
|
||||
// Host. Ein untergeschobener Host läuft also weiterhin ins Leere.
|
||||
|
||||
export async function signInWithEntra() {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Die Herkunft kommt aus dem Request statt aus einer Umgebungsvariablen,
|
||||
// damit lokal, Vorschau und Produktion denselben Code benutzen. Supabase
|
||||
// nimmt das Ziel nur an, wenn es in der Redirect-Allowlist des Projekts
|
||||
// steht — ein untergeschobener Host läuft also ins Leere.
|
||||
const origin = (await headers()).get("origin") ?? "http://localhost:3000";
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "azure",
|
||||
options: {
|
||||
// openid/profile/email sind das Minimum für Anmeldung und Anzeigename.
|
||||
// Weitere Berechtigungen holt sich die Anwendung bewusst nicht.
|
||||
scopes: "openid profile email",
|
||||
redirectTo: `${origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error || !data.url) redirect("/login?error=sso_failed");
|
||||
redirect(data.url);
|
||||
// Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in
|
||||
// Next.js.
|
||||
await signIn("microsoft-entra-id", { redirectTo: "/" });
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.signOut();
|
||||
redirect("/login");
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
|
||||
@@ -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 || "";
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
9
app/api/auth/[...nextauth]/route.ts
Normal file
9
app/api/auth/[...nextauth]/route.ts
Normal 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;
|
||||
@@ -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}/`);
|
||||
}
|
||||
63
auth.ts
Normal file
63
auth.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import "server-only";
|
||||
import NextAuth from "next-auth";
|
||||
import { authConfig } from "@/lib/auth/config";
|
||||
import { asSystem, sql } from "@/lib/db";
|
||||
|
||||
// Die vollständige Anmeldung — die Fassung, die die Datenbank kennt.
|
||||
//
|
||||
// Aufgeteilt ist sie, weil proxy.ts nur den Teil aus lib/auth/config.ts lädt.
|
||||
// Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der
|
||||
// Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung
|
||||
// versteht.
|
||||
|
||||
/**
|
||||
* Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung,
|
||||
* die überall sonst als `userId` durchgereicht wird.
|
||||
*
|
||||
* Die Datenbankfunktion ist SECURITY DEFINER und darf genau dieses eine:
|
||||
* app_users schreiben. Für den einen Schreibvorgang, für den es noch keinen
|
||||
* Sitzungskontext geben kann, ist das der kleinstmögliche Hebel — früher lag
|
||||
* hier ein Dienstschlüssel, der jede Zeile jeder Tabelle lesen konnte.
|
||||
*/
|
||||
async function upsertAppUser(externalId: string, email: string, fullName: string | null): Promise<string> {
|
||||
const row = await asSystem(async (tx) => {
|
||||
const result = await sql<{ id: string }>`
|
||||
select app_upsert_user(${externalId}, ${email}, ${fullName}) as id
|
||||
`.execute(tx);
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
if (!row?.id) throw new Error("app_upsert_user() lieferte keine Kennung.");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth(() => ({
|
||||
...authConfig(),
|
||||
callbacks: {
|
||||
async jwt({ token, profile }) {
|
||||
// `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra
|
||||
// vor. Danach wird das Token nur noch weitergereicht — die Datenbank
|
||||
// wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf.
|
||||
if (!profile) return token;
|
||||
|
||||
const externalId = typeof profile.oid === "string" ? profile.oid : null;
|
||||
const email = [profile.email, profile.preferred_username, profile.upn].find(
|
||||
(v): v is string => typeof v === "string" && v.length > 0
|
||||
);
|
||||
|
||||
// Lieber abbrechen als eine Sitzung ohne Kennung ausstellen: die käme
|
||||
// als `null` bei withUser() an, und die Policies gäben dann konsequent
|
||||
// nichts zurück — was sich als „die Anwendung ist leer" zeigt statt als
|
||||
// Anmeldefehler.
|
||||
if (!externalId || !email) throw new Error("Entra lieferte weder oid noch E-Mail-Adresse.");
|
||||
|
||||
token.uid = await upsertAppUser(externalId, email, typeof profile.name === "string" ? profile.name : null);
|
||||
return token;
|
||||
},
|
||||
|
||||
async session({ session, token }) {
|
||||
if (token.uid) session.user.id = token.uid;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -1,12 +1,10 @@
|
||||
services:
|
||||
app:
|
||||
# Ohne Build-Argumente: alles, was die Anwendung braucht — DATABASE_URL,
|
||||
# AUTH_* — liest sie zur Laufzeit aus .env. Das Abbild ist damit für jede
|
||||
# Umgebung dasselbe.
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
# NEXT_PUBLIC_* vars are inlined into the browser bundle at build
|
||||
# time, so they have to be passed here, not just in env_file below.
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Anmeldung über Entra ID
|
||||
|
||||
Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Supabase Auth
|
||||
bleibt dabei die Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz.
|
||||
Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Die
|
||||
Sitzungsverwaltung macht **Auth.js** (`auth.ts`, `lib/auth/config.ts`) — es gibt
|
||||
keinen Anmeldedienst eines Anbieters mehr dazwischen.
|
||||
|
||||
**Das ist der Grund, warum der Umstieg klein ist:** `auth.uid()` liefert
|
||||
weiterhin eine UUID, `profiles.id` trägt weiterhin `role` und `is_active`, und
|
||||
**Warum das trotzdem eine kleine Änderung ist:** die Anmeldung liefert nach wie
|
||||
vor nur eine UUID. `profiles.id` trägt weiterhin `role` und `is_active`, und
|
||||
damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die
|
||||
Sicherheitsgrenze wandert nicht in den Anwendungscode.
|
||||
|
||||
@@ -16,55 +17,69 @@ App-Registrierung, einmalig — angelegt im Mandanten *loudspring management Gmb
|
||||
|---|---|
|
||||
| Name | Alpenwerk HR |
|
||||
| Kontotypen | Nur ein Mandant |
|
||||
| Umleitungs-URI (Web) | `https://<projekt-ref>.supabase.co/auth/v1/callback` |
|
||||
| Umleitungs-URI (Web) | `https://<produktion>/api/auth/callback/microsoft-entra-id` |
|
||||
| Anwendungs-ID (Client) | `<client-id>` |
|
||||
| Verzeichnis-ID (Mandant) | `<tenant-id>` |
|
||||
|
||||
Die konkreten Werte stehen bewusst nicht hier, sondern in der
|
||||
Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Schlüssel gibt
|
||||
eine Projekt-URL nichts her, und RLS greift ohnehin —, aber sie zeigen auf die
|
||||
echte Umgebung, und dieses Repository wandert weiter als sie.
|
||||
Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Client-Geheimnis
|
||||
gibt eine ID nichts her, und RLS greift ohnehin —, aber sie zeigen auf die echte
|
||||
Umgebung, und dieses Repository wandert weiter als sie.
|
||||
|
||||
Der *Wert* des Client-Geheimnisses gehört ausschliesslich ins Supabase-Feld
|
||||
„Secret Value" und in keine Datei im Projekt.
|
||||
|
||||
Die Umleitungs-URI ist **Supabases** Callback, nicht der der Anwendung. Der
|
||||
eigene Callback (`/auth/callback`) steht nur in der Redirect-Allowlist des
|
||||
Supabase-Projekts.
|
||||
Die Umleitungs-URI zeigt jetzt auf die **Anwendung selbst**. Für die lokale
|
||||
Entwicklung kommt `http://localhost:3000/api/auth/callback/microsoft-entra-id`
|
||||
als zweite URI dazu; Entra erlaubt `http` nur für `localhost`.
|
||||
|
||||
Danach:
|
||||
|
||||
1. **Zertifikate & Geheimnisse** → neues Client-Geheimnis. Der *Wert* wird
|
||||
gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar.
|
||||
2. **API-Berechtigungen** → `openid`, `profile`, `email` (Microsoft Graph,
|
||||
delegiert), Administratorzustimmung erteilen.
|
||||
delegiert), Administratorzustimmung erteilen. `User.Read` wird **nicht**
|
||||
gebraucht: der eingebaute Anbieter von Auth.js fordert es an, um das
|
||||
Profilbild aus dem Graph zu holen — `lib/auth/config.ts` schaltet beides ab.
|
||||
3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten.
|
||||
|
||||
## Einrichtung in Supabase
|
||||
## Konfiguration der Anwendung
|
||||
|
||||
Authentication → Providers → Azure:
|
||||
Vier Werte, alle server-seitig — nichts davon landet im Browser-Bundle:
|
||||
|
||||
| Feld | Wert |
|
||||
| Variable | Wert |
|
||||
|---|---|
|
||||
| Application (Client) ID | `<client-id>` |
|
||||
| Secret Value | der Wert aus „Zertifikate & Geheimnisse" |
|
||||
| Azure Tenant URL | `https://login.microsoftonline.com/<tenant-id>` |
|
||||
| `AUTH_SECRET` | `npx auth secret` oder `openssl rand -base64 32` |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Anwendungs-ID (Client) |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | der Wert aus „Zertifikate & Geheimnisse" |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<tenant-id>/v2.0` |
|
||||
|
||||
Die Tenant URL ist bei „Nur ein Mandant" nicht optional. Bleibt sie leer,
|
||||
benutzt Supabase `common`, und Entra weist die Anmeldung ab, weil die
|
||||
Registrierung nur den eigenen Mandanten akzeptiert.
|
||||
Der Aussteller ist bei „Nur ein Mandant" **nicht optional**. Bleibt er leer,
|
||||
benutzt Auth.js `common` — dann dürfte sich jedes Microsoft-Konto anmelden, auch
|
||||
ein privates Outlook-Konto. Die Freischaltung über `profiles` fängt das zwar ab,
|
||||
aber die Eingangstür soll erst gar nicht so weit offenstehen.
|
||||
|
||||
Authentication → URL Configuration:
|
||||
`AUTH_SECRET` verschlüsselt das Sitzungscookie. Ein Wechsel meldet alle ab — im
|
||||
Ernstfall genau das gewünschte Mittel.
|
||||
|
||||
- Site URL: die Produktions-URL
|
||||
- Redirect URLs: `http://localhost:3000/auth/callback` und
|
||||
`https://<produktion>/auth/callback`
|
||||
## Was bei der ersten Anmeldung passiert
|
||||
|
||||
1. Auth.js prüft das Token von Entra (`state`, `nonce`, Signatur, Aussteller).
|
||||
2. Der Rückruf in `auth.ts` nimmt daraus die **`oid`** — nicht `sub`, nicht die
|
||||
E-Mail. Die `oid` identifiziert dieselbe Person über Anwendungen hinweg und
|
||||
überlebt Namens- und Adressänderungen.
|
||||
3. `app_upsert_user(oid, email, name)` legt die `app_users`-Zeile an und liefert
|
||||
die Kennung, die von da an in jeder Transaktion als `app.user_id` steht.
|
||||
4. **Gibt es zu der Adresse bereits ein `profiles`-Eintrag, übernimmt die
|
||||
Funktion dessen Kennung** statt eine neue zu vergeben. Das ist der Grund,
|
||||
warum bestehende Zugänge nach der Umstellung weiterlaufen: Notizen,
|
||||
Entwürfe und Protokolleinträge hängen an dieser ID.
|
||||
|
||||
Der Abgleich über die Adresse ist genau hier vertretbar und sonst nirgends: sie
|
||||
kommt aus einem von Entra ausgestellten Token, nicht aus einem Formular. Wer sie
|
||||
behauptet, hat sie bereits bewiesen.
|
||||
|
||||
## Freischaltung über die Entra-Gruppe
|
||||
|
||||
Wer sich anmeldet, hat damit **noch keinen Zugriff**. Zugriff hat, wer eine
|
||||
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt. Diese Zeile
|
||||
entsteht aus der Mitgliedschaft in einer Entra-Gruppe.
|
||||
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt.
|
||||
|
||||
### Woher der Gruppen-Anspruch kommt
|
||||
|
||||
@@ -76,46 +91,47 @@ ist. Zwei Varianten:
|
||||
| Sicherheitsgruppen | frei | Schickt *alle* Sicherheitsgruppen mit. Ab etwa 200 Gruppen liefert Entra statt der Liste einen Verweis, und die Auswertung greift ins Leere. |
|
||||
| Der Anwendung zugewiesene Gruppen | Entra ID P1 | Nur die zugewiesene Gruppe steht im Token. |
|
||||
|
||||
### Warum die Auswertung aus `auth.identities` liest, nicht aus `auth.users`
|
||||
### Wo die Auswertung hingehört
|
||||
|
||||
Das ist kein Detail, sondern der Kern der Absicherung.
|
||||
In den `jwt`-Rückruf in `auth.ts`, neben `app_upsert_user()` — dort liegt
|
||||
`profile.groups` aus dem ID-Token vor.
|
||||
|
||||
`auth.users.raw_user_meta_data` ist **von der angemeldeten Person selbst
|
||||
beschreibbar** — `supabase.auth.updateUser({ data: … })` schreibt genau dorthin.
|
||||
Läse die Freischaltung von dort, könnte sich jede:r Angemeldete den HR-Anspruch
|
||||
selbst eintragen und hätte damit Zugriff auf sämtliche Personaldaten.
|
||||
|
||||
`auth.identities.identity_data` schreibt ausschliesslich GoTrue aus der Antwort
|
||||
des Anbieters. Nur das ist eine belastbare Quelle.
|
||||
Das ist belastbar, und der Grund ist wichtig: das ID-Token ist von Entra
|
||||
signiert und wurde von Auth.js gegen den Aussteller geprüft. Die angemeldete
|
||||
Person kann seinen Inhalt nicht beeinflussen. (Unter GoTrue war dieselbe Stelle
|
||||
eine Falle: `auth.users.raw_user_meta_data` war von der Person selbst
|
||||
beschreibbar, und eine Freischaltung, die von dort gelesen hätte, wäre
|
||||
selbstbedienbar gewesen.)
|
||||
|
||||
### Reihenfolge
|
||||
|
||||
Der Trigger wird erst gebaut, wenn feststeht, wie der Anspruch tatsächlich
|
||||
ankommt — das hängt an der gewählten Variante und an der Konfiguration des
|
||||
Mandanten. Ablauf:
|
||||
Gebaut wird das erst, wenn feststeht, wie der Anspruch tatsächlich ankommt — das
|
||||
hängt an der gewählten Variante und an der Konfiguration des Mandanten. Ablauf:
|
||||
|
||||
1. SSO in Betrieb nehmen, einmal anmelden.
|
||||
2. `node --env-file=.env.local supabase/entra-claims.ts <e-mail>` zeigt, was in
|
||||
`identity_data` gelandet ist.
|
||||
3. Erst dann die Migration mit der konkreten Gruppen-ID schreiben.
|
||||
2. Im `jwt`-Rückruf einmalig `console.log(profile)` — das zeigt die Ansprüche so,
|
||||
wie der Mandant sie tatsächlich schickt.
|
||||
3. Erst dann die Auswertung mit der konkreten Gruppen-ID schreiben.
|
||||
|
||||
Ohne Schritt 2 wäre die Migration geraten.
|
||||
Ohne Schritt 2 wäre sie geraten. Bis dahin wird `profiles` von Hand gepflegt.
|
||||
|
||||
### Was die Gruppe nicht kann
|
||||
|
||||
Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert
|
||||
den Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den
|
||||
sofortigen Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt
|
||||
beim nächsten Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest.
|
||||
Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert den
|
||||
Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den sofortigen
|
||||
Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt beim nächsten
|
||||
Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest.
|
||||
|
||||
## Bestehende Zugänge
|
||||
## Wer prüft was
|
||||
|
||||
Ein bestehendes Konto mit Passwort-Anmeldung und ein Entra-Konto derselben
|
||||
Person sind für Supabase **zwei verschiedene Benutzer** mit verschiedenen IDs.
|
||||
Die `profiles`-Zeile hängt an der alten ID; nach der ersten Entra-Anmeldung
|
||||
zeigt sie ins Leere und die Person ist ausgesperrt.
|
||||
| Stelle | Prüft | Wann |
|
||||
|---|---|---|
|
||||
| `proxy.ts` | Gibt es überhaupt eine Sitzung? | jede Anfrage |
|
||||
| `app/(app)/layout.tsx` | `profiles.role` / `is_active` | jeder Seitenaufbau |
|
||||
| `lib/auth/require-hr.ts` | dasselbe, für `/api/export/*` | jeder Aufruf |
|
||||
| RLS-Policies | `is_hr_user()` | jede einzelne Abfrage |
|
||||
|
||||
`supabase/relink-profile.ts` hängt sie um. Es überträgt auch die
|
||||
Fremdschlüssel, die auf die alte Benutzer-ID zeigen (`audit_log.actor_user_id`,
|
||||
`employee_notes.author_user_id`, …), sonst stünde in der Historie eine Kennung,
|
||||
zu der es kein Konto mehr gibt.
|
||||
Der Proxy prüft die HR-Rechte **nicht** — er hat keine Datenbankverbindung. Sie
|
||||
in das Sitzungstoken zu schreiben wäre schneller gewesen und hätte eine
|
||||
Behauptung eingefroren: eine entzogene Freischaltung wirkte dann erst mit dem
|
||||
nächsten Token. Bei einer Personalanwendung ist das die falsche Richtung.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
import { Kysely, PostgresDialect, sql, type Transaction } from "kysely";
|
||||
import { pool } from "./pool";
|
||||
import { getPool } from "./pool";
|
||||
import type { Schema } from "./schema";
|
||||
|
||||
// Der einzige Weg an die Datenbank.
|
||||
@@ -33,8 +33,11 @@ import type { Schema } from "./schema";
|
||||
// BYPASSRLS. Fehlt der Kontext trotz allem, liefern die Policies nichts
|
||||
// zurück — nicht alles.
|
||||
|
||||
// Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely
|
||||
// ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses
|
||||
// Moduls noch keine Zugangsdaten — siehe getPool().
|
||||
const db = new Kysely<Schema>({
|
||||
dialect: new PostgresDialect({ pool }),
|
||||
dialect: new PostgresDialect({ pool: async () => getPool() }),
|
||||
});
|
||||
|
||||
export type Tx = Transaction<Schema>;
|
||||
|
||||
@@ -10,30 +10,46 @@ import { Pool } from "pg";
|
||||
// die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den
|
||||
// Import von `pg` und von dieser Datei überall sonst.
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
"DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " +
|
||||
"die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht."
|
||||
);
|
||||
let instance: Pool | undefined;
|
||||
|
||||
/**
|
||||
* Der Verbindungspool — erst beim ersten Zugriff angelegt, nicht beim Import.
|
||||
*
|
||||
* Der Unterschied ist nicht kosmetisch: `next build` importiert jedes Route-
|
||||
* Modul, um die Seitendaten einzusammeln. Entstünde der Pool dabei, bräuchte
|
||||
* schon der Bau Zugangsdaten zur Datenbank — ein Container-Abbild liesse sich
|
||||
* in einer Baustrecke ohne Produktionsgeheimnisse nicht mehr erzeugen.
|
||||
*/
|
||||
export function getPool(): Pool {
|
||||
if (instance) return instance;
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
"DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " +
|
||||
"die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht."
|
||||
);
|
||||
}
|
||||
|
||||
instance = new Pool({
|
||||
connectionString,
|
||||
// Der Standard sind 10; bei serverseitigem Rendering hängt an jeder
|
||||
// Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank
|
||||
// zulässt bringen nur Wartezeit an einer anderen Stelle.
|
||||
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
|
||||
// Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt
|
||||
// eine Verbindung zu belegen.
|
||||
statement_timeout: 20_000,
|
||||
idle_in_transaction_session_timeout: 20_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
// Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht.
|
||||
ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
// Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess.
|
||||
instance.on("error", (err) => {
|
||||
console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err);
|
||||
});
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString,
|
||||
// Der Standard sind 10; bei serverseitigem Rendering hängt an jeder
|
||||
// Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank
|
||||
// zulässt bringen nur Wartezeit an einer anderen Stelle.
|
||||
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
|
||||
// Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt
|
||||
// eine Verbindung zu belegen.
|
||||
statement_timeout: 20_000,
|
||||
idle_in_transaction_session_timeout: 20_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
// Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht.
|
||||
ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
// Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess.
|
||||
pool.on("error", (err) => {
|
||||
console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err);
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import type { Database } from "./types";
|
||||
|
||||
// For use in Client Components that need interactivity (filters, live
|
||||
// hints, etc). Server Components/Actions should use lib/supabase/server.ts.
|
||||
// Only ever reads NEXT_PUBLIC_* vars — this file is bundled for the browser.
|
||||
export function createClient() {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
||||
}
|
||||
if (!supabaseAnonKey) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY");
|
||||
}
|
||||
|
||||
return createBrowserClient<Database>(supabaseUrl, supabaseAnonKey);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import "server-only";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import type { Database } from "./types";
|
||||
|
||||
// For use in Server Components and Server Actions. Respects the signed-in
|
||||
// user's session, so all reads/writes go through RLS as that user. Uses only
|
||||
// the anon key (never the service role key) — the user's own session cookie
|
||||
// is what determines access, via RLS.
|
||||
export async function createClient() {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
||||
}
|
||||
if (!supabaseAnonKey) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY");
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient<Database>(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {
|
||||
// Called from a Server Component during render — safe to ignore
|
||||
// because proxy.ts refreshes the session cookie on every request.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,25 +2,28 @@ import type { NextConfig } from "next";
|
||||
|
||||
// Report-only rather than enforcing, deliberately: the policy is derived from
|
||||
// what this app is known to load — its own bundle, the self-hosted Nunito
|
||||
// files from next/font, and the Supabase project from
|
||||
// NEXT_PUBLIC_SUPABASE_URL — but an unenforced policy that logs violations is
|
||||
// worth more than a guessed one that blanks the app for every HR user.
|
||||
// Promote it to `Content-Security-Policy` once the reports come back clean.
|
||||
// files from next/font, and nothing else — but an unenforced policy that logs
|
||||
// violations is worth more than a guessed one that blanks the app for every
|
||||
// HR user. Promote it to `Content-Security-Policy` once the reports come back
|
||||
// clean.
|
||||
//
|
||||
// 'unsafe-inline' on script-src is not laziness: Next.js inlines its
|
||||
// bootstrap and hydration payload as inline <script> tags, so a nonce-based
|
||||
// policy means threading a per-request nonce through proxy.ts — a separate
|
||||
// change, and the reason this starts in report-only.
|
||||
function contentSecurityPolicy(): string {
|
||||
let supabaseOrigin = "";
|
||||
// Die Anmeldung schickt ein Formular an /api/auth/signin/…, und von dort
|
||||
// geht es per Weiterleitung zum Anmeldeserver. `form-action` gilt auch für
|
||||
// die Weiterleitungen nach einem Formularversand — steht der Aussteller
|
||||
// nicht darin, bricht der Browser die Anmeldung ab. Genau hier wäre die
|
||||
// Nur-Bericht-Fassung später eine böse Überraschung.
|
||||
let issuerOrigin = "";
|
||||
try {
|
||||
supabaseOrigin = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL ?? "").origin;
|
||||
issuerOrigin = new URL(process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER ?? "").origin;
|
||||
} catch {
|
||||
supabaseOrigin = "";
|
||||
issuerOrigin = "";
|
||||
}
|
||||
// Supabase Auth refreshes tokens over https and Realtime opens a websocket
|
||||
// against the same host.
|
||||
const connect = ["'self'", supabaseOrigin, supabaseOrigin.replace(/^http/, "ws")].filter(Boolean).join(" ");
|
||||
const formAction = ["'self'", issuerOrigin].filter(Boolean).join(" ");
|
||||
|
||||
return [
|
||||
"default-src 'self'",
|
||||
@@ -28,10 +31,13 @@ function contentSecurityPolicy(): string {
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self'",
|
||||
`connect-src ${connect}`,
|
||||
// Die Anwendung spricht im Browser mit niemandem ausser sich selbst: die
|
||||
// Datenbank erreicht nur der Server, und seit die API-Schicht weg ist,
|
||||
// gibt es keine Gegenstelle mehr, die von aussen angesprochen würde.
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
`form-action ${formAction}`,
|
||||
"object-src 'none'",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
95
package-lock.json
generated
95
package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"kysely": "^0.29.4",
|
||||
"lucide-react": "^1.26.0",
|
||||
"next": "^16.2.11",
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
"pg": "^8.22.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
@@ -115,6 +116,35 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@auth/core": {
|
||||
"version": "0.41.3",
|
||||
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz",
|
||||
"integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@panva/hkdf": "^1.2.1",
|
||||
"jose": "^6.0.6",
|
||||
"oauth4webapi": "^3.3.0",
|
||||
"preact": "10.24.3",
|
||||
"preact-render-to-string": "6.5.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.2",
|
||||
"nodemailer": "^7.0.7 || ^8.0.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@simplewebauthn/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@simplewebauthn/server": {
|
||||
"optional": true
|
||||
},
|
||||
"nodemailer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -1774,6 +1804,15 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@panva/hkdf": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
@@ -6910,7 +6949,6 @@
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
@@ -7895,6 +7933,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-auth": {
|
||||
"version": "5.0.0-beta.32",
|
||||
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz",
|
||||
"integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@auth/core": "0.41.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.2",
|
||||
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
|
||||
"nodemailer": "^7.0.7 || ^8.0.5",
|
||||
"react": "^18.2.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@simplewebauthn/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@simplewebauthn/server": {
|
||||
"optional": true
|
||||
},
|
||||
"nodemailer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-exports-info": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
|
||||
@@ -7933,6 +7998,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "3.8.6",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
|
||||
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -8407,6 +8481,25 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.24.3",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/preact-render-to-string": {
|
||||
"version": "6.5.11",
|
||||
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
|
||||
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"preact": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"kysely": "^0.29.4",
|
||||
"lucide-react": "^1.26.0",
|
||||
"next": "^16.2.11",
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
"pg": "^8.22.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Zeigt, was Entra ID beim Anmelden tatsächlich mitgeschickt hat.
|
||||
//
|
||||
// Run with: node --env-file=.env.local supabase/entra-claims.ts <e-mail>
|
||||
//
|
||||
// Die Freischaltung über eine Entra-Gruppe hängt daran, wie der Anspruch im
|
||||
// Token heisst und wie er aussieht — das unterscheidet sich je nachdem, ob im
|
||||
// Mandanten „Sicherheitsgruppen" oder „der Anwendung zugewiesene Gruppen"
|
||||
// eingestellt ist. Diese Ausgabe ist die Grundlage für den Trigger; ohne sie
|
||||
// wäre er geraten.
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
|
||||
}
|
||||
|
||||
const email = process.argv[2];
|
||||
if (!email) {
|
||||
console.error("Aufruf: node --env-file=.env.local supabase/entra-claims.ts <e-mail>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
|
||||
const { data, error } = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
const matches = data.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase());
|
||||
if (matches.length === 0) {
|
||||
console.error(`Kein Konto zu ${email}. Vorhanden:`);
|
||||
for (const u of data.users) console.error(` ${u.email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Mehrere Treffer sind der Normalfall in der Umstellungsphase: das alte Konto
|
||||
// mit Passwort und das neue über Entra sind für Supabase zwei Benutzer.
|
||||
for (const user of matches) {
|
||||
console.log(`\n── ${user.email} ──`);
|
||||
console.log(` id: ${user.id}`);
|
||||
console.log(` erstellt: ${user.created_at}`);
|
||||
console.log(` Anbieter: ${user.identities?.map((i) => i.provider).join(", ") || "keiner"}`);
|
||||
|
||||
for (const identity of user.identities ?? []) {
|
||||
console.log(`\n identity_data (${identity.provider}) — von GoTrue aus der Antwort des Anbieters:`);
|
||||
console.log(
|
||||
Object.entries(identity.identity_data ?? {})
|
||||
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
||||
.join("\n") || " (leer)"
|
||||
);
|
||||
}
|
||||
|
||||
// Zum Vergleich, und als Warnung: hierher schreibt auch updateUser(), also
|
||||
// die angemeldete Person selbst. Als Grundlage für eine Freischaltung ist
|
||||
// das unbrauchbar.
|
||||
console.log("\n raw_user_meta_data — auch von der Person selbst beschreibbar, NICHT als Quelle verwenden:");
|
||||
console.log(
|
||||
Object.entries(user.user_metadata ?? {})
|
||||
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
||||
.join("\n") || " (leer)"
|
||||
);
|
||||
}
|
||||
|
||||
const { data: profiles } = await supabase.from("profiles").select("id, email, role, is_active").eq("email", email);
|
||||
console.log(`\n── profiles zu ${email} ──`);
|
||||
for (const p of profiles ?? []) console.log(` ${p.id} role=${p.role} is_active=${p.is_active}`);
|
||||
if (!profiles?.length) console.log(" (keine Zeile — damit besteht kein Zugriff)");
|
||||
@@ -54,7 +54,20 @@ $$;
|
||||
comment on function app_current_user_id() is
|
||||
'Kennung der angemeldeten Person: erst app.user_id aus der Sitzung, ersatzweise auth.uid(). Der zweite Zweig ist Übergang.';
|
||||
|
||||
grant execute on function app_current_user_id() to anon, authenticated, service_role;
|
||||
-- Zugeteilt wird nur an Rollen, die es auch gibt. Auf einem gewöhnlichen
|
||||
-- PostgreSQL existieren anon/authenticated/service_role nicht, und ein
|
||||
-- `grant` auf eine unbekannte Rolle bricht die Migration ab — dieselbe Datei
|
||||
-- liefe dort also nicht. Genau das soll sie aber.
|
||||
do $$
|
||||
declare r text;
|
||||
begin
|
||||
foreach r in array array['anon', 'authenticated', 'service_role'] loop
|
||||
if exists (select 1 from pg_roles where rolname = r) then
|
||||
execute format('grant execute on function app_current_user_id() to %I', r);
|
||||
end if;
|
||||
end loop;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ═══ 2. Benutzertabelle ══════════════════════════════════════════
|
||||
-- Tritt an die Stelle von auth.users. Die neun Fremdschlüssel, die heute
|
||||
@@ -83,8 +96,19 @@ drop policy if exists "app_users_select_own" on app_users;
|
||||
create policy "app_users_select_own" on app_users
|
||||
for select using (id = app_current_user_id() or is_hr_user());
|
||||
|
||||
grant select on table app_users to anon, authenticated;
|
||||
grant all on table app_users to service_role;
|
||||
do $$
|
||||
begin
|
||||
if exists (select 1 from pg_roles where rolname = 'anon') then
|
||||
execute 'grant select on table app_users to anon';
|
||||
end if;
|
||||
if exists (select 1 from pg_roles where rolname = 'authenticated') then
|
||||
execute 'grant select on table app_users to authenticated';
|
||||
end if;
|
||||
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
||||
execute 'grant all on table app_users to service_role';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ═══ 3. Die eine Brücke umlegen ══════════════════════════════════
|
||||
-- Ab hier fragt die Absicherung nicht mehr Supabase, sondern den eigenen
|
||||
|
||||
117
supabase/migrations/20260731090000_app_upsert_user.sql
Normal file
117
supabase/migrations/20260731090000_app_upsert_user.sql
Normal file
@@ -0,0 +1,117 @@
|
||||
-- Schritt 2: die Anmeldung braucht einen Weg, ihre Zeile in app_users
|
||||
-- anzulegen — bevor es einen Sitzungskontext gibt.
|
||||
--
|
||||
-- Das ist das Henne-Ei-Problem jeder eigenen Anmeldung: app.user_id kann erst
|
||||
-- gesetzt werden, wenn die Kennung feststeht, und die entsteht genau hier.
|
||||
-- Bisher löste ein Dienstschlüssel mit BYPASSRLS solche Fälle. Den gibt es
|
||||
-- nicht mehr, und er soll auch nicht zurückkommen — eine Verbindung, die
|
||||
-- alles darf, ist für einen einzigen Schreibvorgang ein zu grosser Hebel.
|
||||
--
|
||||
-- Stattdessen: eine SECURITY-DEFINER-Funktion mit genau einer Befugnis.
|
||||
-- Sie schreibt in app_users und liest lesend in profiles — sonst nichts. Wer
|
||||
-- sie aufruft, bekommt eine UUID zurück und sonst keine Auskunft.
|
||||
create or replace function app_upsert_user(
|
||||
p_external_id text,
|
||||
p_email text,
|
||||
p_full_name text
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_id uuid;
|
||||
begin
|
||||
if p_external_id is null or btrim(p_external_id) = '' then
|
||||
raise exception 'Externe Kennung fehlt.';
|
||||
end if;
|
||||
if p_email is null or btrim(p_email) = '' then
|
||||
raise exception 'E-Mail-Adresse fehlt.';
|
||||
end if;
|
||||
|
||||
-- Bekanntes Konto: nur nachziehen, was sich beim Anbieter geändert haben
|
||||
-- kann. Die Kennung bleibt, auch wenn Name oder Adresse wechseln — daran
|
||||
-- hängen Notizen, Entwürfe und Protokolleinträge.
|
||||
update app_users
|
||||
set email = p_email,
|
||||
full_name = coalesce(p_full_name, full_name),
|
||||
last_seen_at = now()
|
||||
where external_id = p_external_id
|
||||
returning id into v_id;
|
||||
|
||||
if v_id is not null then
|
||||
return v_id;
|
||||
end if;
|
||||
|
||||
-- Erstanmeldung. Gibt es zu dieser Adresse bereits ein Profil, wird dessen
|
||||
-- Kennung übernommen statt einer neuen: profiles.id ist heute die
|
||||
-- auth.users.id, und neun Fremdschlüssel zeigen darauf. Eine frisch
|
||||
-- vergebene UUID würde die Person von ihrer eigenen Vorgeschichte trennen
|
||||
-- — sie wäre angemeldet, hätte aber weder Rolle noch Freischaltung.
|
||||
--
|
||||
-- Der Abgleich über die Adresse ist hier vertretbar und sonst nirgends:
|
||||
-- die Adresse kommt aus einem von Entra ausgestellten Token, nicht aus
|
||||
-- einem Formular. Wer sie behauptet, hat sie bereits bewiesen.
|
||||
select p.id into v_id
|
||||
from profiles p
|
||||
where lower(p.email) = lower(p_email)
|
||||
limit 1;
|
||||
|
||||
v_id := coalesce(v_id, gen_random_uuid());
|
||||
|
||||
begin
|
||||
insert into app_users (id, external_id, email, full_name, last_seen_at)
|
||||
values (v_id, p_external_id, p_email, p_full_name, now());
|
||||
exception
|
||||
when unique_violation then
|
||||
-- Zwei gleichzeitige Erstanmeldungen desselben Kontos. Die zweite
|
||||
-- findet die Zeile, die die erste gerade angelegt hat.
|
||||
select id into v_id from app_users where external_id = p_external_id;
|
||||
if v_id is null then
|
||||
raise;
|
||||
end if;
|
||||
end;
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function app_upsert_user(text, text, text) is
|
||||
'Legt die app_users-Zeile zur Erstanmeldung an und liefert die Kennung. Übernimmt bei bekannter E-Mail die vorhandene profiles.id.';
|
||||
|
||||
-- app_users trägt RLS und hat bewusst keine Schreib-Policy: die Anwendung
|
||||
-- kommt an die Tabelle nur durch diese Funktion. Ein Fehler im Anwendungscode
|
||||
-- kann dort also nichts anlegen, ändern oder löschen.
|
||||
do $$
|
||||
declare r text;
|
||||
begin
|
||||
foreach r in array array['anon', 'authenticated', 'service_role'] loop
|
||||
if exists (select 1 from pg_roles where rolname = r) then
|
||||
execute format('grant execute on function app_upsert_user(text, text, text) to %I', r);
|
||||
end if;
|
||||
end loop;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||
-- Zweimal dieselbe externe Kennung muss dieselbe UUID ergeben. Wäre es nicht
|
||||
-- so, bekäme jede Anmeldung ein neues Konto und niemand behielte seine
|
||||
-- Rolle — ein Fehler, der sich erst Wochen später als „meine Notizen sind
|
||||
-- weg" zeigt.
|
||||
do $$
|
||||
declare
|
||||
v_first uuid;
|
||||
v_second uuid;
|
||||
v_probe text := 'probe-' || gen_random_uuid()::text;
|
||||
begin
|
||||
v_first := app_upsert_user(v_probe, v_probe || '@example.invalid', 'Probe');
|
||||
v_second := app_upsert_user(v_probe, v_probe || '@example.invalid', 'Probe');
|
||||
|
||||
if v_first is distinct from v_second then
|
||||
raise exception 'app_upsert_user() vergibt bei zweiter Anmeldung eine neue Kennung — Abbruch.';
|
||||
end if;
|
||||
|
||||
delete from app_users where id = v_first;
|
||||
end;
|
||||
$$;
|
||||
@@ -1,110 +0,0 @@
|
||||
// Hängt eine bestehende profiles-Zeile auf die Entra-Identität derselben
|
||||
// Person um.
|
||||
//
|
||||
// Run with: node --env-file=.env.local supabase/relink-profile.ts <e-mail> [--apply]
|
||||
//
|
||||
// Ein Konto mit Passwort-Anmeldung und das Entra-Konto derselben Person sind
|
||||
// für Supabase zwei Benutzer mit verschiedenen IDs. Die profiles-Zeile hängt an
|
||||
// der alten; nach der ersten Anmeldung über Entra zeigt sie ins Leere und die
|
||||
// Person ist ausgesperrt — mit „Kein HR-Zugriff", obwohl sie HR ist.
|
||||
//
|
||||
// Ohne --apply wird nur angezeigt, was passieren würde.
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
|
||||
}
|
||||
|
||||
const email = process.argv[2];
|
||||
const apply = process.argv.includes("--apply");
|
||||
if (!email) {
|
||||
console.error("Aufruf: node --env-file=.env.local supabase/relink-profile.ts <e-mail> [--apply]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
|
||||
// Die Fremdschlüssel auf auth.users(id). Sie zeigen sonst weiter auf das alte
|
||||
// Konto, und in der Historie stünde eine Kennung ohne Konto dahinter.
|
||||
const REFERENCES: { table: string; column: string }[] = [
|
||||
{ table: "audit_log", column: "actor_user_id" },
|
||||
{ table: "employee_notes", column: "author_user_id" },
|
||||
{ table: "employee_notes", column: "done_by" },
|
||||
{ table: "hire_drafts", column: "created_by" },
|
||||
{ table: "saved_reports", column: "created_by" },
|
||||
{ table: "pending_org_changes", column: "created_by" },
|
||||
{ table: "profiles", column: "created_by" },
|
||||
];
|
||||
|
||||
const { data: userList, error } = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
const accounts = userList.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase());
|
||||
const entra = accounts.find((u) => u.identities?.some((i) => i.provider === "azure"));
|
||||
const alt = accounts.find((u) => u.id !== entra?.id);
|
||||
|
||||
if (!entra) {
|
||||
console.error(`Kein Entra-Konto zu ${email}. Bitte zuerst einmal über „Mit Firmenkonto anmelden" anmelden.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!alt) {
|
||||
console.log(`Zu ${email} gibt es nur das Entra-Konto (${entra.id}) — nichts umzuhängen.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { data: profile } = await supabase.from("profiles").select("*").eq("id", alt.id).maybeSingle();
|
||||
if (!profile) {
|
||||
console.error(`Das alte Konto ${alt.id} hat keine profiles-Zeile. Nichts umzuhängen.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`alt: ${alt.id} (${alt.identities?.map((i) => i.provider).join(", ")})`);
|
||||
console.log(`neu: ${entra.id} (azure)`);
|
||||
console.log(`Rolle: ${profile.role}, aktiv: ${profile.is_active}`);
|
||||
|
||||
if (!apply) {
|
||||
console.log("\nTrockenlauf. Mit --apply ausführen.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Neue Zeile zuerst: profiles.id verweist auf auth.users(id), und die alte
|
||||
// Zeile fällt erst, wenn die neue steht — sonst gibt es einen Moment ohne
|
||||
// HR-Konto, und niemand könnte eines mehr freischalten.
|
||||
const { error: insertError } = await supabase.from("profiles").insert({
|
||||
...profile,
|
||||
id: entra.id,
|
||||
email: entra.email ?? profile.email,
|
||||
});
|
||||
if (insertError) throw new Error(`profiles-Zeile anlegen fehlgeschlagen: ${insertError.message}`);
|
||||
console.log("profiles-Zeile für das Entra-Konto angelegt.");
|
||||
|
||||
for (const ref of REFERENCES) {
|
||||
const { error: updateError, count } = await supabase
|
||||
.from(ref.table)
|
||||
.update({ [ref.column]: entra.id }, { count: "exact" })
|
||||
.eq(ref.column, alt.id);
|
||||
if (updateError) {
|
||||
console.warn(` ${ref.table}.${ref.column}: ${updateError.message}`);
|
||||
continue;
|
||||
}
|
||||
console.log(` ${ref.table}.${ref.column}: ${count ?? 0} Zeile(n) umgehängt`);
|
||||
}
|
||||
|
||||
const { error: deleteProfileError } = await supabase.from("profiles").delete().eq("id", alt.id);
|
||||
if (deleteProfileError) throw new Error(`alte profiles-Zeile löschen fehlgeschlagen: ${deleteProfileError.message}`);
|
||||
|
||||
const { error: deleteUserError } = await supabase.auth.admin.deleteUser(alt.id);
|
||||
if (deleteUserError) {
|
||||
// Kein Abbruch: der Zugriff hängt an profiles, und die ist bereits
|
||||
// umgehängt. Das alte Konto ist damit wirkungslos, nur nicht aufgeräumt.
|
||||
console.warn(`Altes Konto konnte nicht gelöscht werden: ${deleteUserError.message}`);
|
||||
} else {
|
||||
console.log("Altes Konto gelöscht.");
|
||||
}
|
||||
|
||||
console.log("\nFertig. Die Anmeldung läuft jetzt über das Firmenkonto.");
|
||||
95
tests/unit/proxy.test.ts
Normal file
95
tests/unit/proxy.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Geprüft wird die Wegeführung im Proxy, nicht Auth.js.
|
||||
//
|
||||
// Der Wrapper `auth(handler)` gehört Auth.js und hängt die entschlüsselte
|
||||
// Sitzung als `request.auth` an; das ist fremder Code und hier durch eine
|
||||
// Attrappe ersetzt. Was hier zur Debatte steht, ist die Entscheidung, die
|
||||
// proxy.ts daraus ableitet — und die hat eine Schleife als Fehlerfall, die
|
||||
// sich im Betrieb als „die Anmeldeseite lädt endlos neu" zeigt und beim
|
||||
// Lesen des Codes leicht übersehen wird.
|
||||
//
|
||||
// Die Attrappe gibt den umschlossenen Handler bewusst als **Zusage** zurück,
|
||||
// weil Auth.js das in der Funktionsform ebenfalls tut. Eine Attrappe, die
|
||||
// ihn direkt liefert, wäre freundlicher als die Wirklichkeit und liesse ein
|
||||
// fehlendes `await` in proxy.ts durchgehen — genau der Fehler, der beim
|
||||
// ersten Ausprobieren jede Anfrage in einen 404 laufen liess.
|
||||
vi.mock("next-auth", () => ({
|
||||
default: () => ({
|
||||
auth: async (handler: (req: NextRequest) => unknown) => (req: NextRequest) => handler(req),
|
||||
}),
|
||||
}));
|
||||
|
||||
type Session = { user: { id: string } } | null;
|
||||
|
||||
// Bewusst locker getippt: Auth.js gibt dem umschlossenen Handler die
|
||||
// Signatur eines Route Handlers (Anfrage plus Kontext), und der zweite
|
||||
// Parameter spielt hier keine Rolle.
|
||||
let proxy: (req: NextRequest) => unknown;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import("@/proxy");
|
||||
proxy = mod.proxy as unknown as typeof proxy;
|
||||
});
|
||||
|
||||
async function visit(path: string, session: Session): Promise<Response> {
|
||||
const request = new NextRequest(`http://localhost${path}`);
|
||||
Object.assign(request, { auth: session });
|
||||
return (await proxy(request)) as Response;
|
||||
}
|
||||
|
||||
const SIGNED_IN: Session = { user: { id: "11111111-1111-1111-1111-111111111111" } };
|
||||
|
||||
/** NextResponse.next() setzt diesen Header; eine Weiterleitung tut es nicht. */
|
||||
function passedThrough(res: Response): boolean {
|
||||
return res.headers.get("x-middleware-next") === "1";
|
||||
}
|
||||
|
||||
describe("proxy", () => {
|
||||
it("schickt eine Anfrage ohne Sitzung auf die Anmeldeseite", async () => {
|
||||
const res = await visit("/employees", null);
|
||||
expect(res.status).toBe(307);
|
||||
expect(new URL(res.headers.get("location")!).pathname).toBe("/login");
|
||||
});
|
||||
|
||||
it("lässt die Anmeldeseite ohne Sitzung durch", async () => {
|
||||
expect(passedThrough(await visit("/login", null))).toBe(true);
|
||||
});
|
||||
|
||||
it("lässt Auth.js' eigene Endpunkte ohne Sitzung durch", async () => {
|
||||
// Ohne diese Ausnahme landet der Rückweg von Entra auf /login und die
|
||||
// Anmeldung kommt nie zustande — der Code steht in der URL, aber es
|
||||
// entsteht nie ein Sitzungscookie.
|
||||
expect(passedThrough(await visit("/api/auth/callback/microsoft-entra-id", null))).toBe(true);
|
||||
expect(passedThrough(await visit("/api/auth/signin", null))).toBe(true);
|
||||
});
|
||||
|
||||
it("lässt eine angemeldete Anfrage durch", async () => {
|
||||
expect(passedThrough(await visit("/employees", SIGNED_IN))).toBe(true);
|
||||
});
|
||||
|
||||
it("holt eine angemeldete Person von der Anmeldeseite zurück in die Anwendung", async () => {
|
||||
const res = await visit("/login", SIGNED_IN);
|
||||
expect(res.status).toBe(307);
|
||||
expect(new URL(res.headers.get("location")!).pathname).toBe("/");
|
||||
});
|
||||
|
||||
it("belässt eine angemeldete Person auf /login, solange eine Meldung ansteht", async () => {
|
||||
// Der Fall: angemeldet, aber ohne HR-Freischaltung. Das Layout leitet
|
||||
// nach /login?error=no_hr_access. Würde der Proxy auch das nach /
|
||||
// zurückschicken, liefen beide gegeneinander und der Grund wäre nie zu
|
||||
// lesen.
|
||||
expect(passedThrough(await visit("/login?error=no_hr_access", SIGNED_IN))).toBe(true);
|
||||
});
|
||||
|
||||
it("nimmt eine Sitzung ohne Kennung nicht als angemeldet", async () => {
|
||||
// Kommt vor, wenn das Token aus einer älteren Fassung stammt: gültig
|
||||
// signiert, aber ohne `uid`. Ohne diese Prüfung käme die Person durch
|
||||
// und liefe in Abfragen mit userId = null — die geben nichts zurück, und
|
||||
// die Anwendung sähe aus, als wären alle Daten weg.
|
||||
const res = await visit("/employees", { user: {} } as unknown as Session);
|
||||
expect(res.status).toBe(307);
|
||||
expect(new URL(res.headers.get("location")!).pathname).toBe("/login");
|
||||
});
|
||||
});
|
||||
@@ -138,15 +138,15 @@ describe("cron auth guard (/api/cron/apply-pending-changes)", () => {
|
||||
|
||||
describe("protected export route without a session (/api/export/employees)", () => {
|
||||
it("returns 401 instead of running the query when there is no authenticated user", async () => {
|
||||
vi.doMock("@/lib/supabase/server", () => ({
|
||||
createClient: async () => ({
|
||||
auth: { getUser: async () => ({ data: { user: null } }) },
|
||||
}),
|
||||
}));
|
||||
// Gestellt wird die Sitzung, nicht die Datenbank: die Route muss mit 401
|
||||
// antworten, *bevor* sie eine Verbindung aufbaut. Käme sie bis zur
|
||||
// Abfrage, liefe der Test ohne DATABASE_URL in einen Verbindungsfehler
|
||||
// statt in die Zusicherung — und bewiese nichts.
|
||||
vi.doMock("@/auth", () => ({ auth: async () => null }));
|
||||
const { GET } = await import("@/app/api/export/employees/route");
|
||||
const request = new NextRequest("http://localhost/api/export/employees");
|
||||
const res = await GET(request);
|
||||
expect(res.status).toBe(401);
|
||||
vi.doUnmock("@/lib/auth/session");
|
||||
vi.doUnmock("@/auth");
|
||||
});
|
||||
});
|
||||
|
||||
25
types/next-auth.d.ts
vendored
Normal file
25
types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { DefaultSession } from "next-auth";
|
||||
|
||||
// Auth.js kennt von Haus aus keine `id` auf der Sitzung — und `user.id` aus
|
||||
// dem Adapter gibt es nur mit Datenbank-Adapter, den diese Anwendung nicht
|
||||
// benutzt. Hier steht deshalb die eine Ergänzung, die alles andere trägt:
|
||||
// die app_users-Kennung, die in withUser() geht und über die jede RLS-Policy
|
||||
// entscheidet.
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: { id: string } & DefaultSession["user"];
|
||||
}
|
||||
}
|
||||
|
||||
// Ergänzt wird `@auth/core/jwt` und nicht `next-auth/jwt`: letzteres reicht
|
||||
// die Deklaration nur mit `export *` durch, und eine Ergänzung dort legt eine
|
||||
// zweite, unverbundene Schnittstelle an, statt die vorhandene zu erweitern.
|
||||
// Der Typprüfer meldet das als „Type '{}' is not assignable to type
|
||||
// 'string'" — eine Fehlermeldung, die nicht danach aussieht.
|
||||
declare module "@auth/core/jwt" {
|
||||
interface JWT {
|
||||
/** app_users.id — nicht die `oid` von Entra. */
|
||||
uid?: string;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user