|
@@ -7,7 +7,6 @@
|
|
|
|
|
|
|
|
import Cocoa
|
|
import Cocoa
|
|
|
import CryptoKit
|
|
import CryptoKit
|
|
|
-import Network
|
|
|
|
|
import StoreKit
|
|
import StoreKit
|
|
|
import WebKit
|
|
import WebKit
|
|
|
|
|
|
|
@@ -6162,6 +6161,7 @@ final class ZoomOAuthTokenStore {
|
|
|
|
|
|
|
|
final class ZoomOAuthService: NSObject {
|
|
final class ZoomOAuthService: NSObject {
|
|
|
static let shared = ZoomOAuthService()
|
|
static let shared = ZoomOAuthService()
|
|
|
|
|
+ private var inAppOAuthWindowController: InAppOAuthWindowController?
|
|
|
|
|
|
|
|
private let tokenStore = ZoomOAuthTokenStore()
|
|
private let tokenStore = ZoomOAuthTokenStore()
|
|
|
private let clientIdDefaultsKey = "zoom.oauth.clientId"
|
|
private let clientIdDefaultsKey = "zoom.oauth.clientId"
|
|
@@ -6224,12 +6224,12 @@ final class ZoomOAuthService: NSObject {
|
|
|
|
|
|
|
|
private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
|
|
private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
|
|
|
_ = presentingWindow
|
|
_ = presentingWindow
|
|
|
|
|
+ let oauthWindow = await MainActor.run { makeOAuthWindowController() }
|
|
|
guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
|
|
guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
|
|
|
guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
|
|
guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
|
|
|
|
|
|
|
|
- let loopback = try await OAuthLoopbackServer.start()
|
|
|
|
|
- defer { loopback.stop() }
|
|
|
|
|
- let redirectURI = loopback.redirectURI
|
|
|
|
|
|
|
+ // Keep a provider-compatible redirect URI while capturing it in-app (no local server required).
|
|
|
|
|
+ let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
|
|
|
let state = UUID().uuidString
|
|
let state = UUID().uuidString
|
|
|
|
|
|
|
|
var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
|
|
var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
|
|
@@ -6241,17 +6241,27 @@ final class ZoomOAuthService: NSObject {
|
|
|
URLQueryItem(name: "state", value: state)
|
|
URLQueryItem(name: "state", value: state)
|
|
|
]
|
|
]
|
|
|
guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
|
|
guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
|
|
|
- let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
|
|
|
|
|
- guard opened else { throw ZoomOAuthError.unableToOpenBrowser }
|
|
|
|
|
-
|
|
|
|
|
- let callbackURL = try await loopback.waitForCallback()
|
|
|
|
|
- let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
|
|
|
|
|
- guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
|
|
|
|
|
- guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
|
|
|
|
|
- throw ZoomOAuthError.missingAuthorizationCode
|
|
|
|
|
|
|
+ do {
|
|
|
|
|
+ let callbackURL = try await oauthWindow.authenticate(startURL: authURL, callbackURLPrefix: redirectURI)
|
|
|
|
|
+ let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
|
|
|
|
|
+ guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
|
|
|
|
|
+ guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
|
|
|
|
|
+ throw ZoomOAuthError.missingAuthorizationCode
|
|
|
|
|
+ }
|
|
|
|
|
+ return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
|
|
|
|
|
+ } catch GoogleOAuthError.authenticationTimedOut {
|
|
|
|
|
+ throw ZoomOAuthError.authenticationTimedOut
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ throw error
|
|
|
}
|
|
}
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
|
|
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func makeOAuthWindowController() -> InAppOAuthWindowController {
|
|
|
|
|
+ if let existing = inAppOAuthWindowController { return existing }
|
|
|
|
|
+ let controller = InAppOAuthWindowController(title: "Zoom Sign-In")
|
|
|
|
|
+ inAppOAuthWindowController = controller
|
|
|
|
|
+ return controller
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
|
|
private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
|
|
@@ -6405,12 +6415,12 @@ final class GoogleOAuthService: NSObject {
|
|
|
|
|
|
|
|
private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
|
|
private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
|
|
|
_ = presentingWindow
|
|
_ = presentingWindow
|
|
|
|
|
+ let oauthWindow = await MainActor.run { makeOAuthWindowController() }
|
|
|
let codeVerifier = Self.randomURLSafeString(length: 64)
|
|
let codeVerifier = Self.randomURLSafeString(length: 64)
|
|
|
let codeChallenge = Self.pkceChallenge(for: codeVerifier)
|
|
let codeChallenge = Self.pkceChallenge(for: codeVerifier)
|
|
|
let state = Self.randomURLSafeString(length: 32)
|
|
let state = Self.randomURLSafeString(length: 32)
|
|
|
- let loopback = try await OAuthLoopbackServer.start()
|
|
|
|
|
- defer { loopback.stop() }
|
|
|
|
|
- let redirectURI = loopback.redirectURI
|
|
|
|
|
|
|
+ // Google OAuth app settings often already include this callback from prior loopback setup.
|
|
|
|
|
+ let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
|
|
|
|
|
|
|
|
var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
|
|
var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
|
|
|
components.queryItems = [
|
|
components.queryItems = [
|
|
@@ -6426,10 +6436,7 @@ final class GoogleOAuthService: NSObject {
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
|
|
guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
|
|
|
- let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
|
|
|
|
|
- guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
|
|
|
|
|
-
|
|
|
|
|
- let callbackURL = try await loopback.waitForCallback()
|
|
|
|
|
|
|
+ let callbackURL = try await oauthWindow.authenticate(startURL: authURL, callbackURLPrefix: redirectURI)
|
|
|
let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
|
|
let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
|
|
|
guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
|
|
guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
|
|
|
guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
|
|
guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
|
|
@@ -6438,6 +6445,14 @@ final class GoogleOAuthService: NSObject {
|
|
|
return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
|
|
return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func makeOAuthWindowController() -> InAppOAuthWindowController {
|
|
|
|
|
+ if let existing = inAppOAuthWindowController { return existing }
|
|
|
|
|
+ let controller = InAppOAuthWindowController(title: "Google Sign-In")
|
|
|
|
|
+ inAppOAuthWindowController = controller
|
|
|
|
|
+ return controller
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
|
|
private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
|
|
|
var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
|
|
var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
|
|
|
request.httpMethod = "POST"
|
|
request.httpMethod = "POST"
|
|
@@ -6532,42 +6547,49 @@ final class KeychainTokenStore {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-private final class OAuthLoopbackServer {
|
|
|
|
|
- /// Fixed port so Zoom/Google OAuth redirect URLs can be registered exactly (Zoom allow list does not support wildcards for ports).
|
|
|
|
|
- private static let loopbackOAuthPort: UInt16 = 8742
|
|
|
|
|
-
|
|
|
|
|
- // Use a high-enough QoS to avoid Thread Performance Checker QoS inversion
|
|
|
|
|
- // warnings when auth is triggered from user-interactive UI actions.
|
|
|
|
|
- private let queue = DispatchQueue(
|
|
|
|
|
- label: "google.oauth.loopback.server",
|
|
|
|
|
- qos: .userInitiated
|
|
|
|
|
- )
|
|
|
|
|
- private let listener: NWListener
|
|
|
|
|
- private var readyContinuation: CheckedContinuation<Void, Error>?
|
|
|
|
|
|
|
+@MainActor
|
|
|
|
|
+private final class OAuthWebViewContainerView: NSView {
|
|
|
|
|
+ private let webView: WKWebView
|
|
|
|
|
+ init(webView: WKWebView) {
|
|
|
|
|
+ self.webView = webView
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ addSubview(webView)
|
|
|
|
|
+ }
|
|
|
|
|
+ @available(*, unavailable) required init?(coder: NSCoder) { nil }
|
|
|
|
|
+ override func layout() {
|
|
|
|
|
+ super.layout()
|
|
|
|
|
+ webView.frame = bounds
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@MainActor
|
|
|
|
|
+private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate {
|
|
|
|
|
+ private let webView: WKWebView
|
|
|
|
|
+ private var callbackPrefix: String?
|
|
|
private var callbackContinuation: CheckedContinuation<URL, Error>?
|
|
private var callbackContinuation: CheckedContinuation<URL, Error>?
|
|
|
- private var callbackURL: URL?
|
|
|
|
|
|
|
|
|
|
- private init(listener: NWListener) {
|
|
|
|
|
- self.listener = listener
|
|
|
|
|
|
|
+ init(title: String) {
|
|
|
|
|
+ let configuration = WKWebViewConfiguration()
|
|
|
|
|
+ configuration.websiteDataStore = .nonPersistent()
|
|
|
|
|
+ self.webView = WKWebView(frame: .zero, configuration: configuration)
|
|
|
|
|
+ let container = OAuthWebViewContainerView(webView: webView)
|
|
|
|
|
+ let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
|
|
|
|
|
+ window.title = title
|
|
|
|
|
+ window.contentView = container
|
|
|
|
|
+ super.init(window: window)
|
|
|
|
|
+ webView.navigationDelegate = self
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- static func start() async throws -> OAuthLoopbackServer {
|
|
|
|
|
- guard let port = NWEndpoint.Port(rawValue: loopbackOAuthPort) else {
|
|
|
|
|
- throw GoogleOAuthError.invalidCallbackURL
|
|
|
|
|
- }
|
|
|
|
|
- let listener = try NWListener(using: .tcp, on: port)
|
|
|
|
|
- let server = OAuthLoopbackServer(listener: listener)
|
|
|
|
|
- try await server.startListening()
|
|
|
|
|
- return server
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ @available(*, unavailable) required init?(coder: NSCoder) { nil }
|
|
|
|
|
|
|
|
- var redirectURI: String {
|
|
|
|
|
- let port = listener.port?.rawValue ?? 0
|
|
|
|
|
- return "http://127.0.0.1:\(port)/oauth2redirect"
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ func authenticate(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double = 120) async throws -> URL {
|
|
|
|
|
+ self.callbackPrefix = callbackURLPrefix
|
|
|
|
|
+ showWindow(nil)
|
|
|
|
|
+ window?.makeKeyAndOrderFront(nil)
|
|
|
|
|
+ window?.center()
|
|
|
|
|
+ webView.load(URLRequest(url: startURL))
|
|
|
|
|
|
|
|
- func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
|
|
|
|
|
- try await withThrowingTaskGroup(of: URL.self) { group in
|
|
|
|
|
|
|
+ return try await withThrowingTaskGroup(of: URL.self) { group in
|
|
|
group.addTask { [weak self] in
|
|
group.addTask { [weak self] in
|
|
|
guard let self else { throw GoogleOAuthError.invalidCallbackURL }
|
|
guard let self else { throw GoogleOAuthError.invalidCallbackURL }
|
|
|
return try await self.awaitCallback()
|
|
return try await self.awaitCallback()
|
|
@@ -6578,115 +6600,34 @@ private final class OAuthLoopbackServer {
|
|
|
}
|
|
}
|
|
|
let url = try await group.next()!
|
|
let url = try await group.next()!
|
|
|
group.cancelAll()
|
|
group.cancelAll()
|
|
|
|
|
+ self.webView.stopLoading()
|
|
|
|
|
+ self.close()
|
|
|
return url
|
|
return url
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- func stop() {
|
|
|
|
|
- listener.cancel()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func startListening() async throws {
|
|
|
|
|
- try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
|
|
|
- queue.async {
|
|
|
|
|
- self.readyContinuation = continuation
|
|
|
|
|
- self.listener.stateUpdateHandler = { [weak self] state in
|
|
|
|
|
- guard let self else { return }
|
|
|
|
|
- switch state {
|
|
|
|
|
- case .ready:
|
|
|
|
|
- if let readyContinuation = self.readyContinuation {
|
|
|
|
|
- self.readyContinuation = nil
|
|
|
|
|
- readyContinuation.resume()
|
|
|
|
|
- }
|
|
|
|
|
- case .failed(let error):
|
|
|
|
|
- if let readyContinuation = self.readyContinuation {
|
|
|
|
|
- self.readyContinuation = nil
|
|
|
|
|
- readyContinuation.resume(throwing: error)
|
|
|
|
|
- }
|
|
|
|
|
- case .cancelled:
|
|
|
|
|
- if let readyContinuation = self.readyContinuation {
|
|
|
|
|
- self.readyContinuation = nil
|
|
|
|
|
- readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
|
|
|
|
|
- }
|
|
|
|
|
- default:
|
|
|
|
|
- break
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- self.listener.newConnectionHandler = { [weak self] connection in
|
|
|
|
|
- self?.handle(connection: connection)
|
|
|
|
|
- }
|
|
|
|
|
- self.listener.start(queue: self.queue)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
private func awaitCallback() async throws -> URL {
|
|
private func awaitCallback() async throws -> URL {
|
|
|
- if let callbackURL { return callbackURL }
|
|
|
|
|
- return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
|
|
|
|
|
- queue.async { self.callbackContinuation = continuation }
|
|
|
|
|
|
|
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
|
|
|
|
|
+ callbackContinuation = continuation
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func handle(connection: NWConnection) {
|
|
|
|
|
- connection.start(queue: queue)
|
|
|
|
|
- connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
|
|
|
|
|
- guard let self else { return }
|
|
|
|
|
- let requestLine = data.flatMap { String(data: $0, encoding: .utf8) }?.split(separator: "\r\n").first.map(String.init)
|
|
|
|
|
- var parsedURL: URL?
|
|
|
|
|
- if let requestLine {
|
|
|
|
|
- let parts = requestLine.split(separator: " ")
|
|
|
|
|
- if parts.count >= 2 {
|
|
|
|
|
- parsedURL = URL(string: "http://127.0.0.1\(parts[1])")
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
|
|
|
|
|
- if let parsedURL {
|
|
|
|
|
- self.callbackURL = parsedURL
|
|
|
|
|
- self.callbackContinuation?.resume(returning: parsedURL)
|
|
|
|
|
- self.callbackContinuation = nil
|
|
|
|
|
- self.listener.cancel()
|
|
|
|
|
- }
|
|
|
|
|
- connection.cancel()
|
|
|
|
|
|
|
+ func webView(_ webView: WKWebView,
|
|
|
|
|
+ decidePolicyFor navigationAction: WKNavigationAction,
|
|
|
|
|
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
|
|
|
+ guard let targetURL = navigationAction.request.url,
|
|
|
|
|
+ let callbackPrefix,
|
|
|
|
|
+ targetURL.absoluteString.hasPrefix(callbackPrefix) else {
|
|
|
|
|
+ decisionHandler(.allow)
|
|
|
|
|
+ return
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- private func sendHTTPResponse(connection: NWConnection, success: Bool) {
|
|
|
|
|
- let body = success ? "<html><body><h3>Authentication complete</h3><p>You can return to the app.</p></body></html>" : "<html><body><h3>Authentication failed</h3></body></html>"
|
|
|
|
|
- let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)"
|
|
|
|
|
- connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
|
|
|
|
|
|
|
+ callbackContinuation?.resume(returning: targetURL)
|
|
|
|
|
+ callbackContinuation = nil
|
|
|
|
|
+ decisionHandler(.cancel)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-@MainActor
|
|
|
|
|
-private final class OAuthWebViewContainerView: NSView {
|
|
|
|
|
- private let webView: WKWebView
|
|
|
|
|
- init(webView: WKWebView) {
|
|
|
|
|
- self.webView = webView
|
|
|
|
|
- super.init(frame: .zero)
|
|
|
|
|
- addSubview(webView)
|
|
|
|
|
- }
|
|
|
|
|
- @available(*, unavailable) required init?(coder: NSCoder) { nil }
|
|
|
|
|
- override func layout() {
|
|
|
|
|
- super.layout()
|
|
|
|
|
- webView.frame = bounds
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-@MainActor
|
|
|
|
|
-private final class InAppOAuthWindowController: NSWindowController {
|
|
|
|
|
- private let webView: WKWebView
|
|
|
|
|
- init() {
|
|
|
|
|
- self.webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
|
|
|
- let container = OAuthWebViewContainerView(webView: webView)
|
|
|
|
|
- let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
|
|
|
|
|
- window.title = "Google Sign-In"
|
|
|
|
|
- window.contentView = container
|
|
|
|
|
- super.init(window: window)
|
|
|
|
|
- }
|
|
|
|
|
- @available(*, unavailable) required init?(coder: NSCoder) { nil }
|
|
|
|
|
- func load(url: URL) { webView.load(URLRequest(url: url)) }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
extension GoogleOAuthError: LocalizedError {
|
|
extension GoogleOAuthError: LocalizedError {
|
|
|
var errorDescription: String? {
|
|
var errorDescription: String? {
|
|
|
switch self {
|
|
switch self {
|
|
@@ -6709,7 +6650,7 @@ extension ZoomOAuthError: LocalizedError {
|
|
|
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 .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 (must match OAuthLoopbackServer.loopbackOAuthPort in this target)."
|
|
|
|
|
|
|
+ return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect."
|
|
|
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):
|