Просмотр исходного кода

Replace loopback OAuth server with in-app callback capture.

Route Zoom and Google OAuth redirects through an in-app WebView interceptor using the existing redirect URI, removing local listener dependencies while keeping provider-compatible callback validation.

Made-with: Cursor
huzaifahayat12 3 месяцев назад
Родитель
Сommit
8bacdf25a3
1 измененных файлов с 88 добавлено и 147 удалено
  1. 88 147
      zoom_app/ViewController.swift

+ 88 - 147
zoom_app/ViewController.swift

@@ -7,7 +7,6 @@
 
 import Cocoa
 import CryptoKit
-import Network
 import StoreKit
 import WebKit
 
@@ -6162,6 +6161,7 @@ final class ZoomOAuthTokenStore {
 
 final class ZoomOAuthService: NSObject {
     static let shared = ZoomOAuthService()
+    private var inAppOAuthWindowController: InAppOAuthWindowController?
 
     private let tokenStore = ZoomOAuthTokenStore()
     private let clientIdDefaultsKey = "zoom.oauth.clientId"
@@ -6224,12 +6224,12 @@ final class ZoomOAuthService: NSObject {
 
     private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
         _ = presentingWindow
+        let oauthWindow = await MainActor.run { makeOAuthWindowController() }
         guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
         guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
 
-        let loopback = try await OAuthLoopbackServer.start()
-        defer { loopback.stop() }
-        let redirectURI = loopback.redirectURI
+        // 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
 
         var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
@@ -6241,17 +6241,27 @@ final class ZoomOAuthService: NSObject {
             URLQueryItem(name: "state", value: state)
         ]
         guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
-        let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
-        guard opened else { throw ZoomOAuthError.unableToOpenBrowser }
-
-        let callbackURL = try await loopback.waitForCallback()
-        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
+        do {
+            let callbackURL = try await oauthWindow.authenticate(startURL: authURL, callbackURLPrefix: redirectURI)
+            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 GoogleOAuthError.authenticationTimedOut {
+            throw ZoomOAuthError.authenticationTimedOut
+        } catch {
+            throw error
         }
+    }
 
-        return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
+    @MainActor
+    private func makeOAuthWindowController() -> InAppOAuthWindowController {
+        if let existing = inAppOAuthWindowController { return existing }
+        let controller = InAppOAuthWindowController(title: "Zoom Sign-In")
+        inAppOAuthWindowController = controller
+        return controller
     }
 
     private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
@@ -6405,12 +6415,12 @@ final class GoogleOAuthService: NSObject {
 
     private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
         _ = presentingWindow
+        let oauthWindow = await MainActor.run { makeOAuthWindowController() }
         let codeVerifier = Self.randomURLSafeString(length: 64)
         let codeChallenge = Self.pkceChallenge(for: codeVerifier)
         let state = Self.randomURLSafeString(length: 32)
-        let loopback = try await OAuthLoopbackServer.start()
-        defer { loopback.stop() }
-        let redirectURI = loopback.redirectURI
+        // Google OAuth app settings often already include this callback from prior loopback setup.
+        let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
 
         var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
         components.queryItems = [
@@ -6426,10 +6436,7 @@ final class GoogleOAuthService: NSObject {
         ]
 
         guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
-        let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
-        guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
-
-        let callbackURL = try await loopback.waitForCallback()
+        let callbackURL = try await oauthWindow.authenticate(startURL: authURL, callbackURLPrefix: redirectURI)
         let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
         guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
         guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
@@ -6438,6 +6445,14 @@ final class GoogleOAuthService: NSObject {
         return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
     }
 
+    @MainActor
+    private func makeOAuthWindowController() -> InAppOAuthWindowController {
+        if let existing = inAppOAuthWindowController { return existing }
+        let controller = InAppOAuthWindowController(title: "Google Sign-In")
+        inAppOAuthWindowController = controller
+        return controller
+    }
+
     private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
         var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
         request.httpMethod = "POST"
@@ -6532,42 +6547,49 @@ final class KeychainTokenStore {
     }
 }
 
-private final class OAuthLoopbackServer {
-    /// Fixed port so Zoom/Google OAuth redirect URLs can be registered exactly (Zoom allow list does not support wildcards for ports).
-    private static let loopbackOAuthPort: UInt16 = 8742
-
-    // Use a high-enough QoS to avoid Thread Performance Checker QoS inversion
-    // warnings when auth is triggered from user-interactive UI actions.
-    private let queue = DispatchQueue(
-        label: "google.oauth.loopback.server",
-        qos: .userInitiated
-    )
-    private let listener: NWListener
-    private var readyContinuation: CheckedContinuation<Void, Error>?
+@MainActor
+private final class OAuthWebViewContainerView: NSView {
+    private let webView: WKWebView
+    init(webView: WKWebView) {
+        self.webView = webView
+        super.init(frame: .zero)
+        addSubview(webView)
+    }
+    @available(*, unavailable) required init?(coder: NSCoder) { nil }
+    override func layout() {
+        super.layout()
+        webView.frame = bounds
+    }
+}
+
+@MainActor
+private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate {
+    private let webView: WKWebView
+    private var callbackPrefix: String?
     private var callbackContinuation: CheckedContinuation<URL, Error>?
-    private var callbackURL: URL?
 
-    private init(listener: NWListener) {
-        self.listener = listener
+    init(title: String) {
+        let configuration = WKWebViewConfiguration()
+        configuration.websiteDataStore = .nonPersistent()
+        self.webView = WKWebView(frame: .zero, configuration: configuration)
+        let container = OAuthWebViewContainerView(webView: webView)
+        let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
+        window.title = title
+        window.contentView = container
+        super.init(window: window)
+        webView.navigationDelegate = self
     }
 
-    static func start() async throws -> OAuthLoopbackServer {
-        guard let port = NWEndpoint.Port(rawValue: loopbackOAuthPort) else {
-            throw GoogleOAuthError.invalidCallbackURL
-        }
-        let listener = try NWListener(using: .tcp, on: port)
-        let server = OAuthLoopbackServer(listener: listener)
-        try await server.startListening()
-        return server
-    }
+    @available(*, unavailable) required init?(coder: NSCoder) { nil }
 
-    var redirectURI: String {
-        let port = listener.port?.rawValue ?? 0
-        return "http://127.0.0.1:\(port)/oauth2redirect"
-    }
+    func authenticate(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double = 120) async throws -> URL {
+        self.callbackPrefix = callbackURLPrefix
+        showWindow(nil)
+        window?.makeKeyAndOrderFront(nil)
+        window?.center()
+        webView.load(URLRequest(url: startURL))
 
-    func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
-        try await withThrowingTaskGroup(of: URL.self) { group in
+        return try await withThrowingTaskGroup(of: URL.self) { group in
             group.addTask { [weak self] in
                 guard let self else { throw GoogleOAuthError.invalidCallbackURL }
                 return try await self.awaitCallback()
@@ -6578,115 +6600,34 @@ private final class OAuthLoopbackServer {
             }
             let url = try await group.next()!
             group.cancelAll()
+            self.webView.stopLoading()
+            self.close()
             return url
         }
     }
 
-    func stop() {
-        listener.cancel()
-    }
-
-    private func startListening() async throws {
-        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
-            queue.async {
-                self.readyContinuation = continuation
-                self.listener.stateUpdateHandler = { [weak self] state in
-                    guard let self else { return }
-                    switch state {
-                    case .ready:
-                        if let readyContinuation = self.readyContinuation {
-                            self.readyContinuation = nil
-                            readyContinuation.resume()
-                        }
-                    case .failed(let error):
-                        if let readyContinuation = self.readyContinuation {
-                            self.readyContinuation = nil
-                            readyContinuation.resume(throwing: error)
-                        }
-                    case .cancelled:
-                        if let readyContinuation = self.readyContinuation {
-                            self.readyContinuation = nil
-                            readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
-                        }
-                    default:
-                        break
-                    }
-                }
-                self.listener.newConnectionHandler = { [weak self] connection in
-                    self?.handle(connection: connection)
-                }
-                self.listener.start(queue: self.queue)
-            }
-        }
-    }
-
     private func awaitCallback() async throws -> URL {
-        if let callbackURL { return callbackURL }
-        return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
-            queue.async { self.callbackContinuation = continuation }
+        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
+            callbackContinuation = continuation
         }
     }
 
-    private func handle(connection: NWConnection) {
-        connection.start(queue: queue)
-        connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
-            guard let self else { return }
-            let requestLine = data.flatMap { String(data: $0, encoding: .utf8) }?.split(separator: "\r\n").first.map(String.init)
-            var parsedURL: URL?
-            if let requestLine {
-                let parts = requestLine.split(separator: " ")
-                if parts.count >= 2 {
-                    parsedURL = URL(string: "http://127.0.0.1\(parts[1])")
-                }
-            }
-            self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
-            if let parsedURL {
-                self.callbackURL = parsedURL
-                self.callbackContinuation?.resume(returning: parsedURL)
-                self.callbackContinuation = nil
-                self.listener.cancel()
-            }
-            connection.cancel()
+    func webView(_ webView: WKWebView,
+                 decidePolicyFor navigationAction: WKNavigationAction,
+                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+        guard let targetURL = navigationAction.request.url,
+              let callbackPrefix,
+              targetURL.absoluteString.hasPrefix(callbackPrefix) else {
+            decisionHandler(.allow)
+            return
         }
-    }
 
-    private func sendHTTPResponse(connection: NWConnection, success: Bool) {
-        let body = success ? "<html><body><h3>Authentication complete</h3><p>You can return to the app.</p></body></html>" : "<html><body><h3>Authentication failed</h3></body></html>"
-        let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)"
-        connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
+        callbackContinuation?.resume(returning: targetURL)
+        callbackContinuation = nil
+        decisionHandler(.cancel)
     }
 }
 
-@MainActor
-private final class OAuthWebViewContainerView: NSView {
-    private let webView: WKWebView
-    init(webView: WKWebView) {
-        self.webView = webView
-        super.init(frame: .zero)
-        addSubview(webView)
-    }
-    @available(*, unavailable) required init?(coder: NSCoder) { nil }
-    override func layout() {
-        super.layout()
-        webView.frame = bounds
-    }
-}
-
-@MainActor
-private final class InAppOAuthWindowController: NSWindowController {
-    private let webView: WKWebView
-    init() {
-        self.webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
-        let container = OAuthWebViewContainerView(webView: webView)
-        let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
-        window.title = "Google Sign-In"
-        window.contentView = container
-        super.init(window: window)
-    }
-    @available(*, unavailable) required init?(coder: NSCoder) { nil }
-    func load(url: URL) { webView.load(URLRequest(url: url)) }
-}
-
 extension GoogleOAuthError: LocalizedError {
     var errorDescription: String? {
         switch self {
@@ -6709,7 +6650,7 @@ extension ZoomOAuthError: LocalizedError {
         case .missingClientSecret:
             return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
         case .invalidCallbackURL:
-            return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect (must match OAuthLoopbackServer.loopbackOAuthPort in this target)."
+            return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect."
         case .missingAuthorizationCode:
             return "Zoom did not return an authorization code."
         case .tokenExchangeFailed(let details):