|
|
@@ -0,0 +1,132 @@
|
|
|
+import Foundation
|
|
|
+import Network
|
|
|
+
|
|
|
+enum OAuthRedirectListenerError: Error {
|
|
|
+ case failedToStart
|
|
|
+ case invalidRequest
|
|
|
+ case timedOut
|
|
|
+}
|
|
|
+
|
|
|
+/// Listens for a single OAuth redirect hit on `http://127.0.0.1:<port><pathPrefix>?...` and returns the full URL.
|
|
|
+final class OAuthRedirectListener {
|
|
|
+ private let port: NWEndpoint.Port
|
|
|
+ private let pathPrefix: String
|
|
|
+ private var listener: NWListener?
|
|
|
+ private var didFinish = false
|
|
|
+
|
|
|
+ init(port: UInt16, pathPrefix: String) throws {
|
|
|
+ guard let port = NWEndpoint.Port(rawValue: port) else { throw OAuthRedirectListenerError.failedToStart }
|
|
|
+ self.port = port
|
|
|
+ self.pathPrefix = pathPrefix.hasPrefix("/") ? pathPrefix : "/" + pathPrefix
|
|
|
+ }
|
|
|
+
|
|
|
+ func waitForRedirect(timeoutSeconds: TimeInterval) async throws -> URL {
|
|
|
+ try await withThrowingTaskGroup(of: URL.self) { group in
|
|
|
+ group.addTask { [weak self] in
|
|
|
+ guard let self else { throw OAuthRedirectListenerError.failedToStart }
|
|
|
+ return try await self.waitForRedirectNoTimeout()
|
|
|
+ }
|
|
|
+ group.addTask {
|
|
|
+ try await Task.sleep(nanoseconds: UInt64(max(0, timeoutSeconds) * 1_000_000_000))
|
|
|
+ throw OAuthRedirectListenerError.timedOut
|
|
|
+ }
|
|
|
+ let url = try await group.next()!
|
|
|
+ group.cancelAll()
|
|
|
+ return url
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func waitForRedirectNoTimeout() async throws -> URL {
|
|
|
+ if didFinish { throw OAuthRedirectListenerError.invalidRequest }
|
|
|
+
|
|
|
+ let listener: NWListener
|
|
|
+ do {
|
|
|
+ listener = try NWListener(using: .tcp, on: port)
|
|
|
+ } catch {
|
|
|
+ throw OAuthRedirectListenerError.failedToStart
|
|
|
+ }
|
|
|
+
|
|
|
+ self.listener = listener
|
|
|
+ listener.stateUpdateHandler = { [weak self] state in
|
|
|
+ if case .failed = state {
|
|
|
+ self?.stop()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
|
|
|
+ listener.newConnectionHandler = { [weak self] connection in
|
|
|
+ guard let self else { return }
|
|
|
+ connection.start(queue: .global(qos: .userInitiated))
|
|
|
+ self.receiveOnce(connection: connection, continuation: continuation)
|
|
|
+ }
|
|
|
+
|
|
|
+ listener.start(queue: .global(qos: .userInitiated))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func receiveOnce(connection: NWConnection, continuation: CheckedContinuation<URL, Error>) {
|
|
|
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
|
|
|
+ guard let self else { return }
|
|
|
+ defer { connection.cancel() }
|
|
|
+
|
|
|
+ guard self.didFinish == false else { return }
|
|
|
+ self.didFinish = true
|
|
|
+ defer { self.stop() }
|
|
|
+
|
|
|
+ guard let data, let raw = String(data: data, encoding: .utf8) else {
|
|
|
+ continuation.resume(throwing: OAuthRedirectListenerError.invalidRequest)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Expect: "GET /oauth2redirect?code=...&state=... HTTP/1.1"
|
|
|
+ guard let requestLine = raw.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: true).first else {
|
|
|
+ continuation.resume(throwing: OAuthRedirectListenerError.invalidRequest)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let parts = requestLine.split(separator: " ")
|
|
|
+ guard parts.count >= 2, parts[0] == "GET" else {
|
|
|
+ continuation.resume(throwing: OAuthRedirectListenerError.invalidRequest)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let target = String(parts[1])
|
|
|
+ guard target.hasPrefix(pathPrefix) else {
|
|
|
+ continuation.resume(throwing: OAuthRedirectListenerError.invalidRequest)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let url = URL(string: "http://127.0.0.1:\(port.rawValue)\(target)") else {
|
|
|
+ continuation.resume(throwing: OAuthRedirectListenerError.invalidRequest)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let body = """
|
|
|
+ <html>
|
|
|
+ <head><meta charset="utf-8"><title>Signed in</title></head>
|
|
|
+ <body style="font: 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px;">
|
|
|
+ <h3>Sign-in complete</h3>
|
|
|
+ <p>You can close this tab and return to the app.</p>
|
|
|
+ </body>
|
|
|
+ </html>
|
|
|
+ """
|
|
|
+ let response = """
|
|
|
+ HTTP/1.1 200 OK\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 })
|
|
|
+
|
|
|
+ continuation.resume(returning: url)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func stop() {
|
|
|
+ listener?.cancel()
|
|
|
+ listener = nil
|
|
|
+ }
|
|
|
+}
|
|
|
+
|