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 { case unknown case signedOut case signingIn 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 } 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 { 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 } 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)") } } public func signOut() 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() 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 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 { keychain.wipe() status = .signedOut throw AuthError.classify( status: http.statusCode, data: data, retryAfterHeader: http.retryAfterSeconds ) } 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 leer. /// Genutzt nach `deleteAccount()`. func clearSession() { keychain.wipe() 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 } 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 } }