OAuthAuthenticationManager.swift 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. import AppKit
  2. import AuthenticationServices
  3. import WebKit
  4. enum OAuthError: LocalizedError, Equatable {
  5. case alreadyInProgress
  6. case failedToStart
  7. case cancelled
  8. case invalidCallback
  9. case timedOut
  10. case unsupportedPlatform
  11. var errorDescription: String? {
  12. switch self {
  13. case .alreadyInProgress:
  14. "Sign-in is already in progress."
  15. case .failedToStart:
  16. "Could not start sign-in."
  17. case .cancelled:
  18. "Sign-in was cancelled."
  19. case .invalidCallback:
  20. "Sign-in did not return to Reddit."
  21. case .timedOut:
  22. "Sign-in timed out. Please try again."
  23. case .unsupportedPlatform:
  24. "Sign-in requires macOS 14.4 or later. Please update your system."
  25. }
  26. }
  27. }
  28. @MainActor
  29. final class OAuthAuthenticationManager: NSObject {
  30. static let shared = OAuthAuthenticationManager()
  31. private static let authTimeoutSeconds: UInt64 = 300
  32. private var authSession: ASWebAuthenticationSession?
  33. private var isAuthenticating = false
  34. private var currentAuthURL: URL?
  35. private var pendingCompletions: [(Result<URL, Error>) -> Void] = []
  36. private var authTimeoutTask: Task<Void, Never>?
  37. private weak var presentationWindow: NSWindow?
  38. private override init() {
  39. super.init()
  40. }
  41. func authenticate(
  42. url: URL,
  43. presentationWindow: NSWindow? = nil,
  44. completion: @escaping (Result<URL, Error>) -> Void
  45. ) {
  46. if isAuthenticating {
  47. if let currentAuthURL, currentAuthURL.absoluteString == url.absoluteString {
  48. pendingCompletions.append(completion)
  49. return
  50. }
  51. completion(.failure(OAuthError.alreadyInProgress))
  52. return
  53. }
  54. if #available(macOS 14.4, *) {
  55. self.presentationWindow = presentationWindow
  56. startAuthenticationSession(url: url, completion: completion)
  57. } else {
  58. completion(.failure(OAuthError.unsupportedPlatform))
  59. }
  60. }
  61. @available(macOS 14.4, *)
  62. private func startAuthenticationSession(
  63. url: URL,
  64. completion: @escaping (Result<URL, Error>) -> Void
  65. ) {
  66. isAuthenticating = true
  67. currentAuthURL = url
  68. pendingCompletions = [completion]
  69. NSApp.activate(ignoringOtherApps: true)
  70. let callbackHost = RedditWebAuthHelper.oauthCallbackHost(for: url)
  71. let session = ASWebAuthenticationSession(
  72. url: url,
  73. callback: .https(host: callbackHost, path: "")
  74. ) { [weak self] callbackURL, error in
  75. Task { @MainActor in
  76. self?.finishAuthentication(callbackURL: callbackURL, error: error)
  77. }
  78. }
  79. session.prefersEphemeralWebBrowserSession = false
  80. session.presentationContextProvider = self
  81. authSession = session
  82. startAuthTimeout()
  83. guard session.start() else {
  84. deliverResult(.failure(OAuthError.failedToStart))
  85. return
  86. }
  87. }
  88. private func startAuthTimeout() {
  89. authTimeoutTask?.cancel()
  90. authTimeoutTask = Task { [weak self] in
  91. try? await Task.sleep(nanoseconds: Self.authTimeoutSeconds * 1_000_000_000)
  92. guard !Task.isCancelled else { return }
  93. await MainActor.run {
  94. guard let self, self.isAuthenticating else { return }
  95. self.authSession?.cancel()
  96. self.deliverResult(.failure(OAuthError.timedOut))
  97. }
  98. }
  99. }
  100. private func finishAuthentication(callbackURL: URL?, error: Error?) {
  101. let result: Result<URL, Error>
  102. if let error {
  103. let nsError = error as NSError
  104. if nsError.domain == ASWebAuthenticationSessionErrorDomain,
  105. nsError.code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
  106. result = .failure(OAuthError.cancelled)
  107. } else {
  108. result = .failure(error)
  109. }
  110. } else if let callbackURL, RedditWebAuthHelper.isRedditURL(callbackURL) {
  111. result = .success(callbackURL)
  112. } else {
  113. result = .failure(OAuthError.invalidCallback)
  114. }
  115. deliverResult(result)
  116. }
  117. private func deliverResult(_ result: Result<URL, Error>) {
  118. authTimeoutTask?.cancel()
  119. authTimeoutTask = nil
  120. isAuthenticating = false
  121. authSession = nil
  122. presentationWindow = nil
  123. currentAuthURL = nil
  124. let completions = pendingCompletions
  125. pendingCompletions = []
  126. completions.forEach { $0(result) }
  127. }
  128. }
  129. extension OAuthAuthenticationManager: ASWebAuthenticationPresentationContextProviding {
  130. nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
  131. MainActor.assumeIsolated {
  132. presentationWindow ?? NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first ?? ASPresentationAnchor()
  133. }
  134. }
  135. }