feat(auth): Admin-Zugang über Authentik-Gruppe steuern

Statt manuell gesetzter DB-Rolle erhalten Mitglieder der Authentik-Gruppe
AUTHENTIK_ADMIN_GROUP (Default floriannetz-admins) beim SSO-Login automatisch
platform_admin; Nicht-Mitglieder werden abgewiesen. Erstes Seeding entfällt.

- auth.config.ts: Scope 'openid email profile groups' anfordern
- lib/auth/authentik.ts: reine Helfer extractGroups/isAdminGroupMember (+ 7 Unit-Tests)
- auth.ts: signIn wertet groups-Claim aus, upsert (idempotent) als platform_admin
  mit stabiler users.id für Audit/FKs
- env.ts/.env.example: AUTHENTIK_ADMIN_GROUP
- docs/reference/authentik-setup.md: Provider-/Gruppen-/Scope-Setup

Verifiziert offline: tsc OK; lint sauber; vitest 240 passed / 7 skipped.
Wehr-Konten bleiben lokale Accounts (kein Authentik).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-10 09:39:13 +02:00
parent a8d07ba2ab
commit f2578cedab
7 changed files with 169 additions and 7 deletions

View File

@@ -17,6 +17,10 @@ AUTH_TRUST_HOST=true
AUTHENTIK_ISSUER=http://localhost:9000/application/o/floriannetz/
AUTHENTIK_CLIENT_ID=floriannetz
AUTHENTIK_CLIENT_SECRET=bitte-setzen
# Mitglieder dieser Authentik-Gruppe erhalten beim Login automatisch
# platform_admin. Wer NICHT in der Gruppe ist, wird vom SSO-Login abgewiesen.
# Setup siehe docs/reference/authentik-setup.md.
AUTHENTIK_ADMIN_GROUP=floriannetz-admins
# Geo (interne Dienste; Defaults zeigen auf Docker-Compose-Hostnamen)
OSRM_URL=http://osrm:5000

View File

