import { createHash } from 'node:crypto'; import { HttpException, Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common'; import { CryptoService } from '../crypto/crypto.service'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { type AuthFailure, authFailureCode, authFailureToException, getAppPassword, getCurrentUser, pollLoginFlow, revokeAppPassword, startLoginFlow, } from './nextcloud-auth-client'; import { NextcloudCallGate } from './nextcloud-call-gate'; import { type NcSession, type NextcloudFilesAccountView, type NextcloudFilesStatusView, ncErrorDefault, } from './nextcloud-files.types'; import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service'; import { basicAuth, NEXTCLOUD_TRANSPORT, type NextcloudTransport } from './nextcloud-http'; import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard'; type ConnectMethod = 'PASSWORD' | 'LOGIN_FLOW'; interface AccountRow { baseUrl: string; ncUserId: string; /** Basic-Benutzer des App-Passworts (Anmeldename bei der Ausstellung); null = ncUserId. */ ncLoginName: string | null; ncDisplayName: string | null; encryptedAppPassword: string; status: 'ACTIVE' | 'EXPIRED'; connectedVia: ConnectMethod; createdAt: Date; updatedAt: Date; } export type FlowPollResult = | { state: 'pending' } | { state: 'connected' } | { state: 'failed'; code: string; message: string }; /** Hoechstzahl ausstehender Widerrufe im Arbeitsspeicher (WR-02); der aelteste fliegt zuerst raus. */ export const PENDING_REVOKE_MAX = 200; /** So oft wird ein Widerruf hoechstens versucht, bevor er aufgegeben wird. */ export const PENDING_REVOKE_MAX_ATTEMPTS = 6; /** Wartezeit vor einem neuen Versuch nach Netz- oder Serverfehlern. */ export const PENDING_REVOKE_RETRY_MS = 60_000; /** Abstand der Abfragen abgebrochener Browser-Anmeldungen (IN-04). */ export const CANCELLED_FLOW_POLL_MS = 10_000; /** * Ein App-Passwort, dessen Widerruf noch aussteht (WR-02). Liegt NUR im Arbeitsspeicher, * wird nie geloggt und nie an eine andere Adresse als `baseUrl` geschickt. */ interface PendingRevoke { baseUrl: string; loginName: string; appPassword: string; credentialKey?: string; attempts: number; notBefore: number; } type RevokeOutcome = { done: true } | { done: false; retryAfterMs: number }; /** Erste 16 Hex-Zeichen von sha256 ueber den verschluesselten Wert: Zugangsschluessel der Aufrufsperre. */ export function credentialKeyOf(encryptedAppPassword: string): string { return createHash('sha256').update(encryptedAppPassword).digest('hex').slice(0, 16); } /** * Konto je Benutzer (quick-261008-mzu): verbinden mit Passwort oder per * Browser-Anmeldung (Login Flow v2), trennen mit Widerruf, Sitzung fuer die * Dateiaufrufe. Gesamter Zugriff auf `nextcloudFilesAccount` mit der * Benutzerkennung aus dem Token liegt ausschliesslich hier — jede Methode * bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`), die * Zeilenregel laesst nur eigene Zeilen zu, und jedes `where` traegt beides. * * Geheimnisse: das echte Passwort lebt nur in `connectWithPassword`, das App- * Passwort wird mit `CryptoService.encrypt` abgelegt und nur in `getSession` * entschluesselt. Nichts davon steht in einer Antwort, einem Log oder einem * Fehler. * * App-Passwort-Hygiene (D-P): ein frisch ausgestelltes App-Passwort, das nicht * gespeichert werden konnte, wird sofort widerrufen; beim erneuten Verbinden * wird das alte (gleiche Adresse) zuerst widerrufen; ein Zugang fuer eine * andere Adresse wird nie an diese gesendet. * * Scheitert ein Widerruf voruebergehend (Aufrufsperre nach einem 429, Netz, * Zeitueberschreitung, Wartung, 5xx), kommt er in eine kleine Warteschlange im * Arbeitsspeicher und wird nach dem Ende der Sperre bzw. nach einer Minute * erneut versucht (WR-02; hoechstens 200 Eintraege, hoechstens 6 Versuche, ein * Neustart verliert sie). Abgebrochene Browser-Anmeldungen werden bis zu ihrem * Ablauf weiter abgefragt; wird dort doch noch ein App-Passwort ausgestellt, * wird es sofort widerrufen (IN-04). */ @Injectable() export class NextcloudFilesAccountService implements OnModuleDestroy { private readonly logger = new Logger(NextcloudFilesAccountService.name); /** Ende der Warteschlange je Mandant und Benutzer (siehe `withUserLock`). */ private readonly userLocks = new Map>(); /** Ausstehende Widerrufe (WR-02). */ private readonly pendingRevokes: PendingRevoke[] = []; private revokeTimer: NodeJS.Timeout | undefined; private flowSweepTimer: NodeJS.Timeout | undefined; private flowSweepRunning = false; /** Zeitquelle in Millisekunden; Tests ersetzen sie. */ now: () => number = () => Date.now(); constructor( private readonly prisma: PrismaService, private readonly crypto: CryptoService, private readonly settings: NextcloudFilesSettingsService, private readonly guard: NextcloudLoginGuard, private readonly flows: LoginFlowStore, private readonly gate: NextcloudCallGate, @Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport, ) { // Nach einem Adresswechsel gelten offene Browser-Anmeldungen nicht mehr (sie werden an // ihrer alten Adresse noch beobachtet, siehe `sweepCancelledFlows`). this.settings.onAddressChange((tenantId) => { this.flows.clearTenant(tenantId); this.scheduleFlowSweep(); }); } onModuleDestroy(): void { clearTimeout(this.revokeTimer); clearTimeout(this.flowSweepTimer); this.revokeTimer = undefined; this.flowSweepTimer = undefined; } // --- Zeilenzugriff (jeweils eigener, an Mandant UND Benutzer gebundener Klient) ---------- private async findAccount(tenantId: string, userId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const row = await tenantPrisma.nextcloudFilesAccount.findFirst({ where: { tenantId, userId } }); return (row as AccountRow | null) ?? null; } private async upsertAccount( tenantId: string, userId: string, data: { baseUrl: string; ncUserId: string; ncLoginName: string; ncDisplayName: string | null; encryptedAppPassword: string; connectedVia: ConnectMethod; }, ): Promise { const tenantPrisma = forTenant(this.prisma, tenantId, userId); await tenantPrisma.nextcloudFilesAccount.upsert({ where: { tenantId_userId: { tenantId, userId } }, create: { tenantId, userId, ...data, status: 'ACTIVE' }, update: { ...data, status: 'ACTIVE' }, }); } private async deleteAccount(tenantId: string, userId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId, userId); await tenantPrisma.nextcloudFilesAccount.deleteMany({ where: { tenantId, userId } }); } /** Markiert das eigene Konto als abgelaufen (App-Passwort wurde von Nextcloud abgelehnt). */ async markExpired(tenantId: string, userId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId, userId); await tenantPrisma.nextcloudFilesAccount.updateMany({ where: { tenantId, userId, status: 'ACTIVE' }, data: { status: 'EXPIRED' }, }); } // --- Stand ------------------------------------------------------------------------------ async getStatus(tenantId: string, userId: string): Promise { const base = await this.settings.getStatus(tenantId); if (!base.configured) return { ...base, account: null }; const row = await this.findAccount(tenantId, userId); if (!row) return { ...base, account: null }; const expired = row.status !== 'ACTIVE' || row.baseUrl !== base.serverUrl || this.gate.isDead(credentialKeyOf(row.encryptedAppPassword)); const account: NextcloudFilesAccountView = { connected: !expired, expired, status: expired ? 'EXPIRED' : 'ACTIVE', ncUserId: row.ncUserId, displayName: row.ncDisplayName, connectedVia: row.connectedVia, connectedAt: row.updatedAt.toISOString(), }; return { ...base, account }; } // --- Verbinden mit Passwort ------------------------------------------------------------------- async connectWithPassword( tenantId: string, userId: string, loginName: string, password: string, ): Promise { const baseUrl = await this.requireBaseUrl(tenantId); const scope = new URL(baseUrl).origin; // CR-01: den Versuch SYNCHRON reservieren, bevor irgendetwas wartet. Gleichzeitige // Anfragen sehen so die laufenden Versuche und bekommen 429, statt alle an Nextcloud zu gehen. const attempt = this.guard.beginPasswordAttempt(userId, scope); try { return await this.withUserLock(tenantId, userId, async () => { const issued = await getAppPassword( this.transport, this.gate, baseUrl, loginName, password, ); if (!issued.ok) { // 401 ist doppeldeutig (falsches Passwort oder Zwei-Faktor) und zaehlt bei Nextcloud als // Fehlanmeldung; bei einer Zeitueberschreitung kann Nextcloud ihn schon gezaehlt haben. if (issued.kind === 'credentials' || issued.kind === 'timeout') attempt.fail(); throw authFailureToException(issued); } attempt.release(); const ncUser = await getCurrentUser( this.transport, this.gate, baseUrl, loginName, issued.appPassword, ); if (!ncUser.ok) { await this.revokeFresh(baseUrl, loginName, issued.appPassword); throw authFailureToException(unexpectedCredentials(ncUser)); } await this.storeAppPassword( tenantId, userId, baseUrl, loginName, ncUser, issued.appPassword, 'PASSWORD', ); this.guard.recordSuccess(userId); return this.getStatus(tenantId, userId); }); } finally { // Jeder andere Ausgang (403, Netzfehler, gesperrt ...) ist kein Fehlversuch; nach `fail` wirkungslos. attempt.release(); } } /** * Verbindungsvorgaenge desselben Benutzers laufen nacheinander (CR-01). Ohne das * koennten zwei gleichzeitig erfolgreiche Anmeldungen beide dieselbe alte Zeile * widerrufen und dann nacheinander speichern — das zuerst gespeicherte frische * App-Passwort waere ueberschrieben, nirgends abgelegt und nie widerrufen. So * widerruft der zweite Vorgang das Passwort des ersten ganz regulaer als "altes". */ private async withUserLock( tenantId: string, userId: string, fn: () => Promise, ): Promise { const key = `${tenantId}:${userId}`; const previous = this.userLocks.get(key) ?? Promise.resolve(); let unlock!: () => void; const mine = new Promise((resolve) => { unlock = resolve; }); const tail = previous.then(() => mine); this.userLocks.set(key, tail); try { await previous; return await fn(); } finally { unlock(); if (this.userLocks.get(key) === tail) this.userLocks.delete(key); } } // --- Verbinden im Browser (Login Flow v2) ------------------------------------------------------ async startFlow( tenantId: string, userId: string, ): Promise<{ flowId: string; loginUrl: string; expiresAt: string }> { const baseUrl = await this.requireBaseUrl(tenantId); this.guard.checkFlowStart(userId); const started = await startLoginFlow(this.transport, this.gate, baseUrl); if (!started.ok) throw authFailureToException(started); const entry = this.flows.create(tenantId, userId, baseUrl, started.pollToken); // Ein ersetzter frueherer Ablauf desselben Benutzers wird weiter beobachtet (IN-04). this.scheduleFlowSweep(); return { flowId: entry.flowId, loginUrl: started.loginUrl, expiresAt: new Date(entry.expiresAt).toISOString(), }; } async pollFlow(tenantId: string, userId: string, flowId: string): Promise { const found = this.flows.lookup(flowId, tenantId, userId); if (found.state === 'missing') throw ncErrorDefault('notFound'); if (found.state === 'expired') { this.flows.remove(flowId); throw ncErrorDefault('flowExpired'); } const entry = found.entry; // Die Adresse darf sich seit dem Start nicht geaendert haben. const current = await this.settings.getBaseUrl(tenantId); if (current !== entry.baseUrl) { this.flows.cancel(flowId); this.scheduleFlowSweep(); throw ncErrorDefault('flowExpired'); } if (!this.flows.shouldPoll(entry)) return { state: 'pending' }; this.flows.markPolled(entry); const polled = await pollLoginFlow(this.transport, this.gate, entry.baseUrl, entry.pollToken); if (!polled.ok) throw authFailureToException(polled); if (polled.state === 'pending') return { state: 'pending' }; // Bestaetigt. Der Ablauf ist verbraucht (Nextcloud gibt das Ergebnis nur einmal heraus). const stillOpen = this.flows.get(flowId, tenantId, userId) !== undefined; this.flows.remove(flowId); const { loginName, appPassword } = polled; if (!stillOpen) { // Zwischenzeitlich abgebrochen: der frisch ausgestellte Zugang darf nirgends liegen bleiben. await this.revokeFresh(entry.baseUrl, loginName, appPassword); return this.failed(ncErrorDefault('flowExpired')); } const ncUser = await getCurrentUser( this.transport, this.gate, entry.baseUrl, loginName, appPassword, ); if (!ncUser.ok) { await this.revokeFresh(entry.baseUrl, loginName, appPassword); return this.failed(authFailureToException(unexpectedCredentials(ncUser))); } try { // Dieselbe Warteschlange wie die Passwort-Anmeldung (CR-01): nie zwei Speichervorgaenge zugleich. await this.withUserLock(tenantId, userId, () => this.storeAppPassword( tenantId, userId, entry.baseUrl, loginName, ncUser, appPassword, 'LOGIN_FLOW', ), ); } catch (err) { return this.failed(err); } return { state: 'connected' }; } async cancelFlow(tenantId: string, userId: string, flowId: string): Promise<{ cancelled: true }> { const found = this.flows.lookup(flowId, tenantId, userId); if (found.state === 'missing') throw ncErrorDefault('notFound'); if (found.state === 'expired') { this.flows.remove(flowId); } else { // Nicht vergessen, sondern bis zum Ablauf weiter abfragen (IN-04). this.flows.cancel(flowId); this.scheduleFlowSweep(); } return { cancelled: true }; } /** * Fragt abgebrochene Browser-Anmeldungen ab (IN-04): hoechstens alle 10 s je Ablauf, bis er * abgelaufen ist. Bestaetigt der Benutzer die Anmeldung doch noch, wird das ausgestellte * App-Passwort sofort widerrufen und nirgends gespeichert. Der Login Flow zaehlt bei * Nextcloud nicht als Fehlanmeldung; eine Aufrufsperre wird respektiert (dann spaeter). */ async sweepCancelledFlows(): Promise { if (this.flowSweepRunning) return; this.flowSweepRunning = true; try { for (const entry of this.flows.cancelledDue(CANCELLED_FLOW_POLL_MS)) { this.flows.markPolled(entry); let polled: Awaited>; try { polled = await pollLoginFlow(this.transport, this.gate, entry.baseUrl, entry.pollToken); } catch { continue; } if (polled.ok && polled.state === 'granted') { this.flows.remove(entry.flowId); await this.revokeFresh(entry.baseUrl, polled.loginName, polled.appPassword); } } } finally { this.flowSweepRunning = false; } this.scheduleFlowSweep(); } private scheduleFlowSweep(): void { if (this.flowSweepTimer || !this.flows.hasCancelled()) return; this.flowSweepTimer = setTimeout(() => { this.flowSweepTimer = undefined; void this.sweepCancelledFlows().catch(() => undefined); }, CANCELLED_FLOW_POLL_MS); this.flowSweepTimer.unref?.(); } private failed(err: unknown): FlowPollResult { if (err instanceof HttpException) { const body = err.getResponse() as { code?: string; message?: string }; return { state: 'failed', code: body.code ?? 'nextcloudError', message: body.message ?? ncErrorDefault('nextcloudError').message, }; } const fallback = ncErrorDefault('nextcloudError'); return { state: 'failed', code: 'nextcloudError', message: fallback.message }; } // --- Trennen ---------------------------------------------------------------------------------- async disconnect(tenantId: string, userId: string): Promise<{ disconnected: true }> { const row = await this.findAccount(tenantId, userId); if (!row) throw ncErrorDefault('notConnected'); const current = await this.settings.getBaseUrl(tenantId); // Widerrufen nur dort, wo der Zugang gilt: aktives Konto UND gleiche Adresse (nie an einen anderen Host). if (row.status === 'ACTIVE' && current !== null && current === row.baseUrl) { let appPassword: string | null = null; try { appPassword = this.crypto.decrypt(row.encryptedAppPassword); } catch { this.logger.error( `App-Passwort eines Kontos ließ sich nicht entschlüsseln (Mandant ${tenantId}); Konto wird ohne Widerruf entfernt`, ); } if (appPassword !== null) { await this.revokeBestEffort( row.baseUrl, basicUserOf(row), appPassword, credentialKeyOf(row.encryptedAppPassword), ); } } await this.deleteAccount(tenantId, userId); return { disconnected: true }; } // --- Sitzung fuer die Dateiaufrufe -------------------------------------------------------------- async getSession(tenantId: string, userId: string): Promise { const baseUrl = await this.requireBaseUrl(tenantId); const row = await this.findAccount(tenantId, userId); if (!row) throw ncErrorDefault('notConnected'); if (row.status !== 'ACTIVE' || row.baseUrl !== baseUrl) { throw ncErrorDefault('connectionExpired'); } const credentialKey = credentialKeyOf(row.encryptedAppPassword); if (this.gate.isDead(credentialKey)) { await this.markExpired(tenantId, userId); throw ncErrorDefault('connectionExpired'); } let appPassword: string; try { appPassword = this.crypto.decrypt(row.encryptedAppPassword); } catch { this.logger.error(`Gespeichertes App-Passwort ist nicht lesbar (Mandant ${tenantId})`); throw ncErrorDefault('accountBroken'); } return { baseUrl: row.baseUrl, ncUserId: row.ncUserId, authorization: basicAuth(basicUserOf(row), appPassword), credentialKey, }; } // --- Hilfen --------------------------------------------------------------------------------------- private async requireBaseUrl(tenantId: string): Promise { const baseUrl = await this.settings.getBaseUrl(tenantId); if (baseUrl === null) throw ncErrorDefault('notConfigured'); return baseUrl; } /** * App-Passwort ablegen (D-P): zuerst das alte Passwort derselben Adresse * widerrufen, dann verschluesseln und speichern. Scheitert etwas, wird das * FRISCHE Passwort sofort widerrufen und der Fehler weitergegeben. */ private async storeAppPassword( tenantId: string, userId: string, baseUrl: string, loginName: string, ncUser: { id: string; displayName: string | null }, appPassword: string, method: ConnectMethod, ): Promise { try { await this.revokePrevious(tenantId, userId, baseUrl); const encryptedAppPassword = this.crypto.encrypt(appPassword); await this.upsertAccount(tenantId, userId, { baseUrl, ncUserId: ncUser.id, ncLoginName: loginName, ncDisplayName: ncUser.displayName, encryptedAppPassword, connectedVia: method, }); } catch (err) { await this.revokeFresh(baseUrl, loginName, appPassword); throw err; } } /** Das alte App-Passwort derselben Adresse widerrufen (best effort, nie ein Fehler nach aussen). */ private async revokePrevious(tenantId: string, userId: string, baseUrl: string): Promise { let old: AccountRow | null; try { old = await this.findAccount(tenantId, userId); } catch { return; } if (!old || old.baseUrl !== baseUrl) return; let oldPassword: string; try { oldPassword = this.crypto.decrypt(old.encryptedAppPassword); } catch { this.logger.warn(`Altes App-Passwort nicht lesbar (Mandant ${tenantId}); kein Widerruf`); return; } await this.revokeBestEffort( old.baseUrl, basicUserOf(old), oldPassword, credentialKeyOf(old.encryptedAppPassword), ); } private async revokeFresh( baseUrl: string, loginName: string, appPassword: string, ): Promise { await this.revokeBestEffort(baseUrl, loginName, appPassword); } /** * Widerruf "so gut es geht" (nie ein Fehler nach aussen). Scheitert er voruebergehend, * kommt er in die Warteschlange (WR-02) und wird spaeter erneut versucht. */ private async revokeBestEffort( baseUrl: string, loginName: string, appPassword: string, credentialKey?: string, ): Promise { const outcome = await this.tryRevoke(baseUrl, loginName, appPassword, credentialKey); if (outcome.done) return; this.queueRevoke({ baseUrl, loginName, appPassword, credentialKey, attempts: 1, notBefore: this.now() + outcome.retryAfterMs, }); } /** Ein Widerrufsversuch. `done: false` heisst: voruebergehend gescheitert, spaeter erneut. */ private async tryRevoke( baseUrl: string, loginName: string, appPassword: string, credentialKey?: string, ): Promise { let res: Awaited>; try { res = await revokeAppPassword( this.transport, this.gate, baseUrl, loginName, appPassword, credentialKey, ); } catch { this.logger.warn('Widerruf eines App-Passworts fehlgeschlagen; neuer Versuch folgt'); return { done: false, retryAfterMs: PENDING_REVOKE_RETRY_MS }; } if (res.ok) return { done: true }; switch (res.kind) { case 'locked': // Aufrufsperre (429): erst nach ihrem Ende erneut, mit einer Sekunde Abstand. return { done: false, retryAfterMs: ((res.retryAfterSeconds ?? 900) + 1) * 1000 }; case 'network': case 'timeout': case 'maintenance': this.logger.warn( `Widerruf eines App-Passworts nicht bestätigt (${failureLabel(res)}); neuer Versuch folgt`, ); return { done: false, retryAfterMs: PENDING_REVOKE_RETRY_MS }; case 'upstream': if ((res.status ?? 0) >= 500) { this.logger.warn( `Widerruf eines App-Passworts nicht bestätigt (${failureLabel(res)}); neuer Versuch folgt`, ); return { done: false, retryAfterMs: PENDING_REVOKE_RETRY_MS }; } break; default: break; } // 401/403/tot: der Zugang gilt ohnehin nicht (mehr); sonst endgueltig nicht widerrufbar. this.logger.warn(`Widerruf eines App-Passworts nicht bestätigt (${failureLabel(res)})`); return { done: true }; } private queueRevoke(item: PendingRevoke): void { if (this.pendingRevokes.length >= PENDING_REVOKE_MAX) { this.pendingRevokes.shift(); this.logger.warn('Zu viele ausstehende Widerrufe; der älteste wird verworfen'); } this.pendingRevokes.push(item); this.scheduleRevokes(); } private scheduleRevokes(): void { if (this.revokeTimer || this.pendingRevokes.length === 0) return; const next = Math.min(...this.pendingRevokes.map((p) => p.notBefore)); const delay = Math.max(1000, next - this.now()); this.revokeTimer = setTimeout(() => { this.revokeTimer = undefined; void this.retryPendingRevokes().catch(() => undefined); }, delay); this.revokeTimer.unref?.(); } /** Zahl der ausstehenden Widerrufe (fuer Tests und Betrieb, nie die Werte selbst). */ get pendingRevokeCount(): number { return this.pendingRevokes.length; } /** Arbeitet die faelligen ausstehenden Widerrufe ab; die uebrigen bleiben liegen. */ async retryPendingRevokes(): Promise { const now = this.now(); const due = this.pendingRevokes.filter((p) => p.notBefore <= now); for (const item of due) this.pendingRevokes.splice(this.pendingRevokes.indexOf(item), 1); for (const item of due) { const outcome = await this.tryRevoke( item.baseUrl, item.loginName, item.appPassword, item.credentialKey, ); if (outcome.done) continue; item.attempts += 1; if (item.attempts >= PENDING_REVOKE_MAX_ATTEMPTS) { this.logger.warn('Widerruf eines App-Passworts nach mehreren Versuchen aufgegeben'); continue; } item.notBefore = this.now() + outcome.retryAfterMs; if (this.pendingRevokes.length >= PENDING_REVOKE_MAX) this.pendingRevokes.shift(); this.pendingRevokes.push(item); } this.scheduleRevokes(); } } /** * Der Basic-Benutzer eines App-Passworts ist der Anmeldename der Ausstellung * (gemessen: mit der E-Mail-Adresse ausgestellt, antwortet Nextcloud auf die * Kennung mit 401). Konten vor dieser Spalte haben keinen: dann gilt die Kennung. */ function basicUserOf(row: Pick): string { return row.ncLoginName ?? row.ncUserId; } function failureLabel(failure: AuthFailure): string { return authFailureCode(failure); } /** * Ein 401 auf `cloud/user` mit einem GERADE ausgestellten App-Passwort ist kein * "falsches Passwort" fuer den Benutzer, sondern eine unerwartete Antwort. */ function unexpectedCredentials(failure: AuthFailure): AuthFailure { return failure.kind === 'credentials' ? { ...failure, kind: 'upstream' } : failure; }