|
|
@@ -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):
|