Das Secret der App-Registrierung laeuft in Entra ab. Bisher hiess das: jemand
mit SSH-Zugang aendert die .env und startet den Dienst neu - und niemand sieht
vorher, wann es soweit ist. Laeuft es aus, stehen Anmeldung UND
Postfachabgleich still, denn beide haengen an derselben Registrierung.
Neu unter Einstellungen > Anmeldung: Secret eintragen, Ablaufdatum hinterlegen,
Restlaufzeit ablesen. Ab 30 Tagen vor dem Ablauf bekommen Administratoren ueber
jeder Ansicht ein Hinweisband, das direkt dorthin fuehrt; nach dem Ablauf wird
es rot. Der Hinweis reist mit /api/badges mit, das die App ohnehin bei jedem
Laden abruft.
Bewusst so entschieden:
- Das Secret muss dafuer in die Datenbank, entgegen der bisherigen Regel
"keine Geheimnisse in crm_settings". Es liegt deshalb AES-256-GCM-
verschluesselt dort; der Schluessel kommt aus CRM_ENCRYPTION_KEY, ersatz-
weise JWT_SECRET, und steht damit nie im selben Dump.
- Vor dem Speichern holt das CRM mit dem eingegebenen Wert ein Token bei
Microsoft. Ein Tippfehler wird gar nicht erst uebernommen.
- Gelesen wird Datenbank vor .env; der .env-Wert bleibt Rueckfallebene, und
"Hinterlegtes Secret verwerfen" schaltet dorthin zurueck.
- Passt der Schluessel nicht mehr, meldet die Oberflaeche das ausdruecklich,
statt still auf die .env zurueckzufallen - sonst sucht man den Fehler an
der falschen Stelle.
- Das Ablaufdatum wird von Hand eingetragen. Aus Entra lesen braeuchte
Application.Read.All, die die App bewusst nicht hat.
MAIL_CONFIGURED und SSO_CONFIGURED sind dadurch von Konstanten zu den
Funktionen mailConfigured() und ssoConfigured() geworden - die Pruefung muss
jetzt in die Datenbank sehen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 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, 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
- Entra → App-Registrierungen → perinco crm → Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel.
- Wert sofort kopieren - Entra zeigt ihn nur einmal.
- Im CRM unter Einstellungen → Anmeldung eintragen, Ablaufdatum übernehmen, speichern.
- 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
.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 |
| „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 |