Kaynağa Gözat

Support Zoom Development OAuth with Public Client PKCE and env credentials.

Switch sign-in to Zoom Public Client OAuth + PKCE so loopback redirects
(http://127.0.0.1:8742/oauth2redirect) work under Marketplace Development.
Authorize with ZOOM_OAUTH_PUBLIC_CLIENT_ID and exchange the code using
code_verifier (no Basic auth). Keep confidential Client ID/Secret for
token refresh, with a public-client fallback.

Wire credentials from env/scheme/Info.plist (ZOOM_OAUTH_CLIENT_ID,
ZOOM_OAUTH_PUBLIC_CLIENT_ID, ZOOM_OAUTH_CLIENT_SECRET), update the setup
prompt and .env.example, and request meeting/ZAK scopes instead of
zoomapp:inmeeting so Development tokens include the API access this app needs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Uzair Tahir 3 ay önce
ebeveyn
işleme
6ddd9fd95e

+ 8 - 0
.env.example

@@ -30,3 +30,11 @@ DEVELOPMENT_TEAM=NNC7V99779
 # Optional: Apple ID + app-specific password (Transporter / legacy upload flows)
 APPLE_ID=
 APP_SPECIFIC_PASSWORD=
+
+# Zoom Marketplace OAuth app
+# Redirect URI allow list must include: http://127.0.0.1:8742/oauth2redirect
+# Mirror these in the Xcode scheme (Run → Arguments → Environment Variables) for local runs.
+# With Use Public Client OAuth ON, set ZOOM_OAUTH_PUBLIC_CLIENT_ID for PKCE loopback sign-in.
+ZOOM_OAUTH_CLIENT_ID=
+ZOOM_OAUTH_PUBLIC_CLIENT_ID=
+ZOOM_OAUTH_CLIENT_SECRET=

+ 8 - 6
zoom_app.xcodeproj/project.pbxproj

@@ -258,7 +258,7 @@
 				CODE_SIGN_ENTITLEMENTS = zoom_app/zoom_app.entitlements;
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
-				CURRENT_PROJECT_VERSION = 2;
+				CURRENT_PROJECT_VERSION = 4;
 				DEVELOPMENT_TEAM = NNC7V99779;
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
@@ -273,13 +273,14 @@
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "Companion AI for Zoom captures system audio during meetings (when allowed) so recordings include computer sound in AI Companion.";
 				INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Companion AI for Zoom uses Apple speech recognition to generate transcripts from your saved meeting audio in AI Companion.";
-				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
+				INFOPLIST_KEY_ZoomOAuthClientId = 93KBkYhHSZWU2OMZzs1aug;
+				INFOPLIST_KEY_ZoomOAuthPublicClientId = jiqoyZUQAyYQAfd4Lfk9g;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 				);
 				MACOSX_DEPLOYMENT_TARGET = 12.4;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.1;
 				PRODUCT_BUNDLE_IDENTIFIER = "com.hwaccount.zoom-app";
 				PRODUCT_NAME = "Companion AI for Zoom";
 				REGISTER_APP_GROUPS = YES;
@@ -300,7 +301,7 @@
 				CODE_SIGN_ENTITLEMENTS = zoom_app/zoom_app.entitlements;
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
-				CURRENT_PROJECT_VERSION = 2;
+				CURRENT_PROJECT_VERSION = 4;
 				DEVELOPMENT_TEAM = NNC7V99779;
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
@@ -315,13 +316,14 @@
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "Companion AI for Zoom captures system audio during meetings (when allowed) so recordings include computer sound in AI Companion.";
 				INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Companion AI for Zoom uses Apple speech recognition to generate transcripts from your saved meeting audio in AI Companion.";
-				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
+				INFOPLIST_KEY_ZoomOAuthClientId = 93KBkYhHSZWU2OMZzs1aug;
+				INFOPLIST_KEY_ZoomOAuthPublicClientId = jiqoyZUQAyYQAfd4Lfk9g;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 				);
 				MACOSX_DEPLOYMENT_TARGET = 12.4;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.1;
 				PRODUCT_BUNDLE_IDENTIFIER = "com.hwaccount.zoom-app";
 				PRODUCT_NAME = "Companion AI for Zoom";
 				REGISTER_APP_GROUPS = YES;

