swiftauthenticationswiftuioauth-2.0discord

Swift Discord OAuth2 redirect URi not supported by Client


I'm simply trying to get a users Discord username and UID from my iOS app. I registered an app in the Discord Dev portal and setup a redirect. Then in the OAuth2 URL Generator I clicked on the Identify scope and it generated me a url.

image1

My Swift code pops open a sheet that loads the Discord authentication screen with my app. I click authorize at the bottom of the page then the following error loads.

image2

I also went to the info.plist file in Xcode and added the entry in the image. I'd appreciate some help getting this issue fixed.

image3

xx

import SwiftUI
import AuthenticationServices

let clientId = "1331826676266434631"
let redirectUri = "wealth://callback"
let scope = "identify"
let discordOAuthURL = "https://discord.com/api/oauth2/authorize"
let discordTokenURL = "https://discord.com/api/oauth2/token"
let userInfoURL = "https://discord.com/api/users/@me"

struct MainTaskView: View {
    @State private var presentationContextProvider = PresentationContextProvider()
    
    var body: some View {
        VStack {
            
        }
        .onAppear(perform: {
            authenticateWithDiscord()
        })
    }
    func authenticateWithDiscord() {
        let urlString = "\(discordOAuthURL)?client_id=\(clientId)&response_type=code&redirect_uri=\(redirectUri)&scope=\(scope)"
        
        guard let url = URL(string: urlString) else { return }
        
        let session = ASWebAuthenticationSession(
            url: url,
            callbackURLScheme: "wealth"
        ) { callbackURL, error in
            guard
                error == nil,
                let callbackURL = callbackURL,
                let code = URLComponents(string: callbackURL.absoluteString)?
                    .queryItems?
                    .first(where: { $0.name == "code" })?.value
            else {
                print(error?.localizedDescription ?? "")
                return
            }
            
            exchangeCodeForToken(code: code)
        }
        
        session.presentationContextProvider = presentationContextProvider
        session.start()
    }
    func exchangeCodeForToken(code: String) {
        guard let url = URL(string: discordTokenURL) else { return }
        
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        let body = "client_id=\(clientId)&client_secret=YOUR_CLIENT_SECRET&grant_type=authorization_code&code=\(code)&redirect_uri=\(redirectUri)"
        request.httpBody = body.data(using: .utf8)
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            guard
                let data = data,
                let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                let accessToken = json["access_token"] as? String
            else {
                print(error?.localizedDescription ?? "")
                return
            }
            
            fetchDiscordUserInfo(accessToken: accessToken)
        }.resume()
    }
    func fetchDiscordUserInfo(accessToken: String) {
        guard let url = URL(string: userInfoURL) else { return }
        
        var request = URLRequest(url: url)
        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            guard
                let data = data,
                let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                let username = json["username"] as? String,
                let id = json["id"] as? String
            else {
                print(error?.localizedDescription ?? "")
                return
            }
            
            print(username)
            print(id)

        }.resume()
    }
}

class PresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
            fatalError("Unable to find a valid UIWindowScene")
        }
        return windowScene.windows.first { $0.isKeyWindow } ?? UIWindow()
    }
}

I also tried using swift oath package. I set it up exactly as outlined in the readme and I got the same issue.

I have also tried replacing wealth with something more unique such as wealthAIOprivate to ensure that this scheme isn't taken by another app.


