Files
perinco-CRM/docs/13-anmeldung-sso.md
Sascha WolfandClaude Opus 5.5 e2ad8ab03e feat(auth): Sitzungen laufen standardmaessig 7 Tage
JWT_EXPIRES_IN faellt ohne Eintrag in .env jetzt auf 7d statt 12h zurueck.
Doku und .env.example angepasst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:05:24 +02:00

13 KiB

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

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,<br/>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

flowchart TD
    A["Profil von Graph:<br/>Objekt-ID, E-Mail"] --> B{"crm_users.entra_oid<br/>= Objekt-ID?"}
    B -->|ja| Z["anmelden"]
    B -->|nein| C{"crm_users.email = Adresse<br/>und entra_oid leer?"}
    C -->|ja| D["entra_oid festschreiben"] --> Z
    C -->|nein| E["abweisen:<br/>kein CRM-Konto"]
    Z --> F{"is_active?"}
    F -->|nein| G["abweisen:<br/>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 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=<Meldung>; 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.

flowchart LR
    A["Einstellungen →<br/>Anmeldung"] -->|"PUT /microsoft-secret"| B["Probe bei Microsoft:<br/>Client-Credentials-Token"]
    B -->|abgelehnt| C["nichts gespeichert,<br/>Meldung von Entra"]
    B -->|angenommen| D["AES-256-GCM<br/>verschlüsseln"]
    D --> E[("crm_settings")]
    E --> F["Graph-Token verwerfen -<br/>der Zwischenspeicher<br/>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