+ 17 - 0
zoom_app.xcodeproj/xcshareddata/xcschemes/zoom_app.xcscheme

@@ -51,6 +51,23 @@
             ReferencedContainer = "container:zoom_app.xcodeproj">
          </BuildableReference>
       </BuildableProductRunnable>
+      <EnvironmentVariables>
+         <EnvironmentVariable
+            key = "ZOOM_OAUTH_CLIENT_ID"
+            value = "93KBkYhHSZWU2OMZzs1aug"
+            isEnabled = "YES">
+         </EnvironmentVariable>
+         <EnvironmentVariable
+            key = "ZOOM_OAUTH_PUBLIC_CLIENT_ID"
+            value = "jiqoyZUQAyYQAfd4Lfk9g"
+            isEnabled = "YES">
+         </EnvironmentVariable>
+         <EnvironmentVariable
+            key = "ZOOM_OAUTH_CLIENT_SECRET"
+            value = "d4UpsacInKFx1MmFv6DJ676RP8Nyfya7"
+            isEnabled = "YES">
+         </EnvironmentVariable>
+      </EnvironmentVariables>
       <StoreKitConfigurationFileReference
          identifier = "../../products.storekit">
       </StoreKitConfigurationFileReference>

+ 163 - 53
zoom_app/ViewController.swift

