// // LoopbackServer.swift // AT-IRC // // Tiny one-shot HTTP listener for OAuth loopback redirects // (http://127.0.0.1:/) — the native-app pattern from the // AT Protocol OAuth spec. // import Foundation import Network import OSLog @MainActor final class LoopbackServer { private var listener: NWListener? private var startContinuation: CheckedContinuation? private var callbackContinuation: CheckedContinuation<[String: String], Error>? private var received: [String: String]? private(set) var port: UInt16 = 0 /// Start listening on an ephemeral loopback port and return it. Never /// binds a wildcard interface: the OAuth redirect listener must stay /// reachable from this device only. Two loopback-only spellings are tried /// because some devices reject one of them. func start() async throws -> UInt16 { guard listener == nil else { return port } var lastError: Error = NSError( domain: "ATIRC.LoopbackServer", code: 1, userInfo: [NSLocalizedDescriptionKey: "no loopback listener variant succeeded"] ) for host: NWEndpoint.Host in [.ipv4(.loopback), "127.0.0.1"] { do { return try await startListener(host: host) } catch { self.listener = nil lastError = error Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "market-oauth") .error("loopback listener \(host.debugDescription, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") } } throw lastError } private func startListener(host: NWEndpoint.Host) async throws -> UInt16 { try await withCheckedThrowingContinuation { continuation in startContinuation = continuation do { let parameters = NWParameters.tcp parameters.allowLocalEndpointReuse = true // Loopback only, so the redirect can only be delivered // locally; never 0.0.0.0 / any interface. parameters.requiredLocalEndpoint = .hostPort(host: host, port: .any) let listener = try NWListener(using: parameters) self.listener = listener listener.stateUpdateHandler = { [weak self] state in Task { @MainActor in guard let self else { return } switch state { case .ready: guard let assigned = listener.port?.rawValue else { return } self.port = assigned if let continuation = self.startContinuation { self.startContinuation = nil continuation.resume(returning: assigned) } case .failed(let error): listener.cancel() self.listener = nil if let continuation = self.startContinuation { self.startContinuation = nil continuation.resume(throwing: error) } default: break } } } listener.newConnectionHandler = { [weak self] connection in Task { @MainActor in self?.handle(connection) } } listener.start(queue: .global(qos: .userInitiated)) } catch { startContinuation = nil continuation.resume(throwing: error) } } } /// Wait for the authorization server to redirect back with query items. func waitForCallback() async throws -> [String: String] { if let received { return received } return try await withCheckedThrowingContinuation { continuation in callbackContinuation = continuation } } /// Keep the listener for the next redirect (the requester's OAuth flow /// reuses one loopback port for every sign-in attempt). func resetCallback() { received = nil } func stop() { listener?.cancel() listener = nil } // MARK: - Connection handling private func handle(_ connection: NWConnection) { // Defense in depth: even though the listener is pinned to loopback, // refuse anything whose remote endpoint is not local. guard Self.isLoopback(connection.endpoint) else { connection.cancel() return } connection.start(queue: .global(qos: .userInitiated)) connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, _, error in let requestData = data Task { @MainActor in guard let self else { return } if let error { self.finish(with: .failure(error)) connection.cancel() return } guard let requestData, let text = String(data: requestData, encoding: .utf8) else { connection.cancel() return } let path = Self.requestTarget(from: text) let query = Self.queryItems(path) self.respond(to: connection, query: query) if !query.isEmpty { self.finish(with: .success(query)) } } } } private func respond(to connection: NWConnection, query: [String: String]) { let message: String if query["error"] != nil { message = "Sign-in failed: \(query["error"] ?? "unknown error"). You can close this window." } else if query["code"] != nil { message = "Signed in. You can return to AT-IRC." } else { message = "Waiting for sign-in…" } let html = """ AT-IRC

\(message)

""" let body = Data(html.utf8) var response = Data("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: \(body.count)\r\nConnection: close\r\n\r\n".utf8) response.append(body) connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() }) } private func finish(with result: Result<[String: String], Error>) { guard let continuation = callbackContinuation else { return } callbackContinuation = nil switch result { case .success(let query): received = query continuation.resume(returning: query) case .failure(let error): continuation.resume(throwing: error) } } /// True for IPv4 127.0.0.1, IPv6 ::1, or localhost. private static func isLoopback(_ endpoint: NWEndpoint) -> Bool { guard case let .hostPort(host, _) = endpoint else { return false } switch host { case .ipv4(let address): return address == .loopback case .ipv6(let address): return address == .loopback case .name(let name, _): return name.lowercased() == "localhost" @unknown default: return false } } /// Extract the request target from the HTTP request line. private static func requestTarget(from request: String) -> String { guard let firstLine = request.split(separator: "\r\n", maxSplits: 1).first else { return "" } let parts = firstLine.split(separator: " ") guard parts.count >= 2 else { return "" } return String(parts[1]) } private static func queryItems(_ target: String) -> [String: String] { guard let components = URLComponents(string: "http://127.0.0.1\(target)"), let items = components.queryItems else { return [:] } var result: [String: String] = [:] for item in items { result[item.name] = item.value ?? "" } return result } }