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:
2026-07-31 14:57:32 +02:00
parent b3a0af2b8f
commit 2ba9b37aa7
29 changed files with 853 additions and 531 deletions

View File

@@ -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)");

View File

@@ -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

View 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;
$$;

View File

@@ -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.");