Nicht jedes CRM-Konto traegt eine @perinco.de-Adresse. Ohne den Eintrag weist domainAllowed() diese Leute ab, obwohl Tenant und CRM-Konto stimmen. Auf dem Server ist MS_SSO_ALLOWED_DOMAINS bereits entsprechend gesetzt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8.8 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. Im Betrieb: perinco.de,icon-experts.de |
MS_TENANT_ID, MS_CLIENT_ID, MS_CLIENT_SECRET |
ja | dieselben Werte wie beim Postfach |
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
.envmussperinco-crm-backendneu 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.
adminbleibt 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 |