import Foundation struct TokenResponse: Decodable, Equatable { let token: String let room: String let user: User let conversationID: String? enum CodingKeys: String, CodingKey { case token, room, user case conversationID = "conversation_id" } struct User: Decodable, Equatable { let id: String let displayName: String? enum CodingKeys: String, CodingKey { case id case displayName = "display_name" } } } enum TokenError: LocalizedError { case invalidServerURL(String) case httpStatus(Int, String) case decodeFailed case network(Error) var errorDescription: String? { switch self { case .invalidServerURL(let raw): return "Invalid server URL: \(raw)" case .httpStatus(let code, let detail): if code == 503 { return "Server is starting up or the user file is missing (HTTP 503). Check the deployment on the server. \(detail)" } return "Token request failed with HTTP \(code). \(detail)" case .decodeFailed: return "Unexpected response from the token endpoint." case .network(let error): return "Could not reach the server: \(error.localizedDescription)" } } } final class TokenClient { let config: ServerConfig private let session: URLSession var onUnauthorized: (() async -> Void)? let bearerToken: String var conversationID: String? init(config: ServerConfig, bearerToken: String = "", session: URLSession? = nil) { self.config = config self.session = session ?? TLSPinning.session() self.bearerToken = bearerToken } func cancel() { session.getAllTasks { $0.forEach { $0.cancel() } } } func fetchToken() async throws -> TokenResponse { #if DEBUG if ProcessInfo.processInfo.arguments.contains("--ui-testing"), !ProcessInfo.processInfo.arguments.contains("--live-testing") { throw URLError(.cannotConnectToHost) // offline fixtures never contact any configured server } #endif guard !bearerToken.isEmpty else { throw AuthError.status(401) } var request = URLRequest(url: config.baseURL.appendingPathComponent("token")) request.httpMethod = "POST" if let conversationID { request.httpBody = try JSONSerialization.data(withJSONObject: ["conversation_id": conversationID]) request.setValue("application/json", forHTTPHeaderField: "Content-Type") } request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 10 let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw TokenError.decodeFailed } guard (200..<300).contains(http.statusCode) else { if http.statusCode == 401 || http.statusCode == 403 { await onUnauthorized?() } let detail = "" throw TokenError.httpStatus(http.statusCode, detail) } do { return try JSONDecoder().decode(TokenResponse.self, from: data) } catch { throw TokenError.decodeFailed } } }