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>
249 lines
8.0 KiB
JavaScript
249 lines
8.0 KiB
JavaScript
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 async function exchangeCode(code, verifier) {
|
|
return postToken({
|
|
client_id: CLIENT_ID,
|
|
client_secret: await 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();
|
|
}
|