Files
sshkeyvault/tests/sitzung.test.ts
T
florian.wildeandClaude Opus 5.5 5ef90310e1 feat(auth): Anmeldung per OIDC und Notfall-Admin mit TOTP (#004)
Sitzung, OIDC-Fluss und Rueckruf aus Laufzettel uebernommen und auf
sshkv angepasst. Abweichungen mit Absicht:

- Ohne passende sshkv-Gruppe gibt es keinen Zugang, auch keinen
  Lesezugriff: schon die Hostliste verraet, wer wohin darf.
- Notfall-Admin nur mit TOTP (Freigabe Frage 5). Ein richtiges
  Passwort fuehrt beim ersten Mal nur zur Einrichtung. Jeder Code gilt
  einmal, fuenf Fehlversuche sperren 15 Minuten.
- Kritische Rechte verlangen eine Bestaetigung, die hoechstens fuenf
  Minuten alt ist. Bei OIDC mit prompt=login, max_age=0 und Pruefung
  von auth_time, sonst zaehlte eine laufende SSO-Sitzung als Beweis.
- Sitzungen laufen acht statt zwoelf Stunden.

Die Seiten folgen, sobald das Design-System (#002) steht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:17:44 +02:00

282 lines
10 KiB
TypeScript

import { SignJWT } from "jose";
import { afterEach, describe, expect, it } from "vitest";
import {
SITZUNG_COOKIE,
SITZUNG_LAUFZEIT_STUNDEN,
SitzungsFehler,
sitzungErzeugen,
sitzungLesen,
type Sitzung,
} from "@/lib/sitzung";
// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen ihn danach
// wieder her, damit die Reihenfolge der Tests nichts aendert.
const APP_SECRET_VORHER = process.env["SSHKV_APP_SECRET"];
function appSecretSetzen(wert: string): void {
process.env["SSHKV_APP_SECRET"] = wert;
}
afterEach(() => {
if (APP_SECRET_VORHER === undefined) {
delete process.env["SSHKV_APP_SECRET"];
} else {
process.env["SSHKV_APP_SECRET"] = APP_SECRET_VORHER;
}
});
const DATEN: Omit<Sitzung, "ausgestelltAm"> = {
benutzerId: "b-0815",
email: "anna.mueller@example.org",
name: "Anna Müller",
herkunft: "notfall",
};
function schluessel(quelle: string): Uint8Array {
return new TextEncoder().encode(quelle);
}
/** Baut ein Token von Hand, damit sich Ablauf, Aussteller und Zielgruppe einzeln verbiegen lassen. */
async function tokenBauen(optionen: {
aussteller?: string;
zielgruppe?: string;
ausgestelltAm?: number;
laeuftAbAm?: number;
geheimnis?: string;
inhalt?: Record<string, unknown>;
}): Promise<string> {
const jetzt = Math.floor(Date.now() / 1000);
return await new SignJWT({
email: DATEN.email,
name: DATEN.name,
herkunft: DATEN.herkunft,
...optionen.inhalt,
})
.setProtectedHeader({ alg: "HS256" })
.setSubject(DATEN.benutzerId)
.setIssuer(optionen.aussteller ?? "sshkv")
.setAudience(optionen.zielgruppe ?? "sshkv")
.setIssuedAt(optionen.ausgestelltAm ?? jetzt)
.setExpirationTime(optionen.laeuftAbAm ?? jetzt + 3600)
.sign(schluessel(optionen.geheimnis ?? APP_SECRET_VORHER ?? ""));
}
/** Aendert genau ein Zeichen im angegebenen Abschnitt des Tokens. */
function zeichenVerfaelschen(jwt: string, abschnitt: number): string {
const teile = jwt.split(".");
const teil = teile[abschnitt];
if (teil === undefined || teil.length === 0) {
throw new Error(`Abschnitt ${abschnitt} fehlt im Token.`);
}
teile[abschnitt] = (teil.startsWith("A") ? "B" : "A") + teil.slice(1);
return teile.join(".");
}
describe("Sitzung erzeugen und lesen", () => {
it("ist verlustfrei, auch bei Umlauten im Namen", async () => {
const jwt = await sitzungErzeugen(DATEN);
const sitzung = await sitzungLesen(jwt);
expect(sitzung).toBeDefined();
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
expect(sitzung?.email).toBe(DATEN.email);
expect(sitzung?.name).toBe("Anna Müller");
expect(sitzung?.herkunft).toBe("notfall");
});
it.each([
["Umlaute und Eszett", "Jörg Weiß-Bäcker"],
["franzoesische Akzente", "Zoé Lefèvre"],
["langer Doppelname", "Maximilian Freiherr von Hohenstein-Überlingen"],
])("behaelt %s im Namen", async (_fall, name) => {
const jwt = await sitzungErzeugen({ ...DATEN, name });
expect((await sitzungLesen(jwt))?.name).toBe(name);
});
it("setzt ausgestelltAm auf die aktuelle Zeit in Sekunden", async () => {
const vorher = Math.floor(Date.now() / 1000);
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
const nachher = Math.floor(Date.now() / 1000);
expect(sitzung?.ausgestelltAm).toBeGreaterThanOrEqual(vorher);
expect(sitzung?.ausgestelltAm).toBeLessThanOrEqual(nachher);
});
it("uebernimmt beide Herkuenfte", async () => {
for (const herkunft of ["notfall", "oidc"] as const) {
const sitzung = await sitzungLesen(await sitzungErzeugen({ ...DATEN, herkunft }));
expect(sitzung?.herkunft).toBe(herkunft);
}
});
it("signiert mit HS256 und laeuft nach der festgelegten Laufzeit ab", async () => {
const jwt = await sitzungErzeugen(DATEN);
const [kopfTeil, inhaltTeil] = jwt.split(".");
const kopf = JSON.parse(Buffer.from(kopfTeil ?? "", "base64url").toString("utf8")) as {
alg: string;
};
const inhalt = JSON.parse(Buffer.from(inhaltTeil ?? "", "base64url").toString("utf8")) as {
iat: number;
exp: number;
iss: string;
aud: string;
};
expect(kopf.alg).toBe("HS256");
expect(inhalt.exp - inhalt.iat).toBe(SITZUNG_LAUFZEIT_STUNDEN * 3600);
expect(inhalt.iss).toBe("sshkv");
expect(inhalt.aud).toBe("sshkv");
});
it("nennt den Cookie-Namen und die Laufzeit unveraendert", () => {
// Der Name steht im Proxy und in der Anmelderoute; eine Umbenennung meldet sich hier.
expect(SITZUNG_COOKIE).toBe("sshkv_sitzung");
expect(SITZUNG_LAUFZEIT_STUNDEN).toBe(8);
});
it("legt den Namen nicht im Klartext offen, aber signiert ihn nachvollziehbar", async () => {
// Ein JWT ist nur signiert, nicht verschluesselt -- das soll hier niemand fuer
// Geheimhaltung halten. Der Test haelt fest, dass nichts Vertrauliches hineingehoert.
const jwt = await sitzungErzeugen(DATEN);
expect(jwt.split(".")).toHaveLength(3);
});
});
describe("Sitzung lesen bei kaputtem Token", () => {
it("gibt bei undefined undefined zurueck", async () => {
await expect(sitzungLesen(undefined)).resolves.toBeUndefined();
});
it.each([
["leerer Wert", ""],
["nur Leerzeichen", " "],
["Klartext ohne Punkte", "keinTokenSondernText"],
["zu wenige Abschnitte", "aaa.bbb"],
["kein base64", "!!!.???.###"],
])("gibt bei %s undefined zurueck", async (_fall, jwt) => {
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
});
it("gibt bei einem an einem Zeichen verfaelschten Token undefined zurueck", async () => {
const jwt = await sitzungErzeugen(DATEN);
// Die Signatur passt danach nicht mehr -- genau das soll auffallen.
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 2))).resolves.toBeUndefined();
});
it("gibt bei verfaelschtem Inhalt undefined zurueck", async () => {
const jwt = await sitzungErzeugen(DATEN);
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 1))).resolves.toBeUndefined();
});
it("gibt bei verfaelschtem Kopf undefined zurueck", async () => {
const jwt = await sitzungErzeugen(DATEN);
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 0))).resolves.toBeUndefined();
});
it("gibt bei einem abgeschnittenen Token undefined zurueck", async () => {
const jwt = await sitzungErzeugen(DATEN);
await expect(sitzungLesen(jwt.slice(0, jwt.length - 5))).resolves.toBeUndefined();
await expect(sitzungLesen(jwt.split(".").slice(0, 2).join("."))).resolves.toBeUndefined();
});
it("gibt bei einem Token ohne Signatur undefined zurueck", async () => {
// Der klassische alg-none-Versuch: Signatur weglassen und hoffen.
const jwt = await sitzungErzeugen(DATEN);
await expect(sitzungLesen(`${jwt.split(".").slice(0, 2).join(".")}.`)).resolves.toBeUndefined();
});
it("gibt bei einem mit anderem Schluessel signierten Token undefined zurueck", async () => {
const fremd = await tokenBauen({ geheimnis: "ein-voellig-anderes-geheimnis-mit-genug-zeichen" });
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
});
it("gibt bei einem Token undefined zurueck, dessen SSHKV_APP_SECRET inzwischen rotiert wurde", async () => {
const jwt = await sitzungErzeugen(DATEN);
appSecretSetzen("nach-der-rotation-ein-anderes-geheimnis-mit-laenge");
// Haelt fest, dass der Schluessel nicht am Zwischenspeicher vorbeilaeuft.
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
});
});
describe("Sitzung lesen bei abgelaufenem oder fremdem Token", () => {
it("gibt bei einem abgelaufenen Token undefined zurueck", async () => {
const jetzt = Math.floor(Date.now() / 1000);
const abgelaufen = await tokenBauen({
ausgestelltAm: jetzt - 13 * 3600,
laeuftAbAm: jetzt - 3600,
});
await expect(sitzungLesen(abgelaufen)).resolves.toBeUndefined();
});
it("nimmt ein Token an, das gerade noch laeuft", async () => {
const jetzt = Math.floor(Date.now() / 1000);
const knapp = await tokenBauen({ laeuftAbAm: jetzt + 60 });
expect(await sitzungLesen(knapp)).toBeDefined();
});
it("gibt bei falschem Aussteller undefined zurueck", async () => {
// Ein mit demselben Geheimnis signiertes Token aus einem anderen Zweck darf nicht als
// Sitzung durchrutschen.
const fremd = await tokenBauen({ aussteller: "proxledger" });
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
});
it("gibt bei falscher Zielgruppe undefined zurueck", async () => {
const fremd = await tokenBauen({ zielgruppe: "einladung" });
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
});
it.each([
["fehlende E-Mail", { email: undefined }],
["fehlender Name", { name: undefined }],
["fehlende Herkunft", { herkunft: undefined }],
["unbekannte Herkunft", { herkunft: "ldap" }],
["Name als Zahl", { name: 42 }],
])("gibt bei %s undefined zurueck", async (_fall, inhalt) => {
const luecke = await tokenBauen({ inhalt });
await expect(sitzungLesen(luecke)).resolves.toBeUndefined();
});
});
describe("SSHKV_APP_SECRET pruefen", () => {
it.each([
["fehlendes", ""],
["viel zu kurzes", "kurz"],
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
])("wirft beim Erzeugen bei %s SSHKV_APP_SECRET mit Hinweis auf openssl", async (_fall, wert) => {
appSecretSetzen(wert);
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(SitzungsFehler);
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/openssl rand -base64 48/);
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/mindestens 32 Zeichen/);
});
it("wirft auch beim Lesen bei zu kurzem SSHKV_APP_SECRET", async () => {
// Die eine Ausnahme von "sitzungLesen wirft nicht": ein zu kurzes Geheimnis ist ein
// Konfigurationsfehler und darf nicht als "nicht angemeldet" durchgehen.
const jwt = await sitzungErzeugen(DATEN);
appSecretSetzen("kurz");
await expect(sitzungLesen(jwt)).rejects.toThrow(SitzungsFehler);
await expect(sitzungLesen(undefined)).rejects.toThrow(/openssl rand -base64 48/);
});
it("nimmt genau 32 Zeichen an", async () => {
appSecretSetzen("b".repeat(32));
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
});
it("stellt den Standardwert aus setup-env wieder her", async () => {
// Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die Tests je nach
// Reihenfolge unterschiedlich ausgehen.
expect(process.env["SSHKV_APP_SECRET"]).toBe(APP_SECRET_VORHER);
expect(await sitzungLesen(await sitzungErzeugen(DATEN))).toBeDefined();
});
});