OAuthAuthenticationManager.swift 3.9 KB

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