瀏覽代碼

Fix Reddit Google and Apple sign-in by routing OAuth through system auth.

Embedded WKWebView cannot complete provider login, so OAuth now uses ASWebAuthenticationSession with the correct Reddit callback and a shared-cookie sign-in panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 月之前
父節點
當前提交
06f3c93176

+ 130 - 0
Reddit App/Managers/OAuthAuthenticationManager.swift

@@ -0,0 +1,130 @@
+import AppKit
+import AuthenticationServices
+import WebKit
+
+enum OAuthError: LocalizedError {
+    case alreadyInProgress
+    case failedToStart
+    case cancelled
+    case invalidCallback
+    case unsupportedPlatform
+
+    var errorDescription: String? {
+        switch self {
+        case .alreadyInProgress:
+            "Sign-in is already in progress."
+        case .failedToStart:
+            "Could not start sign-in."
+        case .cancelled:
+            "Sign-in was cancelled."
+        case .invalidCallback:
+            "Sign-in did not return to Reddit."
+        case .unsupportedPlatform:
+            "Sign-in requires macOS 14.4 or later. Please update your system."
+        }
+    }
+}
+
+@MainActor
+final class OAuthAuthenticationManager: NSObject {
+    static let shared = OAuthAuthenticationManager()
+
+    private var authSession: ASWebAuthenticationSession?
+    private var isAuthenticating = false
+    private weak var presentationWindow: NSWindow?
+
+    private override init() {
+        super.init()
+    }
+
+    func authenticate(
+        url: URL,
+        presentationWindow: NSWindow? = nil,
+        completion: @escaping (Result<URL, Error>) -> Void
+    ) {
+        guard !isAuthenticating else {
+            completion(.failure(OAuthError.alreadyInProgress))
+            return
+        }
+
+        if #available(macOS 14.4, *) {
+            self.presentationWindow = presentationWindow
+            startAuthenticationSession(url: url, completion: completion)
+        } else {
+            NSWorkspace.shared.open(url)
+            completion(.failure(OAuthError.unsupportedPlatform))
+        }
+    }
+
+    @available(macOS 14.4, *)
+    private func startAuthenticationSession(
+        url: URL,
+        completion: @escaping (Result<URL, Error>) -> Void
+    ) {
+        isAuthenticating = true
+        NSApp.activate(ignoringOtherApps: true)
+
+        let callbackHost = RedditWebAuthHelper.oauthCallbackHost(for: url)
+
+        let session = ASWebAuthenticationSession(
+            url: url,
+            callback: .https(host: callbackHost, path: "")
+        ) { [weak self] callbackURL, error in
+            Task { @MainActor in
+                self?.finishAuthentication(
+                    callbackURL: callbackURL,
+                    error: error,
+                    completion: completion
+                )
+            }
+        }
+
+        session.prefersEphemeralWebBrowserSession = false
+        session.presentationContextProvider = self
+        authSession = session
+
+        guard session.start() else {
+            isAuthenticating = false
+            authSession = nil
+            presentationWindow = nil
+            completion(.failure(OAuthError.failedToStart))
+            return
+        }
+    }
+
+    private func finishAuthentication(
+        callbackURL: URL?,
+        error: Error?,
+        completion: @escaping (Result<URL, Error>) -> Void
+    ) {
+        isAuthenticating = false
+        authSession = nil
+        presentationWindow = nil
+
+        if let error {
+            let nsError = error as NSError
+            if nsError.domain == ASWebAuthenticationSessionErrorDomain,
+               nsError.code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
+                completion(.failure(OAuthError.cancelled))
+            } else {
+                completion(.failure(error))
+            }
+            return
+        }
+
+        guard let callbackURL, RedditWebAuthHelper.isRedditURL(callbackURL) else {
+            completion(.failure(OAuthError.invalidCallback))
+            return
+        }
+
+        completion(.success(callbackURL))
+    }
+}
+
+extension OAuthAuthenticationManager: ASWebAuthenticationPresentationContextProviding {
+    nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
+        MainActor.assumeIsolated {
+            presentationWindow ?? NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first ?? ASPresentationAnchor()
+        }
+    }
+}

+ 128 - 0
Reddit App/Managers/RedditOAuthPanelController.swift

