docs: OIDC PKCE login, LT admin screens, backend-served Flutter build
Plan + both READMEs updated for the Authentik Authorization-Code + PKCE flow, the Leitungsteam admin screen, the widened LT-admin guards, the issuer trailing-slash normalisation, and the backend now serving the Flutter web build (SPA fallback for /v1/auth/callback). Verification section records the local-Postgres E2E for LT admin + onboarding approval, and notes the OIDC browser round-trip still needs a test account. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,14 +61,17 @@ in (`backend/prisma/migrations/`); the backend has been run end to end
|
||||
against a local PostgreSQL 16. `npm test` covers the assignment algorithm
|
||||
and the new auth/onboarding services (56 tests).
|
||||
|
||||
Phase 7 (Flutter client) started: `client/app/` is a single Flutter
|
||||
codebase with the **web** target enabled — login (guest / local Teamer /
|
||||
invite redemption), role-aware home, guest Workshop-Wahl, file list,
|
||||
read-only chat. `flutter build web` and `flutter test` pass. Mobile/desktop
|
||||
targets, the Authentik Authorization-Code flow, WebSocket chat send, and LT
|
||||
admin screens are still to come.
|
||||
Phase 7 (Flutter client) in progress: `client/app/` is a single Flutter
|
||||
codebase with the **web** target enabled — guest / local-Teamer / invite
|
||||
login, the Authentik Authorization-Code + PKCE flow (`lib/oidc.dart`) for
|
||||
Leitungsteam/Verantwortliche, role-aware home, guest Workshop-Wahl (wishes +
|
||||
result), file list, live WebSocket chat, and a Leitungsteam admin screen
|
||||
(KCs, Gemeinden, onboarding approvals). `flutter build web` / `flutter test`
|
||||
pass; the backend serves the build at `/` (SPA fallback covers the OIDC
|
||||
redirect `/v1/auth/callback`). Still to do: a live browser test of the OIDC
|
||||
round-trip, the Teamer-management and Verantwortlichen-self-registration
|
||||
screens, LT Wahl administration, mobile/desktop targets, and push.
|
||||
|
||||
The backend also serves the interim `client/web/` placeholder at `/` (API
|
||||
under `/api`). Running end to end still needs a real Authentik instance and
|
||||
Nextcloud/S3 credentials (see `backend/.env.example`).
|
||||
Running end to end needs the Authentik redirect registered + a test account,
|
||||
plus Nextcloud/S3 credentials (see `backend/.env.example`).
|
||||
|
||||
|
||||
+34
-8
@@ -7,25 +7,46 @@ added later with `flutter create --platforms=...` in this directory — the
|
||||
|
||||
## Run
|
||||
|
||||
The Authentik redirect URI is `http://localhost:3000/v1/auth/callback`, so
|
||||
the app must be reached on `:3000` — i.e. served by the backend, not `flutter
|
||||
run`'s own dev server. Build it and let NestJS serve it:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run -d chrome --dart-define=API_BASE=http://localhost:3000/api
|
||||
flutter build web # backend serves client/app/build/web at /
|
||||
# then run the backend (npm run start:dev in ../../backend) and open :3000
|
||||
```
|
||||
|
||||
`API_BASE` defaults to `http://localhost:3000/api` (the local NestJS
|
||||
backend, which also serves the interim plain-HTML client at `/`).
|
||||
For pure UI work without the OIDC flow, `flutter run -d chrome
|
||||
--dart-define=API_BASE=http://localhost:3000/api` still works (guest / local
|
||||
Teamer login only).
|
||||
|
||||
### Dart-defines
|
||||
|
||||
| define | default |
|
||||
|---|---|
|
||||
| `API_BASE` | `http://localhost:3000/api` |
|
||||
| `OIDC_ISSUER` | `https://sso.konfi-castle.com/application/o/konfi-castle-app/` |
|
||||
| `OIDC_CLIENT_ID` | the konfi-castle public client id |
|
||||
| `OIDC_REDIRECT_URI` | `http://localhost:3000/v1/auth/callback` |
|
||||
|
||||
## What's implemented
|
||||
|
||||
- **Login** (`lib/screens/login_screen.dart`) — three tabs:
|
||||
- *Konfi / Gast*: KC invite code + first/last name → `POST /auth/guest`.
|
||||
- *Team-Login*: email + password for local Gemeinde Teamer →
|
||||
`POST /auth/team-login`. (Leitungsteam / Verantwortliche use the
|
||||
Authentik Authorization-Code flow, not yet wired into this client.)
|
||||
- *Leitungsteam / Verantwortliche*: "Mit Konfi-Castle-ID anmelden" starts
|
||||
the Authentik **Authorization Code + PKCE** flow (`lib/oidc.dart`);
|
||||
below it, the local Gemeinde-Teamer password form
|
||||
(`POST /auth/team-login`).
|
||||
- *Einladung*: redeem a Teamer invite token → `POST /auth/teamer/register`.
|
||||
- The token is stored via `shared_preferences` (localStorage on web) and
|
||||
restored on start; `GET /auth/me` resolves the role for a role-aware home.
|
||||
- OIDC: discovery + S256 challenge, `?code=` handled on bootstrap, access +
|
||||
refresh token persisted (`shared_preferences` / localStorage), expired
|
||||
access token refreshed on restart. `GET /auth/me` resolves the role.
|
||||
- **Home** (`lib/screens/home_screen.dart`) — identity card + navigation.
|
||||
- **Verwaltung** (`lib/screens/admin_screen.dart`, Leitungsteam only) —
|
||||
list/create KCs; per KC the Gemeinden (list/create) and pending
|
||||
Verantwortlichen self-registrations (`GET /onboarding/requests`,
|
||||
approve / reject).
|
||||
- **Workshop-Wahl** (`lib/screens/wahl_screen.dart`, guests) — two tabs:
|
||||
*Wünsche* (`GET /wahl/guest/overview`, tap workshops in order, max 3,
|
||||
`POST /wahl/:id/teilnehmer`) and *Ergebnis* (`GET /wahl/guest/results` —
|
||||
@@ -40,6 +61,11 @@ backend, which also serves the interim plain-HTML client at `/`).
|
||||
|
||||
- `lib/api.dart` — `Api` (thin REST wrapper + models) and `AppState`
|
||||
(`ChangeNotifier`: session, login/logout, token persistence).
|
||||
- `lib/oidc.dart` — Authentik PKCE flow. Browser-only bits (sessionStorage,
|
||||
redirect, `window.location`) sit behind a conditional import
|
||||
(`browser.dart` → `browser_web.dart` / `browser_stub.dart`) so
|
||||
`flutter test` compiles on the Dart VM.
|
||||
- `lib/chat_socket.dart` — `/chat` WebSocket wrapper.
|
||||
- `lib/main.dart` — `AppScope` (an `InheritedNotifier<AppState>`) exposes
|
||||
`AppScope.of(context)`; `_AuthGate` switches Login/Home. No third-party
|
||||
state-management package.
|
||||
|
||||
@@ -38,8 +38,8 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
|
||||
|---|---|---|
|
||||
| Backend | NestJS 10 + PostgreSQL + Prisma 5 | bestätigt vom Nutzer; Nest 10 statt CLI-Default (siehe unten) |
|
||||
| Client | Flutter, eine Codebase Mobile/Web/Desktop — `client/app/`, aktuell nur Web-Target aktiviert | vom Nutzer delegiert; Flutter-SDK lokal via Homebrew installiert (nur Web-Toolchain, Android/iOS wegen Speicher weggelassen); `lib/` ist plattformneutral, weitere Targets per `flutter create --platforms=…` nachrüstbar |
|
||||
| Web-Interimslösung | Backend liefert `client/web/` (reines HTML/CSS/JS, kein Build-Schritt) über `ServeStaticModule` aus; REST-API liegt unter `/api/*` | Nutzerwunsch: "Server soll auch Web-Client bereitstellen"; vermeidet Kollision zwischen API-Routen und statischen Dateien |
|
||||
| Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend | Clients machen Authorization Code + PKCE direkt gegen Authentik; Backend validiert nur Access Token + löst lokale `Membership` auf |
|
||||
| Web-Auslieferung | `ServeStaticModule` liefert primär den **Flutter-Web-Build** (`client/app/build/web`) auf `:3000` aus, mit SPA-Fallback (u. a. für den OIDC-Redirect `/v1/auth/callback`); fällt auf `client/web/` zurück, falls der Build fehlt. REST-API unter `/api/*`. | Ein Origin für App + API; der registrierte Authentik-Redirect zeigt auf `http://localhost:3000/v1/auth/callback` |
|
||||
| Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend. Client macht **Authorization Code + PKCE (S256)** direkt gegen Authentik (`sso.konfi-castle.com`, Public Client, `oidc.dart`), Backend validiert nur das Access-Token. Issuer-Trailing-Slash wird normalisiert (beide `iss`-Schreibweisen akzeptiert). | Public Client kann kein Secret halten; PKCE genügt |
|
||||
| Auth (Guest) | Rein lokale JWTs (`GUEST_JWT_SECRET`), kein Authentik | explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik |
|
||||
| Auth (Gemeinde Teamer) | Lokale Accounts: `User` mit `passwordHash`+`kcId`, `authentikSub` bleibt leer; eigenes JWT (`TEAM_JWT_SECRET`, Payload `typ:'team'`), Passwort-Login oder Invite-Redemption. Verantwortliche legen Teamer an (Direkt/Gruppen-Link/E-Mail-Invite) | Nutzervorgabe: Teamer laufen nicht über die Konfi-Castle-ID (Authentik), sondern werden pro KC lokal verwaltet (wie Guests, nur dauerhaft + mit Rolle) |
|
||||
| Datei-Storage | Provider-Abstraktion (`StorageProvider`), Default **Nextcloud/WebDAV**, umschaltbar auf S3 via `STORAGE_PROVIDER=s3` | Nutzer bestätigte: Nextcloud-Zugangsdaten kommen aus `.env` |
|
||||
@@ -53,7 +53,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
|
||||
- **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist.
|
||||
- **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit).
|
||||
- **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt.
|
||||
- **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` automatisch an (`AuthentikStrategy` → `resolveOrProvisionAuthentikUser`, JIT, race-sicher) **und** setzt `isLeitungsteam` aus dem `groups`-Claim. Voraussetzung: der Authentik-Provider muss den `groups`-Claim ins Access-Token schreiben (Scope „groups" hinzufügen) und der LT-Gruppenname muss zu `AUTHENTIK_LEITUNGSTEAM_GROUP` passen — sonst wird niemand als LT erkannt. Gemeinde Verantwortliche brauchen weiterhin die `onboarding/`-Freigabe durch LT. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.)
|
||||
- **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` automatisch an (`AuthentikStrategy` → `resolveOrProvisionAuthentikUser`, JIT, race-sicher) **und** setzt `isLeitungsteam` aus dem `groups`-Claim. Voraussetzung: der Authentik-Provider muss den `groups`-Claim ins Access-Token schreiben (Scope „groups" hinzufügen) und der LT-Gruppenname muss zu `AUTHENTIK_LEITUNGSTEAM_GROUP` passen — sonst wird niemand als LT erkannt. Gemeinde Verantwortliche brauchen weiterhin die `onboarding/`-Freigabe durch LT. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.) Der Client-seitige PKCE-Flow (`oidc.dart`) ist gebaut, aber der volle Browser-Roundtrip ist noch nicht live getestet — dafür muss der genutzte Redirect (`http://localhost:3000/v1/auth/callback` bzw. die Prod-URL) am Authentik-Provider hinterlegt sein und ein Testaccount existieren.
|
||||
- **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert.
|
||||
- **E-Mail-Versand**: `mail/`-Modul mit `MailProvider`-Abstraktion. Persönliche `teamer-invites` (mit `email`) werden verschickt; Default-Provider ist **log-only** (schreibt nur ins Log), echter Versand erst mit `MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`. Onboarding-Benachrichtigungen an LT gibt es noch nicht.
|
||||
|
||||
@@ -64,7 +64,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
|
||||
| Modul | Kernfunktion | Wichtige Endpunkte |
|
||||
|---|---|---|
|
||||
| `prisma/` | Geteilter `PrismaClient`-Provider | – |
|
||||
| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`) mit **JIT-`User`-Anlage** beim ersten Login + **LT-Abgleich** aus dem `groups`-Claim → `User.isLeitungsteam` → virtuelle globale LT-`Membership` (`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`, race-sicher), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur `ACTIVE`-Memberships. | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` |
|
||||
| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`) mit **JIT-`User`-Anlage** beim ersten Login + **LT-Abgleich** aus dem `groups`-Claim → `User.isLeitungsteam` → virtuelle globale LT-`Membership` (`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`, race-sicher), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur `ACTIVE`-Memberships; auch der Team-Token-Pfad geht durch `toAuthenticatedUser` (synthetische LT-`Membership` bei `isLeitungsteam`). LT-Admin-Controller (`kc`/`gemeinde`/`onboarding`/`sync`/`teamer`) akzeptieren `['authentik','team']`. | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register`, `GET /api/auth/me` |
|
||||
| `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` |
|
||||
| `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` |
|
||||
| `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) |
|
||||
@@ -116,6 +116,8 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
|
||||
3b. Flutter-Client (`client/app/`): `flutter analyze` sauber, `flutter build web --release` erfolgreich, `flutter test` grün (Login-Screen-Smoke-Test); CORS-Preflight vom Browser-Origin ok.
|
||||
3c. Guest-Ergebnis: `/wahl/guest/results` gegen echtes Postgres in beiden Zuständen geprüft (PENDING nach Einreichung, ASSIGNED nach manuell gesetzter `Zuteilung` → Workshop-Name + Wunschrang).
|
||||
3d. WS-Chat: Zwei-Client-E2E gegen echtes Postgres (zwei lokale Teamer im selben `GEMEINDE_GRUPPE`-Kanal, `chat:send` → der andere empfängt `chat:message`). Dabei behoben: `ChatGateway` speicherte den Caller erst nach dem asynchronen Token-Check, wodurch ein sofortiges `chat:join` mit 4001 abgewiesen wurde — jetzt wartet der Handler auf das Caller-Promise.
|
||||
3e. LT-Admin gegen echtes Postgres mit einem `isLeitungsteam`-Account (Team-Token): `POST/GET /api/kc`, `POST /api/gemeinde`, `GET /api/onboarding/requests`, sowie eine PENDING-Verantwortlichen-Anfrage → `approve` → Status `ACTIVE`, Liste danach leer.
|
||||
3f. Authentik-OIDC-Discovery von `sso.konfi-castle.com` abgerufen (PKCE `S256`, Scopes inkl. `groups`, `authorization_code`+`refresh_token`). **Noch offen:** vollständiger Browser-Roundtrip (Redirect → Login → Code-Tausch) — braucht den registrierten Redirect + einen LT-/Verantwortlichen-Testaccount.
|
||||
4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, `npm test` grün, 56 Tests):
|
||||
- `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl.
|
||||
- `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login.
|
||||
@@ -128,7 +130,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
|
||||
|
||||
## 8. Nächste Schritte
|
||||
|
||||
1. Flutter-Client ausbauen: **Authentik-Authorization-Code-Flow** (LT/Verantwortliche — braucht eine erreichbare Authentik-Instanz), LT-/Verantwortlichen-Admin-Screens (KC/Gemeinde/Teamer anlegen, Onboarding-Anfragen freigeben), Verantwortlichen-Selbstregistrierung, dann Mobile/Desktop-Targets aktivieren.
|
||||
1. Flutter-Client: ✅ Authentik-PKCE-Login-Flow (`oidc.dart`) + LT-Admin-Screens (KC/Gemeinde anlegen, Onboarding-Anfragen freigeben). 🔜 Browser-Roundtrip einmal live testen (Redirect + Testaccount); Teamer-Verwaltungs-Screen (für Verantwortliche/LT: Teamer + Invites), Verantwortlichen-Selbstregistrierungs-Screen, Wahl-Verwaltung für LT, dann Mobile/Desktop-Targets.
|
||||
2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), LT-Gruppe = `AUTHENTIK_LEITUNGSTEAM_GROUP`. (Ops/Config, Code ist fertig.)
|
||||
3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und Invite-Mail-Templates finalisieren (aktuell Plain-Text); optional Onboarding-Benachrichtigungen an LT.
|
||||
4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen.
|
||||
|
||||
Reference in New Issue
Block a user