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>
This commit is contained in:
Sascha Wolf
2026-09-21 08:14:23 +02:00
co-authored by Claude Opus 5
parent 2d84bac91d
commit 419e57b55d
23 changed files with 1188 additions and 19 deletions
+248
View File
@@ -0,0 +1,248 @@
import crypto from 'crypto';
import {
AUTHORITY,
CLIENT_ID,
GRAPH_ROOT,
REDIRECT_URI,
SCOPES,
TENANT_ID,
clientSecret,
} from './config.js';
/*
* Authorization Code Flow mit PKCE, vollstaendig serverseitig.
*
* Ablauf:
* 1. beginLogin() - state, nonce und PKCE-Verifier merken, Browser zu Entra
* 2. Entra - Anmeldung beim Benutzer, Rueckleitung mit code + state
* 3. exchangeCode()- code gegen Token tauschen (Backchannel mit Client-Secret)
* 4. fetchProfile()- /me bei Graph; das ist der Identitaetsnachweis
*
* Warum der Graph-Aufruf und nicht nur das ID-Token: das ID-Token kommt ueber
* TLS direkt vom Token-Endpunkt und ist damit vertrauenswuerdig (OIDC Core
* 3.1.3.7 erlaubt in diesem Fall den Verzicht auf die Signaturpruefung), aber
* /me beweist zusaetzlich, dass das Zugriffstoken wirklich gilt, und liefert
* die Objekt-ID, an der das CRM-Konto haengt.
*/
const REQUEST_TIMEOUT_MS = 20_000;
/** Wie lange eine angefangene Anmeldung gueltig bleibt. */
const STATE_TTL_MS = 10 * 60 * 1000;
/** Wie lange das Ticket gilt, mit dem das Frontend das CRM-Token abholt. */
const TICKET_TTL_MS = 60 * 1000;
/*
* Beide Speicher liegen im Prozess, nicht in der Datenbank: es sind Werte mit
* Sekunden- bis Minutenlebensdauer, und das Backend laeuft als einzelne
* Instanz. Ein Neustart verwirft hoechstens gerade laufende Anmeldungen - der
* Benutzer klickt dann noch einmal.
*/
const pendingLogins = new Map();
const tickets = new Map();
function prune(store, ttl) {
const now = Date.now();
for (const [key, entry] of store) {
if (now - entry.createdAt > ttl) store.delete(key);
}
}
const base64url = (buffer) => buffer.toString('base64url');
const randomToken = () => base64url(crypto.randomBytes(32));
export class SsoError extends Error {
constructor(message, { code = null } = {}) {
super(message);
this.name = 'SsoError';
this.code = code;
}
}
/**
* Beginnt eine Anmeldung und liefert die URL, auf die der Browser umgeleitet
* wird. prompt=select_account, damit an einem geteilten Rechner nicht
* stillschweigend das zuletzt benutzte Konto genommen wird.
*/
export function beginLogin() {
prune(pendingLogins, STATE_TTL_MS);
const state = randomToken();
const nonce = randomToken();
const verifier = randomToken();
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
pendingLogins.set(state, { nonce, verifier, createdAt: Date.now() });
const params = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
redirect_uri: REDIRECT_URI,
response_mode: 'query',
scope: SCOPES.join(' '),
state,
nonce,
code_challenge: challenge,
code_challenge_method: 'S256',
prompt: 'select_account',
});
return `${AUTHORITY}/oauth2/v2.0/authorize?${params.toString()}`;
}
/** Holt den gemerkten Vorgang und verbraucht ihn - jeder state gilt genau einmal. */
export function takePendingLogin(state) {
prune(pendingLogins, STATE_TTL_MS);
if (!state) return null;
const entry = pendingLogins.get(state);
if (!entry) return null;
pendingLogins.delete(state);
return entry;
}
async function postToken(body) {
let response;
try {
response = await fetch(`${AUTHORITY}/oauth2/v2.0/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(body),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
throw new SsoError(`Microsoft ist nicht erreichbar: ${err.message}`);
}
const data = await response.json().catch(() => null);
if (!response.ok || !data?.access_token) {
const detail = data?.error_description || data?.error || `HTTP ${response.status}`;
throw new SsoError(String(detail).split('\n')[0], { code: data?.error ?? null });
}
return data;
}
/** Tauscht den Code gegen Tokens. Der Verifier weist uns als Starter des Vorgangs aus. */
export function exchangeCode(code, verifier) {
return postToken({
client_id: CLIENT_ID,
client_secret: clientSecret(),
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
scope: SCOPES.join(' '),
});
}
/** Nutzlast eines JWT ohne Signaturpruefung - siehe Begruendung oben im Modul. */
function decodePayload(token) {
const part = String(token || '').split('.')[1];
if (!part) return null;
try {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
} catch {
return null;
}
}
/**
* Prueft das ID-Token gegen den erwarteten Tenant, die eigene Client-ID, die
* Laufzeit und die zu diesem Vorgang gehoerende nonce. Damit ist eine
* untergeschobene Antwort aus einem fremden Verzeichnis ausgeschlossen.
*/
export function verifyIdToken(idToken, expectedNonce) {
const claims = decodePayload(idToken);
if (!claims) throw new SsoError('Antwort von Microsoft war unlesbar.');
if (claims.aud !== CLIENT_ID) throw new SsoError('Antwort gehört zu einer anderen Anwendung.');
if (claims.tid !== TENANT_ID) throw new SsoError('Anmeldung kommt aus einem fremden Verzeichnis.');
if (claims.nonce !== expectedNonce) throw new SsoError('Antwort passt nicht zur Anfrage.');
const now = Math.floor(Date.now() / 1000);
// 120 s Spielraum fuer auseinanderlaufende Uhren.
if (typeof claims.exp === 'number' && claims.exp + 120 < now) {
throw new SsoError('Antwort von Microsoft war bereits abgelaufen.');
}
return claims;
}
async function graphGet(path, accessToken, { accept = 'application/json' } = {}) {
try {
return await fetch(`${GRAPH_ROOT}${path}`, {
headers: { Authorization: `Bearer ${accessToken}`, Accept: accept },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
throw new SsoError(`Microsoft Graph ist nicht erreichbar: ${err.message}`);
}
}
/** Das angemeldete Konto: Objekt-ID, Anzeigename, Adresse. */
export async function fetchProfile(accessToken) {
const select = 'id,displayName,givenName,surname,mail,userPrincipalName';
const response = await graphGet(`/me?$select=${select}`, accessToken);
if (!response.ok) {
throw new SsoError(`Profil konnte nicht gelesen werden (HTTP ${response.status}).`);
}
const data = await response.json();
return {
oid: String(data.id || ''),
name: String(data.displayName || '').trim(),
email: String(data.mail || data.userPrincipalName || '')
.trim()
.toLowerCase(),
upn: String(data.userPrincipalName || '')
.trim()
.toLowerCase(),
};
}
/**
* Profilfoto in 96x96 als Data-URL, oder null. Fehler sind hier nie fatal: wer
* kein Foto hinterlegt hat, bekommt 404, und ohne Foto laesst sich trotzdem
* anmelden.
*/
export async function fetchPhoto(accessToken) {
try {
const response = await graphGet('/me/photos/96x96/$value', accessToken, { accept: 'image/jpeg' });
if (!response.ok) return null;
const type = (response.headers.get('content-type') || 'image/jpeg').split(';')[0];
if (!type.startsWith('image/')) return null;
const buffer = Buffer.from(await response.arrayBuffer());
// Sicherheitsnetz gegen unerwartet grosse Bilder - avatar ist zwar LONGTEXT,
// soll aber keine halbe Megabyte pro Benutzer aufnehmen.
if (buffer.length === 0 || buffer.length > 200_000) return null;
return `data:${type};base64,${buffer.toString('base64')}`;
} catch {
return null;
}
}
/**
* Legt ein Einmal-Ticket an. Das CRM-Token wandert damit nicht als
* Adressparameter durch Browserverlauf und Server-Logs - der Browser holt es
* mit einem POST ab, und das Ticket verfaellt beim ersten Gebrauch.
*/
export function createTicket(payload) {
prune(tickets, TICKET_TTL_MS);
const ticket = randomToken();
tickets.set(ticket, { payload, createdAt: Date.now() });
return ticket;
}
export function redeemTicket(ticket) {
prune(tickets, TICKET_TTL_MS);
if (!ticket) return null;
const entry = tickets.get(ticket);
if (!entry) return null;
tickets.delete(ticket);
return entry.payload;
}
/** Nur fuer Tests - leert beide Speicher. */
export function resetSsoState() {
pendingLogins.clear();
tickets.clear();
}