Forráskód Böngészése

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 hete
szülő
commit
df11419b84

+ 0 - 2
zoom_app.xcodeproj/project.pbxproj

@@ -273,7 +273,6 @@
 				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;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/../Frameworks",
@@ -315,7 +314,6 @@
 				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;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/../Frameworks",

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

@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
    LastUpgradeVersion = "2640"
-   version = "1.8">
+   version = "1.7">
    <BuildAction
       parallelizeBuildables = "YES"
       buildImplicitDependencies = "YES"
@@ -51,6 +51,13 @@
             ReferencedContainer = "container:zoom_app.xcodeproj">
          </BuildableReference>
       </BuildableProductRunnable>
+      <EnvironmentVariables>
+         <EnvironmentVariable
+            key = "ZOOM_OAUTH_REDIRECT_URI"
+            value = "http://127.0.0.1:8742/oauth2redirect"
+            isEnabled = "NO">
+         </EnvironmentVariable>
+      </EnvironmentVariables>
       <StoreKitConfigurationFileReference
          identifier = "../../products.storekit">
       </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()
         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 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()
         wrapper.orientation = .vertical
@@ -10752,6 +10752,7 @@ struct ZoomOAuthTokens: Codable, Equatable {
 enum ZoomOAuthError: Error {
     case missingClientId
     case missingClientSecret
+    case missingPublicClientId
     case invalidCallbackURL
     case missingAuthorizationCode
     case tokenExchangeFailed(String)
@@ -10803,13 +10804,15 @@ final class ZoomOAuthService: NSObject {
     private let tokenStore = ZoomOAuthTokenStore()
     private let clientIdDefaultsKey = "zoom.oauth.clientId"
     private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
+    private let publicClientIdDefaultsKey = "zoom.oauth.publicClientId"
     private let infoPlistClientIdKey = "ZoomOAuthClientId"
+    private let infoPlistPublicClientIdKey = "ZoomOAuthPublicClientId"
+    private let infoPlistRedirectURIKey = "ZoomOAuthRedirectURI"
     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) {
         UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
@@ -10824,7 +10827,8 @@ final class ZoomOAuthService: NSObject {
         let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
             .trimmingCharacters(in: .whitespacesAndNewlines)
         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? {
@@ -10835,7 +10839,39 @@ final class ZoomOAuthService: NSObject {
         let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
             .trimmingCharacters(in: .whitespacesAndNewlines)
         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() {
@@ -10907,10 +10943,21 @@ 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 }
 
-        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
         // Explicitly request the scopes we rely on.
         // 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")!
         components.queryItems = [
             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: "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.
             URLQueryItem(name: "prompt", value: "consent"),
             // 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 {
                 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?() }
             return tokens
         } catch GoogleOAuthError.authenticationTimedOut {
@@ -10979,7 +11048,32 @@ final class ZoomOAuthService: NSObject {
         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")!)
         request.httpMethod = "POST"
         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([
             "grant_type": "authorization_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)
         guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
             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? {
         guard let refreshToken = tokens.refreshToken else { return nil }
         guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
@@ -11091,6 +11194,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
@@ -11116,8 +11230,8 @@ enum GoogleOAuthError: Error {
 final class GoogleOAuthService: NSObject {
     static let shared = GoogleOAuthService()
     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 scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
     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)."
         case .missingClientSecret:
             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:
-            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:
             return "Zoom did not return an authorization code."
         case .tokenExchangeFailed(let details):