|
@@ -2186,7 +2186,7 @@ class ViewController: NSViewController {
|
|
|
do {
|
|
do {
|
|
|
let accessToken = try await zoomOAuth.validAccessToken(
|
|
let accessToken = try await zoomOAuth.validAccessToken(
|
|
|
presentingWindow: view.window,
|
|
presentingWindow: view.window,
|
|
|
- requiringAnyOf: ["user_zak:read"]
|
|
|
|
|
|
|
+ requiringAnyOf: ["user_zak:read", "user:read:zak"]
|
|
|
)
|
|
)
|
|
|
return try await fetchZoomUserZAK(accessToken: accessToken)
|
|
return try await fetchZoomUserZAK(accessToken: accessToken)
|
|
|
} catch {
|
|
} catch {
|
|
@@ -2201,7 +2201,7 @@ class ViewController: NSViewController {
|
|
|
zoomOAuth.clearSavedTokens()
|
|
zoomOAuth.clearSavedTokens()
|
|
|
let accessToken = try await zoomOAuth.validAccessToken(
|
|
let accessToken = try await zoomOAuth.validAccessToken(
|
|
|
presentingWindow: view.window,
|
|
presentingWindow: view.window,
|
|
|
- requiringAnyOf: ["user_zak:read"]
|
|
|
|
|
|
|
+ requiringAnyOf: ["user_zak:read", "user:read:zak"]
|
|
|
)
|
|
)
|
|
|
return try await fetchZoomUserZAK(accessToken: accessToken)
|
|
return try await fetchZoomUserZAK(accessToken: accessToken)
|
|
|
} catch {
|
|
} catch {
|
|
@@ -3584,7 +3584,9 @@ class ViewController: NSViewController {
|
|
|
/// Returns false if the user cancelled or left credentials empty.
|
|
/// Returns false if the user cancelled or left credentials empty.
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func ensureZoomOAuthClientConfigured() -> Bool {
|
|
private func ensureZoomOAuthClientConfigured() -> Bool {
|
|
|
- if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
|
|
|
|
|
|
|
+ if zoomOAuth.configuredPublicClientId() != nil,
|
|
|
|
|
+ zoomOAuth.configuredClientId() != nil,
|
|
|
|
|
+ zoomOAuth.configuredClientSecret() != nil {
|
|
|
return true
|
|
return true
|
|
|
}
|
|
}
|
|
|
return presentZoomOAuthCredentialPrompt()
|
|
return presentZoomOAuthCredentialPrompt()
|
|
@@ -4242,6 +4244,10 @@ class ViewController: NSViewController {
|
|
|
self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
self.promptForZoomOAuthCredentialsIfNeeded()
|
|
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 {
|
|
} else if case ZoomOAuthError.missingClientSecret = error {
|
|
|
self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
self.meetingsPageStatusLabel?.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 {
|
|
if case ZoomOAuthError.missingClientId = error {
|
|
|
self.applyMeetings([])
|
|
self.applyMeetings([])
|
|
|
|
|
+ } else if case ZoomOAuthError.missingPublicClientId = error {
|
|
|
|
|
+ self.applyMeetings([])
|
|
|
} else if case ZoomOAuthError.missingClientSecret = error {
|
|
} else if case ZoomOAuthError.missingClientSecret = error {
|
|
|
self.applyMeetings([])
|
|
self.applyMeetings([])
|
|
|
} else if case ZoomOAuthError.missingRequiredScope = error {
|
|
} else if case ZoomOAuthError.missingRequiredScope = error {
|
|
@@ -4276,20 +4284,23 @@ 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 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()
|
|
let wrapper = NSStackView()
|
|
|
wrapper.orientation = .vertical
|
|
wrapper.orientation = .vertical
|
|
|
wrapper.spacing = 8
|
|
wrapper.spacing = 8
|
|
|
wrapper.translatesAutoresizingMaskIntoConstraints = false
|
|
wrapper.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
|
|
+ let publicClientIdField = NSTextField()
|
|
|
|
|
+ publicClientIdField.placeholderString = "Zoom Public Client ID (PKCE)"
|
|
|
|
|
+ publicClientIdField.stringValue = zoomOAuth.configuredPublicClientId() ?? ""
|
|
|
let clientIdField = NSTextField()
|
|
let clientIdField = NSTextField()
|
|
|
clientIdField.placeholderString = "Zoom Client ID"
|
|
clientIdField.placeholderString = "Zoom Client ID"
|
|
|
clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
|
|
clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
|
|
|
let clientSecretField = NSSecureTextField()
|
|
let clientSecretField = NSSecureTextField()
|
|
|
clientSecretField.placeholderString = "Zoom Client Secret"
|
|
clientSecretField.placeholderString = "Zoom Client Secret"
|
|
|
clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
|
|
clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
|
|
|
- [clientIdField, clientSecretField].forEach { field in
|
|
|
|
|
|
|
+ [publicClientIdField, clientIdField, clientSecretField].forEach { field in
|
|
|
field.translatesAutoresizingMaskIntoConstraints = false
|
|
field.translatesAutoresizingMaskIntoConstraints = false
|
|
|
field.widthAnchor.constraint(equalToConstant: 420).isActive = true
|
|
field.widthAnchor.constraint(equalToConstant: 420).isActive = true
|
|
|
wrapper.addArrangedSubview(field)
|
|
wrapper.addArrangedSubview(field)
|
|
@@ -4301,15 +4312,17 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
let result = alert.runModal()
|
|
let result = alert.runModal()
|
|
|
if result == .alertFirstButtonReturn {
|
|
if result == .alertFirstButtonReturn {
|
|
|
|
|
+ var publicClientId = publicClientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ if publicClientId.isEmpty { publicClientId = zoomOAuth.configuredPublicClientId() ?? "" }
|
|
|
if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
|
|
if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
|
|
|
if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
|
|
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
|
|
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
|
|
return false
|
|
|
}
|
|
}
|
|
@@ -9113,6 +9126,8 @@ class ViewController: NSViewController {
|
|
|
self.aiCompanionStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
|
|
self.aiCompanionStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
|
|
|
} else if case ZoomOAuthError.missingClientId = error {
|
|
} else if case ZoomOAuthError.missingClientId = error {
|
|
|
self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
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 {
|
|
} else if case ZoomOAuthError.missingClientSecret = error {
|
|
|
self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
} else if case ZoomOAuthError.authenticationCancelled = error {
|
|
} else if case ZoomOAuthError.authenticationCancelled = error {
|
|
@@ -10751,6 +10766,7 @@ struct ZoomOAuthTokens: Codable, Equatable {
|
|
|
|
|
|
|
|
enum ZoomOAuthError: Error {
|
|
enum ZoomOAuthError: Error {
|
|
|
case missingClientId
|
|
case missingClientId
|
|
|
|
|
+ case missingPublicClientId
|
|
|
case missingClientSecret
|
|
case missingClientSecret
|
|
|
case invalidCallbackURL
|
|
case invalidCallbackURL
|
|
|
case missingAuthorizationCode
|
|
case missingAuthorizationCode
|
|
@@ -10802,40 +10818,65 @@ 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 publicClientIdDefaultsKey = "zoom.oauth.publicClientId"
|
|
|
private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
|
|
private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
|
|
|
private let infoPlistClientIdKey = "ZoomOAuthClientId"
|
|
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"
|
|
private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
|
|
|
// Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
|
|
// 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.
|
|
/// 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(clientId, forKey: clientIdDefaultsKey)
|
|
|
UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
|
|
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? {
|
|
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? {
|
|
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 }
|
|
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() {
|
|
func clearSavedTokens() {
|
|
@@ -10868,7 +10909,12 @@ final class ZoomOAuthService: NSObject {
|
|
|
let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
|
|
let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
|
|
|
try tokenStore.writeTokens(tokens)
|
|
try tokenStore.writeTokens(tokens)
|
|
|
guard tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) else {
|
|
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
|
|
return tokens.accessToken
|
|
|
}
|
|
}
|
|
@@ -10907,28 +10953,30 @@ 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 }
|
|
|
|
|
|
|
+ // 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 redirectURI = "http://127.0.0.1:8742/oauth2redirect"
|
|
|
let state = UUID().uuidString
|
|
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 = [
|
|
let requestedScopes = [
|
|
|
"meeting:read:list_meetings",
|
|
"meeting:read:list_meetings",
|
|
|
"meeting:write:meeting",
|
|
"meeting:write:meeting",
|
|
|
"meeting:read:summary",
|
|
"meeting:read:summary",
|
|
|
- "zoomapp:inmeeting",
|
|
|
|
|
- "user_zak:read"
|
|
|
|
|
|
|
+ "user:read:zak"
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
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: publicClientId),
|
|
|
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
|
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: " ")),
|
|
URLQueryItem(name: "scope", value: requestedScopes.joined(separator: " ")),
|
|
|
// 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"),
|
|
@@ -10955,7 +11003,12 @@ 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 = try await exchangeCodeForTokens(
|
|
|
|
|
+ code: code,
|
|
|
|
|
+ redirectURI: redirectURI,
|
|
|
|
|
+ publicClientId: publicClientId,
|
|
|
|
|
+ codeVerifier: codeVerifier
|
|
|
|
|
+ )
|
|
|
await MainActor.run { self.onInteractiveSignInCompleted?() }
|
|
await MainActor.run { self.onInteractiveSignInCompleted?() }
|
|
|
return tokens
|
|
return tokens
|
|
|
} catch GoogleOAuthError.authenticationTimedOut {
|
|
} catch GoogleOAuthError.authenticationTimedOut {
|
|
@@ -10979,15 +11032,22 @@ final class ZoomOAuthService: NSObject {
|
|
|
return controller
|
|
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")!)
|
|
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")
|
|
|
- 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([
|
|
request.httpBody = Self.formURLEncoded([
|
|
|
"grant_type": "authorization_code",
|
|
"grant_type": "authorization_code",
|
|
|
"code": 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)
|
|
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? {
|
|
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 clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
- 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)
|
|
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 {
|
|
|
|
|
+ // 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 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 {
|
|
struct RefreshResponse: Decodable {
|
|
|
let access_token: String
|
|
let access_token: String
|
|
|
let refresh_token: String?
|
|
let refresh_token: String?
|
|
@@ -11043,7 +11138,7 @@ final class ZoomOAuthService: NSObject {
|
|
|
let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
|
|
let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
|
|
|
return ZoomOAuthTokens(
|
|
return ZoomOAuthTokens(
|
|
|
accessToken: decoded.access_token,
|
|
accessToken: decoded.access_token,
|
|
|
- refreshToken: decoded.refresh_token ?? refreshToken,
|
|
|
|
|
|
|
+ refreshToken: decoded.refresh_token ?? fallbackRefreshToken,
|
|
|
expiresAt: Date().addingTimeInterval(decoded.expires_in),
|
|
expiresAt: Date().addingTimeInterval(decoded.expires_in),
|
|
|
scope: decoded.scope ?? tokens.scope,
|
|
scope: decoded.scope ?? tokens.scope,
|
|
|
tokenType: decoded.token_type ?? tokens.tokenType
|
|
tokenType: decoded.token_type ?? tokens.tokenType
|
|
@@ -11073,8 +11168,10 @@ final class ZoomOAuthService: NSObject {
|
|
|
return granted.contains("meeting:read") || granted.contains("meeting:read:admin")
|
|
return granted.contains("meeting:read") || granted.contains("meeting:read:admin")
|
|
|
case "meeting:write:meeting":
|
|
case "meeting:write:meeting":
|
|
|
return granted.contains("meeting:write") || granted.contains("meeting:write:admin")
|
|
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:
|
|
default:
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
@@ -11091,6 +11188,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
|
|
@@ -11455,11 +11563,13 @@ extension ZoomOAuthError: LocalizedError {
|
|
|
var errorDescription: String? {
|
|
var errorDescription: String? {
|
|
|
switch self {
|
|
switch self {
|
|
|
case .missingClientId:
|
|
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:
|
|
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:
|
|
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:
|
|
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):
|