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.
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.
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.
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.
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) {}
}