feat(auth): Client-Secret in den Einstellungen pflegen, Warnung vor Ablauf

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>
This commit is contained in:
Sascha Wolf
2026-09-21 08:38:28 +02:00
co-authored by Claude Opus 5
parent e271f5c1e8
commit c8eaaa1947
22 changed files with 937 additions and 64 deletions
+13 -9
View File
@@ -1,4 +1,5 @@
import { getSettings, asBool, asInt } from '../settings.js';
import { hasSecret, requireSecret } from '../msSecret.js';
/*
* Konfiguration der M365-Anbindung.
@@ -14,7 +15,6 @@ import { getSettings, asBool, asInt } from '../settings.js';
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();
export const MAILBOX = (process.env.MS_MAILBOX || 'crm@perinco.de').trim().toLowerCase();
/** Ordner im Postfach, der abgeglichen wird. `inbox` ist ein Wellknown-Name. */
@@ -23,23 +23,27 @@ export const MAIL_FOLDER = (process.env.MS_MAIL_FOLDER || 'inbox').trim();
/** Harter Aus-Schalter fuer den Hintergrundabgleich, unabhaengig von der DB. */
export const SCHEDULER_ENABLED = process.env.MAIL_SYNC !== 'false';
/** Ohne diese vier Werte ist die Anbindung nicht betriebsbereit. */
export const MAIL_CONFIGURED = Boolean(TENANT_ID && CLIENT_ID && CLIENT_SECRET && MAILBOX);
export function clientSecret() {
if (!CLIENT_SECRET) throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.');
return CLIENT_SECRET;
/*
* Ohne diese vier Werte ist die Anbindung nicht betriebsbereit. Die Pruefung ist
* asynchron, seit das Secret auch in der Datenbank liegen darf (siehe
* msSecret.js) - vorher war es eine Konstante aus der .env.
*/
export async function mailConfigured() {
return Boolean(TENANT_ID && CLIENT_ID && MAILBOX && (await hasSecret()));
}
/** Das gueltige Client-Secret: Datenbank vor .env. */
export const clientSecret = requireSecret;
/**
* Welche Zugangsdaten fehlen - fuer eine brauchbare Meldung in der Oberflaeche,
* ohne Werte zu verraten.
*/
export function missingCredentials() {
export async function missingCredentials() {
const missing = [];
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 (!(await hasSecret())) missing.push('MS_CLIENT_SECRET');
if (!MAILBOX) missing.push('MS_MAILBOX');
return missing;
}
+1 -1
View File
@@ -41,7 +41,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function fetchAccessToken() {
const body = new URLSearchParams({
client_id: CLIENT_ID,
client_secret: clientSecret(),
client_secret: await clientSecret(),
scope: 'https://graph.microsoft.com/.default',
grant_type: 'client_credentials',
});
+2 -2
View File
@@ -1,5 +1,5 @@
import cron from 'node-cron';
import { MAIL_CONFIGURED, SCHEDULER_ENABLED, mailSettings } from './config.js';
import { SCHEDULER_ENABLED, mailConfigured, mailSettings } from './config.js';
import { getSyncState, saveSyncState } from './store.js';
import { isMailSyncRunning, runMailSync } from './sync.js';
@@ -26,7 +26,7 @@ export function isMailSchedulerRunning() {
}
export async function startMailScheduler() {
if (!MAIL_CONFIGURED) {
if (!(await mailConfigured())) {
console.log('[Mail] Zugangsdaten fehlen - Abgleich bleibt aus, die Oberflaeche laeuft.');
return;
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import {
MAILBOX,
MAIL_FOLDER,
MAIL_CONFIGURED,
mailConfigured,
MAX_PAGES_PER_RUN,
MAX_BODY_CHARS,
mailSettings,
@@ -156,8 +156,8 @@ async function fetchDeltaPage(url, { allowFilterFallback = false } = {}) {
* Laeuft immer nur einer; ein zweiter Aufruf kommt sofort mit skipped zurueck.
*/
export async function runMailSync({ trigger = 'manuell' } = {}) {
if (!MAIL_CONFIGURED) {
return { skipped: 'nicht-konfiguriert', missing: missingCredentials() };
if (!(await mailConfigured())) {
return { skipped: 'nicht-konfiguriert', missing: await missingCredentials() };
}
if (running) return { skipped: 'laeuft-bereits' };
+243
View File
@@ -0,0 +1,243 @@
import crypto from 'crypto';
import { getSettings, setSetting } from './settings.js';
/*
* Das Client-Secret der Microsoft-App-Registrierung - hinterlegbar in der
* Oberflaeche, mit Ablaufdatum.
*
* Warum ueberhaupt in der Datenbank: das Secret laeuft in Entra ab (Standard 6
* oder 24 Monate). Bisher hiess das: jemand mit SSH-Zugang muss die .env
* aendern und den Dienst neu starten - und niemand sieht vorher, wann es soweit
* ist. Beides erledigen jetzt Administratoren in den Einstellungen.
*
* Damit ein Datenbank-Dump trotzdem nichts hergibt, liegt der Wert
* AES-256-GCM-verschluesselt dort. Der Schluessel steht in der .env und kommt
* nie in die Datenbank - Dump und Schluessel sind also getrennt.
*
* Reihenfolge beim Lesen: erst die Datenbank, dann die .env. Der .env-Wert
* bleibt damit die Rueckfallebene, und wer nichts umstellt, merkt von der
* Neuerung nichts.
*
* Dasselbe Secret bedient Anmeldung UND Postfachanbindung - es ist dieselbe
* App-Registrierung. Eine Aenderung wirkt also auf beides.
*/
const KEY_CIPHER = 'ms_client_secret';
const KEY_EXPIRES = 'ms_client_secret_expires_at';
const KEY_SAVED = 'ms_client_secret_saved_at';
const DEFAULTS = {
[KEY_CIPHER]: '',
[KEY_EXPIRES]: '',
[KEY_SAVED]: '',
};
/** Ab so vielen Tagen vor dem Ablauf warnt das CRM. */
export const WARN_DAYS = 30;
/** Kennzeichnet das Format - falls das Verfahren je wechselt. */
const PREFIX = 'v1:';
/**
* Schluesselmaterial: bevorzugt CRM_ENCRYPTION_KEY, sonst JWT_SECRET. Der
* Rueckfall erspart eine zweite Pflichtvariable; wer JWT_SECRET wechselt, muss
* das Secret allerdings neu eingeben (die Oberflaeche sagt das dann auch).
*/
function cipherKey() {
const material = process.env.CRM_ENCRYPTION_KEY || process.env.JWT_SECRET || '';
if (material.length < 16) {
throw new Error('Kein Schlüssel zum Verschlüsseln: CRM_ENCRYPTION_KEY oder JWT_SECRET setzen.');
}
return crypto.scryptSync(material, 'perinco-crm-ms-secret', 32);
}
/** Exportiert nur fuer den Test - im Betrieb geht beides ueber saveSecret(). */
export function encrypt(text) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', cipherKey(), iv);
const data = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()]);
return PREFIX + Buffer.concat([iv, cipher.getAuthTag(), data]).toString('base64');
}
/** Entschluesselt; leerer String, wenn der Schluessel nicht mehr passt. */
export function decrypt(value) {
if (!value || !value.startsWith(PREFIX)) return '';
try {
const raw = Buffer.from(value.slice(PREFIX.length), 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', cipherKey(), raw.subarray(0, 12));
decipher.setAuthTag(raw.subarray(12, 28));
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8');
} catch {
return '';
}
}
const envSecret = () => (process.env.MS_CLIENT_SECRET || '').trim();
/**
* Rohwerte aus der Datenbank.
*
* Ist die Datenbank nicht erreichbar, gilt das als "nichts hinterlegt" - dann
* greift der Wert aus der .env. Eine nicht erreichbare Datenbank ist ohnehin
* ein groesseres Problem als ein fehlendes Secret, und der Postfachabgleich
* soll daran nicht mit einer irrefuehrenden Meldung scheitern.
*/
async function stored() {
let raw;
try {
raw = await getSettings(DEFAULTS);
} catch (err) {
console.warn('[Microsoft] Secret konnte nicht gelesen werden, nutze .env:', err.message);
raw = { ...DEFAULTS };
}
return {
cipher: raw[KEY_CIPHER] || '',
expiresAt: raw[KEY_EXPIRES] || '',
savedAt: raw[KEY_SAVED] || '',
};
}
/**
* Das tatsaechlich benutzte Secret samt Herkunft. Absichtlich ohne
* Zwischenspeicher: der Aufruf passiert selten (Tokens werden zwischengespeichert),
* und eine Aenderung soll sofort greifen.
*/
export async function effectiveSecret() {
const { cipher } = await stored();
if (cipher) {
const value = decrypt(cipher);
if (value) return { value, source: 'db' };
// Schluessel passt nicht mehr - nicht stillschweigend auf die .env
// zurueckfallen, sonst sucht man den Fehler an der falschen Stelle.
return { value: '', source: 'db-unlesbar' };
}
const value = envSecret();
return value ? { value, source: 'env' } : { value: '', source: null };
}
/** Fuer die Konfigurationspruefung: gibt es ueberhaupt ein brauchbares Secret? */
export async function hasSecret() {
const { value } = await effectiveSecret();
return Boolean(value);
}
/** Wirft mit klarer Meldung, wenn keins da ist - Gegenstueck zum alten clientSecret(). */
export async function requireSecret() {
const { value, source } = await effectiveSecret();
if (value) return value;
if (source === 'db-unlesbar') {
throw new Error(
'Das hinterlegte Client-Secret lässt sich nicht entschlüsseln. Wurde JWT_SECRET oder ' +
'CRM_ENCRYPTION_KEY geändert? Dann das Secret in den Einstellungen neu eintragen.'
);
}
throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.');
}
/** Tage bis zum Ablauf, auf Kalendertage gerechnet. Null ohne Datum. */
export function daysUntil(date, today = new Date()) {
if (!date) return null;
const target = Date.parse(`${date}T00:00:00Z`);
if (!Number.isFinite(target)) return null;
const start = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
return Math.round((target - start) / 86_400_000);
}
/** `ok`, `warnung`, `abgelaufen` oder `unbekannt` (kein Datum hinterlegt). */
export function expiryLevel(days) {
if (days === null) return 'unbekannt';
if (days < 0) return 'abgelaufen';
if (days <= WARN_DAYS) return 'warnung';
return 'ok';
}
/** Zustand fuer die Oberflaeche - ohne das Secret selbst. */
export async function secretStatus() {
const { expiresAt, savedAt } = await stored();
const { value, source } = await effectiveSecret();
const days = daysUntil(expiresAt);
return {
configured: Boolean(value),
/** 'db', 'env', 'db-unlesbar' oder null */
source,
expires_at: expiresAt || null,
saved_at: savedAt || null,
days_left: days,
level: expiryLevel(days),
warn_days: WARN_DAYS,
};
}
/** Leeres Datum loescht den Eintrag; ungueltiges Datum wird abgewiesen. */
export function normalizeExpiry(value) {
const text = String(value ?? '').trim();
if (text === '') return { value: '' };
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
return { error: 'Das Ablaufdatum muss im Format JJJJ-MM-TT angegeben werden.' };
}
const parsed = new Date(`${text}T00:00:00Z`);
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== text) {
return { error: 'Dieses Ablaufdatum gibt es nicht.' };
}
return { value: text };
}
/**
* Prueft ein Secret bei Microsoft, bevor es gespeichert wird: ein
* Client-Credentials-Token gegen denselben Tenant. Ein falsches Secret faellt so
* sofort auf, statt beim naechsten Postfachabgleich.
*/
export async function verifySecret(candidate) {
const tenant = (process.env.MS_TENANT_ID || '').trim();
const client = (process.env.MS_CLIENT_ID || '').trim();
if (!tenant || !client) {
return { ok: false, error: 'MS_TENANT_ID oder MS_CLIENT_ID fehlt in der .env.' };
}
let response;
try {
response = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: client,
client_secret: candidate,
scope: 'https://graph.microsoft.com/.default',
grant_type: 'client_credentials',
}),
signal: AbortSignal.timeout(20_000),
});
} catch (err) {
return { ok: false, error: `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}`;
return { ok: false, error: String(detail).split('\n')[0] };
}
return { ok: true };
}
/**
* Speichert Secret und/oder Ablaufdatum. `secret` leer bedeutet: nur das Datum
* anfassen, das gespeicherte Secret bleibt.
*/
export async function saveSecret({ secret = '', expiresAt = null } = {}) {
if (secret) {
await setSetting(KEY_CIPHER, encrypt(secret));
await setSetting(KEY_SAVED, new Date().toISOString());
}
if (expiresAt !== null) {
await setSetting(KEY_EXPIRES, expiresAt);
}
return secretStatus();
}
/** Nimmt das hinterlegte Secret zurueck - ab dann gilt wieder die .env. */
export async function clearStoredSecret() {
await setSetting(KEY_CIPHER, '');
await setSetting(KEY_SAVED, '');
return secretStatus();
}
+91 -15
View File
@@ -11,10 +11,18 @@ import {
import {
ALLOWED_DOMAINS,
REDIRECT_URI,
SSO_CONFIGURED,
domainAllowed,
missingSsoConfig,
ssoConfigured,
} from '../sso/config.js';
import {
clearStoredSecret,
normalizeExpiry,
saveSecret,
secretStatus,
verifySecret,
} from '../msSecret.js';
import { resetTokenCache } from '../mail/graph.js';
import {
SsoError,
beginLogin,
@@ -183,31 +191,99 @@ function redirectWithError(res, message) {
// 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 });
router.get('/sso/status', async (req, res, next) => {
try {
res.json({ enabled: await ssoConfigured() });
} catch (err) {
next(err);
}
});
// 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,
});
router.get('/sso/config', requireAuth, requireAdmin, async (req, res, next) => {
try {
res.json({
enabled: await ssoConfigured(),
missing: await missingSsoConfig(),
redirect_uri: REDIRECT_URI || null,
allowed_domains: ALLOWED_DOMAINS,
secret: await secretStatus(),
});
} catch (err) {
next(err);
}
});
/* ---------------------------------------------------------------------------
* Client-Secret der App-Registrierung
*
* Dasselbe Secret bedient Anmeldung und Postfachanbindung. Es liegt
* verschluesselt in crm_settings (siehe msSecret.js); der Wert aus der .env
* bleibt Rueckfallebene. Ausgeliefert wird das Secret nie - weder ganz noch
* teilweise, auch nicht an Administratoren.
* ------------------------------------------------------------------------- */
// PUT /api/auth/microsoft-secret { secret?, expires_at? }
router.put('/microsoft-secret', requireAuth, requireAdmin, async (req, res, next) => {
const secret = String(req.body?.secret ?? '').trim();
const hasExpiry = req.body?.expires_at !== undefined;
const expiry = hasExpiry ? normalizeExpiry(req.body.expires_at) : { value: null };
if (expiry.error) return res.status(400).json({ error: expiry.error });
if (!secret && !hasExpiry) {
return res.status(400).json({ error: 'Kein Secret und kein Ablaufdatum übergeben.' });
}
try {
// Erst bei Microsoft ausprobieren, dann speichern: ein vertipptes Secret
// legt sonst still Anmeldung und Postfachabgleich lahm.
if (secret) {
const check = await verifySecret(secret);
if (!check.ok) {
return res.status(400).json({
error: `Microsoft hat dieses Secret abgelehnt: ${check.error}`,
});
}
}
const status = await saveSecret({ secret, expiresAt: expiry.value });
// Das zwischengespeicherte Graph-Token stammt noch vom alten Secret.
if (secret) resetTokenCache();
res.json({ ok: true, verified: Boolean(secret), secret: status });
} catch (err) {
next(err);
}
});
// DELETE /api/auth/microsoft-secret - hinterlegtes Secret verwerfen, .env gilt wieder
router.delete('/microsoft-secret', requireAuth, requireAdmin, async (req, res, next) => {
try {
const status = await clearStoredSecret();
resetTokenCache();
res.json({ ok: true, secret: status });
} catch (err) {
next(err);
}
});
// 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.' });
router.get('/sso/start', async (req, res, next) => {
try {
if (!(await ssoConfigured())) {
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
}
res.redirect(302, beginLogin());
} catch (err) {
next(err);
}
res.redirect(302, beginLogin());
});
// GET /api/auth/sso/callback - Rueckleitung von Microsoft.
router.get('/sso/callback', async (req, res, next) => {
if (!SSO_CONFIGURED) {
if (!(await ssoConfigured())) {
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
}
+24
View File
@@ -2,6 +2,7 @@ import express from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import { Match } from '../scanner/models/index.js';
import { secretStatus } from '../msSecret.js';
import {
ARCHIVE_AFTER_DAYS,
DELETE_AFTER_DAYS,
@@ -54,12 +55,35 @@ router.get('/', async (req, res, next) => {
// Scanner-Datenbank nicht erreichbar - dann eben keine Zahl.
}
/*
* Hinweis auf das bald ablaufende Client-Secret. Er reist mit den Zaehlern
* mit, weil die ohnehin bei jedem Laden der App geholt werden - ein eigener
* Endpunkt waere eine zweite Anfrage fuer eine Zahl. Nur Administratoren
* bekommen ihn: nur sie koennen das Secret erneuern.
*/
let msSecret = null;
if (req.user.role === 'admin') {
try {
const status = await secretStatus();
if (status.level === 'warnung' || status.level === 'abgelaufen') {
msSecret = {
level: status.level,
days_left: status.days_left,
expires_at: status.expires_at,
};
}
} catch {
// Kein Grund, die Zaehler scheitern zu lassen.
}
}
res.json({
acquisition_archive: Number(counts.acquisition_archive),
acquisition_trash: Number(counts.acquisition_trash),
opportunity_trash: Number(counts.opportunity_trash),
mail_trash: Number(counts.mail_trash),
scanner_rejected: scannerRejected,
ms_secret: msSecret,
});
} catch (err) {
next(err);
+10 -7
View File
@@ -4,7 +4,7 @@ import { requireAdmin } from '../middleware/auth.js';
import { setSetting } from '../settings.js';
import {
MAILBOX,
MAIL_CONFIGURED,
mailConfigured,
MAIL_FOLDER,
MAIL_SETTING_DEFAULTS,
SCHEDULER_ENABLED,
@@ -70,6 +70,7 @@ router.get('/status', async (req, res, next) => {
try {
const settings = await mailSettings();
const state = await getSyncState();
const configured = await mailConfigured();
const [[counts]] = await pool.query(
`SELECT
@@ -86,8 +87,8 @@ router.get('/status', async (req, res, next) => {
);
res.json({
configured: MAIL_CONFIGURED,
missing: MAIL_CONFIGURED ? [] : missingCredentials(),
configured,
missing: configured ? [] : await missingCredentials(),
mailbox: MAILBOX,
folder: MAIL_FOLDER,
scheduler_enabled: SCHEDULER_ENABLED,
@@ -167,11 +168,11 @@ router.post('/settings', requireAdmin, async (req, res, next) => {
// POST /api/mail/test - Token holen und wirklich auf das Postfach zugreifen
router.post('/test', requireAdmin, async (req, res, next) => {
try {
if (!MAIL_CONFIGURED) {
if (!(await mailConfigured())) {
return res.status(400).json({
ok: false,
stage: 'konfiguration',
error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}`,
error: `Zugangsdaten fehlen: ${(await missingCredentials()).join(', ')}`,
hint: 'Werte in server/.env setzen und den Dienst neu starten.',
});
}
@@ -184,8 +185,10 @@ router.post('/test', requireAdmin, async (req, res, next) => {
// POST /api/mail/sync - Abgleich sofort anstossen
router.post('/sync', requireAdmin, async (req, res, next) => {
try {
if (!MAIL_CONFIGURED) {
return res.status(409).json({ error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}` });
if (!(await mailConfigured())) {
return res.status(409).json({
error: `Zugangsdaten fehlen: ${(await missingCredentials()).join(', ')}`,
});
}
if (isMailSyncRunning()) {
return res.status(409).json({ error: 'Ein Abgleich läuft bereits. Bitte kurz warten.' });
+13 -9
View File
@@ -12,9 +12,10 @@
* Client-ID zu sehen; er kennt nur /api/auth/sso/start.
*/
import { hasSecret, requireSecret } from '../msSecret.js';
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';
@@ -40,24 +41,27 @@ 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;
/*
* Ohne diese Werte ist die Anmeldung nicht betriebsbereit. Asynchron, weil das
* Secret auch aus der Datenbank kommen darf (msSecret.js).
*/
export async function ssoConfigured() {
return Boolean(ENABLED && TENANT_ID && CLIENT_ID && REDIRECT_URI && (await hasSecret()));
}
/** Das gueltige Client-Secret: Datenbank vor .env. */
export const clientSecret = requireSecret;
/**
* Was zum Betrieb fehlt - fuer eine brauchbare Meldung in den Einstellungen,
* ohne Werte zu verraten.
*/
export function missingSsoConfig() {
export async 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 (!(await hasSecret())) missing.push('MS_CLIENT_SECRET');
if (!REDIRECT_URI) missing.push('MS_SSO_REDIRECT_URI');
return missing;
}
+2 -2
View File
@@ -123,10 +123,10 @@ async function postToken(body) {
}
/** Tauscht den Code gegen Tokens. Der Verifier weist uns als Starter des Vorgangs aus. */
export function exchangeCode(code, verifier) {
export async function exchangeCode(code, verifier) {
return postToken({
client_id: CLIENT_ID,
client_secret: clientSecret(),
client_secret: await clientSecret(),
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,