@@ -0,0 +1,128 @@
+import AppKit
+import WebKit
+
+@MainActor
+final class RedditOAuthPanelController: NSObject {
+    static let shared = RedditOAuthPanelController()
+
+    private var panel: NSPanel?
+    private var webView: WKWebView?
+    private var onComplete: (() -> Void)?
+
+    private override init() {
+        super.init()
+    }
+
+    func completeSignIn(
+        callbackURL: URL,
+        onFinished: @escaping () -> Void
+    ) {
+        dismiss()
+
+        onComplete = onFinished
+
+        let panel = NSPanel(
+            contentRect: NSRect(x: 0, y: 0, width: 520, height: 640),
+            styleMask: [.titled, .closable, .fullSizeContentView],
+            backing: .buffered,
+            defer: false
+        )
+        panel.title = "Sign in to Reddit"
+        panel.isReleasedWhenClosed = false
+        panel.center()
+        panel.level = .floating
+
+        let webView = WKWebView(frame: panel.contentView?.bounds ?? .zero, configuration: RedditWebViewManager.makeConfiguration())
+        webView.navigationDelegate = self
+        webView.autoresizingMask = [.width, .height]
+        panel.contentView?.addSubview(webView)
+
+        self.panel = panel
+        self.webView = webView
+
+        panel.makeKeyAndOrderFront(nil)
+        NSApp.activate(ignoringOtherApps: true)
+
+        webView.load(URLRequest(url: callbackURL))
+    }
+
+    func dismiss() {
+        panel?.close()
+        panel = nil
+        webView = nil
+        onComplete = nil
+    }
+
+    private func finish() {
+        let completion = onComplete
+        dismiss()
+        completion?()
+    }
+}
+
+extension RedditOAuthPanelController: WKNavigationDelegate {
+    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+        guard let url = webView.url, RedditWebAuthHelper.isRedditURL(url) else { return }
+
+        if shouldFinish(for: url) {
+            finish()
+            return
+        }
+
+        if url.host?.lowercased() == "accounts.reddit.com" {
+            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
+                guard let self, self.panel != nil else { return }
+                self.finish()
+            }
+        }
+    }
+
+    func webView(
+        _ webView: WKWebView,
+        decidePolicyFor navigationAction: WKNavigationAction,
+        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
+    ) {
+        guard let url = navigationAction.request.url else {
+            decisionHandler(.allow)
+            return
+        }
+
+        if RedditWebAuthHelper.isOAuthProviderURL(url) {
+            decisionHandler(.cancel)
+            OAuthAuthenticationManager.shared.authenticate(
+                url: url,
+                presentationWindow: panel
+            ) { [weak self] result in
+                guard let self else { return }
+                switch result {
+                case .success(let callbackURL):
+                    webView.load(URLRequest(url: callbackURL))
+                case .failure:
+                    self.finish()
+                }
+            }
+            return
+        }
+
+        if shouldFinish(for: url) {
+            decisionHandler(.allow)
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
+                self?.finish()
+            }
+            return
+        }
+
+        decisionHandler(.allow)
+    }
+
+    private func shouldFinish(for url: URL) -> Bool {
+        guard let host = url.host?.lowercased() else { return false }
+        let path = url.path.lowercased()
+
+        if host == "www.reddit.com" || host == "reddit.com" {
+            return !path.contains("login") && !path.contains("register")
+        }
+
+        return RedditWebAuthHelper.looksLikeLoggedInURL(url)
+    }
+}

+ 85 - 0
Reddit App/Utilities/RedditWebAuthHelper.swift

