~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atirc-ios-client

0
1
main
219 lines · 8.4 KB Raw History
//
//  LoopbackServer.swift
//  AT-IRC
//
//  Tiny one-shot HTTP listener for OAuth loopback redirects
//  (http://127.0.0.1:<port>/) — 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<UInt16, Error>?
    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 = """
        <!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1">
        <title>AT-IRC</title></head>
        <body style="font:17px/1.5 -apple-system,system-ui;margin:3rem 1.5rem;text-align:center">
        <p>\(message)</p></body></html>
        """
        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
    }
}