import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { MAX_PASSKEYS_JE_BENUTZER, PASSKEY_MELDUNG, PasskeyFehler, challengeAusAntwort, passkeysMoeglich, } from '@/lib/auth/passkey' import { passkeyBrowserfehler } from '@/lib/passkey-browser' import { env } from '@/lib/env' /** * Passkeys — die Teile, die sich ohne Datenbank und ohne Browser prüfen * lassen. * * Der Schwerpunkt liegt auf der Herleitung von `rpId` und Herkunft aus * `AUTH_URL`. Das ist bei WebAuthn die Stelle, an der eine Installation * still kaputtgeht: Stimmt die Bereichskennung nicht, lässt sich zwar ein * Passkey einrichten, aber nie wieder verwenden — und die Fehlermeldung des * Browsers zeigt auf alles Mögliche, nur nicht auf die Konfiguration. * * Dieselbe Fehlerklasse hat uns bei `APP_BIND` und `ENC_KEY_V2` schon zwei * Fassungen gekostet: eine Umgebungsvariable, die falsch ausgewertet wird, * und ein Fehlerbild, das woandershin zeigt. */ let vorher: string | undefined beforeEach(() => { vorher = process.env.AUTH_URL }) afterEach(() => { if (vorher === undefined) delete process.env.AUTH_URL else process.env.AUTH_URL = vorher }) describe('Bereichskennung aus AUTH_URL', () => { it('nimmt den nackten Hostnamen als rpId, ohne Schema und ohne Port', () => { process.env.AUTH_URL = 'https://cockpit.example.de' expect(env.passkey.rpId).toBe('cockpit.example.de') expect(env.passkey.herkunft).toBe('https://cockpit.example.de') }) it('lässt den Port in der Herkunft stehen, aber nicht in der rpId', () => { // Die Herkunft wird zeichengenau mit dem verglichen, was im Browser in // der Adresszeile stand - der Port gehoert dazu. Die rpId ist dagegen // die Domain, an die der Schluessel gebunden wird; ein Port darf dort // nicht auftauchen, sonst weist der Authenticator sie zurueck. process.env.AUTH_URL = 'https://cockpit.example.de:8443' expect(env.passkey.rpId).toBe('cockpit.example.de') expect(env.passkey.herkunft).toBe('https://cockpit.example.de:8443') }) it('verträgt einen Schrägstrich am Ende', () => { process.env.AUTH_URL = 'https://cockpit.example.de/' expect(env.passkey.herkunft).toBe('https://cockpit.example.de') }) it('erkennt eine Unterdomain als eigene Kennung', () => { // Ein Passkey fuer betreuung.example.de gilt NICHT fuer example.de - // genau das ist der Phishingschutz. process.env.AUTH_URL = 'https://betreuung.example.de' expect(env.passkey.rpId).toBe('betreuung.example.de') }) }) describe('Verfügbarkeit', () => { it('bietet Passkeys über https an', () => { process.env.AUTH_URL = 'https://cockpit.example.de' expect(env.passkey.sicherAufrufbar).toBe(true) expect(passkeysMoeglich()).toBe(true) }) it('bietet sie auf localhost an, auch ohne https', () => { // Der Browser wertet localhost als sicheren Kontext - sonst liesse // sich in der Entwicklung nichts ausprobieren. process.env.AUTH_URL = 'http://localhost:3000' expect(env.passkey.sicherAufrufbar).toBe(true) }) it('bietet sie NICHT über einfaches http an', () => { // Wichtig, dass das der Server entscheidet und nicht der Browser: Ein // Knopf, der beim Klick eine kryptische DOMException wirft, ist // schlechter als gar kein Knopf. process.env.AUTH_URL = 'http://cockpit.example.de' expect(env.passkey.sicherAufrufbar).toBe(false) expect(passkeysMoeglich()).toBe(false) }) it('bietet sie auch über eine nackte IP ohne https nicht an', () => { process.env.AUTH_URL = 'http://10.0.0.5:3000' expect(env.passkey.sicherAufrufbar).toBe(false) }) }) describe('Challenge aus der Antwort des Browsers', () => { function clientData(inhalt: unknown): string { return Buffer.from(JSON.stringify(inhalt), 'utf8').toString('base64url') } it('liest die Challenge aus den clientData', () => { const daten = clientData({ type: 'webauthn.get', challenge: 'AbCdEf-1234_xyz', origin: 'https://cockpit.example.de', }) expect(challengeAusAntwort(daten)).toBe('AbCdEf-1234_xyz') }) it('weist eine Antwort ohne Challenge ab', () => { const daten = clientData({ type: 'webauthn.get', origin: 'https://cockpit.example.de' }) expect(() => challengeAusAntwort(daten)).toThrowError(PasskeyFehler) }) it('weist eine leere Challenge ab', () => { expect(() => challengeAusAntwort(clientData({ challenge: '' }))).toThrowError(PasskeyFehler) }) it('weist eine Challenge ab, die keine Zeichenkette ist', () => { expect(() => challengeAusAntwort(clientData({ challenge: 42 }))).toThrowError(PasskeyFehler) }) it('weist Unsinn ab, statt daran abzustürzen', () => { expect(() => challengeAusAntwort('das ist kein base64url-JSON')).toThrowError(PasskeyFehler) expect(() => challengeAusAntwort('')).toThrowError(PasskeyFehler) }) }) describe('Meldungen', () => { it('hat zu jedem Grund einen Satz, den man jemandem zeigen kann', () => { // Ein fehlender Eintrag wuerde als "undefined" auf dem Bildschirm // landen. Der Typ verhindert das beim Uebersetzen; hier wird geprueft, // dass auch nichts Leeres durchrutscht. for (const [grund, text] of Object.entries(PASSKEY_MELDUNG)) { expect(text.length, `Meldung für "${grund}"`).toBeGreaterThan(20) expect(text, `Meldung für "${grund}"`).not.toMatch(/undefined/) } }) it('nennt in der Obergrenze die tatsächliche Zahl', () => { expect(PASSKEY_MELDUNG['zu-viele']).toContain(String(MAX_PASSKEYS_JE_BENUTZER)) }) it('erklärt bei fehlender Nutzerverifikation, warum abgelehnt wird', () => { // Das ist die Meldung, die am ehesten nach Schikane aussieht. Sie muss // den Grund nennen, sonst haelt man sie fuer einen Fehler. expect(PASSKEY_MELDUNG['nicht-verifiziert']).toMatch(/Fingerabdruck|PIN/) }) }) describe('Fehler des Browsers', () => { function dom(name: string): Error { const fehler = new Error('irgendwas Englisches') fehler.name = name return fehler } it('deutet NotAllowedError als Abbruch, nicht als Verbot', () => { // Der Name ist irrefuehrend: Er kommt auch, wenn schlicht niemand den // Finger aufgelegt hat. "Nicht erlaubt" waere die falsche Auskunft. const text = passkeyBrowserfehler(dom('NotAllowedError')) expect(text).toMatch(/Abgebrochen/) expect(text).not.toMatch(/verboten|nicht erlaubt/i) }) it('erklärt beim Einrichten, dass dieses Gerät schon einen Passkey hat', () => { expect(passkeyBrowserfehler(dom('InvalidStateError'), true)).toMatch(/bereits/) }) it('sagt bei SecurityError, dass die Adresse nicht passt', () => { expect(passkeyBrowserfehler(dom('SecurityError'))).toMatch(/Adress/) }) it('fällt bei unbekannten Fehlern auf einen brauchbaren Satz zurück', () => { expect(passkeyBrowserfehler(dom('VoellingNeuerFehler'))).toMatch(/noch einmal/) expect(passkeyBrowserfehler('gar kein Fehlerobjekt')).toMatch(/noch einmal/) expect(passkeyBrowserfehler(undefined)).toMatch(/noch einmal/) }) it('gibt nie die englische Originalmeldung weiter', () => { for (const name of [ 'NotAllowedError', 'InvalidStateError', 'SecurityError', 'NotSupportedError', 'AbortError', 'Unbekannt', ]) { expect(passkeyBrowserfehler(dom(name))).not.toContain('irgendwas Englisches') } }) })