# 13 Anmeldung über Microsoft (SSO) Wer ein Microsoft-365-Konto von perinco hat, meldet sich im CRM mit einem Klick an - ohne zweites Passwort. Die Anmeldung mit Benutzername und Passwort bleibt daneben bestehen. ## Auf einen Blick | | | | --- | --- | | Verfahren | OpenID Connect, Authorization Code Flow mit PKCE | | Wo läuft es | vollständig im Backend; der Browser sieht keine Tenant- oder Client-ID | | App-Registrierung | dieselbe wie die [Postfachanbindung](11-postfach.md) - kein zweites Secret | | Neue Konten | **werden nicht angelegt**; es braucht ein bestehendes Konto in der Nutzerverwaltung | | Passwortanmeldung | bleibt für alle bestehen (Notzugang, falls Entra ausfällt) | | Sitzung | wie bisher ein CRM-JWT aus `signToken()`, Standard 7 Tage | ## Ablauf ```mermaid sequenceDiagram participant B as Browser participant API as Backend participant E as Entra participant G as Microsoft Graph participant DB as MariaDB B->>API: GET /api/auth/sso/start Note right of API: state, nonce,
PKCE-Verifier merken API-->>B: 302 zu login.microsoftonline.com B->>E: Anmeldung, Kontoauswahl E-->>B: 302 /api/auth/sso/callback?code&state B->>API: GET /callback API->>API: state verbrauchen (gilt einmal) API->>E: code + Verifier + Client-Secret E-->>API: id_token, access_token API->>API: aud, tid, nonce, exp prüfen API->>G: GET /me (delegiert) G-->>API: Objekt-ID, Name, Adresse API->>DB: Konto über entra_oid, sonst über E-Mail DB-->>API: Benutzer oder nichts API->>G: GET /me/photos/96x96/$value API-->>B: 302 /?sso_ticket=... B->>API: POST /api/auth/sso/ticket API-->>B: CRM-Token + Benutzer ``` ### Warum ein Ticket und kein Token in der Adresszeile Die Rückleitung von Microsoft ist ein `GET`. Stünde das CRM-Token darin, läge es im Browserverlauf, im Apache-Log und in jedem weitergegebenen Link. Stattdessen kommt ein Einmal-Ticket zurück, das der Browser sofort per `POST` gegen das Token tauscht. Das Ticket gilt 60 Sekunden und genau einmal; `auth.tsx` räumt die Adresszeile anschliessend mit `history.replaceState()` auf. ### Warum zusätzlich `/me` bei Graph Das ID-Token kommt über TLS direkt vom Token-Endpunkt und ist damit vertrauenswürdig - OIDC Core 3.1.3.7 erlaubt in diesem Fall den Verzicht auf die Signaturprüfung, deshalb braucht das Backend keine JWKS-Bibliothek. Geprüft werden trotzdem `aud`, `tid`, `nonce` und `exp`. Der Graph-Aufruf kommt dazu, weil er zweierlei belegt: dass das Zugriffstoken wirklich gilt, und welche Objekt-ID zum Konto gehört. ## Zuordnung zum CRM-Konto ```mermaid flowchart TD A["Profil von Graph:
Objekt-ID, E-Mail"] --> B{"crm_users.entra_oid
= Objekt-ID?"} B -->|ja| Z["anmelden"] B -->|nein| C{"crm_users.email = Adresse
und entra_oid leer?"} C -->|ja| D["entra_oid festschreiben"] --> Z C -->|nein| E["abweisen:
kein CRM-Konto"] Z --> F{"is_active?"} F -->|nein| G["abweisen:
Konto deaktiviert"] ``` Die Spalte `crm_users.entra_oid` (Unique) ist die dauerhafte Verknüpfung: die E-Mail-Adresse eines Menschen kann sich ändern, die Objekt-ID des Microsoft-Kontos nicht. Sie wird bei der ersten Anmeldung gesetzt und danach nicht mehr über die Adresse gesucht. Ein Konto, das bereits an einer **anderen** Objekt-ID hängt, wird nicht übernommen - die Bedingung `entra_oid IS NULL` schliesst das aus. ### Profilfoto Hat der Benutzer im CRM noch kein Foto, holt die erste Anmeldung `/me/photos/96x96/$value` und legt es als Data-URL in `crm_users.avatar` ab - dieselbe Spalte, die auch der Bild-Upload und [der LinkedIn-Weg](09-quellcode.md) benutzen. Ein selbst gesetztes Foto bleibt unangetastet, und wer kein Foto in M365 hat, meldet sich trotzdem an (Graph antwortet dann mit 404, das ist eingeplant). ## Endpunkte | Methode | Pfad | Zugriff | Zweck | | --- | --- | --- | --- | | `GET` | `/api/auth/sso/status` | offen | `{ enabled }` - zeigt die Anmeldemaske den Knopf? | | `GET` | `/api/auth/sso/start` | offen | Umleitung zu Entra | | `GET` | `/api/auth/sso/callback` | offen | Rückleitung von Entra, endet in `/?sso_ticket=...` | | `POST` | `/api/auth/sso/ticket` | offen | Ticket gegen CRM-Token tauschen | | `GET` | `/api/auth/sso/config` | admin | Status, fehlende Variablen, Umleitungsadresse, Zustand des Secrets | | `PUT` | `/api/auth/microsoft-secret` | admin | Secret und/oder Ablaufdatum setzen | | `DELETE` | `/api/auth/microsoft-secret` | admin | hinterlegtes Secret verwerfen, `.env` gilt wieder | Scheitert etwas, landet der Browser auf `/?sso_error=`; die Anmeldemaske zeigt den Text im gewohnten roten Kasten. ## Client-Secret pflegen Das Secret der App-Registrierung läuft ab - in Entra sind 6 oder 24 Monate üblich. Läuft es aus, stehen **Anmeldung und Postfachabgleich gleichzeitig** still, denn beide hängen an derselben Registrierung. Damit dafür niemand auf den Server muss, lässt es sich unter *Einstellungen → Anmeldung* eintragen. ```mermaid flowchart LR A["Einstellungen →
Anmeldung"] -->|"PUT /microsoft-secret"| B["Probe bei Microsoft:
Client-Credentials-Token"] B -->|abgelehnt| C["nichts gespeichert,
Meldung von Entra"] B -->|angenommen| D["AES-256-GCM
verschlüsseln"] D --> E[("crm_settings")] E --> F["Graph-Token verwerfen -
der Zwischenspeicher
gehört zum alten Secret"] ``` **Reihenfolge beim Lesen:** erst `crm_settings`, dann `MS_CLIENT_SECRET` aus der `.env`. Wer nichts einträgt, merkt von der Neuerung nichts; der `.env`-Wert bleibt die Rückfallebene. *Hinterlegtes Secret verwerfen* schaltet bewusst dorthin zurück. **Warum verschlüsselt:** ein Geheimnis im Klartext in der Datenbank stünde in jedem Dump. Der Schlüssel kommt aus `CRM_ENCRYPTION_KEY`, ersatzweise aus `JWT_SECRET` - beide stehen in der `.env` und damit nie im selben Dump. Wird eine dieser Variablen gewechselt, lässt sich das gespeicherte Secret nicht mehr lesen; die Oberfläche sagt das dann ausdrücklich, und es wird einmal neu eingetragen. Ein stiller Rückfall auf die `.env` findet in diesem Fall **nicht** statt - sonst sucht man den Fehler an der falschen Stelle. **Probe vor dem Speichern:** das CRM holt mit dem eingegebenen Wert ein Client-Credentials-Token. Erst wenn Microsoft es ausstellt, wird gespeichert. Ein Tippfehler legt so nichts lahm. ### Warnung vor dem Ablauf Das Ablaufdatum wird von Hand eingetragen - es aus Entra zu lesen bräuchte die Berechtigung `Application.Read.All`, die die App bewusst nicht hat. Ab **30 Tagen** vor dem Ablauf (`WARN_DAYS` in `msSecret.js`) erscheint für Administratoren über jeder Ansicht ein Hinweisband, das direkt in diesen Einstellungsreiter führt; nach dem Ablauf wird es rot. Der Hinweis reist mit `GET /api/badges` mit, das die App ohnehin bei jedem Laden abruft - ein eigener Endpunkt wäre eine zweite Anfrage für eine Zahl. Benutzer ohne Adminrolle bekommen ihn nicht: sie können das Secret nicht erneuern. | Zustand | Bedeutung | | --- | --- | | `ok` | mehr als 30 Tage übrig | | `warnung` | 0 bis 30 Tage übrig - Hinweisband in Gelb | | `abgelaufen` | Datum liegt in der Vergangenheit - Hinweisband in Rot | | `unbekannt` | kein Datum hinterlegt, keine Warnung möglich | ### So wird erneuert 1. Entra → *App-Registrierungen* → **perinco crm** → *Zertifikate & Geheimnisse* → **Neuer geheimer Clientschlüssel**. 2. Wert sofort kopieren - Entra zeigt ihn nur einmal. 3. Im CRM unter *Einstellungen → Anmeldung* eintragen, Ablaufdatum übernehmen, speichern. 4. Erst danach das alte Geheimnis in Entra löschen. Ein Neustart des Backends ist nicht nötig: das Secret wird bei jedem Bedarf frisch gelesen, und das zwischengespeicherte Graph-Token wird beim Speichern verworfen. ## Einrichtung in Entra Die App-Registrierung **perinco crm** existiert bereits für die Postfachanbindung. Für die Anmeldung kommen zwei Dinge dazu. **1. Umleitungsadresse.** *App-Registrierungen → perinco crm → Authentifizierung → Plattform hinzufügen → Web*: ``` https://crm.perinco.de/api/auth/sso/callback ``` Zeichengenau, sonst antwortet Entra mit `AADSTS50011`. Front-Channel-Abmelde-URL bleibt leer, die Haken bei *Implizite Genehmigung* bleiben **aus** - der Code Flow braucht sie nicht. Für die lokale Entwicklung zusätzlich `http://localhost:3100/api/auth/sso/callback`; `http` erlaubt Entra nur für localhost. **2. Delegierte Berechtigungen** mit Administratorzustimmung: | Berechtigung | Wofür | | --- | --- | | `openid`, `profile` | Anmeldung, Grunddaten | | `User.Read` | `/me` - Objekt-ID, Name, Adresse | | `ProfilePhoto.Read.All` | Profilfoto beim ersten Anmelden | Die Anwendungsberechtigung `Mail.Read` gehört zur Postfachanbindung und hat mit der Anmeldung nichts zu tun. Weiter empfohlen: *Unterstützte Kontotypen* auf den eigenen Mandanten begrenzen und *Öffentliche Clientflows zulassen* auf **Nein** - das Backend ist ein vertraulicher Client mit Secret. ## Umgebungsvariablen `server/.env`, Vorlage in `server/.env.example`. | Variable | Pflicht | Bedeutung | | --- | --- | --- | | `MS_SSO_ENABLED` | ja | nur `true` schaltet die Anmeldung ein | | `MS_SSO_REDIRECT_URI` | ja | exakt die in Entra eingetragene Adresse | | `MS_SSO_ALLOWED_DOMAINS` | nein | Positivliste von E-Mail-Domains, leer = keine zusätzliche Prüfung. Im Betrieb: `perinco.de,icon-experts.de` | | `MS_TENANT_ID`, `MS_CLIENT_ID` | ja | dieselben Werte wie beim Postfach | | `MS_CLIENT_SECRET` | nein | Rückfallebene; Vorrang hat das in den Einstellungen hinterlegte Secret | | `CRM_ENCRYPTION_KEY` | nein | verschlüsselt das hinterlegte Secret; ohne den Wert dient `JWT_SECRET` als Schlüssel | Nicht jedes CRM-Konto trägt eine `@perinco.de`-Adresse - `icon-experts.de` gehört dazu. Wer eine Domain vergisst, sperrt die betroffenen Leute vom Microsoft-Weg aus; die Anmeldung mit Passwort funktioniert für sie weiterhin. > Die Werte werden **beim Start** gelesen. Nach einer Änderung an der `.env` > muss `perinco-crm-backend` neu starten. Fehlt etwas, bleibt die Anmeldung aus > und der Einstellungsreiter *Anmeldung* nennt die fehlende Variable beim Namen - > genau wie beim Postfach. ## Was das Backend nicht tut * **Keine Konten anlegen.** Wer im CRM kein Konto hat, wird abgewiesen - mit dem Hinweis, sich an einen Administrator zu wenden. Zugriff vergibt weiterhin die Nutzerverwaltung. * **Keine Rollen aus Entra übernehmen.** `admin` bleibt eine Entscheidung im CRM. * **Keine Cookies.** Die Sitzung ist dasselbe JWT wie bei der Passwortanmeldung, gespeichert im `localStorage`. * **Kein Refresh-Token verwenden.** Entra stellt zwar eines aus, das CRM legt es nicht ab; die Sitzungsdauer regelt allein `JWT_EXPIRES_IN`. * **Keine Abmeldung bei Microsoft.** *Abmelden* beendet die CRM-Sitzung; das Microsoft-Konto bleibt im Browser angemeldet. ## Störungssuche | Symptom | Ursache | | --- | --- | | Kein Microsoft-Knopf auf der Anmeldemaske | `MS_SSO_ENABLED` ist nicht `true` oder eine Variable fehlt - Reiter *Einstellungen → Anmeldung* zeigt welche | | `AADSTS50011: redirect_uri mismatch` | `MS_SSO_REDIRECT_URI` und der Eintrag in Entra weichen ab (Schrägstrich, `http`/`https`, Gross-/Kleinschreibung) | | `AADSTS7000215: Invalid client secret` | `MS_CLIENT_SECRET` abgelaufen oder falsch - betrifft dann auch das Postfach | | „Für … gibt es kein CRM-Konto" | kein Benutzer mit dieser E-Mail-Adresse; in der Nutzerverwaltung anlegen | | „Die Anmeldung ist abgelaufen" | älter als 10 Minuten, oder das Backend wurde zwischendurch neu gestartet (der offene Vorgang liegt im Prozessspeicher) | | „Anmeldung kommt aus einem fremden Verzeichnis" | Antwort gehört zu einem anderen Tenant - `MS_TENANT_ID` prüfen | | Profilfoto fehlt | kein Foto in M365, Bild grösser als 200 KB, oder `ProfilePhoto.Read.All` ohne Zustimmung | | „Microsoft hat dieses Secret abgelehnt" | Wert falsch kopiert oder das Geheimnis in Entra bereits gelöscht - es wurde nichts gespeichert | | „nicht entschlüsselbar" im Reiter *Anmeldung* | `JWT_SECRET` oder `CRM_ENCRYPTION_KEY` wurde gewechselt; Secret einmal neu eintragen | | Hinweisband erscheint nicht | kein Ablaufdatum hinterlegt, oder das Konto ist kein Administrator | ## Quellen im Code | Datei | Inhalt | | --- | --- | | `server/src/sso/config.js` | Variablen, `SSO_CONFIGURED`, Domainprüfung | | `server/src/sso/flow.js` | PKCE, Authorize-URL, Token-Tausch, `/me`, Foto, Einmal-Tickets | | `server/src/routes/auth.js` | die fünf Endpunkte und die Zuordnung `findSsoUser()` | | `server/src/schema.sql` | Spalte `crm_users.entra_oid` samt Unique-Index | | `src/lib/auth.tsx` | Ticket einlösen, Adresszeile aufräumen, `ssoError` | | `src/components/LoginScreen.tsx` | Knopf „Mit Microsoft anmelden" | | `src/components/settings/SsoSettings.tsx` | Reiter *Anmeldung* samt Pflege des Secrets | | `server/src/msSecret.js` | Secret verschlüsseln, Ablauf berechnen, bei Microsoft prüfen | | `src/App.tsx` | Hinweisband für Administratoren vor dem Ablauf | | `server/test/sso.test.mjs` | Prüfungen ohne Netz und Datenbank |