Solution

  • Discord only allows http(s) redirct uri for normal OAuth2 (not sure about PKC flow). But if your looking to get a iOS users Disocrd username and ID. You can use this very simple code, no need for a domain/backend to redirct the user. It basically pops open a sheet with a WKWebView and does a little trick to scrape the Local Storage and extract the username and uid. This method literally is taking me 0-1 seconds to get the dUID and dUsername, and no button clicks required.

    you can put this view in a .sheet or any view. The isLoggedIn Binding bool could be used to show a loader while the Local Storage is being scraped after the user logs in. Most of the time the user does not need to login if they use discord on their phone.

    import SwiftUI
    import WebKit
    import Foundation
    
    struct DiscordWebView: UIViewRepresentable {
        @Binding var username: String
        @Binding var id: String
        @Binding var isLoggedIn: Bool
    
        class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
            var parent: DiscordWebView
            weak var webView: WKWebView?
            var timer: Timer?
            var added = false
    
            init(parent: DiscordWebView) {
                self.parent = parent
            }
    
            func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
                self.webView = webView
                webView.configuration.userContentController.removeAllUserScripts()
                webView.configuration.userContentController.removeAllScriptMessageHandlers()
                startPollingAccount(in: webView)
            }
    
            func startPollingAccount(in webView: WKWebView) {
                timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
                    webView.evaluateJavaScript("document.location.href") { (result, _) in
                        if let urlString = result as? String, urlString != "https://discord.com/login" {
                            withAnimation(.easeInOut(duration: 0.3)) {
                                self?.parent.isLoggedIn = true
                            }
                            self?.extractMultiAccountStore(webView: webView)
                        }
                    }
                }
            }
    
            func extractMultiAccountStore(webView: WKWebView) {
                let jsScript = """
                var iframe = document.createElement('iframe');
                iframe.style.display = 'none';
                iframe.onload = function() {
                    var storage = {};
                    var iframeLocalStorage = iframe.contentWindow.localStorage;
                    for (var i = 0; i < iframeLocalStorage.length; i++) {
                        var key = iframeLocalStorage.key(i);
                        var value = iframeLocalStorage.getItem(key);
                        storage[key] = value;
                    }
                    document.body.removeChild(iframe);
                    window.webkit.messageHandlers.AuthDiscord.postMessage(JSON.stringify(storage));
                };
                iframe.src = 'about:blank';
                document.body.appendChild(iframe);
                """
                
                if !added {
                    added = true
                    webView.configuration.userContentController.add(self, name: "AuthDiscord")
                }
                webView.evaluateJavaScript(jsScript)
            }
    
            func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
                if message.name == "AuthDiscord", let jsonString = message.body as? String {
                    self.parseMultiAccountStore(jsonString)
                }
            }
    
            func parseMultiAccountStore(_ jsonString: String) {
                if let jsonData = jsonString.data(using: .utf8) {
                    do {
                        if let json = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any],
                           let multiAccountStore = json["MultiAccountStore"] as? String {
                            
                            if let multiAccountData = multiAccountStore.data(using: .utf8),
                               let multiAccountJson = try JSONSerialization.jsonObject(with: multiAccountData, options: []) as? [String: Any],
                               let state = multiAccountJson["_state"] as? [String: Any],
                               let users = state["users"] as? [[String: Any]],
                               let firstUser = users.first {
                                
                                let username = firstUser["username"] as? String
                                let id = firstUser["id"] as? String
    
                                if let username, let id, !username.isEmpty && !id.isEmpty {
                                    self.parent.username = username
                                    self.parent.id = id
                                }
                                
                                timer?.invalidate()
                                if webView != nil {
                                    webView?.configuration.userContentController.removeAllUserScripts()
                                    webView?.configuration.userContentController.removeAllScriptMessageHandlers()
                                }
                            }
                        }
                    } catch {
                        print("Error parsing JSON: \(error)")
                    }
                }
            }
    
            deinit {
                timer?.invalidate()
                if webView != nil {
                    webView?.configuration.userContentController.removeAllUserScripts()
                    webView?.configuration.userContentController.removeAllScriptMessageHandlers()
                }
            }
        }
    
        func makeCoordinator() -> Coordinator {
            Coordinator(parent: self)
        }
    
        func makeUIView(context: Context) -> WKWebView {
            let webView = WKWebView()
            webView.navigationDelegate = context.coordinator
            webView.load(URLRequest(url: URL(string: "https://discord.com/login")!))
            return webView
        }
    
        func updateUIView(_ webView: WKWebView, context: Context) {}
    }