bugger/Sources/Utils/LocalOAuthServer.swift

142 lines
5.0 KiB
Swift

import Foundation
import Network
/// Starts a one-shot HTTP server on localhost to receive the OAuth redirect.
/// Returns the authorization code extracted from the callback query string.
actor LocalOAuthServer {
private var listener: NWListener?
private var continuation: CheckedContinuation<String, Error>?
private let port: UInt16
init(port: UInt16 = 18_923) {
self.port = port
}
/// The full redirect URI that must be registered in the Feishu app console.
nonisolated var redirectURI: String {
"http://127.0.0.1:\(port)/callback"
}
/// Start listening and return the authorization code when the browser redirects.
func receiveCode() async throws -> String {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
do {
let params = NWParameters.tcp
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
self.listener = listener
listener.newConnectionHandler = { [weak self] connection in
connection.start(queue: .global())
guard let self else { return }
Self.readConnection(connection) { code in
Task {
await self.didReceive(code: code, from: connection)
}
}
}
listener.stateUpdateHandler = { state in
if case .failed(let error) = state {
continuation.resume(throwing: error)
}
}
listener.start(queue: .global())
} catch {
continuation.resume(throwing: error)
}
}
}
/// Stop the server (called after receiving the code or on timeout).
func stop() {
listener?.cancel()
listener = nil
}
// MARK: - Private (actor-isolated)
private func didReceive(code: String?, from connection: NWConnection) {
guard let code else {
Self.sendResponse(to: connection, status: 400, body: errorPage)
return
}
Self.sendResponse(to: connection, status: 200, body: successPage)
continuation?.resume(returning: code)
continuation = nil
stop()
}
// MARK: - Non-isolated helpers
/// Read the first chunk of an HTTP connection and extract the code.
private static func readConnection(
_ connection: NWConnection,
completion: @escaping (String?) -> Void
) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, _, _ in
guard let data,
let request = String(data: data, encoding: .utf8) else {
completion(nil)
return
}
completion(extractCode(from: request))
}
}
private static func extractCode(from request: String) -> String? {
// Parse "GET /callback?code=xxx HTTP/1.1"
guard let firstLine = request.components(separatedBy: "\r\n").first,
firstLine.hasPrefix("GET"),
let pathAndQuery = firstLine.components(separatedBy: " ").dropFirst().first,
pathAndQuery.hasPrefix("/callback"),
let queryStart = pathAndQuery.firstIndex(of: "?") else {
return nil
}
let query = String(pathAndQuery[pathAndQuery.index(after: queryStart)...])
let params = query.components(separatedBy: "&")
for param in params {
let pair = param.components(separatedBy: "=")
if pair.first == "code", pair.count > 1 {
return pair[1].removingPercentEncoding
}
}
return nil
}
private static func sendResponse(to connection: NWConnection, status: Int, body: String) {
let statusText = status == 200 ? "OK" : "Bad Request"
let response = """
HTTP/1.1 \(status) \(statusText)\r
Content-Type: text/html; charset=utf-8\r
Content-Length: \(body.utf8.count)\r
Connection: close\r
\r
\(body)
"""
connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in
connection.cancel()
})
}
}
private let successPage = """
<!DOCTYPE html><html><head><meta charset="utf-8"><title>Bugger</title>
<style>body{font-family:-apple-system,sans-serif;display:flex;justify-content:center;
align-items:center;height:100vh;margin:0;background:#f5f5f5}
.card{background:white;padding:40px;border-radius:12px;text-align:center;box-shadow:0
2px 12px rgba(0,0,0,0.1)}h1{color:#333} p{color:#666}</style></head>
<body><div class="card"><h1>Connected ✓</h1><p>Bugger has been authorized.
You can close this window.</p></div></body></html>
"""
private let errorPage = """
<!DOCTYPE html><html><head><meta charset="utf-8"><title>Bugger</title></head>
<body><h1>Authorization Failed</h1><p>Could not extract the authorization code.
Please try again.</p></body></html>
"""