mana-swift-core/Sources/ManaCore/Auth/AuthClient.swift
Till JS f9f740ff98 ManaCore 1.16.0: Shared-Keychain-Logout-Kaskade gefixt
Alle 16 nativen mana-Apps teilen EINEN Keychain (ev.mana.session) — ein
keychain.wipe() loggt die ganze Flotte aus. Vier zusammenhängende Ursachen:

- Default RefreshFailurePolicy .immediateWipe → .softFirst (13/16 Apps
  liefen auf dem gefährlichen Default).
- softFirst korrigiert: zählt rein Failure-Count, erst der zweite
  invalidierende Fehler in Folge wiped. refreshOnceSucceeded triggert
  keinen Wipe mehr — der scenePhase-Heartbeat setzte es beim App-Start
  sofort true und entwertete softFirst praktisch komplett (der Bug).
- performRefresh liest vor jedem Wipe den Keychain neu: hat ein anderer
  Prozess (App/Widget) frisch geschrieben, retry statt Flotten-Wipe.
- Transport refresht nur noch bei echtem JWT-Ablauf (JWT.expiry > 60s →
  401 durchreichen) statt blind bei jedem 401.

Plus TokenResponse.refreshToken optional (best-effort get-session kann
fehlen → kein DecodingError-Loop). Keine API-Signatur-Änderungen.

4 neue/aktualisierte Tests in AuthClientGuestAndResilienceTests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:04:26 +02:00

485 lines
22 KiB
Swift

