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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user