@@ -0,0 +1,85 @@
+import Foundation
+
+enum RedditWebAuthHelper {
+    static let redditHomeURL = URL(string: "https://www.reddit.com")!
+
+    static func isOAuthProviderURL(_ url: URL) -> Bool {
+        guard let host = url.host?.lowercased() else { return false }
+
+        let oauthHosts = [
+            "accounts.google.com",
+            "appleid.apple.com",
+            "idmsa.apple.com",
+        ]
+
+        if oauthHosts.contains(where: { host == $0 || host.hasSuffix(".\($0)") }) {
+            return true
+        }
+
+        if host == "google.com" || host.hasSuffix(".google.com") {
+            let path = url.path.lowercased()
+            return path.contains("oauth")
+                || path.contains("signin")
+                || path.contains("gsi")
+        }
+
+        return false
+    }
+
+    static func isRedditURL(_ url: URL) -> Bool {
+        guard let host = url.host?.lowercased() else { return false }
+
+        let redditHosts = [
+            "reddit.com",
+            "www.reddit.com",
+            "old.reddit.com",
+            "new.reddit.com",
+            "accounts.reddit.com",
+            "oauth.reddit.com",
+            "reddit.app.link",
+            "redd.it",
+        ]
+
+        return redditHosts.contains(where: { host == $0 || host.hasSuffix(".\($0)") })
+    }
+
+    static func looksLikeLoggedInURL(_ url: URL) -> Bool {
+        guard isRedditURL(url) else { return false }
+
+        let path = url.path.lowercased()
+        let blockedPaths = [
+            "/login",
+            "/register",
+            "/password",
+            "/account/login",
+        ]
+
+        if blockedPaths.contains(where: { path.hasPrefix($0) }) {
+            return false
+        }
+
+        if url.host?.lowercased() == "accounts.reddit.com" {
+            return path.isEmpty || path == "/" || path.hasPrefix("/api/")
+        }
+
+        return true
+    }
+
+    static func oauthCallbackHost(for url: URL) -> String {
+        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
+            return "accounts.reddit.com"
+        }
+
+        let redirectURI = components.queryItems?.first(where: { $0.name == "redirect_uri" })?.value
+            ?? components.queryItems?.first(where: { $0.name == "redirect_url" })?.value
+
+        if let redirectURI,
+           let redirectURL = URL(string: redirectURI),
+           let host = redirectURL.host?.lowercased(),
+           isRedditURL(redirectURL) {
+            return host
+        }
+
+        return "accounts.reddit.com"
+    }
+}

+ 34 - 16
Reddit App/Views/RedditWebView.swift

@@ -58,6 +58,12 @@ struct RedditWebView: NSViewRepresentable {
                 return
             }
 
+            if RedditWebAuthHelper.isOAuthProviderURL(url) {
+                beginOAuthSignIn(url: url)
+                decisionHandler(.cancel)
+                return
+            }
+
             if navigationAction.targetFrame == nil {
                 webView.load(URLRequest(url: url))
                 decisionHandler(.cancel)
@@ -65,7 +71,7 @@ struct RedditWebView: NSViewRepresentable {
             }
 
             if navigationAction.navigationType == .linkActivated,
-               !Self.isRedditURL(url) {
+               !RedditWebAuthHelper.isRedditURL(url) {
                 NSWorkspace.shared.open(url)
                 decisionHandler(.cancel)
                 return
@@ -80,25 +86,37 @@ struct RedditWebView: NSViewRepresentable {
             for navigationAction: WKNavigationAction,
             windowFeatures: WKWindowFeatures
         ) -> WKWebView? {
-            if let url = navigationAction.request.url {
-                webView.load(URLRequest(url: url))
+            guard let url = navigationAction.request.url else { return nil }
+
+            if RedditWebAuthHelper.isOAuthProviderURL(url) {
+                beginOAuthSignIn(url: url)
+                return nil
             }
+
+            webView.load(URLRequest(url: url))
             return nil
         }
 
-        private static func isRedditURL(_ url: URL) -> Bool {
-            guard let host = url.host?.lowercased() else { return false }
-
-            let redditHosts = [
-                "reddit.com",
-                "www.reddit.com",
-                "old.reddit.com",
-                "new.reddit.com",
-                "reddit.app.link",
-                "redd.it",
-            ]
-
-            return redditHosts.contains(where: { host == $0 || host.hasSuffix(".\($0)") })
+        private func beginOAuthSignIn(url: URL) {
+            let presentationWindow = container?.window
+
+            OAuthAuthenticationManager.shared.authenticate(
+                url: url,
+                presentationWindow: presentationWindow
+            ) { [weak self] result in
+                guard let self, let container = self.container else { return }
+
+                switch result {
+                case .success(let callbackURL):
+                    RedditOAuthPanelController.shared.completeSignIn(callbackURL: callbackURL) {
+                        container.load(url: RedditWebAuthHelper.redditHomeURL)
+                    }
+                case .failure(let error as OAuthError) where error == .cancelled:
+                    break
+                case .failure:
+                    break
+                }
+            }
         }
     }
 }