import Foundation
import Observation
/// Auth-Client gegen mana-auth. Beobachtbarer Status, Login-Flow,
/// proaktiver Token-Refresh.
///
/// Eine Instanz pro App, beim App-Start initialisiert. `bootstrap()`
/// füllt den Status aus dem Keychain (offline möglich).
///
/// Die Methoden für Sign-Up, Passwort-Reset, Account-Management etc.
/// leben in ``AuthClient+Account`` — gleiche Klasse, separate Datei
/// damit dieser File die Status-Maschine und den Login/Logout/Refresh-
/// Kern hält.
@MainActor
@Observable
public final class AuthClient {
public enum Status: Equatable, Sendable {
/// Vor `bootstrap()` — Keychain noch nicht ausgewertet.
case unknown
/// Kein Account, keine Guest-Identität. UI sollte Login/Sign-Up
/// anbieten oder via `enterGuestMode()` einen anonymen Modus
/// starten.
case signedOut
/// Anonyme lokale Identität ohne Server-Account. Apps können
/// lokale Daten unter dieser UUID persistieren und sie später
/// beim Sign-In einem Server-Account zuordnen. Read-only-
/// Server-Endpoints, die keinen User brauchen, sind in diesem
/// Status erreichbar; schreibende Endpoints nicht.
case guest(id: String)
case signingIn
/// Sign-In war erfolgreich aber der Account hat 2FA aktiviert.
/// UI fragt nun den TOTP-Code (oder einen Backup-Code) ab und
/// ruft `verifyTotp(code:trustDevice:)` / `verifyBackupCode(...)`
/// mit dem hier gespeicherten Token auf.
///
/// `token` ist der Better-Auth-`two_factor`-Cookie-Wert, vom
/// Server in der Login-Response als `twoFactorToken` mitgeliefert.
case twoFactorRequired(token: String, methods: [String], email: String)
case signedIn(email: String)
case error(String)
}
public private(set) var status: Status = .unknown
/// Strukturierter Fehler des letzten Sign-In-/Refresh-/Account-
/// Operations. Wird beim nächsten `signIn(...)`-Aufruf gelöscht.
///
/// Ergänzt `status == .error(String)`: während `status` einen für
/// die UI angezeigten Text trägt (deutsche Lokalisierung), liefert
/// `lastError` den klassifizierten ``AuthError``-Case — z.B. um
/// `.emailNotVerified` programmatisch zu erkennen und den Resend-
/// Mail-Gate freizuschalten.
public private(set) var lastError: AuthError?
let config: ManaAppConfig
let keychain: KeychainStore
let session: URLSession
/// True nach mindestens einem erfolgreichen Refresh in diesem
/// Prozess. Genutzt von `RefreshFailurePolicy.softFirst` um zu
/// erkennen, ob ein "Session tot"-Response vertrauenswürdig ist
/// (= die Session war in diesem Prozess schon mal nachweislich
/// gültig, also ist sie jetzt echt invalidiert) oder ob er ein
/// transienter Server-Glitch sein könnte.
private(set) var refreshOnceSucceeded: Bool = false
/// Zählt die Anzahl Session-invalidierender Refresh-Fehler im
/// aktuellen Prozess. Genutzt von `RefreshFailurePolicy.softFirst`
/// um beim zweiten Fehler in Folge dann doch zu wipen.
private(set) var refreshFailureCount: Int = 0
/// Coalescing-Guard: der gerade laufende Refresh, falls aktiv.
/// Mehrere parallele Aufrufer (z.B. zwei gleichzeitige
/// authentifizierte Requests plus der scenePhase-Heartbeat) teilen
/// sich denselben Task, statt jeweils einen eigenen `/refresh`
/// abzufeuern. Ohne das schickt der zweite Aufruf den server-seitig
/// bereits rotierten Refresh-Token → 401 „session invalid" → (bei
/// `.immediateWipe`) sofortiger Logout. `@MainActor`-Isolation macht
/// Check-und-Set atomar — kein zusätzliches Locking nötig.
private var inFlightRefresh: Task<String, Error>?
public init(config: ManaAppConfig, session: URLSession = .shared) {
self.config = config
keychain = KeychainStore(service: config.keychainService, accessGroup: config.keychainAccessGroup)
self.session = session
}
public var currentEmail: String? {
if case let .signedIn(email) = status { return email }
return nil
}
/// Liest den persistierten Auth-Zustand aus dem Keychain. Reihenfolge:
/// vollwertige Session > Guest-Identität > komplett ausgeloggt.
///
/// Keine Server-Roundtrips — Apps starten offline-fähig und mana-auth-
/// Downtime kann den letzten gültigen Zustand nicht überschreiben.
/// Token-Gültigkeit wird erst beim nächsten authentifizierten Call
/// geprüft (siehe ``refreshAccessToken()``).
public func bootstrap() {
let email = keychain.getString(for: .email)
let hasToken = keychain.getString(for: .accessToken) != nil
if let email, hasToken {
status = .signedIn(email: email)
} else if let guestId = keychain.getString(for: .guestId) {
status = .guest(id: guestId)
} else {
status = .signedOut
}
}
/// Aktive Guest-Identität, falls vorhanden. Persistiert über
/// App-Starts hinweg. Existiert sowohl im ``Status/guest(id:)`` als
/// auch parallel zu einer eingeloggten Session — letzteres erlaubt
/// es einer App, beim Sign-In die zuvor lokal gesammelten anonymen
/// Daten dem neuen Server-Account zuzuordnen.
public func currentGuestId() -> String? {
keychain.getString(for: .guestId)
}
/// Setzt die App in den anonymen Modus. Idempotent: liefert eine
/// bestehende Guest-ID zurück, wenn schon eine im Keychain liegt;
/// sonst wird eine neue UUID erzeugt und persistiert.
///
/// Ändert den Status nur, wenn aktuell `.signedOut` oder `.unknown`
/// vorliegt. Bei aktiver Session (`.signedIn`/`.signingIn`) bleibt
/// der Status unverändert — die App kann trotzdem `currentGuestId()`
/// nutzen, um die anonyme ID zu lesen, ohne die Session zu stören.
///
/// - Throws: ``AuthError/keychain(_:)`` wenn der Keychain-Write
/// scheitert.
@discardableResult
public func enterGuestMode() throws -> String {
if let existing = keychain.getString(for: .guestId) {
if case .signedIn = status {} else if case .signingIn = status {} else {
status = .guest(id: existing)
}
return existing
}
let newId = UUID().uuidString
try keychain.setString(newId, for: .guestId)
if case .signedIn = status {} else if case .signingIn = status {} else {
status = .guest(id: newId)
}
CoreLog.auth.info("Entered guest mode")
return newId
}
/// Löscht die Guest-Identität aus dem Keychain. Genutzt nach einer
/// erfolgreichen Migration der lokalen Guest-Daten auf einen
/// Server-Account — die anonyme ID wird dann nicht mehr gebraucht.
/// Wenn der Status aktuell `.guest` ist, wechselt er auf `.signedOut`.
public func clearGuestId() {
keychain.remove(for: .guestId)
if case .guest = status {
status = .signedOut
}
}
public func signIn(email: String, password: String) async {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !password.isEmpty else {
let err = AuthError.validation(message: "Email und Passwort sind erforderlich")
lastError = err
status = .error(err.errorDescription ?? "")
return
}
lastError = nil
status = .signingIn
do {
let url = config.authBaseURL.appending(path: "/api/v1/auth/login")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(LoginRequest(email: trimmed, password: password))
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
status = .error("Ungültige Server-Antwort")
return
}
guard http.statusCode == 200 else {
let err = AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
lastError = err
status = .error(err.errorDescription ?? "Login fehlgeschlagen")
return
}
// 2FA-Pfad: Server hat statt Tokens einen
// `twoFactorRequired`-Response geliefert. UI muss jetzt
// den TOTP-Code abfragen und `verifyTotp(...)` aufrufen.
if let twoFactor = try? JSONDecoder().decode(TwoFactorChallenge.self, from: data),
twoFactor.twoFactorRequired == true,
let tfToken = twoFactor.twoFactorToken
{
status = .twoFactorRequired(
token: tfToken,
methods: twoFactor.twoFactorMethods ?? ["totp"],
email: trimmed
)
lastError = nil
CoreLog.auth.info("Sign-in needs 2FA challenge")
return
}
let token = try JSONDecoder().decode(TokenResponse.self, from: data)
try keychain.setString(token.accessToken, for: .accessToken)
// Login liefert den Session-Token immer mit; defensiv geguarded,
// seit `refreshToken` optional ist (Refresh-Pfad, siehe unten).
if let refresh = token.refreshToken, !refresh.isEmpty {
try keychain.setString(refresh, for: .refreshToken)
}
try keychain.setString(trimmed, for: .email)
status = .signedIn(email: trimmed)
lastError = nil
CoreLog.auth.info("Sign-in successful")
} catch let error as URLError {
let err = AuthError.networkFailure(error.localizedDescription)
lastError = err
status = .error(err.errorDescription ?? "Netzwerk-Fehler")
CoreLog.auth.error("Sign-in network error: \(error.localizedDescription, privacy: .public)")
} catch {
let err = AuthError.networkFailure(String(describing: error))
lastError = err
status = .error(err.errorDescription ?? String(describing: error))
CoreLog.auth.error("Sign-in error: \(String(describing: error), privacy: .public)")
}
}
/// Logged den User aus. Versucht den Server zu informieren (Logout-
/// Endpoint), wischt den lokalen Keychain und setzt den Status.
///
/// - Parameter keepGuestMode: Wenn `true`, bleibt die App in einem
/// anonymen Local-First-Modus aktiv. Eine bestehende Guest-ID
/// wird beibehalten oder eine neue erzeugt; der Status wechselt
/// auf ``Status/guest(id:)``. Default `false` für strict-Logout
/// (Status → `.signedOut`, auch Guest-ID gelöscht).
public func signOut(keepGuestMode: Bool = false) async {
if let token = keychain.getString(for: .accessToken) {
let url = config.authBaseURL.appending(path: "/api/v1/auth/logout")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
_ = try? await session.data(for: request)
}
keychain.wipe()
if keepGuestMode {
if let existing = keychain.getString(for: .guestId) {
status = .guest(id: existing)
} else {
let newId = UUID().uuidString
try? keychain.setString(newId, for: .guestId)
status = .guest(id: newId)
}
CoreLog.auth.info("Signed out — kept guest mode")
} else {
keychain.remove(for: .guestId)
status = .signedOut
CoreLog.auth.info("Signed out")
}
}
public func currentAccessToken() throws -> String {
guard let token = keychain.getString(for: .accessToken) else {
throw AuthError.notSignedIn
}
return token
}
/// Liefert den Session-Token (das wire-protocol `refreshToken`-Feld).
/// Wird von Better-Auth-`auth.api.*`-Endpoints akzeptiert wenn der
/// `bearer`-Plugin server-seitig aktiv ist — z.B. für `changeEmail`,
/// `changePassword`, `deleteAccount`. Der App-eigene JWT (aus
/// `currentAccessToken`) gilt für app-spezifische Backends, der
/// Session-Token nur für mana-auths Account-Aktionen.
public func currentSessionToken() throws -> String {
guard let token = keychain.getString(for: .refreshToken) else {
throw AuthError.notSignedIn
}
return token
}
/// Liefert einen Access-Token, der nicht innerhalb der nächsten
/// `refreshLeeway` Sekunden abläuft. Refreshed proaktiv, wenn nötig.
public func freshAccessToken(refreshLeeway: TimeInterval = 300) async throws -> String {
let token = try currentAccessToken()
if let expiry = JWT.expiry(of: token), expiry.timeIntervalSinceNow > refreshLeeway {
return token
}
CoreLog.auth.notice("Token expiring within \(Int(refreshLeeway), privacy: .public)s — refreshing proactively")
return try await refreshAccessToken()
}
public func refreshAccessToken() async throws -> String {
// Bereits ein Refresh unterwegs → dessen Ergebnis abwarten statt
// einen zweiten `/refresh` mit demselben (gleich rotierten)
// Token abzufeuern. Siehe `inFlightRefresh`.
if let existing = inFlightRefresh {
return try await existing.value
}
let task = Task { try await self.performRefresh() }
inFlightRefresh = task
do {
let result = try await task.value
inFlightRefresh = nil
return result
} catch {
inFlightRefresh = nil
throw error
}
}
private func performRefresh(attempt: Int = 0) async throws -> String {
guard let refresh = keychain.getString(for: .refreshToken) else {
throw AuthError.notSignedIn
}
let url = config.authBaseURL.appending(path: "/api/v1/auth/refresh")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(RefreshRequest(refreshToken: refresh))
CoreLog.auth.info("Refresh attempt — token len \(refresh.count, privacy: .public), once-succeeded=\(self.refreshOnceSucceeded, privacy: .public), failure-count=\(self.refreshFailureCount, privacy: .public), policy=\(String(describing: self.config.refreshFailurePolicy), privacy: .public)")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw AuthError.networkFailure("Keine HTTP-Antwort")
}
guard http.statusCode == 200 else {
let err = AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
let bodyExcerpt = String(data: data.prefix(256), encoding: .utf8) ?? "<binary>"
// Wipe nur bei tatsächlich invalidierter Session. Transiente
// Fehler (5xx, Rate-Limit) lassen den Token erhalten — sonst
// wirft jeder mana-auth-Downtime-Moment alle Apps in den
// Login-Screen. Falls eine Guest-Identität existiert, wird
// beim Wipe in den Guest-Status zurückgefallen statt in
// strict `.signedOut`.
if err.invalidatesSession {
// Shared-Keychain-Sicherheit: Service + Access-Group sind
// flotten-weit identisch (`ev.mana.session`) — ein
// `keychain.wipe()` löscht den Token ALLER mana-Apps. Bevor
// wir das auch nur erwägen: Keychain neu lesen. Hat ein
// anderer Prozess (andere App, Widget) zwischenzeitlich
// einen frischen Token geschrieben, war unser 401 nur ein
// Stale-Token-Artefakt → einmal mit dem neuen Token
// wiederholen statt die Flotte auszuloggen.
if attempt == 0,
let current = keychain.getString(for: .refreshToken),
current != refresh {
CoreLog.auth.notice("Refresh \(http.statusCode, privacy: .public) invalidates session, aber Keychain-Token wurde unter uns ausgetauscht — retry mit frischem Token statt Wipe")
return try await performRefresh(attempt: attempt + 1)
}
refreshFailureCount += 1
let shouldWipe: Bool
switch config.refreshFailurePolicy {
case .immediateWipe:
shouldWipe = true
case .softFirst:
// Auf dem geteilten Keychain ist ein EINZELNER 401 nie
// genug Beweis für eine tote Session — ein transienter
// mana-auth-Glitch oder ein Authz-401 eines App-Backends
// darf nicht die ganze Flotte ausloggen. Erst der zweite
// invalidierende Fehler in Folge wiped. `refreshOnce-
// Succeeded` wird bewusst NICHT mehr als Wipe-Trigger
// genutzt: der Heartbeat-Refresh beim App-Start setzt es
// sofort true und würde softFirst sonst entwerten (war
// der eigentliche Logout-Bug, 2026-06-02).
shouldWipe = refreshFailureCount >= 2
}
if shouldWipe {
CoreLog.auth.error("Wiping keychain — refresh \(http.statusCode, privacy: .public) invalidates session (attempt #\(self.refreshFailureCount, privacy: .public), once-succeeded=\(self.refreshOnceSucceeded, privacy: .public)). Body: \(bodyExcerpt, privacy: .public)")
keychain.wipe()
if let guestId = keychain.getString(for: .guestId) {
status = .guest(id: guestId)
} else {
status = .signedOut
}
} else {
CoreLog.auth.notice("Refresh \(http.statusCode, privacy: .public) invalidates session, but softFirst policy retains keychain (attempt #\(self.refreshFailureCount, privacy: .public)). Body: \(bodyExcerpt, privacy: .public)")
}
} else {
CoreLog.auth.notice("Refresh failed transiently (\(http.statusCode, privacy: .public)) — keeping session. Body: \(bodyExcerpt, privacy: .public)")
}
throw err
}
let token = try JSONDecoder().decode(TokenResponse.self, from: data)
try keychain.setString(token.accessToken, for: .accessToken)
// `refreshToken` ist optional: der Server liefert ihn aus dem
// best-effort `get-session`-Subcall (auth.ts), der transient
// ausfallen kann → Feld fehlt im 200-Response. Früher war
// `TokenResponse.refreshToken` non-optional → DecodingError auf
// einem eigentlich erfolgreichen Refresh → Access-Token wurde nie
// gespeichert → Retry-Loop. Jetzt: fehlt/leer → bestehenden
// (stabilen, 90d) Session-Token behalten.
if let newRefresh = token.refreshToken, !newRefresh.isEmpty {
try keychain.setString(newRefresh, for: .refreshToken)
}
refreshOnceSucceeded = true
refreshFailureCount = 0
CoreLog.auth.info("Refresh succeeded")
return token.accessToken
}
// MARK: - Internal Helpers (used by AuthClient+Account)
/// Persistiert ein frisch erhaltenes Token-Paar und setzt den Status
/// auf `.signedIn`. Genutzt von ``AuthClient+Account``-Flows, die
/// in den eingeloggten Zustand führen (z.B. `register` wenn der
/// Server bereits eine Session liefert).
func persistSession(email: String, accessToken: String, refreshToken: String) throws {
try keychain.setString(accessToken, for: .accessToken)
try keychain.setString(refreshToken, for: .refreshToken)
try keychain.setString(email, for: .email)
status = .signedIn(email: email)
}
/// Setzt den Status auf `.signedOut` und wirft den Keychain
/// vollständig leer — inklusive Guest-ID. Genutzt nach
/// `deleteAccount()`: Account ist gelöscht, also auch die
/// (potenziell verknüpfte) anonyme Identität.
func clearSession() {
keychain.wipeAll()
status = .signedOut
}
}
// MARK: - Wire-Format
struct LoginRequest: Encodable {
let email: String
let password: String
}
struct RefreshRequest: Encodable {
let refreshToken: String
}
/// Antwort von `/login`, `/refresh` und (manchmal) `/register`.
///
/// `register` liefert je nach Server-Konfig:
/// - mit `requireEmailVerification: true` (Default): nur `user`, keine Tokens
/// - mit `false`: vollständiges Token-Paar
///
/// Optionale Felder sind explizit `Optional` damit beide Pfade dekodierbar sind.
struct TokenResponse: Decodable {
let accessToken: String
/// Optional: der Server leitet ihn aus einem best-effort
/// `get-session`-Subcall ab, der transient fehlschlagen kann. Fehlt
/// er, bleibt der bestehende (stabile) Session-Token im Keychain.
let refreshToken: String?
}
/// 2FA-Pfad aus `/api/v1/auth/login`. Wenn `twoFactorRequired == true`,
/// kein Token-Paar — UI muss `verifyTotp(...)` / `verifyBackupCode(...)`
/// aufrufen. `twoFactorToken` ist der Server-injecten `two_factor`-Cookie-
/// Wert (opaque).
struct TwoFactorChallenge: Decodable {
let twoFactorRequired: Bool?
let twoFactorMethods: [String]?
let twoFactorToken: String?
}
extension HTTPURLResponse {
/// Liest `Retry-After` als Anzahl Sekunden. Server schickt Integer-
/// Sekunden (siehe `lib/auth-errors.ts`). HTTP-Datum-Variante wird
/// nicht unterstützt — mana-auth nutzt sie nicht.
var retryAfterSeconds: TimeInterval? {
guard let raw = value(forHTTPHeaderField: "Retry-After"),
let seconds = TimeInterval(raw)
else {
return nil
}
return seconds
}
}