Files
perinco-CRM/docs/13-anmeldung-sso.md
T
Sascha WolfandClaude Opus 5 419e57b55d 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>
2026-09-21 08:14:23 +02:00

8.6 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 12 h

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

Scheitert etwas, landet der Browser auf /?sso_error=<Meldung>; die Anmeldemaske zeigt den Text im gewohnten roten Kasten.

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
MS_TENANT_ID, MS_CLIENT_ID, MS_CLIENT_SECRET ja dieselben Werte wie beim Postfach

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

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 in den Einstellungen
server/test/sso.test.mjs Prüfungen ohne Netz und Datenbank