feat(auth): Anmeldung ueber Microsoft Entra (SSO)

OpenID Connect mit Authorization Code Flow und PKCE, vollstaendig
serverseitig - der Browser sieht weder Tenant- noch Client-ID. Genutzt wird
dieselbe App-Registrierung wie die Postfachanbindung, also kein zweites
Secret; delegiert statt app-only.

Nach dem Token-Tausch beweist ein Aufruf von /me bei Graph, dass das
Zugriffstoken gilt, und liefert die Objekt-ID. Sie landet in der neuen Spalte
crm_users.entra_oid und ist danach die stabile Verknuepfung - die E-Mail-
Adresse eines Menschen kann sich aendern, die Objekt-ID nicht.

Bewusst so entschieden:
  - Es werden keine Konten angelegt. Wer kein CRM-Konto mit derselben Adresse
    hat, wird abgewiesen; Zugriff vergibt weiter die Nutzerverwaltung.
  - Die Anmeldung mit Benutzername und Passwort bleibt fuer alle bestehen -
    Notzugang, falls Entra oder das Secret ausfaellt.
  - Das CRM-Token kommt als Einmal-Ticket zurueck, nicht in der Adresszeile:
    die Rueckleitung ist ein GET und stuende sonst in Browserverlauf und
    Apache-Log.
  - Keine Signaturpruefung des ID-Tokens und damit keine JWKS-Bibliothek. Es
    kommt ueber TLS direkt vom Token-Endpunkt (OIDC Core 3.1.3.7); geprueft
    werden aud, tid, nonce und exp, und der Graph-Aufruf belegt den Rest.

Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
bisher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-21 08:14:23 +02:00
co-authored by Claude Opus 5
parent 2d84bac91d
commit 419e57b55d
23 changed files with 1188 additions and 19 deletions
+31 -2
View File
@@ -94,7 +94,7 @@ Alle unter `router.use(requireAuth)`.
| Router | Endpunkte | Rechte |
| --- | --- | --- |
| `auth.js` | `POST /login`, `GET /me`, `PATCH /me`, `POST /change-password` | `/login` offen, Rest angemeldet |
| `auth.js` | `POST /login`, `GET /me`, `PATCH /me`, `POST /change-password`; SSO: `GET /sso/status`, `GET /sso/start`, `GET /sso/callback`, `POST /sso/ticket`, `GET /sso/config` | `/login` und die vier offenen SSO-Endpunkte ohne Anmeldung, `/sso/config` Admin, Rest angemeldet |
| `users.js` | `GET /directory`; danach `requireAdmin`: `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | `/directory` für alle, Rest Admin |
| `leads.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`; Vorbereich: `GET /mail-pool/sources`, `POST /mail-pool/promote`, `POST /:id/promote` | jeder Angemeldete |
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
@@ -200,6 +200,35 @@ Eigenheiten, die man kennen muss:
* Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf
kommt sofort mit `skipped` zurück, statt parallel zu laufen.
### `server/src/sso/`
Anmeldung über Microsoft Entra, fachlich in
[13 Anmeldung über Microsoft](13-anmeldung-sso.md). Dieselbe App-Registrierung
wie das Postfach, aber delegiert statt app-only - deshalb ein eigenes Modul und
kein Import aus `mail/`: die Anmeldung läuft auch, wenn die Postfachanbindung
aus ist.
| Datei | Inhalt |
| --- | --- |
| `config.js` | `.env` lesen, `SSO_CONFIGURED`, `missingSsoConfig()`, `domainAllowed()` |
| `flow.js` | `beginLogin()` (PKCE, state, nonce), `takePendingLogin()`, `exchangeCode()`, `verifyIdToken()`, `fetchProfile()`, `fetchPhoto()`, `createTicket()` / `redeemTicket()` |
Eigenheiten, die man kennen muss:
* Offene Anmeldungen und Tickets liegen **im Prozessspeicher**, nicht in der
Datenbank: Lebensdauer 10 Minuten bzw. 60 Sekunden, und das Backend läuft als
einzelne Instanz. Ein Neustart verwirft höchstens eine gerade laufende
Anmeldung.
* `state` und Ticket gelten je genau einmal - beide werden beim Lesen entfernt.
* `verifyIdToken()` prüft `aud`, `tid`, `nonce` und `exp`, aber nicht die
Signatur. Das Token kommt über TLS direkt vom Token-Endpunkt (OIDC Core
3.1.3.7), und der anschliessende `/me`-Aufruf belegt ohnehin, dass das
Zugriffstoken gilt. Das spart eine JWKS-Bibliothek.
* `fetchPhoto()` schluckt jeden Fehler und gibt `null` zurück - wer kein
Profilfoto hat, soll sich trotzdem anmelden können.
* `findSsoUser()` in `routes/auth.js` legt **nie** ein Konto an und sucht über
die E-Mail-Adresse nur, solange `entra_oid IS NULL` ist.
### `server/src/routes/scanner.js`
Die portierte Scanner-API unter `/api/scanner`, vollständig in
@@ -294,7 +323,7 @@ Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.
| Datei | Hält | Eigenheit |
| --- | --- | --- |
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prüft beim Start das gespeicherte Token gegen `/auth/me` |
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser`, `ssoError` | prüft beim Start das gespeicherte Token gegen `/auth/me`; kommt der Browser von Microsoft zurück, hat `?sso_ticket=` Vorrang - es wird eingelöst und die Adresszeile per `history.replaceState()` geleert |
| `theme.tsx` | Farbwelt (perinco/Pixel Art/Outdoor/Cyberpunk/Rainy/Snow, Hintergrundgrafiken in `public/themes/`; bei Rainy hängt er zusätzlich `components/RainyWeather.tsx` mit Regen und Wetterleuchten ein, bei Snow `components/SnowWeather.tsx` mit Schneefall, Schneedecke und Räumfahrzeug, bei Aura `components/AuraWeather.tsx` mit Sonnenstrahlen) und Modus (hell/dunkel/System) | Beides gehört dem Benutzer und steht in `crm_users`; `localStorage` ist nur der Zwischenspeicher fürs Inline-Skript in `index.html`. Doppelklick auf den Modus-Schalter folgt wieder dem System |
| `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` öffnet den Kontakt direkt |
| `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker für die ganze App |