Files
Sascha WolfandClaude Opus 5 c8eaaa1947 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>
2026-09-21 08:38:28 +02:00

77 lines
3.4 KiB
JavaScript

/*
* Prueft das hinterlegte Client-Secret, soweit es ohne Datenbank geht:
* Verschluesselung, Ablaufrechnung und die Pruefung des Datums.
*
* Der Schluessel muss vor dem Import stehen - msSecret.js liest ihn beim
* Verschluesseln aus der Umgebung, genau wie im Betrieb.
*/
process.env.JWT_SECRET = 'test-schluessel-lang-genug-fuer-die-pruefung';
delete process.env.CRM_ENCRYPTION_KEY;
const { WARN_DAYS, daysUntil, decrypt, encrypt, expiryLevel, normalizeExpiry } = await import(
'../src/msSecret.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;
}
}
console.log('\nClient-Secret');
console.log('\n Verschluesselung');
const klartext = 'Abc8Q~geheimes-client-secret-aus-entra';
const cipher = encrypt(klartext);
check('Ergebnis ist nicht der Klartext', !cipher.includes('geheimes-client-secret'));
check('Format ist gekennzeichnet', cipher.startsWith('v1:'));
check('Entschluesseln ergibt das Original', decrypt(cipher) === klartext);
check(
'zweimal verschluesselt ergibt zwei verschiedene Werte',
encrypt(klartext) !== encrypt(klartext),
'eigener Zufallsvektor je Aufruf'
);
check('veraenderter Text wird nicht entschluesselt', decrypt(`${cipher}xx`) === '', 'GCM erkennt es');
check('fremdes Format wird abgelehnt', decrypt('irgendwas') === '');
check('leerer Wert bleibt leer', decrypt('') === '');
// Ein anderer Schluessel darf nicht passen - genau das passiert, wenn jemand
// JWT_SECRET wechselt. Die Oberflaeche zeigt dann "nicht entschluesselbar".
process.env.CRM_ENCRYPTION_KEY = 'ein-ganz-anderer-schluessel-fuer-den-test';
check('anderer Schluessel entschluesselt nicht', decrypt(cipher) === '');
delete process.env.CRM_ENCRYPTION_KEY;
check('mit dem alten Schluessel geht es wieder', decrypt(cipher) === klartext);
console.log('\n Restlaufzeit');
const heute = new Date('2026-09-21T08:00:00Z');
check('heute sind es 0 Tage', daysUntil('2026-09-21', heute) === 0);
check('morgen ist 1 Tag', daysUntil('2026-09-22', heute) === 1);
check('gestern sind -1 Tage', daysUntil('2026-09-20', heute) === -1);
check('ohne Datum null', daysUntil('', heute) === null);
check('unlesbares Datum ergibt null', daysUntil('irgendwann', heute) === null);
check('Jahreswechsel zaehlt richtig', daysUntil('2027-09-21', heute) === 365);
console.log('\n Warnstufe');
check('kein Datum: unbekannt', expiryLevel(null) === 'unbekannt');
check('abgelaufen bei -1', expiryLevel(-1) === 'abgelaufen');
check('Warnung am Tag des Ablaufs', expiryLevel(0) === 'warnung');
check(`Warnung genau ${WARN_DAYS} Tage vorher`, expiryLevel(WARN_DAYS) === 'warnung');
check('einen Tag frueher noch ok', expiryLevel(WARN_DAYS + 1) === 'ok');
console.log('\n Ablaufdatum pruefen');
check('gueltiges Datum wird uebernommen', normalizeExpiry('2027-03-01').value === '2027-03-01');
check('leer loescht den Eintrag', normalizeExpiry('').value === '');
check('deutsches Format wird abgelehnt', Boolean(normalizeExpiry('01.03.2027').error));
check('31. Februar wird abgelehnt', Boolean(normalizeExpiry('2027-02-31').error));
check('Monat 13 wird abgelehnt', Boolean(normalizeExpiry('2027-13-01').error));
console.log(
failures === 0 ? '\nAlle Pruefungen bestanden.\n' : `\n${failures} Pruefung(en) fehlgeschlagen.\n`
);
process.exit(failures === 0 ? 0 : 1);