Ver Fonte

Use default browser for Zoom OAuth redirect

Capture the OAuth callback via a localhost redirect listener so the user signs in within their default browser session.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 há 2 meses atrás
pai
commit
83013a4da5
2 ficheiros alterados com 140 adições e 7 exclusões
  1. 132 0
      zoom_app/OAuthRedirectListener.swift
  2. 8 7
      zoom_app/ViewController.swift

+ 132 - 0
zoom_app/OAuthRedirectListener.swift

@@ -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
+    }
+}
+

+ 8 - 7
zoom_app/ViewController.swift

@@ -7038,11 +7038,9 @@ final class ZoomOAuthService: NSObject {
 
     private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
         guard let presentingWindow else { throw ZoomOAuthError.unableToOpenBrowser }
-        let oauthWindow = await MainActor.run { makeOAuthWindowController() }
         guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
         guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
 
-        // Keep a provider-compatible redirect URI while capturing it in-app (no local server required).
         let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
         let state = UUID().uuidString
 
@@ -7056,17 +7054,20 @@ final class ZoomOAuthService: NSObject {
         ]
         guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
         do {
-            let callbackURL = try await oauthWindow.authenticate(
-                startURL: authURL,
-                callbackURLPrefix: redirectURI,
-                presentingWindow: presentingWindow
-            )
+            _ = presentingWindow // keep signature stable; used for parity with Google flow + future UI alerts
+            let listener = try OAuthRedirectListener(port: 8742, pathPrefix: "/oauth2redirect")
+            guard NSWorkspace.shared.open(authURL) else {
+                throw ZoomOAuthError.unableToOpenBrowser
+            }
+            let callbackURL = try await listener.waitForRedirect(timeoutSeconds: 180)
             let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
             guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
             guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
                 throw ZoomOAuthError.missingAuthorizationCode
             }
             return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
+        } catch OAuthRedirectListenerError.timedOut {
+            throw ZoomOAuthError.authenticationTimedOut
         } catch GoogleOAuthError.authenticationTimedOut {
             throw ZoomOAuthError.authenticationTimedOut
         } catch GoogleOAuthError.authenticationCancelled {