Files
Wabenhain/src/lib/session.test.ts
T
Nick SzaunigandClaude Opus 5.5 45ca0ea745
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s
test(session): tamper inside the signature, not its last character
The last base64url character of a 32-byte HMAC signature partly carries
padding bits that decoders ignore, so the tampered token was sometimes
still valid and the test failed at random. Changing a character in the
middle of the signature always alters the signature bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:39:09 +02:00

171 lines
6.4 KiB
TypeScript

// @vitest-environment node
//
// Abweichend von der globalen jsdom-Umgebung (vitest.config.ts): "jose" prueft
// seine Eingabe mit "instanceof Uint8Array", und das von jsdom bereitgestellte
// TextEncoder liefert ein Uint8Array aus einem anderen JS-Realm — die Pruefung
// schlaegt dann fehl, obwohl der Code stimmt. Im Betrieb laeuft dieses Modul
// ohnehin nie in einem DOM.
/**
* Tests fuer die Token-Schicht der Anmeldung.
*
* Alle Token in session.ts sind mit DEMSELBEN Geheimnis signiert und tragen
* denselben Aussteller und dieselbe Zielgruppe. Was sie unterscheidet, ist
* allein das "type"-Feld. Damit ist die zentrale Frage nicht, ob ein gueltiger
* Token angenommen wird, sondern ob ein Token FUER EINEN ANDEREN ZWECK
* abgelehnt wird — sonst laesst sich der zweite Faktor umgehen, indem man das
* Challenge-Cookie in das Session-Cookie kopiert.
*/
import { describe, test, expect } from "vitest";
// Vor dem Import setzen: getSecretKey wirft ohne diesen Wert.
process.env.SESSION_SECRET = "test-secret-mit-mindestens-32-zeichen-laenge";
const {
signSessionToken,
verifySessionToken,
signTwoFactorChallenge,
verifyTwoFactorChallenge,
signTwoFactorSetup,
verifyTwoFactorSetup,
signTwoFactorBackupCodesDisplay,
verifyTwoFactorBackupCodesDisplay,
signEntraOAuthState,
verifyEntraOAuthState,
} = await import("./session");
const CUSTOMER_ID = "customer_123";
describe("Session-Token", () => {
test("gibt Kunde, Rolle und Sitzungsversion unveraendert zurueck", async () => {
const token = await signSessionToken({
customerId: CUSTOMER_ID,
role: "ADMIN",
sessionVersion: 3,
});
await expect(verifySessionToken(token)).resolves.toEqual({
customerId: CUSTOMER_ID,
role: "ADMIN",
sessionVersion: 3,
});
});
test("stuft eine unbekannte Rolle auf KUNDE herunter statt sie zu uebernehmen", async () => {
// Die Rolle im Token ist nur der schnelle Vorfilter fuer den Proxy;
// autoritativ ist die Datenbank. Ein unerwarteter Wert darf trotzdem nie
// als etwas anderes als die geringste Berechtigung durchgehen.
const token = await signSessionToken({
customerId: CUSTOMER_ID,
role: "SUPERADMIN" as never,
sessionVersion: 0,
});
await expect(verifySessionToken(token)).resolves.toMatchObject({ role: "KUNDE" });
});
test("lehnt einen manipulierten Token ab", async () => {
const token = await signSessionToken({
customerId: CUSTOMER_ID,
role: "KUNDE",
sessionVersion: 0,
});
// Ein Zeichen MITTEN in der Signatur veraendern. Das letzte Zeichen taugt
// nicht: bei 32 Signaturbytes traegt es in Base64url teils nur Fuellbits,
// die der Decoder ignoriert — der Test schlug dann zufaellig fehl, obwohl
// die Signatur korrekt geprueft wurde.
const signatureStart = token.lastIndexOf(".") + 1;
const position = signatureStart + 10;
const replacement = token[position] === "A" ? "B" : "A";
const tampered = token.slice(0, position) + replacement + token.slice(position + 1);
await expect(verifySessionToken(tampered)).resolves.toBeNull();
});
test("lehnt Unsinn ab, statt zu werfen", async () => {
await expect(verifySessionToken("kein.jwt.hier")).resolves.toBeNull();
await expect(verifySessionToken("")).resolves.toBeNull();
});
});
describe("Token-Verwechslung", () => {
/**
* Der wichtigste Test dieser Datei.
*
* Das Challenge-Cookie wird ausgestellt, NACHDEM das Passwort stimmt, aber
* BEVOR der zweite Faktor geprueft ist. Gilt es als Session, ist die
* Zwei-Faktor-Pruefung eine Formalie, die sich mit einem Kopiervorgang im
* Browser ueberspringen laesst.
*/
test("ein 2FA-Challenge-Token ist KEINE gueltige Anmeldung", async () => {
const challenge = await signTwoFactorChallenge(CUSTOMER_ID);
await expect(verifySessionToken(challenge)).resolves.toBeNull();
});
test("ein 2FA-Einrichtungstoken ist KEINE gueltige Anmeldung", async () => {
const setup = await signTwoFactorSetup(CUSTOMER_ID, "JBSWY3DPEHPK3PXP");
await expect(verifySessionToken(setup)).resolves.toBeNull();
});
test("ein Backup-Code-Anzeigetoken ist KEINE gueltige Anmeldung", async () => {
const display = await signTwoFactorBackupCodesDisplay(CUSTOMER_ID, ["aaaa-bbbb"]);
await expect(verifySessionToken(display)).resolves.toBeNull();
});
test("ein Entra-OAuth-State ist KEINE gueltige Anmeldung", async () => {
const state = await signEntraOAuthState("zufallswert", "code-verifier");
await expect(verifySessionToken(state)).resolves.toBeNull();
});
test("ein Session-Token taugt umgekehrt fuer keinen der Sonderzwecke", async () => {
const session = await signSessionToken({
customerId: CUSTOMER_ID,
role: "ADMIN",
sessionVersion: 0,
});
await expect(verifyTwoFactorChallenge(session)).resolves.toBeNull();
await expect(verifyTwoFactorSetup(session)).resolves.toBeNull();
await expect(verifyTwoFactorBackupCodesDisplay(session)).resolves.toBeNull();
await expect(verifyEntraOAuthState(session)).resolves.toBeNull();
});
test("die Sondertoken lassen sich auch untereinander nicht tauschen", async () => {
const challenge = await signTwoFactorChallenge(CUSTOMER_ID);
const setup = await signTwoFactorSetup(CUSTOMER_ID, "JBSWY3DPEHPK3PXP");
// Ein Einrichtungstoken darf keinen Anmeldeschritt abschliessen, und ein
// Challenge-Token traegt kein Secret, mit dem sich 2FA einrichten liesse.
await expect(verifyTwoFactorChallenge(setup)).resolves.toBeNull();
await expect(verifyTwoFactorSetup(challenge)).resolves.toBeNull();
});
});
describe("Sondertoken im Normalfall", () => {
test("Challenge und Einrichtung geben ihren Inhalt zurueck", async () => {
const challenge = await signTwoFactorChallenge(CUSTOMER_ID);
await expect(verifyTwoFactorChallenge(challenge)).resolves.toEqual({
customerId: CUSTOMER_ID,
});
const setup = await signTwoFactorSetup(CUSTOMER_ID, "JBSWY3DPEHPK3PXP");
await expect(verifyTwoFactorSetup(setup)).resolves.toEqual({
customerId: CUSTOMER_ID,
secret: "JBSWY3DPEHPK3PXP",
});
});
test("der Entra-State haelt den PKCE-Verifier, der nie in die URL gehoert", async () => {
const state = await signEntraOAuthState("zufallswert", "code-verifier");
await expect(verifyEntraOAuthState(state)).resolves.toEqual({
state: "zufallswert",
codeVerifier: "code-verifier",
});
});
});