142 lines
5.0 KiB
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>
|
|
"""
|