import Foundation struct AuthUser: Codable, Equatable { let id: String let username: String let displayName: String? let role: String let status: String enum CodingKeys: String, CodingKey { case id, username, role, status case displayName = "display_name" } } struct AuthLoginResponse: Decodable { let token: String let user: AuthUser let expiresAt: Double enum CodingKeys: String, CodingKey { case token, user case expiresAt = "expires_at" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) token = try c.decode(String.self, forKey: .token) user = try c.decode(AuthUser.self, forKey: .user) if let seconds = try? c.decode(Double.self, forKey: .expiresAt) { expiresAt = seconds } else { let raw = try c.decode(String.self, forKey: .expiresAt) guard let date = ISO8601DateFormatter().date(from: raw) else { throw AuthError.invalidResponse } expiresAt = date.timeIntervalSince1970 } } } enum ConnectionFailure { static func message(_ error: Error, server: URL) -> String { let ns = error as NSError let certificateCodes: Set = [-1200, -1201, -1202, -1203, -1204, -1205, -1206] if ns.domain == NSURLErrorDomain && certificateCodes.contains(ns.code) { return "The certificate for \(server.host ?? "this server") is not trusted. For your private Hope server, install the verified certificate profile in Safari, then enable it in Settings → General → About → Certificate Trust Settings. Do not continue if the server address is unfamiliar." } return error.localizedDescription } } enum AuthError: LocalizedError { case status(Int) case invalidResponse var errorDescription: String? { switch self { case .status(401): return "Your sign-in expired or the username and password were not accepted. Sign in again." case .status(403): return "This account is not authorized. Contact your server administrator." case .status: return "The server could not complete sign-in. Try again later." case .invalidResponse: return "The server returned an unexpected authentication response." } } } final class AuthClient { let config: ServerConfig private let session: URLSession init(config: ServerConfig, session: URLSession? = nil) { self.config = config self.session = session ?? TLSPinning.session() } private func request(_ path: String, method: String, token: String? = nil, body: Data? = nil) async throws -> Data { var request = URLRequest(url: config.baseURL.appendingPathComponent(path)) request.httpMethod = method request.timeoutInterval = 10 request.httpBody = body if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") } if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw AuthError.invalidResponse } guard (200..<300).contains(http.statusCode) else { throw AuthError.status(http.statusCode) } return data } func login(username: String, password: String) async throws -> AuthLoginResponse { let body = try JSONSerialization.data(withJSONObject: ["username": username, "password": password]) return try JSONDecoder().decode(AuthLoginResponse.self, from: await request("api/auth/login", method: "POST", body: body)) } func me(token: String) async throws -> AuthUser { struct Response: Decodable { let user: AuthUser } return try JSONDecoder().decode(Response.self, from: await request("api/auth/me", method: "GET", token: token)).user } func voices(token: String) async throws -> VoiceCatalogResponse { #if DEBUG if ProcessInfo.processInfo.arguments.contains("--ui-testing"), !ProcessInfo.processInfo.arguments.contains("--live-testing") { return VoiceCatalogResponse(defaultVoice: Voices.defaultID, voices: Voices.all, catalogAvailable: false, message: "Offline test voice catalog.") } #endif guard !token.isEmpty else { throw AuthError.status(401) } return try JSONDecoder().decode(VoiceCatalogResponse.self, from: await request("voices", method: "GET", token: token)) } func logout(token: String) async throws { _ = try await request("api/auth/logout", method: "POST", token: token) } }