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>
194 lines
8.6 KiB
Markdown
194 lines
8.6 KiB
Markdown
# 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 12 h |
|
|
|
|
## 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,<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
|
|
|
|
```mermaid
|
|
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](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 |
|
|
|
|
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 |
|