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 = { 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; }): Promise { 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(); }); });