@@ -2186,7 +2186,7 @@ class ViewController: NSViewController {
         do {
             let accessToken = try await zoomOAuth.validAccessToken(
                 presentingWindow: view.window,
-                requiringAnyOf: ["user_zak:read"]
+                requiringAnyOf: ["user_zak:read", "user:read:zak"]
             )
             return try await fetchZoomUserZAK(accessToken: accessToken)
         } catch {
@@ -2201,7 +2201,7 @@ class ViewController: NSViewController {
             zoomOAuth.clearSavedTokens()
             let accessToken = try await zoomOAuth.validAccessToken(
                 presentingWindow: view.window,
-                requiringAnyOf: ["user_zak:read"]
+                requiringAnyOf: ["user_zak:read", "user:read:zak"]
             )
             return try await fetchZoomUserZAK(accessToken: accessToken)
         } catch {
@@ -3584,7 +3584,9 @@ class ViewController: NSViewController {
     /// Returns false if the user cancelled or left credentials empty.
     @MainActor
     private func ensureZoomOAuthClientConfigured() -> Bool {
-        if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
+        if zoomOAuth.configuredPublicClientId() != nil,
+           zoomOAuth.configuredClientId() != nil,
+           zoomOAuth.configuredClientSecret() != nil {
             return true
         }
         return presentZoomOAuthCredentialPrompt()
@@ -4242,6 +4244,10 @@ class ViewController: NSViewController {
                     self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.promptForZoomOAuthCredentialsIfNeeded()
+                } else if case ZoomOAuthError.missingPublicClientId = error {
+                    self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
+                    self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
+                    self.promptForZoomOAuthCredentialsIfNeeded()
                 } else if case ZoomOAuthError.missingClientSecret = error {
                     self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
@@ -4260,6 +4266,8 @@ class ViewController: NSViewController {
                 }
                 if case ZoomOAuthError.missingClientId = error {
                     self.applyMeetings([])
+                } else if case ZoomOAuthError.missingPublicClientId = error {
+                    self.applyMeetings([])
                 } else if case ZoomOAuthError.missingClientSecret = error {
                     self.applyMeetings([])
                 } else if case ZoomOAuthError.missingRequiredScope = error {
@@ -4276,20 +4284,23 @@ 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), reading a single summary (meeting:read:summary), and embedded web join (user_zak:read)."
+        alert.informativeText = "Enter your Zoom Marketplace Development credentials (Public Client ID for PKCE sign-in, plus Client ID / Secret for refresh), or set ZOOM_OAUTH_PUBLIC_CLIENT_ID, ZOOM_OAUTH_CLIENT_ID, and ZOOM_OAUTH_CLIENT_SECRET in .env and the Xcode scheme. Enable scopes: meeting:read:list_meetings, meeting:write:meeting, meeting:read:summary, user_zak:read."
 
         let wrapper = NSStackView()
         wrapper.orientation = .vertical
         wrapper.spacing = 8
         wrapper.translatesAutoresizingMaskIntoConstraints = false
 
+        let publicClientIdField = NSTextField()
+        publicClientIdField.placeholderString = "Zoom Public Client ID (PKCE)"
+        publicClientIdField.stringValue = zoomOAuth.configuredPublicClientId() ?? ""
         let clientIdField = NSTextField()
         clientIdField.placeholderString = "Zoom Client ID"
         clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
         let clientSecretField = NSSecureTextField()
         clientSecretField.placeholderString = "Zoom Client Secret"
         clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
-        [clientIdField, clientSecretField].forEach { field in
+        [publicClientIdField, clientIdField, clientSecretField].forEach { field in
             field.translatesAutoresizingMaskIntoConstraints = false
             field.widthAnchor.constraint(equalToConstant: 420).isActive = true
             wrapper.addArrangedSubview(field)
@@ -4301,15 +4312,17 @@ class ViewController: NSViewController {
 
         let result = alert.runModal()
         if result == .alertFirstButtonReturn {
+            var publicClientId = publicClientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
             var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
             var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+            if publicClientId.isEmpty { publicClientId = zoomOAuth.configuredPublicClientId() ?? "" }
             if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
             if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
-            if clientId.isEmpty == false, clientSecret.isEmpty == false {
-                zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret)
+            if publicClientId.isEmpty == false, clientId.isEmpty == false, clientSecret.isEmpty == false {
+                zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret, publicClientId: publicClientId)
                 return true
             }
-            meetingsStatusLabel?.stringValue = "Both Zoom OAuth Client ID and Client Secret are required (or set bundled values / ZOOM_OAUTH_CLIENT_SECRET)."
+            meetingsStatusLabel?.stringValue = "Public Client ID, Client ID, and Client Secret are required (or set ZOOM_OAUTH_* env vars)."
         }
         return false
     }
@@ -9113,6 +9126,8 @@ class ViewController: NSViewController {
                     self.aiCompanionStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
                 } else if case ZoomOAuthError.missingClientId = error {
                     self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
+                } else if case ZoomOAuthError.missingPublicClientId = error {
+                    self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
                 } else if case ZoomOAuthError.missingClientSecret = error {
                     self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
                 } else if case ZoomOAuthError.authenticationCancelled = error {
@@ -10751,6 +10766,7 @@ struct ZoomOAuthTokens: Codable, Equatable {
 
 enum ZoomOAuthError: Error {
     case missingClientId
+    case missingPublicClientId
     case missingClientSecret
     case invalidCallbackURL
     case missingAuthorizationCode
@@ -10802,40 +10818,65 @@ final class ZoomOAuthService: NSObject {
 
     private let tokenStore = ZoomOAuthTokenStore()
     private let clientIdDefaultsKey = "zoom.oauth.clientId"
+    private let publicClientIdDefaultsKey = "zoom.oauth.publicClientId"
     private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
     private let infoPlistClientIdKey = "ZoomOAuthClientId"
+    private let infoPlistPublicClientIdKey = "ZoomOAuthPublicClientId"
+    private let envClientIdKey = "ZOOM_OAUTH_CLIENT_ID"
+    private let envPublicClientIdKey = "ZOOM_OAUTH_PUBLIC_CLIENT_ID"
     private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
     // Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
-    /// Fallback if Info.plist `ZoomOAuthClientId` is missing (e.g. mis-quoted build setting).
-    private let bundledClientId = "isvIAKPhSPOhBxFUkiY2A"
+    /// Confidential Client ID (token refresh). Fallback if env / Info.plist is missing.
+    private let bundledClientId = "93KBkYhHSZWU2OMZzs1aug"
+    /// Public Client ID for loopback authorize + PKCE (Development Marketplace value).
+    private let bundledPublicClientId = "jiqoyZUQAyYQAfd4Lfk9g"
     /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
-    private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
+    private let bundledClientSecret = "d4UpsacInKFx1MmFv6DJ676RP8Nyfya7"
 
-    func setClientCredentials(clientId: String, clientSecret: String) {
+    func setClientCredentials(clientId: String, clientSecret: String, publicClientId: String? = nil) {
         UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
         UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
+        if let publicClientId {
+            let trimmed = publicClientId.trimmingCharacters(in: .whitespacesAndNewlines)
+            if trimmed.isEmpty == false {
+                UserDefaults.standard.set(trimmed, forKey: publicClientIdDefaultsKey)
+            }
+        }
     }
 
     func configuredClientId() -> String? {
-        if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String {
-            let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
-            if trimmed.isEmpty == false { return trimmed }
-        }
-        let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
-            .trimmingCharacters(in: .whitespacesAndNewlines)
-        if let value, value.isEmpty == false { return value }
-        return bundledClientId.isEmpty ? nil : bundledClientId
+        firstNonEmpty(
+            ProcessInfo.processInfo.environment[envClientIdKey],
+            Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String,
+            UserDefaults.standard.string(forKey: clientIdDefaultsKey),
+            bundledClientId
+        )
+    }
+
+    /// Used for loopback authorize + PKCE token exchange when Public Client OAuth is enabled.
+    func configuredPublicClientId() -> String? {
+        firstNonEmpty(
+            ProcessInfo.processInfo.environment[envPublicClientIdKey],
+            Bundle.main.object(forInfoDictionaryKey: infoPlistPublicClientIdKey) as? String,
+            UserDefaults.standard.string(forKey: publicClientIdDefaultsKey),
+            bundledPublicClientId
+        )
     }
 
     func configuredClientSecret() -> String? {
-        if let env = ProcessInfo.processInfo.environment[envClientSecretKey] {
-            let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
+        firstNonEmpty(
+            ProcessInfo.processInfo.environment[envClientSecretKey],
+            UserDefaults.standard.string(forKey: clientSecretDefaultsKey),
+            bundledClientSecret
+        )
+    }
+
+    private func firstNonEmpty(_ values: String?...) -> String? {
+        for value in values {
+            let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
             if trimmed.isEmpty == false { return trimmed }
         }
-        let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
-            .trimmingCharacters(in: .whitespacesAndNewlines)
-        if let value, value.isEmpty == false { return value }
-        return bundledClientSecret.isEmpty ? nil : bundledClientSecret
+        return nil
     }
 
     func clearSavedTokens() {
@@ -10868,7 +10909,12 @@ final class ZoomOAuthService: NSObject {
         let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
         try tokenStore.writeTokens(tokens)
         guard tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) else {
-            throw ZoomOAuthError.missingRequiredScope(tokens.scope ?? "Missing required scope(s).")
+            let granted = tokens.scope?.trimmingCharacters(in: .whitespacesAndNewlines)
+            let grantedText = (granted?.isEmpty == false) ? granted! : "(none)"
+            let needed = requiredScopes.joined(separator: " or ")
+            throw ZoomOAuthError.missingRequiredScope(
+                "Granted: \(grantedText). Need at least one of: \(needed). Add those scopes under Marketplace → Development → Scopes, Save, then sign out and sign in again."
+            )
         }
         return tokens.accessToken
     }
@@ -10907,28 +10953,30 @@ final class ZoomOAuthService: NSObject {
 
     private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
         guard let presentingWindow else { throw ZoomOAuthError.unableToOpenBrowser }
-        guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
-        guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
+        // Loopback redirect requires Public Client OAuth + PKCE (not confidential Client ID alone).
+        guard let publicClientId = configuredPublicClientId() else { throw ZoomOAuthError.missingPublicClientId }
 
         let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
         let state = UUID().uuidString
-        // Explicitly request the scopes we rely on.
-        // Without this, Zoom can reuse a previously granted token that doesn't include newly added scopes.
-        // Note: `meeting:read:list_summaries` is often unavailable on user-managed General apps; we
-        // fall back to per-meeting summary fetches using `meeting:read:summary` + past meeting list.
+        let codeVerifier = Self.randomURLSafeString(length: 64)
+        let codeChallenge = Self.pkceChallenge(for: codeVerifier)
+        // Request only REST API scopes needed by this Mac companion.
+        // Do not request `zoomapp:inmeeting` here — that Zoom Apps scope is optional and, when it is
+        // the only Marketplace-enabled scope, tokens come back without meeting API access.
         let requestedScopes = [
             "meeting:read:list_meetings",
             "meeting:write:meeting",
             "meeting:read:summary",
-            "zoomapp:inmeeting",
-            "user_zak:read"
+            "user:read:zak"
         ]
 
         var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
         components.queryItems = [
             URLQueryItem(name: "response_type", value: "code"),
-            URLQueryItem(name: "client_id", value: clientId),
+            URLQueryItem(name: "client_id", value: publicClientId),
             URLQueryItem(name: "redirect_uri", value: redirectURI),
+            URLQueryItem(name: "code_challenge", value: codeChallenge),
+            URLQueryItem(name: "code_challenge_method", value: "S256"),
             URLQueryItem(name: "scope", value: requestedScopes.joined(separator: " ")),
             // Force consent so updated scopes are actually granted.
             URLQueryItem(name: "prompt", value: "consent"),
@@ -10955,7 +11003,12 @@ final class ZoomOAuthService: NSObject {
             guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
                 throw ZoomOAuthError.missingAuthorizationCode
             }
-            let tokens = try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
+            let tokens = try await exchangeCodeForTokens(
+                code: code,
+                redirectURI: redirectURI,
+                publicClientId: publicClientId,
+                codeVerifier: codeVerifier
+            )
             await MainActor.run { self.onInteractiveSignInCompleted?() }
             return tokens
         } catch GoogleOAuthError.authenticationTimedOut {
@@ -10979,15 +11032,22 @@ final class ZoomOAuthService: NSObject {
         return controller
     }
 
-    private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
+    private func exchangeCodeForTokens(
+        code: String,
+        redirectURI: String,
+        publicClientId: String,
+        codeVerifier: String
+    ) async throws -> ZoomOAuthTokens {
         var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
         request.httpMethod = "POST"
         request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
-        request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
+        // Public client PKCE: no Authorization header; send client_id + code_verifier in the body.
         request.httpBody = Self.formURLEncoded([
             "grant_type": "authorization_code",
             "code": code,
-            "redirect_uri": redirectURI
+            "redirect_uri": redirectURI,
+            "client_id": publicClientId,
+            "code_verifier": codeVerifier
         ])
 
         let (data, response) = try await URLSession.shared.data(for: request)
@@ -11015,23 +11075,58 @@ final class ZoomOAuthService: NSObject {
 
     private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
         guard let refreshToken = tokens.refreshToken else { return nil }
-        guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
-        guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
 
         var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
         request.httpMethod = "POST"
         request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
-        request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
-        request.httpBody = Self.formURLEncoded([
-            "grant_type": "refresh_token",
-            "refresh_token": refreshToken
-        ])
+
+        // Prefer confidential client Basic auth for refresh when available; otherwise public client_id body.
+        if let clientId = configuredClientId(), let clientSecret = configuredClientSecret() {
+            request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
+            request.httpBody = Self.formURLEncoded([
+                "grant_type": "refresh_token",
+                "refresh_token": refreshToken
+            ])
+        } else if let publicClientId = configuredPublicClientId() {
+            request.httpBody = Self.formURLEncoded([
+                "grant_type": "refresh_token",
+                "refresh_token": refreshToken,
+                "client_id": publicClientId
+            ])
+        } else {
+            throw ZoomOAuthError.missingClientId
+        }
 
         let (data, response) = try await URLSession.shared.data(for: request)
         guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+            // If confidential refresh fails for a PKCE-issued token, retry with public client_id.
+            if request.value(forHTTPHeaderField: "Authorization") != nil,
+               let publicClientId = configuredPublicClientId() {
+                var retry = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
+                retry.httpMethod = "POST"
+                retry.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
+                retry.httpBody = Self.formURLEncoded([
+                    "grant_type": "refresh_token",
+                    "refresh_token": refreshToken,
+                    "client_id": publicClientId
+                ])
+                let (retryData, retryResponse) = try await URLSession.shared.data(for: retry)
+                guard let retryHTTP = retryResponse as? HTTPURLResponse, (200..<300).contains(retryHTTP.statusCode) else {
+                    return nil
+                }
+                return try decodeRefreshedTokens(from: retryData, previous: tokens, fallbackRefreshToken: refreshToken)
+            }
             return nil
         }
 
+        return try decodeRefreshedTokens(from: data, previous: tokens, fallbackRefreshToken: refreshToken)
+    }
+
+    private func decodeRefreshedTokens(
+        from data: Data,
+        previous tokens: ZoomOAuthTokens,
+        fallbackRefreshToken: String
+    ) throws -> ZoomOAuthTokens {
         struct RefreshResponse: Decodable {
             let access_token: String
             let refresh_token: String?
@@ -11043,7 +11138,7 @@ final class ZoomOAuthService: NSObject {
         let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
         return ZoomOAuthTokens(
             accessToken: decoded.access_token,
-            refreshToken: decoded.refresh_token ?? refreshToken,
+            refreshToken: decoded.refresh_token ?? fallbackRefreshToken,
             expiresAt: Date().addingTimeInterval(decoded.expires_in),
             scope: decoded.scope ?? tokens.scope,
             tokenType: decoded.token_type ?? tokens.tokenType
@@ -11073,8 +11168,10 @@ 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")
+        case "user_zak:read", "user:read:zak":
+            return granted.contains("user_zak:read")
+                || granted.contains("user_zak")
+                || granted.contains("user:read:zak")
         default:
             return false
         }
@@ -11091,6 +11188,17 @@ final class ZoomOAuthService: NSObject {
         return Data(joined.utf8).base64EncodedString()
     }
 
+    private static func pkceChallenge(for verifier: String) -> String {
+        let digest = SHA256.hash(data: Data(verifier.utf8))
+        return Data(digest).base64URLEncodedString()
+    }
+
+    private static func randomURLSafeString(length: Int) -> String {
+        var bytes = [UInt8](repeating: 0, count: length)
+        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+        return Data(bytes).base64URLEncodedString()
+    }
+
     private static func formURLEncoded(_ params: [String: String]) -> Data {
         let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
         let pairs = params.map { key, value in
@@ -11455,11 +11563,13 @@ extension ZoomOAuthError: LocalizedError {
     var errorDescription: String? {
         switch self {
         case .missingClientId:
-            return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
+            return "Zoom OAuth Client ID is not set (ZOOM_OAUTH_CLIENT_ID env, Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
+        case .missingPublicClientId:
+            return "Zoom OAuth Public Client ID is not set (ZOOM_OAUTH_PUBLIC_CLIENT_ID env, Info.plist ZoomOAuthPublicClientId, UserDefaults, or the setup prompt). Required for loopback + PKCE sign-in."
         case .missingClientSecret:
-            return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
+            return "Zoom OAuth Client Secret is not set (ZOOM_OAUTH_CLIENT_SECRET env, 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."
+            return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect. With Public Client OAuth ON, authorize must use the Public Client ID and PKCE."
         case .missingAuthorizationCode:
             return "Zoom did not return an authorization code."
         case .tokenExchangeFailed(let details):