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 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) try keychain.setString(token.refreshToken, 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 { 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)) 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 ) // 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 { keychain.wipe() if let guestId = keychain.getString(for: .guestId) { status = .guest(id: guestId) } else { status = .signedOut } } else { CoreLog.auth.notice( "Refresh failed transiently (\(http.statusCode, privacy: .public)) — keeping session" ) } throw err } let token = try JSONDecoder().decode(TokenResponse.self, from: data) try keychain.setString(token.accessToken, for: .accessToken) if !token.refreshToken.isEmpty { try keychain.setString(token.refreshToken, for: .refreshToken) } 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 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 } }