Kaynağa Gözat

Add Zoom public-client PKCE sign-in and obfuscate OAuth credentials.

Use the Marketplace public client ID for loopback redirects, keep confidential credentials for token refresh, and store bundled OAuth secrets as XOR-obfuscated bytes instead of plain Info.plist strings.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 3 hafta önce
ebeveyn
işleme
df11419b84

+ 0 - 2
zoom_app.xcodeproj/project.pbxproj

@@ -273,7 +273,6 @@
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				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_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_NSSpeechRecognitionUsageDescription = "Companion AI for Zoom uses Apple speech recognition to generate transcripts from your saved meeting audio in AI Companion.";
-				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
 				LD_RUNPATH_SEARCH_PATHS = (
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 					"@executable_path/../Frameworks",
@@ -315,7 +314,6 @@
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				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_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_NSSpeechRecognitionUsageDescription = "Companion AI for Zoom uses Apple speech recognition to generate transcripts from your saved meeting audio in AI Companion.";
-				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
 				LD_RUNPATH_SEARCH_PATHS = (
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 					"@executable_path/../Frameworks",

+ 8 - 1
zoom_app.xcodeproj/xcshareddata/xcschemes/zoom_app.xcscheme

@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
 <Scheme
    LastUpgradeVersion = "2640"
    LastUpgradeVersion = "2640"
-   version = "1.8">
+   version = "1.7">
    <BuildAction
    <BuildAction
       parallelizeBuildables = "YES"
       parallelizeBuildables = "YES"
       buildImplicitDependencies = "YES"
       buildImplicitDependencies = "YES"
@@ -51,6 +51,13 @@
             ReferencedContainer = "container:zoom_app.xcodeproj">
             ReferencedContainer = "container:zoom_app.xcodeproj">
          </BuildableReference>
          </BuildableReference>
       </BuildableProductRunnable>
       </BuildableProductRunnable>
+      <EnvironmentVariables>
+         <EnvironmentVariable
+            key = "ZOOM_OAUTH_REDIRECT_URI"
+            value = "http://127.0.0.1:8742/oauth2redirect"
+            isEnabled = "NO">
+         </EnvironmentVariable>
+      </EnvironmentVariables>
       <StoreKitConfigurationFileReference
       <StoreKitConfigurationFileReference
          identifier = "../../products.storekit">
          identifier = "../../products.storekit">
       </StoreKitConfigurationFileReference>
       </StoreKitConfigurationFileReference>

+ 35 - 0
zoom_app/Utilities/BundledOAuthCredentials.swift

@@ -0,0 +1,35 @@
+import Foundation
+
+/// Default OAuth client identifiers and secrets (obfuscated at rest in the binary).
+enum BundledOAuthCredentials {
+    private static let zoomClientIdBytes: [UInt8] = [
+        0x2A, 0x1C, 0x1B, 0x39, 0x20, 0x25, 0x39, 0x07, 0x3D, 0x11, 0x06, 0x2E, 0x2D, 0x0A, 0x1C, 0x3A,
+        0x04, 0x04, 0x1A, 0x5D, 0x2C
+    ]
+    private static let zoomPublicClientIdBytes: [UInt8] = [
+        0x02, 0x06, 0x19, 0x3E, 0x14, 0x3F, 0x00, 0x2A, 0x3D, 0x13, 0x28, 0x10, 0x0B, 0x37, 0x00, 0x27,
+        0x06, 0x59, 0x00, 0x3B, 0x22, 0x31
+    ]
+    private static let zoomClientSecretBytes: [UInt8] = [
+        0x29, 0x3F, 0x0B, 0x12, 0x05, 0x18, 0x1D, 0x5E, 0x5A, 0x02, 0x02, 0x0E, 0x5B, 0x4A, 0x2C, 0x24,
+        0x2A, 0x0A, 0x70, 0x21, 0x07, 0x34, 0x11, 0x3A, 0x20, 0x08, 0x2D, 0x25, 0x7B, 0x34, 0x2B, 0x03
+    ]
+    private static let googleClientIdBytes: [UInt8] = [
+        0x72, 0x5F, 0x58, 0x48, 0x50, 0x57, 0x58, 0x58, 0x5F, 0x75, 0x7D, 0x76, 0x57, 0x5F, 0x33, 0x58,
+        0x0B, 0x01, 0x2A, 0x0C, 0x0C, 0x02, 0x11, 0x1E, 0x03, 0x5F, 0x1C, 0x35, 0x79, 0x21, 0x07, 0x1C,
+        0x63, 0x03, 0x00, 0x02, 0x36, 0x59, 0x5D, 0x46, 0x0D, 0x03, 0x04, 0x5F, 0x0A, 0x33, 0x67, 0x27,
+        0x1F, 0x02, 0x29, 0x41, 0x08, 0x02, 0x2C, 0x08, 0x01, 0x15, 0x14, 0x1D, 0x0C, 0x1D, 0x0D, 0x2E,
+        0x27, 0x32, 0x0A, 0x1C, 0x2E, 0x41, 0x0C, 0x02, 0x2E
+    ]
+    private static let googleClientSecretBytes: [UInt8] = [
+        0x04, 0x20, 0x2E, 0x23, 0x31, 0x36, 0x44, 0x22, 0x36, 0x28, 0x7C, 0x33, 0x37, 0x5F, 0x22, 0x21,
+        0x36, 0x37, 0x75, 0x1E, 0x37, 0x02, 0x2D, 0x26, 0x5A, 0x2D, 0x34, 0x31, 0x23, 0x30, 0x5A, 0x05,
+        0x2C, 0x22, 0x16
+    ]
+
+    static var zoomClientId: String { ObfuscatedCredential.reveal(zoomClientIdBytes) }
+    static var zoomPublicClientId: String { ObfuscatedCredential.reveal(zoomPublicClientIdBytes) }
+    static var zoomClientSecret: String { ObfuscatedCredential.reveal(zoomClientSecretBytes) }
+    static var googleClientId: String { ObfuscatedCredential.reveal(googleClientIdBytes) }
+    static var googleClientSecret: String { ObfuscatedCredential.reveal(googleClientSecretBytes) }
+}

+ 13 - 0
zoom_app/Utilities/ObfuscatedCredential.swift

@@ -0,0 +1,13 @@
+import Foundation
+
+/// Reveals compile-time obfuscated credential bytes (XOR). Not encryption — deters casual string scanning only.
+enum ObfuscatedCredential {
+    private static let key = Array("CompanionAIForZoom".utf8)
+
+    static func reveal(_ obfuscated: [UInt8]) -> String {
+        let revealed = obfuscated.enumerated().map { index, byte in
+            byte ^ key[index % key.count]
+        }
+        return String(bytes: revealed, encoding: .utf8) ?? ""
+    }
+}

+ 134 - 18
zoom_app/ViewController.swift

@@ -4276,7 +4276,7 @@ class ViewController: NSViewController {
         let alert = NSAlert()
         let alert = NSAlert()
         alert.alertStyle = .informational
         alert.alertStyle = .informational
         alert.messageText = "Configure Zoom OAuth"
         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 OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). Local sign-in uses http://127.0.0.1:8742/oauth2redirect with PKCE. 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()
         let wrapper = NSStackView()
         wrapper.orientation = .vertical
         wrapper.orientation = .vertical
@@ -10752,6 +10752,7 @@ struct ZoomOAuthTokens: Codable, Equatable {
 enum ZoomOAuthError: Error {
 enum ZoomOAuthError: Error {
     case missingClientId
     case missingClientId
     case missingClientSecret
     case missingClientSecret
+    case missingPublicClientId
     case invalidCallbackURL
     case invalidCallbackURL
     case missingAuthorizationCode
     case missingAuthorizationCode
     case tokenExchangeFailed(String)
     case tokenExchangeFailed(String)
@@ -10803,13 +10804,15 @@ final class ZoomOAuthService: NSObject {
     private let tokenStore = ZoomOAuthTokenStore()
     private let tokenStore = ZoomOAuthTokenStore()
     private let clientIdDefaultsKey = "zoom.oauth.clientId"
     private let clientIdDefaultsKey = "zoom.oauth.clientId"
     private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
     private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
+    private let publicClientIdDefaultsKey = "zoom.oauth.publicClientId"
     private let infoPlistClientIdKey = "ZoomOAuthClientId"
     private let infoPlistClientIdKey = "ZoomOAuthClientId"
+    private let infoPlistPublicClientIdKey = "ZoomOAuthPublicClientId"
+    private let infoPlistRedirectURIKey = "ZoomOAuthRedirectURI"
     private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
     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"
-    /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
-    private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
+    private let envPublicClientIdKey = "ZOOM_OAUTH_PUBLIC_CLIENT_ID"
+    private let envRedirectURIKey = "ZOOM_OAUTH_REDIRECT_URI"
+    private let redirectURIDefaultsKey = "zoom.oauth.redirectURI"
+    private let defaultRedirectURI = "http://127.0.0.1:8742/oauth2redirect"
 
 
     func setClientCredentials(clientId: String, clientSecret: String) {
     func setClientCredentials(clientId: String, clientSecret: String) {
         UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
         UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
@@ -10824,7 +10827,8 @@ final class ZoomOAuthService: NSObject {
         let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
         let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
             .trimmingCharacters(in: .whitespacesAndNewlines)
             .trimmingCharacters(in: .whitespacesAndNewlines)
         if let value, value.isEmpty == false { return value }
         if let value, value.isEmpty == false { return value }
-        return bundledClientId.isEmpty ? nil : bundledClientId
+        let bundled = BundledOAuthCredentials.zoomClientId
+        return bundled.isEmpty ? nil : bundled
     }
     }
 
 
     func configuredClientSecret() -> String? {
     func configuredClientSecret() -> String? {
@@ -10835,7 +10839,39 @@ final class ZoomOAuthService: NSObject {
         let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
         let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
             .trimmingCharacters(in: .whitespacesAndNewlines)
             .trimmingCharacters(in: .whitespacesAndNewlines)
         if let value, value.isEmpty == false { return value }
         if let value, value.isEmpty == false { return value }
-        return bundledClientSecret.isEmpty ? nil : bundledClientSecret
+        let bundled = BundledOAuthCredentials.zoomClientSecret
+        return bundled.isEmpty ? nil : bundled
+    }
+
+    func configuredPublicClientId() -> String? {
+        if let env = ProcessInfo.processInfo.environment[envPublicClientIdKey] {
+            let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
+            if trimmed.isEmpty == false { return trimmed }
+        }
+        if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistPublicClientIdKey) as? String {
+            let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
+            if trimmed.isEmpty == false { return trimmed }
+        }
+        let value = UserDefaults.standard.string(forKey: publicClientIdDefaultsKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        if let value, value.isEmpty == false { return value }
+        let bundled = BundledOAuthCredentials.zoomPublicClientId
+        return bundled.isEmpty ? nil : bundled
+    }
+
+    func configuredRedirectURI() -> String {
+        if let env = ProcessInfo.processInfo.environment[envRedirectURIKey] {
+            let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
+            if trimmed.isEmpty == false { return trimmed }
+        }
+        if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistRedirectURIKey) as? String {
+            let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
+            if trimmed.isEmpty == false { return trimmed }
+        }
+        let value = UserDefaults.standard.string(forKey: redirectURIDefaultsKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        if let value, value.isEmpty == false { return value }
+        return defaultRedirectURI
     }
     }
 
 
     func clearSavedTokens() {
     func clearSavedTokens() {
@@ -10907,10 +10943,21 @@ final class ZoomOAuthService: NSObject {
 
 
     private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
     private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
         guard let presentingWindow else { throw ZoomOAuthError.unableToOpenBrowser }
         guard let presentingWindow else { throw ZoomOAuthError.unableToOpenBrowser }
-        guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
-        guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
 
 
-        let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
+        let redirectURI = configuredRedirectURI()
+        let usePublicClientPKCE = Self.isLoopbackRedirectURI(redirectURI)
+        let authorizeClientId: String
+        if usePublicClientPKCE {
+            guard let publicClientId = configuredPublicClientId() else { throw ZoomOAuthError.missingPublicClientId }
+            authorizeClientId = publicClientId
+        } else {
+            guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
+            guard configuredClientSecret() != nil else { throw ZoomOAuthError.missingClientSecret }
+            authorizeClientId = clientId
+        }
+
+        let codeVerifier = Self.randomURLSafeString(length: 64)
+        let codeChallenge = Self.pkceChallenge(for: codeVerifier)
         let state = UUID().uuidString
         let state = UUID().uuidString
         // Explicitly request the scopes we rely on.
         // Explicitly request the scopes we rely on.
         // Without this, Zoom can reuse a previously granted token that doesn't include newly added scopes.
         // Without this, Zoom can reuse a previously granted token that doesn't include newly added scopes.
@@ -10927,9 +10974,11 @@ final class ZoomOAuthService: NSObject {
         var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
         var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
         components.queryItems = [
         components.queryItems = [
             URLQueryItem(name: "response_type", value: "code"),
             URLQueryItem(name: "response_type", value: "code"),
-            URLQueryItem(name: "client_id", value: clientId),
+            URLQueryItem(name: "client_id", value: authorizeClientId),
             URLQueryItem(name: "redirect_uri", value: redirectURI),
             URLQueryItem(name: "redirect_uri", value: redirectURI),
             URLQueryItem(name: "scope", value: requestedScopes.joined(separator: " ")),
             URLQueryItem(name: "scope", value: requestedScopes.joined(separator: " ")),
+            URLQueryItem(name: "code_challenge", value: codeChallenge),
+            URLQueryItem(name: "code_challenge_method", value: "S256"),
             // Force consent so updated scopes are actually granted.
             // Force consent so updated scopes are actually granted.
             URLQueryItem(name: "prompt", value: "consent"),
             URLQueryItem(name: "prompt", value: "consent"),
             // Zoom-specific: forces interactive verification even if already signed in.
             // Zoom-specific: forces interactive verification even if already signed in.
@@ -10955,7 +11004,27 @@ final class ZoomOAuthService: NSObject {
             guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
             guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
                 throw ZoomOAuthError.missingAuthorizationCode
                 throw ZoomOAuthError.missingAuthorizationCode
             }
             }
-            let tokens = try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
+            let tokens: ZoomOAuthTokens
+            if usePublicClientPKCE {
+                tokens = try await exchangeCodeForTokensWithPublicPKCE(
+                    code: code,
+                    codeVerifier: codeVerifier,
+                    redirectURI: redirectURI,
+                    publicClientId: authorizeClientId
+                )
+            } else {
+                guard let clientId = configuredClientId(),
+                      let clientSecret = configuredClientSecret() else {
+                    throw ZoomOAuthError.missingClientSecret
+                }
+                tokens = try await exchangeCodeForTokensWithConfidentialPKCE(
+                    code: code,
+                    codeVerifier: codeVerifier,
+                    redirectURI: redirectURI,
+                    clientId: clientId,
+                    clientSecret: clientSecret
+                )
+            }
             await MainActor.run { self.onInteractiveSignInCompleted?() }
             await MainActor.run { self.onInteractiveSignInCompleted?() }
             return tokens
             return tokens
         } catch GoogleOAuthError.authenticationTimedOut {
         } catch GoogleOAuthError.authenticationTimedOut {
@@ -10979,7 +11048,32 @@ final class ZoomOAuthService: NSObject {
         return controller
         return controller
     }
     }
 
 
-    private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
+    private func exchangeCodeForTokensWithPublicPKCE(
+        code: String,
+        codeVerifier: String,
+        redirectURI: String,
+        publicClientId: 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.httpBody = Self.formURLEncoded([
+            "grant_type": "authorization_code",
+            "code": code,
+            "client_id": publicClientId,
+            "redirect_uri": redirectURI,
+            "code_verifier": codeVerifier
+        ])
+        return try await decodeTokenResponse(from: request)
+    }
+
+    private func exchangeCodeForTokensWithConfidentialPKCE(
+        code: String,
+        codeVerifier: String,
+        redirectURI: String,
+        clientId: String,
+        clientSecret: String
+    ) async throws -> ZoomOAuthTokens {
         var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
         var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
         request.httpMethod = "POST"
         request.httpMethod = "POST"
         request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
         request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
@@ -10987,9 +11081,13 @@ final class ZoomOAuthService: NSObject {
         request.httpBody = Self.formURLEncoded([
         request.httpBody = Self.formURLEncoded([
             "grant_type": "authorization_code",
             "grant_type": "authorization_code",
             "code": code,
             "code": code,
-            "redirect_uri": redirectURI
+            "redirect_uri": redirectURI,
+            "code_verifier": codeVerifier
         ])
         ])
+        return try await decodeTokenResponse(from: request)
+    }
 
 
+    private func decodeTokenResponse(from request: URLRequest) async throws -> ZoomOAuthTokens {
         let (data, response) = try await URLSession.shared.data(for: request)
         let (data, response) = try await URLSession.shared.data(for: request)
         guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
         guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
             throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
             throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
@@ -11013,6 +11111,11 @@ final class ZoomOAuthService: NSObject {
         )
         )
     }
     }
 
 
+    private static func isLoopbackRedirectURI(_ uri: String) -> Bool {
+        guard let host = URL(string: uri)?.host?.lowercased() else { return false }
+        return host == "127.0.0.1" || host == "localhost" || host == "::1"
+    }
+
     private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
     private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
         guard let refreshToken = tokens.refreshToken else { return nil }
         guard let refreshToken = tokens.refreshToken else { return nil }
         guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
         guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
@@ -11091,6 +11194,17 @@ final class ZoomOAuthService: NSObject {
         return Data(joined.utf8).base64EncodedString()
         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 {
     private static func formURLEncoded(_ params: [String: String]) -> Data {
         let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
         let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
         let pairs = params.map { key, value in
         let pairs = params.map { key, value in
@@ -11116,8 +11230,8 @@ enum GoogleOAuthError: Error {
 final class GoogleOAuthService: NSObject {
 final class GoogleOAuthService: NSObject {
     static let shared = GoogleOAuthService()
     static let shared = GoogleOAuthService()
     private var inAppOAuthWindowController: InAppOAuthWindowController?
     private var inAppOAuthWindowController: InAppOAuthWindowController?
-    private let clientId = "1058191714408-i7dlicarppj0rt0ghn9loou606lmm0dr.apps.googleusercontent.com"
-    private let clientSecret = "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"
+    private let clientId = BundledOAuthCredentials.googleClientId
+    private let clientSecret = BundledOAuthCredentials.googleClientSecret
     private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
     private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
     private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
     private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
     private lazy var tokenStore = KeychainTokenStore(account: "googleOAuthTokens.\(clientId)")
     private lazy var tokenStore = KeychainTokenStore(account: "googleOAuthTokens.\(clientId)")
@@ -11458,8 +11572,10 @@ extension ZoomOAuthError: LocalizedError {
             return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
             return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
         case .missingClientSecret:
         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 (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
+        case .missingPublicClientId:
+            return "Zoom Public Client ID is not set. In Zoom Marketplace enable Use Public Client OAuth and set ZoomOAuthPublicClientId in Info.plist (or ZOOM_OAUTH_PUBLIC_CLIENT_ID)."
         case .invalidCallbackURL:
         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 Zoom → Basic Information (Development), set Redirect URL to http://127.0.0.1:8742/oauth2redirect and enable Use Public Client OAuth."
         case .missingAuthorizationCode:
         case .missingAuthorizationCode:
             return "Zoom did not return an authorization code."
             return "Zoom did not return an authorization code."
         case .tokenExchangeFailed(let details):
         case .tokenExchangeFailed(let details):