Ver código fonte

Sync embedded Zoom web joins with OAuth account session.

Share WKWebView cookies with Zoom sign-in, attach ZAK on join URLs, and auto-refresh consent when user_zak:read is missing so signed-in users are not treated as guests.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 meses atrás
pai
commit
77dafb2a03
1 arquivos alterados com 332 adições e 30 exclusões
  1. 332 30
      zoom_app/ViewController.swift

+ 332 - 30
zoom_app/ViewController.swift

@@ -31,6 +31,11 @@ private enum AppExternalURLs {
 private let embeddedZoomWebViewUserAgent =
     "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15"
 
+/// Shared cookie jar for embedded Zoom meetings and Zoom OAuth so the in-app browser reuses the same Zoom web session.
+private let zoomEmbeddedWebsiteDataStore = WKWebsiteDataStore.default()
+/// Shared renderer process so OAuth cookies are visible to the embedded meeting web client.
+private let zoomEmbeddedWebProcessPool = WKProcessPool()
+
 /// Errors from the main-window embedded OAuth web session (right panel).
 private enum EmbeddedOAuthFlowError: Error {
     case webViewUnavailable
@@ -418,6 +423,9 @@ class ViewController: NSViewController {
     private var embeddedBrowserMainColumnCollapsedWidthConstraint: NSLayoutConstraint?
     private var oauthSessionContinuation: CheckedContinuation<URL, Error>?
     private var oauthSessionCallbackPrefix: String?
+    /// ZAK + display name for the next embedded join; used to re-auth the web client if the guest name screen appears.
+    private var embeddedZoomPendingWebAuth: (zak: String, displayName: String?)?
+    private var zoomSignedInDisplayName: String?
     /// Gap between main column and right web panel (must match Auto Layout constant).
     private let homeEmbeddedBrowserColumnGap: CGFloat = 10
     private weak var homePremiumLockOverlay: NSButton?
@@ -739,6 +747,22 @@ class ViewController: NSViewController {
         ensureUsageSessionStartDate()
         scheduleUsageBasedRatingPromptIfNeeded()
         recordedMeetings = MeetingRecordingManager.shared.recordings
+        zoomOAuth.embeddedOAuthSession = { [weak self] startURL, callbackPrefix, timeout in
+            guard let self else { throw ZoomOAuthError.unableToOpenBrowser }
+            do {
+                return try await self.runEmbeddedOAuthSession(
+                    startURL: startURL,
+                    callbackURLPrefix: callbackPrefix,
+                    timeoutSeconds: timeout
+                )
+            } catch EmbeddedOAuthFlowError.userCancelled {
+                throw ZoomOAuthError.authenticationCancelled
+            } catch EmbeddedOAuthFlowError.timedOut {
+                throw ZoomOAuthError.authenticationTimedOut
+            } catch EmbeddedOAuthFlowError.webViewUnavailable {
+                throw ZoomOAuthError.unableToOpenBrowser
+            }
+        }
         NotificationCenter.default.addObserver(
             forName: MeetingRecordingManager.recordingsDidChangeNotification,
             object: nil,
@@ -1024,6 +1048,7 @@ class ViewController: NSViewController {
                         UserDefaults.standard.set(profileTimeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
                     }
                     self.completeLoginAndRestart(profile: profile)
+                    self.primeZoomEmbeddedWebSession()
                 }
             } catch {
                 await MainActor.run {
@@ -2031,32 +2056,219 @@ class ViewController: NSViewController {
         }
     }
 
-    /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd`).
-    private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String) -> URL? {
+    /// Maps `zoom.us` join links to `app.zoom.us` (Workplace web client host).
+    private func zoomEmbeddedWebHostURL(for url: URL) -> URL {
+        guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
+        let host = components.host?.lowercased() ?? ""
+        if host == "zoom.us" || host.hasSuffix(".zoom.us") {
+            components.host = "app.zoom.us"
+        }
+        return components.url ?? url
+    }
+
+    private func zakQueryValueFromZoomURL(_ url: URL) -> String? {
+        let fromQuery = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+            .queryItems?
+            .first(where: { $0.name.caseInsensitiveCompare("zak") == .orderedSame })?
+            .value?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        if let fromQuery, fromQuery.isEmpty == false { return fromQuery }
+        return nil
+    }
+
+    /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd` + optional `zak`).
+    private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String, zak: String? = nil, displayName: String? = nil) -> URL? {
         guard meetingIdDigits.isEmpty == false else { return nil }
         var components = URLComponents()
         components.scheme = "https"
-        components.host = "zoom.us"
+        components.host = "app.zoom.us"
         components.path = "/wc/join/\(meetingIdDigits)"
-        let trimmed = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
-        if trimmed.isEmpty == false {
-            components.queryItems = [URLQueryItem(name: "pwd", value: trimmed)]
+        var queryItems: [URLQueryItem] = []
+        let trimmedPasscode = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
+        if trimmedPasscode.isEmpty == false {
+            queryItems.append(URLQueryItem(name: "pwd", value: trimmedPasscode))
+        }
+        let trimmedZAK = zak?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        if trimmedZAK.isEmpty == false {
+            queryItems.append(URLQueryItem(name: "zak", value: trimmedZAK))
+        }
+        let trimmedName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        if trimmedName.isEmpty == false {
+            queryItems.append(URLQueryItem(name: "uname", value: trimmedName))
+        }
+        if queryItems.isEmpty == false {
+            components.queryItems = queryItems
         }
         return components.url
     }
 
+    /// Adds ZAK and display name so the embedded web client joins as the OAuth-signed-in user (not a guest).
+    private func zoomMeetingURLByAppendingWebAuth(_ url: URL, zak: String, displayName: String?) -> URL {
+        let normalizedHost = zoomEmbeddedWebHostURL(for: url)
+        let trimmedZAK = zak.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard trimmedZAK.isEmpty == false else { return normalizedHost }
+        var components = URLComponents(url: normalizedHost, resolvingAgainstBaseURL: false) ?? URLComponents()
+        var queryItems = components.queryItems ?? []
+        queryItems.removeAll { $0.name.caseInsensitiveCompare("zak") == .orderedSame }
+        queryItems.removeAll { $0.name.caseInsensitiveCompare("uname") == .orderedSame }
+        queryItems.append(URLQueryItem(name: "zak", value: trimmedZAK))
+        let trimmedName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        if trimmedName.isEmpty == false {
+            queryItems.append(URLQueryItem(name: "uname", value: trimmedName))
+        }
+        components.queryItems = queryItems
+        return components.url ?? normalizedHost
+    }
+
+    private func javaScriptStringLiteral(_ value: String) -> String {
+        let escaped = value
+            .replacingOccurrences(of: "\\", with: "\\\\")
+            .replacingOccurrences(of: "'", with: "\\'")
+            .replacingOccurrences(of: "\n", with: "\\n")
+            .replacingOccurrences(of: "\r", with: "")
+        return "'\(escaped)'"
+    }
+
+    /// Loads a Zoom Workplace page in the shared web view so OAuth cookies apply to later joins (panel may stay closed).
+    @MainActor
+    private func primeZoomEmbeddedWebSession() {
+        guard isUserLoggedIn(), let webView = embeddedBrowserWebView else { return }
+        guard let homeURL = URL(string: "https://app.zoom.us/wc/home") else { return }
+        webView.load(URLRequest(url: homeURL))
+    }
+
+    private func zoomOAuthErrorIndicatesMissingZAKScope(_ error: Error) -> Bool {
+        if case ZoomOAuthError.missingRequiredScope(let raw) = error {
+            let lower = raw.lowercased()
+            return lower.contains("user_zak") || lower.contains("zak")
+        }
+        let lower = error.localizedDescription.lowercased()
+        return lower.contains("user_zak") || lower.contains("does not contain scopes")
+    }
+
+    /// Fetches ZAK for embedded join. If the saved token predates `user_zak:read`, opens Zoom OAuth once (you stay signed in to the app).
+    @MainActor
+    private func fetchEmbeddedZoomZAKAllowingReConsent() async -> String? {
+        do {
+            let accessToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
+            return try await fetchZoomUserZAK(accessToken: accessToken)
+        } catch {
+            if case ZoomOAuthError.authenticationCancelled = error { return nil }
+            guard zoomOAuthErrorIndicatesMissingZAKScope(error) else {
+                NSLog("ViewController: ZAK unavailable for embedded join — \(error.localizedDescription)")
+                return nil
+            }
+        }
+
+        do {
+            zoomOAuth.clearSavedTokens()
+            let accessToken = try await zoomOAuth.validAccessToken(
+                presentingWindow: view.window,
+                requiringAnyOf: ["user_zak:read"]
+            )
+            return try await fetchZoomUserZAK(accessToken: accessToken)
+        } catch {
+            if case ZoomOAuthError.authenticationCancelled = error { return nil }
+            NSLog("ViewController: ZAK unavailable after OAuth refresh — \(error.localizedDescription)")
+            return nil
+        }
+    }
+
+    @MainActor
+    private func zoomMeetingURLWithSignedInDisplayNameFallback(_ url: URL) -> URL {
+        guard let name = zoomSignedInDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines),
+              name.isEmpty == false else { return url }
+        var components = URLComponents(url: url, resolvingAgainstBaseURL: false) ?? URLComponents()
+        var queryItems = components.queryItems ?? []
+        if queryItems.contains(where: { $0.name.caseInsensitiveCompare("uname") == .orderedSame }) == false {
+            queryItems.append(URLQueryItem(name: "uname", value: name))
+        }
+        components.queryItems = queryItems
+        return components.url ?? url
+    }
+
+    /// When signed in, attach a fresh ZAK so `/wc/join/` loads with the same Zoom account as native OAuth.
+    @MainActor
+    private func embeddedZoomMeetingLoadURL(for url: URL) async -> URL {
+        let normalized = zoomEmbeddedWebHostURL(for: url)
+        guard isZoomMeetingJoinOrStartURL(normalized), isUserLoggedIn() else { return normalized }
+
+        if let existingZAK = zakQueryValueFromZoomURL(normalized) {
+            let displayName = zoomSignedInDisplayName
+            embeddedZoomPendingWebAuth = (zak: existingZAK, displayName: displayName)
+            return zoomMeetingURLByAppendingWebAuth(normalized, zak: existingZAK, displayName: displayName)
+        }
+
+        primeZoomEmbeddedWebSession()
+
+        if let zak = await fetchEmbeddedZoomZAKAllowingReConsent() {
+            let displayName = zoomSignedInDisplayName
+            embeddedZoomPendingWebAuth = (zak: zak, displayName: displayName)
+            return zoomMeetingURLByAppendingWebAuth(normalized, zak: zak, displayName: displayName)
+        }
+
+        // No ZAK (scope not on OAuth app yet): still pre-fill name and rely on shared web cookies.
+        embeddedZoomPendingWebAuth = nil
+        return zoomMeetingURLWithSignedInDisplayNameFallback(normalized)
+    }
+
+    @MainActor
+    private func applyEmbeddedZoomWebAuthenticationIfNeeded(webView: WKWebView) {
+        guard let pending = embeddedZoomPendingWebAuth else { return }
+        guard let pageURL = webView.url, isZoomMeetingJoinOrStartURL(pageURL) else { return }
+        let title = webView.title ?? ""
+        let onGuestJoinScreen = title.localizedCaseInsensitiveContains("Enter Meeting Info")
+            || title.localizedCaseInsensitiveContains("Enter your name")
+        guard onGuestJoinScreen else { return }
+
+        let zakLiteral = javaScriptStringLiteral(pending.zak)
+        let nameLiteral = pending.displayName.map(javaScriptStringLiteral) ?? "null"
+        let script = """
+        (function() {
+          var zak = \(zakLiteral);
+          var uname = \(nameLiteral);
+          try {
+            var next = new URL(window.location.href);
+            if (!next.searchParams.get('zak')) {
+              next.searchParams.set('zak', zak);
+              if (uname) next.searchParams.set('uname', uname);
+              window.location.replace(next.toString());
+              return;
+            }
+          } catch (e) {}
+          try {
+            sessionStorage.setItem('zak', zak);
+            if (uname) sessionStorage.setItem('uname', uname);
+          } catch (e2) {}
+        })();
+        """
+        webView.evaluateJavaScript(script, completionHandler: nil)
+    }
+
     /// Prefer `/wc/join/` so the embedded web client loads directly (avoids Workplace app vs browser chooser on `/s/` start links).
-    private func embeddedWebClientURLForZoomMeeting(meetingId: Int?, joinURLString: String?) -> URL? {
+    private func embeddedWebClientURLForZoomMeeting(
+        meetingId: Int?,
+        joinURLString: String?,
+        startURLString: String? = nil
+    ) -> URL? {
         let trimmedJoin = joinURLString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        let trimmedStart = startURLString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
         var passcode: String?
+        var zakFromAPI: String?
+        if trimmedStart.isEmpty == false, let startURL = URL(string: trimmedStart) {
+            zakFromAPI = zakQueryValueFromZoomURL(startURL)
+        }
         if trimmedJoin.isEmpty == false, let joinURL = URL(string: trimmedJoin) {
             if joinURL.path.contains("/wc/join/") {
-                return joinURL
+                return zoomEmbeddedWebHostURL(for: joinURL)
             }
             passcode = URLComponents(url: joinURL, resolvingAgainstBaseURL: false)?
                 .queryItems?
                 .first(where: { $0.name == "pwd" })?
                 .value
+            if zakFromAPI == nil {
+                zakFromAPI = zakQueryValueFromZoomURL(joinURL)
+            }
         }
 
         let numericId: String? = {
@@ -2064,11 +2276,19 @@ class ViewController: NSViewController {
             if trimmedJoin.isEmpty == false, let joinURL = URL(string: trimmedJoin) {
                 return meetingIdDigitsFromZoomJoinPath(joinURL.path)
             }
+            if trimmedStart.isEmpty == false, let startURL = URL(string: trimmedStart) {
+                return meetingIdDigitsFromZoomJoinPath(startURL.path)
+            }
             return nil
         }()
 
         guard let numericId, numericId.isEmpty == false else { return nil }
-        return zoomWebClientJoinURL(meetingIdDigits: numericId, passcode: passcode ?? "")
+        return zoomWebClientJoinURL(
+            meetingIdDigits: numericId,
+            passcode: passcode ?? "",
+            zak: zakFromAPI,
+            displayName: zoomSignedInDisplayName
+        )
     }
 
     private func meetingIdDigitsFromZoomJoinPath(_ path: String) -> String? {
@@ -2331,10 +2551,14 @@ class ViewController: NSViewController {
         if isZoomMeetingJoinOrStartURL(url) {
             Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
         }
-        webView.load(URLRequest(url: url))
-        updateEmbeddedMeetingRecordingPresence(for: url)
-        refreshEmbeddedBrowserNavButtons()
-        view.window?.contentView?.layoutSubtreeIfNeeded()
+        Task { @MainActor in
+            let loadURL = await self.embeddedZoomMeetingLoadURL(for: url)
+            guard self.embeddedBrowserWebView === webView else { return }
+            webView.load(URLRequest(url: loadURL))
+            self.updateEmbeddedMeetingRecordingPresence(for: loadURL)
+            self.refreshEmbeddedBrowserNavButtons()
+            self.view.window?.contentView?.layoutSubtreeIfNeeded()
+        }
     }
 
     /// Sandbox camera entitlement + TCC prompt so `WKWebView` can enumerate devices for Zoom web meetings.
@@ -2412,7 +2636,7 @@ class ViewController: NSViewController {
     }
 
     @MainActor
-    private func closeEmbeddedBrowserPanel() {
+    private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
         if MeetingRecordingManager.shared.isRecording {
             endZoomMeetingRecordingAutomatically()
         }
@@ -2421,7 +2645,10 @@ class ViewController: NSViewController {
             oauthSessionCallbackPrefix = nil
             cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
         }
-        embeddedBrowserWebView?.stopLoading()
+        if stoppingWebLoad {
+            embeddedBrowserWebView?.stopLoading()
+        }
+        embeddedZoomPendingWebAuth = nil
         stopEmbeddedMeetingRecordingMonitor()
         MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(false)
         embeddedBrowserPanelWidthConstraint?.constant = 0
@@ -2463,8 +2690,10 @@ class ViewController: NSViewController {
                 await MainActor.run {
                     self.oauthSessionContinuation = nil
                     self.oauthSessionCallbackPrefix = nil
-                    self.closeEmbeddedBrowserPanel()
-                    webView.load(URLRequest(url: URL(string: "about:blank")!))
+                    if let primeURL = URL(string: "https://app.zoom.us/wc/home") {
+                        webView.load(URLRequest(url: primeURL))
+                    }
+                    self.closeEmbeddedBrowserPanel(stoppingWebLoad: false)
                 }
                 return url
             }
@@ -2503,6 +2732,8 @@ class ViewController: NSViewController {
         clearMeetingsScrollObserver()
         googleOAuth.clearSavedTokens()
         zoomOAuth.clearSavedTokens()
+        zoomSignedInDisplayName = nil
+        embeddedZoomPendingWebAuth = nil
         persistLoggedInState(false)
         allScheduledMeetings = []
         applyAuthenticationUIState(triggerInitialRefresh: false)
@@ -3032,6 +3263,8 @@ class ViewController: NSViewController {
     @MainActor
     private func completeLoginAndRestart(profile: GoogleUserProfile?) {
         persistLoggedInState(true)
+        let trimmedName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        zoomSignedInDisplayName = trimmedName.isEmpty ? nil : trimmedName
         showHomeView(profile: profile)
         if let window = view.window {
             window.makeKeyAndOrderFront(nil)
@@ -3841,7 +4074,7 @@ class ViewController: NSViewController {
         let alert = NSAlert()
         alert.alertStyle = .informational
         alert.messageText = "Configure Zoom OAuth"
-        alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically. Enable granular scopes for listing meetings (meeting:read:list_meetings), scheduling (meeting:write:meeting), listing AI summaries (meeting:read:list_summaries), and reading a single summary (meeting:read:summary)."
+        alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically. Enable granular scopes for listing meetings (meeting:read:list_meetings), scheduling (meeting:write:meeting), listing AI summaries (meeting:read:list_summaries), reading a single summary (meeting:read:summary), and embedded web join (user_zak:read)."
 
         let wrapper = NSStackView()
         wrapper.orientation = .vertical
@@ -3924,6 +4157,45 @@ class ViewController: NSViewController {
         return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
     }
 
+    private struct ZoomZAKTokenResponse: Decodable {
+        let token: String
+    }
+
+    /// ZAK authenticates the embedded Zoom web client as the signed-in OAuth user ([`GET /users/me/token`](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/userZAK)).
+    private func fetchZoomUserZAK(accessToken: String) async throws -> String {
+        var components = URLComponents(string: "https://api.zoom.us/v2/users/me/token")!
+        components.queryItems = [URLQueryItem(name: "type", value: "zak")]
+        guard let url = components.url else {
+            throw GoogleOAuthError.tokenExchangeFailed("Invalid ZAK request URL")
+        }
+        var request = URLRequest(url: url)
+        request.httpMethod = "GET"
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (ZAK)")
+        }
+        if http.statusCode == 429 {
+            let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
+            let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
+            throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
+        }
+        guard (200..<300).contains(http.statusCode) else {
+            let raw = String(data: data, encoding: .utf8) ?? "Failed to load ZAK"
+            if raw.localizedCaseInsensitiveContains("does not contain scopes") {
+                throw ZoomOAuthError.missingRequiredScope(raw)
+            }
+            throw GoogleOAuthError.tokenExchangeFailed(raw)
+        }
+        let decoded = try JSONDecoder().decode(ZoomZAKTokenResponse.self, from: data)
+        let trimmed = decoded.token.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard trimmed.isEmpty == false else {
+            throw GoogleOAuthError.tokenExchangeFailed("Empty ZAK from Zoom")
+        }
+        return trimmed
+    }
+
     private struct ZoomCreateMeetingBody: Encodable {
         let topic: String
         let type: Int
@@ -6871,6 +7143,8 @@ class ViewController: NSViewController {
         webToolBarStack.translatesAutoresizingMaskIntoConstraints = false
 
         let embeddedBrowserConfiguration = WKWebViewConfiguration()
+        embeddedBrowserConfiguration.websiteDataStore = zoomEmbeddedWebsiteDataStore
+        embeddedBrowserConfiguration.processPool = zoomEmbeddedWebProcessPool
         embeddedBrowserConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
         embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
         let meetingLifecycleController = WKUserContentController()
@@ -9380,7 +9654,8 @@ class ViewController: NSViewController {
                 let result = try await createZoomInstantMeeting(accessToken: token)
                 guard let joinURL = self.embeddedWebClientURLForZoomMeeting(
                     meetingId: result.id,
-                    joinURLString: result.join_url
+                    joinURLString: result.join_url,
+                    startURLString: result.start_url
                 ) else {
                     await MainActor.run {
                         self.showSimpleAlert(
@@ -9865,6 +10140,10 @@ extension ViewController: WKNavigationDelegate, WKUIDelegate {
     func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
         guard webView === embeddedBrowserWebView else { return }
         updateEmbeddedMeetingRecordingPresence(for: webView.url, webViewTitle: webView.title)
+        applyEmbeddedZoomWebAuthenticationIfNeeded(webView: webView)
+        if let pageURL = webView.url, isZoomEmbeddedWebClientInCallURL(pageURL) {
+            embeddedZoomPendingWebAuth = nil
+        }
         refreshEmbeddedBrowserNavButtons()
     }
 
@@ -10274,6 +10553,10 @@ final class ZoomOAuthService: NSObject {
     static let shared = ZoomOAuthService()
     private var inAppOAuthWindowController: InAppOAuthWindowController?
 
+    /// When set (by `ViewController`), Zoom OAuth runs in the right-panel web view so cookies match embedded meetings.
+    @MainActor
+    var embeddedOAuthSession: (@MainActor (URL, String, Double) async throws -> URL)?
+
     private let tokenStore = ZoomOAuthTokenStore()
     private let clientIdDefaultsKey = "zoom.oauth.clientId"
     private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
@@ -10394,7 +10677,8 @@ final class ZoomOAuthService: NSObject {
             "meeting:read:list_meetings",
             "meeting:write:meeting",
             "meeting:read:summary",
-            "zoomapp:inmeeting"
+            "zoomapp:inmeeting",
+            "user_zak:read"
         ]
 
         var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
@@ -10410,14 +10694,19 @@ final class ZoomOAuthService: NSObject {
             URLQueryItem(name: "state", value: state)
         ]
         guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
-        let oauthWindow = await MainActor.run { makeOAuthWindowController() }
         do {
-            let callbackURL = try await oauthWindow.authenticate(
-                startURL: authURL,
-                callbackURLPrefix: redirectURI,
-                presentingWindow: presentingWindow,
-                timeoutSeconds: 180
-            )
+            let callbackURL: URL
+            if let runEmbeddedOAuth = await MainActor.run(body: { embeddedOAuthSession }) {
+                callbackURL = try await runEmbeddedOAuth(authURL, redirectURI, 180)
+            } else {
+                let oauthWindow = await MainActor.run { makeOAuthWindowController() }
+                callbackURL = try await oauthWindow.authenticate(
+                    startURL: authURL,
+                    callbackURLPrefix: redirectURI,
+                    presentingWindow: presentingWindow,
+                    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 {
@@ -10436,7 +10725,11 @@ final class ZoomOAuthService: NSObject {
     @MainActor
     private func makeOAuthWindowController() -> InAppOAuthWindowController {
         if let existing = inAppOAuthWindowController { return existing }
-        let controller = InAppOAuthWindowController(title: "Zoom Sign-In")
+        let controller = InAppOAuthWindowController(
+            title: "Zoom Sign-In",
+            websiteDataStore: zoomEmbeddedWebsiteDataStore,
+            processPool: zoomEmbeddedWebProcessPool
+        )
         inAppOAuthWindowController = controller
         return controller
     }
@@ -10535,6 +10828,8 @@ final class ZoomOAuthService: NSObject {
             return granted.contains("meeting:read") || granted.contains("meeting:read:admin")
         case "meeting:write:meeting":
             return granted.contains("meeting:write") || granted.contains("meeting:write:admin")
+        case "user_zak:read":
+            return granted.contains("user_zak") || granted.contains("user:read:zak")
         default:
             return false
         }
@@ -10775,9 +11070,16 @@ private final class InAppOAuthWindowController: NSWindowController, WKNavigation
     /// When true, `windowWillClose` does not treat the close as user cancellation (we closed after success/timeout/error).
     private var suppressUserCloseCancellation = false
 
-    init(title: String) {
+    init(
+        title: String,
+        websiteDataStore: WKWebsiteDataStore = .nonPersistent(),
+        processPool: WKProcessPool? = nil
+    ) {
         let configuration = WKWebViewConfiguration()
-        configuration.websiteDataStore = .nonPersistent()
+        configuration.websiteDataStore = websiteDataStore
+        if let processPool {
+            configuration.processPool = processPool
+        }
         self.webView = WKWebView(frame: .zero, configuration: configuration)
         let container = OAuthWebViewContainerView(webView: webView)
         let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)