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:
co-authored by
Claude Opus 5
parent
2d84bac91d
commit
419e57b55d
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Konfiguration der Anmeldung ueber Microsoft Entra (OpenID Connect).
|
||||
*
|
||||
* Es ist dieselbe App-Registrierung wie bei der Postfachanbindung, aber ein
|
||||
* anderer Zugriffsweg: das Postfach laeuft app-only (Client Credentials), die
|
||||
* Anmeldung delegiert im Namen des Benutzers (Authorization Code + PKCE).
|
||||
* Deshalb werden die Werte hier eigenstaendig gelesen und nicht aus mail/
|
||||
* importiert - die Anmeldung soll auch dann funktionieren, wenn die
|
||||
* Postfachanbindung abgeschaltet ist.
|
||||
*
|
||||
* Zugangsdaten bleiben serverseitig. Der Browser bekommt weder Tenant- noch
|
||||
* Client-ID zu sehen; er kennt nur /api/auth/sso/start.
|
||||
*/
|
||||
|
||||
export const TENANT_ID = (process.env.MS_TENANT_ID || '').trim();
|
||||
export const CLIENT_ID = (process.env.MS_CLIENT_ID || '').trim();
|
||||
const CLIENT_SECRET = (process.env.MS_CLIENT_SECRET || '').trim();
|
||||
|
||||
/** Hauptschalter. Ohne ausdrueckliches "true" bleibt die Anmeldung per Microsoft aus. */
|
||||
const ENABLED = process.env.MS_SSO_ENABLED === 'true';
|
||||
|
||||
/**
|
||||
* Muss in Entra unter "Authentifizierung" als Plattform "Web" eingetragen sein -
|
||||
* zeichengenau, sonst antwortet Entra mit AADSTS50011.
|
||||
*/
|
||||
export const REDIRECT_URI = (process.env.MS_SSO_REDIRECT_URI || '').trim();
|
||||
|
||||
/**
|
||||
* Zusaetzliche Einschraenkung auf E-Mail-Domains. Leer = keine Pruefung; der
|
||||
* Tenant und das vorhandene CRM-Konto begrenzen den Zugang ohnehin.
|
||||
*/
|
||||
export const ALLOWED_DOMAINS = String(process.env.MS_SSO_ALLOWED_DOMAINS || '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toLowerCase().replace(/^@/, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
export const AUTHORITY = `https://login.microsoftonline.com/${TENANT_ID}`;
|
||||
export const GRAPH_ROOT = 'https://graph.microsoft.com/v1.0';
|
||||
|
||||
/** Angefragte delegierte Rechte. Alle vier sind in Entra bereits zugestimmt. */
|
||||
export const SCOPES = ['openid', 'profile', 'email', 'offline_access', 'User.Read'];
|
||||
|
||||
/** Ohne diese Werte ist die Anmeldung nicht betriebsbereit. */
|
||||
export const SSO_CONFIGURED = Boolean(ENABLED && TENANT_ID && CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
|
||||
|
||||
export function clientSecret() {
|
||||
if (!CLIENT_SECRET) throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.');
|
||||
return CLIENT_SECRET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Was zum Betrieb fehlt - fuer eine brauchbare Meldung in den Einstellungen,
|
||||
* ohne Werte zu verraten.
|
||||
*/
|
||||
export function missingSsoConfig() {
|
||||
const missing = [];
|
||||
if (!ENABLED) missing.push('MS_SSO_ENABLED');
|
||||
if (!TENANT_ID) missing.push('MS_TENANT_ID');
|
||||
if (!CLIENT_ID) missing.push('MS_CLIENT_ID');
|
||||
if (!CLIENT_SECRET) missing.push('MS_CLIENT_SECRET');
|
||||
if (!REDIRECT_URI) missing.push('MS_SSO_REDIRECT_URI');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/** Erlaubt die Domain-Liste diese Adresse? Leere Liste heisst: alles erlaubt. */
|
||||
export function domainAllowed(email) {
|
||||
if (ALLOWED_DOMAINS.length === 0) return true;
|
||||
const domain = String(email || '').toLowerCase().split('@')[1] || '';
|
||||
return ALLOWED_DOMAINS.includes(domain);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user