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
+26
View File
@@ -94,3 +94,29 @@ SCANNER_SCHEDULER_OFFSET=0
# Fallback fuer den OpenAI-Key; bevorzugt wird der Wert aus den Einstellungen.
# OPENAI_API_KEY=
# ---------------------------------------------------------------------------
# Anmeldung ueber Microsoft Entra (SSO)
# ---------------------------------------------------------------------------
# Nutzt dieselbe App-Registrierung wie die Postfachanbindung - also dieselben
# MS_TENANT_ID / MS_CLIENT_ID / MS_CLIENT_SECRET, kein zweites Secret.
#
# In Entra zusaetzlich noetig:
# - Authentifizierung > Plattform hinzufuegen > Web
# Umleitungs-URI: der Wert von MS_SSO_REDIRECT_URI, zeichengenau
# Implizite Genehmigung: beide Haken aus
# - Delegierte Berechtigungen mit Administratorzustimmung:
# openid, profile, User.Read (Foto zusaetzlich: ProfilePhoto.Read.All)
#
# Es werden keine Konten angelegt: wer sich per Microsoft anmeldet, braucht ein
# bestehendes CRM-Konto mit derselben E-Mail-Adresse. Die Anmeldung mit
# Benutzername und Passwort bleibt parallel bestehen.
#
# Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
# bisher.
MS_SSO_ENABLED=false
MS_SSO_REDIRECT_URI=https://crm.perinco.de/api/auth/sso/callback
# Optionale Einschraenkung auf E-Mail-Domains, kommagetrennt. Leer = keine
# zusaetzliche Pruefung; Tenant und vorhandenes CRM-Konto begrenzen ohnehin.
MS_SSO_ALLOWED_DOMAINS=perinco.de
+1 -1
View File
@@ -12,7 +12,7 @@
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"migrate": "node src/migrate.js",
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs"
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs && node test/sso.test.mjs"
},
"dependencies": {
"bcryptjs": "^2.4.3",
+175 -1
View File
@@ -1,13 +1,31 @@
import express from 'express';
import bcrypt from 'bcryptjs';
import { pool } from '../db.js';
import { signToken, requireAuth } from '../middleware/auth.js';
import { signToken, requireAuth, requireAdmin } from '../middleware/auth.js';
import {
normalizeAvatar,
normalizeColor,
normalizeTheme,
normalizeThemeMode,
} from '../userFields.js';
import {
ALLOWED_DOMAINS,
REDIRECT_URI,
SSO_CONFIGURED,
domainAllowed,
missingSsoConfig,
} from '../sso/config.js';
import {
SsoError,
beginLogin,
createTicket,
exchangeCode,
fetchPhoto,
fetchProfile,
redeemTicket,
takePendingLogin,
verifyIdToken,
} from '../sso/flow.js';
const router = express.Router();
@@ -135,5 +153,161 @@ router.post('/change-password', requireAuth, async (req, res, next) => {
}
});
/* ---------------------------------------------------------------------------
* Anmeldung ueber Microsoft Entra (OpenID Connect)
*
* Der Browser sieht von der Anbindung nur drei Endpunkte: /sso/status fuer den
* Knopf, /sso/start fuer die Umleitung und /sso/ticket zum Abholen des
* CRM-Tokens. Tenant, Client-ID, Secret und die Antwort von Microsoft bleiben
* beim Backend.
*
* Es werden keine Konten angelegt: wer sich anmeldet, braucht ein bestehendes
* crm_users-Konto mit derselben E-Mail-Adresse. Wer Zugriff bekommt, entscheidet
* damit weiterhin allein die Nutzerverwaltung.
* ------------------------------------------------------------------------- */
/** Basisadresse der Oberflaeche - dieselbe Herkunft wie der Redirect-URI. */
function appOrigin() {
try {
return new URL(REDIRECT_URI).origin;
} catch {
return '';
}
}
/** Zurueck zur Anmeldemaske mit einer Meldung, die das Frontend anzeigt. */
function redirectWithError(res, message) {
const target = `${appOrigin()}/?sso_error=${encodeURIComponent(message)}`;
res.redirect(302, target);
}
// GET /api/auth/sso/status - oeffentlich, damit die Anmeldemaske weiss, ob sie
// den Microsoft-Knopf zeigen soll. Verraet nur ja/nein.
router.get('/sso/status', (req, res) => {
res.json({ enabled: SSO_CONFIGURED });
});
// GET /api/auth/sso/config - Adminsicht fuer die Einstellungen.
router.get('/sso/config', requireAuth, requireAdmin, (req, res) => {
res.json({
enabled: SSO_CONFIGURED,
missing: missingSsoConfig(),
redirect_uri: REDIRECT_URI || null,
allowed_domains: ALLOWED_DOMAINS,
});
});
// GET /api/auth/sso/start - Beginn der Anmeldung, leitet zu Microsoft weiter.
router.get('/sso/start', (req, res) => {
if (!SSO_CONFIGURED) {
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
}
res.redirect(302, beginLogin());
});
// GET /api/auth/sso/callback - Rueckleitung von Microsoft.
router.get('/sso/callback', async (req, res, next) => {
if (!SSO_CONFIGURED) {
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
}
// Abbruch oder Fehler auf der Microsoft-Seite (z.B. Zustimmung verweigert).
if (req.query.error) {
const detail = String(req.query.error_description || req.query.error).split('\n')[0];
return redirectWithError(res, `Microsoft hat die Anmeldung abgelehnt: ${detail}`);
}
const pending = takePendingLogin(String(req.query.state || ''));
if (!pending) {
return redirectWithError(res, 'Die Anmeldung ist abgelaufen. Bitte erneut versuchen.');
}
const code = String(req.query.code || '');
if (!code) return redirectWithError(res, 'Microsoft hat keinen Anmeldecode geschickt.');
try {
const tokens = await exchangeCode(code, pending.verifier);
verifyIdToken(tokens.id_token, pending.nonce);
const profile = await fetchProfile(tokens.access_token);
if (!profile.email) {
return redirectWithError(res, 'Das Microsoft-Konto hat keine E-Mail-Adresse.');
}
if (!domainAllowed(profile.email)) {
return redirectWithError(res, 'Diese E-Mail-Domain ist für das CRM nicht freigegeben.');
}
const user = await findSsoUser(profile);
if (!user) {
return redirectWithError(
res,
`Für ${profile.email} gibt es kein CRM-Konto. Bitte einen Administrator fragen.`
);
}
if (!user.is_active) {
return redirectWithError(res, 'Dieses Benutzerkonto ist deaktiviert.');
}
// Foto nur einmal holen: wer selbst eines gesetzt hat, behaelt es.
if (!user.avatar) {
const photo = await fetchPhoto(tokens.access_token);
if (photo) {
await pool.query('UPDATE crm_users SET avatar = ? WHERE id = ?', [photo, user.id]);
user.avatar = photo;
}
}
const ticket = createTicket({ token: signToken(user), user: publicUser(user) });
res.redirect(302, `${appOrigin()}/?sso_ticket=${encodeURIComponent(ticket)}`);
} catch (err) {
if (err instanceof SsoError) {
console.warn('[SSO] Anmeldung fehlgeschlagen:', err.message);
return redirectWithError(res, err.message);
}
next(err);
}
});
/**
* Sucht das CRM-Konto zum angemeldeten Microsoft-Konto.
*
* Zuerst ueber die Objekt-ID: sie bleibt gleich, auch wenn jemand heiratet und
* die Adresse wechselt. Beim ersten Mal gibt es sie noch nicht - dann greift
* die E-Mail-Adresse, und die Objekt-ID wird festgeschrieben. Konten, die schon
* einer anderen Objekt-ID gehoeren, werden dabei nicht angefasst.
*/
async function findSsoUser(profile) {
const columns = `id, username, email, name, role, color, avatar, theme, theme_mode,
is_active, created_at, updated_at`;
const [byOid] = await pool.query(
`SELECT ${columns} FROM crm_users WHERE entra_oid = ? LIMIT 1`,
[profile.oid]
);
if (byOid[0]) return { ...byOid[0], is_active: Boolean(byOid[0].is_active) };
const candidates = [profile.email, profile.upn].filter(Boolean);
const [byMail] = await pool.query(
`SELECT ${columns} FROM crm_users
WHERE entra_oid IS NULL AND email IN (?) LIMIT 1`,
[candidates]
);
const user = byMail[0];
if (!user) return null;
await pool.query('UPDATE crm_users SET entra_oid = ? WHERE id = ?', [profile.oid, user.id]);
return { ...user, is_active: Boolean(user.is_active) };
}
// POST /api/auth/sso/ticket { ticket }
// Loest das Einmal-Ticket gegen das CRM-Token ein.
router.post('/sso/ticket', (req, res) => {
const payload = redeemTicket(String(req.body?.ticket || ''));
if (!payload) {
return res.status(400).json({ error: 'Die Anmeldung ist abgelaufen. Bitte erneut versuchen.' });
}
res.json(payload);
});
export default router;
export { publicUser };
+8
View File
@@ -34,6 +34,14 @@ ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS avatar LONGTEXT NULL AFTER color;
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme VARCHAR(32) NULL AFTER avatar;
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme_mode VARCHAR(16) NULL AFTER theme;
-- Objekt-ID des Microsoft-Kontos (Entra), gesetzt bei der ersten Anmeldung per
-- SSO. Sie ist die stabile Zuordnung: die E-Mail-Adresse eines Menschen kann
-- sich aendern, die Objekt-ID nicht. NULL = noch nie per Microsoft angemeldet;
-- dann greift der Abgleich ueber die Adresse. Unique, damit nicht zwei
-- CRM-Konten an demselben Microsoft-Konto haengen.
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS entra_oid VARCHAR(64) NULL AFTER email;
ALTER TABLE crm_users ADD UNIQUE INDEX IF NOT EXISTS uq_crm_users_entra_oid (entra_oid);
CREATE TABLE IF NOT EXISTS crm_leads (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
salutation VARCHAR(32) NULL,
+70
View File
@@ -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);
}
+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();
}
+133
View File
@@ -0,0 +1,133 @@
/*
* Prueft die Anmeldung ueber Microsoft Entra, soweit sie ohne Netz und ohne
* Datenbank pruefbar ist: Konfiguration, Aufbau der Authorize-URL, Einmaligkeit
* von state und Ticket sowie die Abwehr eines untergeschobenen ID-Tokens.
*
* Die Umgebungsvariablen muessen vor dem Import stehen - sso/config.js liest
* sie beim Laden des Moduls, genau wie im Betrieb beim Start des Backends.
*/
import { readFileSync } from 'node:fs';
process.env.MS_SSO_ENABLED = 'true';
process.env.MS_TENANT_ID = 'tenant-fuer-den-test';
process.env.MS_CLIENT_ID = 'client-fuer-den-test';
process.env.MS_CLIENT_SECRET = 'geheim-fuer-den-test';
process.env.MS_SSO_REDIRECT_URI = 'https://crm.perinco.de/api/auth/sso/callback';
process.env.MS_SSO_ALLOWED_DOMAINS = 'perinco.de';
const { SSO_CONFIGURED, domainAllowed, missingSsoConfig } = await import('../src/sso/config.js');
const {
SsoError,
beginLogin,
createTicket,
redeemTicket,
resetSsoState,
takePendingLogin,
verifyIdToken,
} = await import('../src/sso/flow.js');
let failures = 0;
function check(name, condition, detail = '') {
if (condition) {
console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`);
} else {
console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`);
failures += 1;
}
}
function throws(name, fn) {
try {
fn();
check(name, false, 'kein Fehler geworfen');
} catch (err) {
check(name, err instanceof SsoError, err instanceof SsoError ? err.message : String(err));
}
}
/** Baut ein ID-Token, wie Entra es liefert - Signatur ist hier ohne Belang. */
function idToken(claims) {
const part = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
return `${part({ alg: 'RS256' })}.${part(claims)}.signatur`;
}
const validClaims = {
aud: 'client-fuer-den-test',
tid: 'tenant-fuer-den-test',
nonce: 'nonce-1',
exp: Math.floor(Date.now() / 1000) + 3600,
};
console.log('\nAnmeldung ueber Microsoft (SSO)');
console.log('\n Konfiguration');
check('vollstaendige Konfiguration gilt als betriebsbereit', SSO_CONFIGURED === true);
check('nichts fehlt', missingSsoConfig().length === 0, missingSsoConfig().join(', '));
check('eigene Domain erlaubt', domainAllowed('sascha.wolf@perinco.de') === true);
check('fremde Domain abgewiesen', domainAllowed('jemand@example.com') === false);
check('Adresse ohne Domain abgewiesen', domainAllowed('kaputt') === false);
console.log('\n Start der Anmeldung');
resetSsoState();
const url = new URL(beginLogin());
const params = url.searchParams;
check('Ziel ist der Tenant', url.origin === 'https://login.microsoftonline.com');
check('Pfad enthaelt die Tenant-ID', url.pathname.includes('tenant-fuer-den-test'), url.pathname);
check('Code Flow', params.get('response_type') === 'code');
check('PKCE mit S256', params.get('code_challenge_method') === 'S256');
check('Code-Challenge gesetzt', (params.get('code_challenge') || '').length >= 43);
check('Umleitungsadresse unveraendert', params.get('redirect_uri') === process.env.MS_SSO_REDIRECT_URI);
check('state und nonce gesetzt', Boolean(params.get('state')) && Boolean(params.get('nonce')));
check('Kontoauswahl erzwungen', params.get('prompt') === 'select_account');
check('kein Secret in der URL', !url.toString().includes('geheim-fuer-den-test'));
console.log('\n state gilt genau einmal');
const state = params.get('state');
const pending = takePendingLogin(state);
check('Vorgang gefunden', Boolean(pending?.verifier && pending?.nonce));
check('nonce passt zur URL', pending?.nonce === params.get('nonce'));
check('zweiter Versuch mit demselben state scheitert', takePendingLogin(state) === null);
check('unbekannter state scheitert', takePendingLogin('erfunden') === null);
console.log('\n Pruefung des ID-Tokens');
check(
'gueltiges Token wird angenommen',
verifyIdToken(idToken(validClaims), 'nonce-1').aud === 'client-fuer-den-test'
);
throws('fremdes Verzeichnis abgewiesen', () =>
verifyIdToken(idToken({ ...validClaims, tid: 'fremder-tenant' }), 'nonce-1')
);
throws('fremde Anwendung abgewiesen', () =>
verifyIdToken(idToken({ ...validClaims, aud: 'andere-app' }), 'nonce-1')
);
throws('falsche nonce abgewiesen', () => verifyIdToken(idToken(validClaims), 'nonce-2'));
throws('abgelaufenes Token abgewiesen', () =>
verifyIdToken(idToken({ ...validClaims, exp: Math.floor(Date.now() / 1000) - 3600 }), 'nonce-1')
);
throws('unlesbares Token abgewiesen', () => verifyIdToken('kein-jwt', 'nonce-1'));
console.log('\n Einmal-Ticket');
const ticket = createTicket({ token: 'crm-token', user: { id: 1 } });
check('Ticket ist kein Klartext des Tokens', !ticket.includes('crm-token'));
check('Einloesen liefert die Nutzlast', redeemTicket(ticket)?.token === 'crm-token');
check('zweites Einloesen scheitert', redeemTicket(ticket) === null);
check('erfundenes Ticket scheitert', redeemTicket('erfunden') === null);
console.log('\n Konten werden nicht angelegt');
const routes = readFileSync(new URL('../src/routes/auth.js', import.meta.url), 'utf8');
check(
'routes/auth.js legt keinen Benutzer an',
!/INSERT\s+INTO\s+crm_users/i.test(routes),
'die Anmeldung setzt ein bestehendes Konto voraus'
);
check(
'Zuordnung ueber entra_oid vorhanden',
routes.includes('entra_oid = ?'),
'stabile Verknuepfung statt nur E-Mail'
);
resetSsoState();
console.log(failures === 0 ? '\nAlle Pruefungen bestanden.\n' : `\n${failures} Pruefung(en) fehlgeschlagen.\n`);
process.exit(failures === 0 ? 0 : 1);