@@ -0,0 +1,53 @@
# Authentik-Integration & Admin-Zugang (FlorianNetz)
FlorianNetz nutzt Authentik als **OIDC-Identitätsanbieter**. Der **Admin-Zugang
(platform_admin) wird zentral über eine Authentik-Gruppe** gesteuert — nicht über
manuell gesetzte DB-Rollen.
## Wie es funktioniert
- Anmeldung über Authentik (OIDC). FlorianNetz fordert die Scopes
`openid email profile groups` an.
- Im `signIn`-Callback (`src/auth.ts`) wird der `groups`-Claim ausgewertet:
- **Mitglied der Admin-Gruppe** (`AUTHENTIK_ADMIN_GROUP`, Standard
`floriannetz-admins`) → wird (idempotent) als `platform_admin` in `users`
angelegt/aktualisiert und eingeloggt.
- **Kein Mitglied** → Login wird **abgewiesen** (`return false`).
- Folge: Admins werden **in Authentik** verwaltet (Gruppenmitgliedschaft), nicht
per `seed-auth`. Ein erstes manuelles Seeding entfällt (kein Henne-Ei-Problem).
- **Wehr-Konten** (wehr_admin/wehr_read) bleiben **lokale** App-Konten
(E-Mail+Passwort), die Wehr-Admins selbst anlegen — sie nutzen NICHT Authentik.
## Einrichtung in Authentik
1. **Gruppe anlegen:** z. B. `floriannetz-admins`; gewünschte Admin-Benutzer
hinzufügen. (Muss exakt `AUTHENTIK_ADMIN_GROUP` entsprechen.)
2. **Provider anlegen:** OAuth2/OpenID Provider
- Redirect-URI: `https://<APP_HOST>/api/auth/callback/authentik`
- Signing Key wie üblich; Client-Typ „Confidential".
3. **Scopes/Property-Mappings:** dem Provider die Scope-Mappings
`openid`, `email`, `profile` **und** das Gruppen-Mapping zuweisen, das den
`groups`-Claim liefert (Authentik-Standard: „authentik default OAuth Mapping:
OpenID 'groups'"). Ohne dieses Mapping enthält das Token keine `groups` und
**niemand** erhält Admin-Zugang.
4. **Application anlegen** und mit dem Provider verknüpfen; Slug muss zum
`AUTHENTIK_ISSUER` passen (`…/application/o/<slug>/`).
5. **Client-ID/-Secret** aus dem Provider übernehmen.
## Umgebungsvariablen
```
AUTHENTIK_ISSUER=https://auth.example.at/application/o/floriannetz/
AUTHENTIK_CLIENT_ID=…
AUTHENTIK_CLIENT_SECRET=…
AUTHENTIK_ADMIN_GROUP=floriannetz-admins
```
## Prüfen
- Mitglied von `floriannetz-admins` meldet sich an → landet als Admin in
`/admin`; in `users` existiert eine Zeile `authTyp='authentik'`,
`rolle='platform_admin'`.
- Nicht-Mitglied meldet sich an → Login abgewiesen (zurück zu `/login`).
- `groups`-Claim fehlt (Mapping nicht zugewiesen) → alle SSO-Logins abgewiesen
(erwartetes Fail-safe-Verhalten: kein Claim ⇒ kein Admin).

View File

@@ -30,6 +30,9 @@ export const authConfig = {
issuer: process.env.AUTHENTIK_ISSUER!,
clientId: process.env.AUTHENTIK_CLIENT_ID!,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
// `groups`-Claim anfordern, damit der Admin-Zugang über die
// Authentik-Gruppenmitgliedschaft gesteuert werden kann (signIn-Callback).
authorization: { params: { scope: "openid email profile groups" } },
}),
],
callbacks: {

View File

@@ -7,12 +7,47 @@ import { users } from "@/db/schema";
import { authConfig } from "./auth.config";
import { verifyPassword } from "@/lib/auth/password";
import { checkRateLimit, recordAttempt } from "@/lib/auth/rate-limit";
import { extractGroups, isAdminGroupMember } from "@/lib/auth/authentik";
import { ROLES } from "@/lib/auth/roles";
import { env } from "@/lib/env";
const credSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
/**
* Stellt sicher, dass ein Authentik-Admin (Mitglied der Admin-Gruppe) als
* platform_admin in `users` existiert — für eine stabile id (Audit/FKs).
* Idempotent über die eindeutige E-Mail; benötigt KEIN vorheriges Seeding.
*/
async function upsertAuthentikAdmin(email: string, name: string | null) {
const normalized = email.toLowerCase();
const rows = await db
.insert(users)
.values({
email: normalized,
name: name ?? normalized,
rolle: ROLES.PLATFORM_ADMIN,
authTyp: "authentik",
aktiv: true,
brigadeId: null,
})
.onConflictDoUpdate({
target: users.email,
set: {
rolle: ROLES.PLATFORM_ADMIN,
authTyp: "authentik",
aktiv: true,
...(name ? { name } : {}),
},
})
.returning();
const row = rows[0];
if (!row) throw new Error("Authentik-Admin-Upsert lieferte keine Zeile");
return row;
}
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
@@ -50,17 +85,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
],
callbacks: {
...authConfig.callbacks,
// Authentik-Login-Gate: nur vorgemerkte, aktive authentik-Konten zulassen.
async signIn({ user, account }) {
// Authentik-Login = Admin-Zugang, gesteuert über die Authentik-GRUPPE:
// Nur Mitglieder von AUTHENTIK_ADMIN_GROUP dürfen rein und werden
// (idempotent) als platform_admin angelegt. Alle anderen werden abgewiesen.
async signIn({ user, account, profile }) {
if (account?.provider === "authentik") {
const email = user.email;
if (!email) return false;
const u = await db.query.users.findFirst({
where: eq(users.email, email),
});
if (!u || !u.aktiv || u.authTyp !== "authentik") return false;
const groups = extractGroups(profile);
if (!isAdminGroupMember(groups, env.AUTHENTIK_ADMIN_GROUP)) return false;
const u = await upsertAuthentikAdmin(email, user.name ?? null);
user.id = u.id;
user.role = u.rolle;
user.brigadeId = u.brigadeId ?? null;
user.brigadeId = null;
}
return true;
},

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { extractGroups, isAdminGroupMember } from "@/lib/auth/authentik";
describe("isAdminGroupMember", () => {
it("true, wenn die Admin-Gruppe enthalten ist", () => {
expect(
isAdminGroupMember(["wehr-x", "floriannetz-admins"], "floriannetz-admins"),
).toBe(true);
});
it("false, wenn die Admin-Gruppe fehlt", () => {
expect(isAdminGroupMember(["wehr-x"], "floriannetz-admins")).toBe(false);
});
it("false bei leerer Gruppenliste", () => {
expect(isAdminGroupMember([], "floriannetz-admins")).toBe(false);
});
});
describe("extractGroups", () => {
it("liest den groups-Claim", () => {
expect(extractGroups({ sub: "1", groups: ["a", "b"] })).toEqual(["a", "b"]);
});
it("leeres Array, wenn kein groups-Claim vorhanden ist", () => {
expect(extractGroups({ sub: "1" })).toEqual([]);
});
it("leeres Array bei undefined/null", () => {
expect(extractGroups(undefined)).toEqual([]);
expect(extractGroups(null)).toEqual([]);
});
it("defensiv: ignoriert nicht-string-Arrays", () => {
expect(extractGroups({ groups: "kein-array" })).toEqual([]);
expect(extractGroups({ groups: [1, 2, 3] })).toEqual([]);
});
});

25
src/lib/auth/authentik.ts Normal file
View File

@@ -0,0 +1,25 @@
import { z } from "zod";
/**
* Reine Helfer für die Authentik-Gruppensteuerung des Admin-Zugangs.
* Bewusst OHNE DB-/Node-Importe, damit sie isoliert unit-testbar sind und
* auch im Edge-Pfad unbedenklich wären.
*/
const profileWithGroups = z
.object({ groups: z.array(z.string()).optional() })
.passthrough();
/** Extrahiert den `groups`-Claim aus dem Authentik-OIDC-Profil (defensiv). */
export function extractGroups(profile: unknown): string[] {
const parsed = profileWithGroups.safeParse(profile);
return parsed.success && parsed.data.groups ? parsed.data.groups : [];
}
/** Entscheidung: Ist der Benutzer Mitglied der konfigurierten Admin-Gruppe? */
export function isAdminGroupMember(
groups: readonly string[],
adminGroup: string,
): boolean {
return groups.includes(adminGroup);
}

View File

@@ -10,6 +10,8 @@ const serverSchema = z.object({
AUTHENTIK_ISSUER: z.string().url(),
AUTHENTIK_CLIENT_ID: z.string().min(1),
AUTHENTIK_CLIENT_SECRET: z.string().min(1),
// Authentik-Gruppe, deren Mitglieder automatisch platform_admin werden.
AUTHENTIK_ADMIN_GROUP: z.string().min(1).default("floriannetz-admins"),
// Geo:
OSRM_URL: z.string().url().default("http://osrm:5000"),
NOMINATIM_URL: z.string().url().default("http://nominatim:8080"),