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). @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 private let config: ManaAppConfig private let keychain: KeychainStore private 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 { status = .error("Email und Passwort sind erforderlich") return } 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 message = (try? JSONDecoder().decode(ServerError.self, from: data))?.message if http.statusCode == 401 { status = .error("Email oder Passwort falsch") } else { status = .error("Login fehlgeschlagen (HTTP \(http.statusCode))" + (message.map { " — \($0)" } ?? "")) } 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) CoreLog.auth.info("Sign-in successful") } catch let error as URLError { status = .error("Netzwerk: \(error.localizedDescription)") CoreLog.auth.error("Sign-in network error: \(error.localizedDescription, privacy: .public)") } catch { status = .error(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.serverError( status: http.statusCode, message: (try? JSONDecoder().decode(ServerError.self, from: data))?.message ) } 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 } } private struct LoginRequest: Encodable { let email: String let password: String } private struct RefreshRequest: Encodable { let refreshToken: String } private struct TokenResponse: Decodable { let accessToken: String let refreshToken: String } private struct ServerError: Decodable { let message: String? }