diff --git a/actions/auth.ts b/actions/auth.ts index 09f3ade..9ce36c3 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -1,20 +1,39 @@ "use server"; +import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; -export async function login(formData: FormData) { - const email = String(formData.get("email") ?? ""); - const password = String(formData.get("password") ?? ""); +// 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. +// +// 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. +export async function signInWithEntra() { const supabase = await createClient(); - const { error } = await supabase.auth.signInWithPassword({ email, password }); - if (error) { - redirect("/login?error=invalid_credentials"); - } + // 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"; - redirect("/"); + 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); } export async function logout() { diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 58e84b7..189da87 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,14 +1,20 @@ -import { login, logout } from "@/actions/auth"; -import { Button } from "@/components/ui/Button"; -import { CONTROL_CLASS } from "@/components/ui/Field"; +import { EntraSignInButton } from "@/components/auth/EntraSignInButton"; +import { logout, signInWithEntra } from "@/actions/auth"; // The query string is attacker-controlled, so the login page renders a message // looked up by code rather than whatever text ?error= carries. Reflecting the // raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt, -// rufen Sie …") on the real, correctly-branded sign-in screen. +// rufen Sie …") on the real, correctly-branded sign-in screen. Dasselbe gilt +// für die Fehlertexte, die Entra im Rückweg mitschickt. const ERROR_MESSAGES = { - no_hr_access: "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.", - invalid_credentials: "E-Mail oder Passwort ist falsch.", + no_hr_access: { + title: "Kein HR-Zugriff", + body: "Ihr Firmenkonto ist bekannt, aber nicht für die Personalverwaltung freigeschaltet. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.", + }, + sso_failed: { + title: "Anmeldung fehlgeschlagen", + body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.", + }, } as const; type ErrorCode = keyof typeof ERROR_MESSAGES; @@ -23,56 +29,105 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { const error = code ? ERROR_MESSAGES[code] : null; return ( -
-
-

Alpenwerk HR

-

Melden Sie sich mit Ihrem Firmenkonto an.

+ // dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte + // im ersten Moment unter dem Faltenrand sitzt. +
+ - {error && ( -
- {error} - {code === "no_hr_access" && ( -
- -
- )} +
+
+
+
- )} -
-
- - -
-
- - -
- -
-
+

Anmelden

+

+ Der Zugang läuft über Ihr Firmenkonto. Ein eigenes Passwort gibt es nicht. +

+ + {error && ( +
+

{error.title}

+

{error.body}

+ {code === "no_hr_access" && ( +
+ +
+ )} +
+ )} + +
+ + + +

+ Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle + Personaldaten verschlossen. +

+
+ +
+ ); +} + +function BrandPanel() { + return ( + + ); +} + +function Wordmark({ className = "" }: { className?: string }) { + return ( +
+ {/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter + drei Einheiten. */} + + Alpenwerk HR
); } diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..2772fd0 --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,30 @@ +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}/`); +} diff --git a/components/auth/EntraSignInButton.tsx b/components/auth/EntraSignInButton.tsx new file mode 100644 index 0000000..a228256 --- /dev/null +++ b/components/auth/EntraSignInButton.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useFormStatus } from "react-dom"; + +// Eigene Schaltfläche statt der Button-Komponente: Microsoft gibt für „Sign in +// with Microsoft" Fläche, Schrift und Logo vor, und eine magentafarbene +// Variante wäre nicht nur regelwidrig, sondern auch irreführend — sie sähe aus +// wie eine Aktion *in* dieser Anwendung, während sie in Wirklichkeit auf eine +// fremde Anmeldeseite springt. +export function EntraSignInButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} diff --git a/docs/entra-sso.md b/docs/entra-sso.md new file mode 100644 index 0000000..f33960e --- /dev/null +++ b/docs/entra-sso.md @@ -0,0 +1,117 @@ +# 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. + +**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 +damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die +Sicherheitsgrenze wandert nicht in den Anwendungscode. + +## Einrichtung im Entra-Mandanten + +App-Registrierung, einmalig — angelegt im Mandanten *loudspring management GmbH*: + +| | | +|---|---| +| Name | Alpenwerk HR | +| Kontotypen | Nur ein Mandant | +| Umleitungs-URI (Web) | `https://wcqzamhttnzhionftyoe.supabase.co/auth/v1/callback` | +| Anwendungs-ID (Client) | `88037b8a-54f6-488f-8fcd-67d72b311a88` | +| Verzeichnis-ID (Mandant) | `b002d91e-4569-4911-8756-3dcdc47152bb` | + +Die beiden IDs sind Kennungen, keine Geheimnisse — sie stehen deshalb hier. +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. + +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. +3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten. + +## Einrichtung in Supabase + +Authentication → Providers → Azure: + +| Feld | Wert | +|---|---| +| Application (Client) ID | `88037b8a-54f6-488f-8fcd-67d72b311a88` | +| Secret Value | der Wert aus „Zertifikate & Geheimnisse" | +| Azure Tenant URL | `https://login.microsoftonline.com/b002d91e-4569-4911-8756-3dcdc47152bb` | + +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. + +Authentication → URL Configuration: + +- Site URL: die Produktions-URL +- Redirect URLs: `http://localhost:3000/auth/callback` und + `https:///auth/callback` + +## 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. + +### Woher der Gruppen-Anspruch kommt + +Entra schickt Gruppen nur mit, wenn es in der Tokenkonfiguration eingestellt +ist. Zwei Varianten: + +| Variante | Lizenz | Haken | +|---|---|---| +| 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` + +Das ist kein Detail, sondern der Kern der Absicherung. + +`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. + +### 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: + +1. SSO in Betrieb nehmen, einmal anmelden. +2. `node --env-file=.env.local supabase/entra-claims.ts ` zeigt, was in + `identity_data` gelandet ist. +3. Erst dann die Migration mit der konkreten Gruppen-ID schreiben. + +Ohne Schritt 2 wäre die Migration geraten. + +### 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. + +## Bestehende Zugänge + +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. + +`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. diff --git a/proxy.ts b/proxy.ts index 2d2c1ef..e0335d1 100644 --- a/proxy.ts +++ b/proxy.ts @@ -38,6 +38,11 @@ export async function proxy(request: NextRequest) { const isLoginRoute = request.nextUrl.pathname.startsWith("/login"); + // 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; const url = request.nextUrl.clone(); diff --git a/supabase/entra-claims.ts b/supabase/entra-claims.ts new file mode 100644 index 0000000..049712c --- /dev/null +++ b/supabase/entra-claims.ts @@ -0,0 +1,70 @@ +// Zeigt, was Entra ID beim Anmelden tatsächlich mitgeschickt hat. +// +// Run with: node --env-file=.env.local supabase/entra-claims.ts +// +// 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 "); + 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)"); diff --git a/supabase/relink-profile.ts b/supabase/relink-profile.ts new file mode 100644 index 0000000..f37d6b9 --- /dev/null +++ b/supabase/relink-profile.ts @@ -0,0 +1,110 @@ +// 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 [--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 [--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.");