OAuthAuthenticationManager.swift 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  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. // Reddit's Google/Apple buttons can fire both decidePolicyFor and
  41. // createWebViewWith for the same OAuth URL; ignore the duplicate.
  42. return
  43. }
  44. if #available(macOS 14.4, *) {
  45. self.presentationWindow = presentationWindow
  46. startAuthenticationSession(url: url, completion: completion)
  47. } else {
  48. NSWorkspace.shared.open(url)
  49. completion(.failure(OAuthError.unsupportedPlatform))
  50. }
  51. }
  52. @available(macOS 14.4, *)
  53. private func startAuthenticationSession(
  54. url: URL,
  55. completion: @escaping (Result<URL, Error>) -> Void
  56. ) {
  57. isAuthenticating = true
  58. NSApp.activate(ignoringOtherApps: true)
  59. let callbackHost = RedditWebAuthHelper.oauthCallbackHost(for: url)
  60. let session = ASWebAuthenticationSession(
  61. url: url,
  62. callback: .https(host: callbackHost, path: "")
  63. ) { [weak self] callbackURL, error in
  64. Task { @MainActor in
  65. self?.finishAuthentication(
  66. callbackURL: callbackURL,
  67. error: error,
  68. completion: completion
  69. )
  70. }
  71. }
  72. session.prefersEphemeralWebBrowserSession = false
  73. session.presentationContextProvider = self
  74. authSession = session
  75. guard session.start() else {
  76. isAuthenticating = false
  77. authSession = nil
  78. presentationWindow = nil
  79. completion(.failure(OAuthError.failedToStart))
  80. return
  81. }
  82. }
  83. private func finishAuthentication(
  84. callbackURL: URL?,
  85. error: Error?,
  86. completion: @escaping (Result<URL, Error>) -> Void
  87. ) {
  88. isAuthenticating = false
  89. authSession = nil
  90. presentationWindow = nil
  91. if let error {
  92. let nsError = error as NSError
  93. if nsError.domain == ASWebAuthenticationSessionErrorDomain,
  94. nsError.code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
  95. completion(.failure(OAuthError.cancelled))
  96. } else {
  97. completion(.failure(error))
  98. }
  99. return
  100. }
  101. guard let callbackURL, RedditWebAuthHelper.isRedditURL(callbackURL) else {
  102. completion(.failure(OAuthError.invalidCallback))
  103. return
  104. }
  105. completion(.success(callbackURL))
  106. }
  107. }
  108. extension OAuthAuthenticationManager: ASWebAuthenticationPresentationContextProviding {
  109. nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
  110. MainActor.assumeIsolated {
  111. presentationWindow ?? NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first ?? ASPresentationAnchor()
  112. }
  113. }
  114. }