From a1d8d895613eaa4f02d31087f2d01c6708c71646 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 22 Jul 2026 16:57:25 -0400 Subject: [PATCH] Migrate from WKWebView to ASWebAuthenticationSession # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # HEAD detached at 79208073 # Changes to be committed: # modified: platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift # modified: platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift # modified: platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Localizable.xcstrings # modified: platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift # modified: platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountLoginView.swift # modified: platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountManager.swift # --- .../Sources/App/SceneDelegate.swift | 8 - .../Sources/Lib/KeychainHelper.swift | 6 +- .../Sources/Localizable.xcstrings | 8 - .../Sources/Scenes/AccountView.swift | 79 +++++- .../CustomerAccountLoginView.swift | 149 ++---------- .../CustomerAccountManager.swift | 229 +++++++++++++----- 6 files changed, 258 insertions(+), 221 deletions(-) diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift index 329b44c0d..a7e9db90e 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift @@ -236,14 +236,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { badgeLabel.isHidden = count <= 0 } - func scene(_: UIScene, openURLContexts URLContexts: Set) { - guard let url = URLContexts.first?.url else { return } - - if CustomerAccountManager.shared.handleCallback(url: url) { - return - } - } - func scene(_: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift index f47adfb94..c103ba1c5 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift @@ -112,7 +112,7 @@ final class KeychainHelper { return } save(key: emailKey, data: data) - logger.debug("Saved email to keychain: \(email)") + logger.debug("Saved email to keychain") } func getEmail() -> String? { @@ -120,8 +120,6 @@ final class KeychainHelper { logger.debug("No email found in keychain") return nil } - let email = String(data: data, encoding: .utf8) - logger.debug("Retrieved email from keychain: \(email ?? "nil")") - return email + return String(data: data, encoding: .utf8) } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Localizable.xcstrings b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Localizable.xcstrings index 7a190442c..ab6118e82 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Localizable.xcstrings +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Localizable.xcstrings @@ -97,10 +97,6 @@ } } }, - "Sign In" : { - "comment" : "The title of the navigation bar at the top of the view.", - "isCommentAutoGenerated" : true - }, "Sign in on the" : { "comment" : "A phrase displayed in a navigation link within the Settings view, directing the user to the Shopify account tab.", "isCommentAutoGenerated" : true @@ -109,10 +105,6 @@ "comment" : "A label describing the action of signing in to access one's account.", "isCommentAutoGenerated" : true }, - "Sign Out" : { - "comment" : "A button label that allows a user to sign out of their account.", - "isCommentAutoGenerated" : true - }, "Signed In" : { "comment" : "A title displayed in the top-center of the view.", "isCommentAutoGenerated" : true diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift index fe555e2a2..03b8e4a9e 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift @@ -1,28 +1,82 @@ +import AuthenticationServices import SwiftUI +import UIKit struct AccountView: View { @ObservedObject var accountManager = CustomerAccountManager.shared - @State private var showingLogin = false + @State private var errorMessage = "" + @State private var showingError = false var body: some View { NavigationView { Group { if accountManager.isAuthenticated { - AuthenticatedAccountView() + AuthenticatedAccountView(logout: logout) } else { - UnauthenticatedAccountView(showingLogin: $showingLogin) + UnauthenticatedAccountView(signIn: signIn) } } .navigationTitle(accountManager.isAuthenticated ? "Account" : "Sign In") } - .sheet(isPresented: $showingLogin) { - LoginSheetView() + .alert("Customer Account", isPresented: $showingError) { + Button("OK", role: .cancel) {} + } message: { + Text(errorMessage) } } + + private func signIn() { + guard let presentationAnchor = UIApplication.shared.customerAccountPresentationAnchor else { + presentError("Unable to find a window for sign in.") + return + } + + Task { + do { + try await accountManager.signIn(from: presentationAnchor) + } catch CustomerAccountError.authorizationCancelled { + return + } catch { + presentError(error.localizedDescription) + } + } + } + + private func logout() { + guard let presentationAnchor = UIApplication.shared.customerAccountPresentationAnchor else { + accountManager.logout() + return + } + + Task { + do { + try await accountManager.logout(from: presentationAnchor) + } catch { + presentError( + "You were signed out of this app, but the browser session may still be active. \(error.localizedDescription)" + ) + } + } + } + + private func presentError(_ message: String) { + errorMessage = message + showingError = true + } +} + +extension UIApplication { + fileprivate var customerAccountPresentationAnchor: ASPresentationAnchor? { + connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .keyWindow + } } struct AuthenticatedAccountView: View { @ObservedObject var accountManager = CustomerAccountManager.shared + let logout: () -> Void var body: some View { VStack(spacing: 24) { @@ -53,15 +107,16 @@ struct AuthenticatedAccountView: View { Spacer() - Button(action: { accountManager.logout() }) { + Button(action: logout) { HStack { Image(systemName: "rectangle.portrait.and.arrow.right") - Text("Sign Out") + Text(accountManager.isLoading ? "Signing Out…" : "Sign Out") } .foregroundColor(.red) .frame(maxWidth: .infinity) .padding() } + .disabled(accountManager.isLoading) .padding(.bottom, 32) } .background(Color(.systemGroupedBackground)) @@ -69,7 +124,8 @@ struct AuthenticatedAccountView: View { } struct UnauthenticatedAccountView: View { - @Binding var showingLogin: Bool + @ObservedObject var accountManager = CustomerAccountManager.shared + let signIn: () -> Void var body: some View { VStack(spacing: 24) { @@ -91,10 +147,8 @@ struct UnauthenticatedAccountView: View { .padding(.horizontal, 32) } - Button(action: { - showingLogin = true - }) { - Text("Sign In") + Button(action: signIn) { + Text(accountManager.isLoading ? "Signing In…" : "Sign In") .fontWeight(.semibold) .foregroundColor(.white) .frame(maxWidth: .infinity) @@ -102,6 +156,7 @@ struct UnauthenticatedAccountView: View { .background(Color(ColorPalette.primaryColor)) .cornerRadius(12) } + .disabled(accountManager.isLoading) .padding(.horizontal, 32) Spacer() diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountLoginView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountLoginView.swift index 7e1bd1ed1..73241b52f 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountLoginView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountLoginView.swift @@ -1,139 +1,18 @@ -import SwiftUI -import WebKit - -struct CustomerAccountLoginView: UIViewRepresentable { - let authorizationURL: URL - let callbackScheme: String - let onCodeReceived: (String) -> Void - let onCancel: () -> Void - - func makeUIView(context: Context) -> WKWebView { - let configuration = WKWebViewConfiguration() - configuration.websiteDataStore = .nonPersistent() - - let webView = WKWebView(frame: .zero, configuration: configuration) - webView.navigationDelegate = context.coordinator - webView.load(URLRequest(url: authorizationURL)) - return webView - } - - func updateUIView(_: WKWebView, context _: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator(callbackScheme: callbackScheme, onCodeReceived: onCodeReceived, onCancel: onCancel) - } - - class Coordinator: NSObject, WKNavigationDelegate { - let callbackScheme: String - let onCodeReceived: (String) -> Void - let onCancel: () -> Void - - init(callbackScheme: String, onCodeReceived: @escaping (String) -> Void, onCancel: @escaping () -> Void) { - self.callbackScheme = callbackScheme - self.onCodeReceived = onCodeReceived - self.onCancel = onCancel - } - - func webView( - _: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void - ) { - guard let url = navigationAction.request.url else { - decisionHandler(.allow) - return - } - - if url.scheme == callbackScheme, url.host == "callback" { - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems - else { - decisionHandler(.cancel) - onCancel() - return - } - - let queryDict = Dictionary(uniqueKeysWithValues: queryItems.compactMap { item -> (String, String)? in - guard let value = item.value else { return nil } - return (item.name, value) - }) - - if let code = queryDict["code"] { - onCodeReceived(code) - } else { - onCancel() - } - - decisionHandler(.cancel) - return - } - - decisionHandler(.allow) - } - - func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) { - if (error as NSError).code == NSURLErrorCancelled { - return - } - print("WebView navigation failed: \(error)") - } - - func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) { - if (error as NSError).code == NSURLErrorCancelled { - return - } - print("WebView provisional navigation failed: \(error)") - } - } -} - -struct LoginSheetView: View { - @ObservedObject var accountManager = CustomerAccountManager.shared - @Environment(\.dismiss) private var dismiss - @State private var authorizationURL: URL? - - private var callbackScheme: String? { - guard let shopId = InfoDictionary.shared.customerAccountApiShopId else { return nil } - return "shop.\(shopId).app" +import AuthenticationServices + +/// Retains the host-provided window while an authentication session is active. +/// +/// Authentication UI is owned by `ASWebAuthenticationSession`; the demo no +/// longer presents a login `WKWebView`. +@MainActor +final class CustomerAccountPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + private let anchor: ASPresentationAnchor + + init(anchor: ASPresentationAnchor) { + self.anchor = anchor } - var body: some View { - NavigationView { - Group { - if let url = authorizationURL, let scheme = callbackScheme { - CustomerAccountLoginView( - authorizationURL: url, - callbackScheme: scheme, - onCodeReceived: { code in - Task { - do { - try await accountManager.exchangeCodeForTokens(code: code) - dismiss() - } catch { - print("Failed to exchange code: \(error)") - } - } - }, - onCancel: { - dismiss() - } - ) - } else { - ProgressView("Loading...") - } - } - .navigationTitle("Sign In") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } - } - } - .onAppear { - authorizationURL = accountManager.buildAuthorizationURL() - } + func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { + anchor } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountManager.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountManager.swift index 0cbdeb587..bd8b6498e 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountManager.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountManager.swift @@ -1,3 +1,4 @@ +import AuthenticationServices import CommonCrypto import Foundation import ShopifyCheckoutKit @@ -9,6 +10,10 @@ enum CustomerAccountError: LocalizedError { case tokenRefreshFailed(String) case notAuthenticated case invalidState + case invalidCallback + case authorizationInProgress + case authorizationCancelled + case authorizationFailed(String) var errorDescription: String? { switch self { @@ -24,6 +29,14 @@ enum CustomerAccountError: LocalizedError { return "User is not authenticated" case .invalidState: return "Invalid state parameter - possible CSRF attack" + case .invalidCallback: + return "Invalid authorization callback" + case .authorizationInProgress: + return "Another authorization request is already in progress" + case .authorizationCancelled: + return "Authorization was cancelled" + case let .authorizationFailed(message): + return "Authorization failed: \(message)" } } } @@ -67,6 +80,8 @@ final class CustomerAccountManager: ObservableObject { private var codeVerifier: String? private var savedState: String? + private var authenticationSession: ASWebAuthenticationSession? + private var presentationContextProvider: CustomerAccountPresentationContextProvider? private init() { shopId = InfoDictionary.shared.customerAccountApiShopId @@ -106,7 +121,7 @@ final class CustomerAccountManager: ObservableObject { return base64URLEncode(Data(bytes)) } - func buildAuthorizationURL() -> URL? { + private func buildAuthorizationURL() -> URL? { guard let authorizationEndpoint, let clientId, let redirectUri else { return nil } @@ -141,7 +156,61 @@ final class CustomerAccountManager: ObservableObject { return state == savedState } - func exchangeCodeForTokens(code: String) async throws { + func signIn(from presentationAnchor: ASPresentationAnchor) async throws { + guard authenticationSession == nil else { + throw CustomerAccountError.authorizationInProgress + } + + guard let authorizationURL = buildAuthorizationURL(), let callbackScheme else { + throw CustomerAccountError.missingConfiguration + } + + isLoading = true + defer { + isLoading = false + codeVerifier = nil + savedState = nil + } + + let callbackURL = try await presentAuthenticationSession( + url: authorizationURL, + callbackScheme: callbackScheme, + from: presentationAnchor + ) + let code = try authorizationCode(from: callbackURL) + try await exchangeCodeForTokens(code: code) + } + + private func authorizationCode(from callbackURL: URL) throws -> String { + guard let callbackScheme, + let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), + components.scheme == callbackScheme, + components.host == "callback" + else { + throw CustomerAccountError.invalidCallback + } + + let queryItems = components.queryItems ?? [] + let parameters = Dictionary(uniqueKeysWithValues: queryItems.compactMap { item -> (String, String)? in + guard let value = item.value else { return nil } + return (item.name, value) + }) + + guard let state = parameters["state"], validateState(state) else { + throw CustomerAccountError.invalidState + } + + if let error = parameters["error"] { + throw CustomerAccountError.authorizationFailed(error) + } + + guard let code = parameters["code"] else { + throw CustomerAccountError.invalidAuthorizationCode + } + return code + } + + private func exchangeCodeForTokens(code: String) async throws { logger.debug("Exchanging authorization code for tokens...") guard let verifier = codeVerifier else { @@ -154,9 +223,6 @@ final class CustomerAccountManager: ObservableObject { throw CustomerAccountError.missingConfiguration } - isLoading = true - defer { isLoading = false } - let body: [String: String] = [ "grant_type": "authorization_code", "client_id": clientId, @@ -173,10 +239,11 @@ final class CustomerAccountManager: ObservableObject { extractEmailFromIdToken(tokens.idToken) CartManager.shared.resetCart() + ShopifyCheckoutKit.invalidate() codeVerifier = nil savedState = nil - logger.debug("Login complete, authenticated: \(isAuthenticated), email: \(customerEmail ?? "nil")") + logger.debug("Login complete") } func refreshAccessToken() async throws { @@ -196,7 +263,7 @@ final class CustomerAccountManager: ObservableObject { let existingEmail = KeychainHelper.shared.getEmail() let existingIdToken = tokens.idToken - logger.debug("Preserved existing email: \(existingEmail ?? "nil"), ID token present: \(existingIdToken != nil)") + logger.debug("Preserved existing ID token: \(existingIdToken != nil)") isLoading = true defer { isLoading = false } @@ -225,7 +292,7 @@ final class CustomerAccountManager: ObservableObject { logger.debug("New ID token received, extracting email") extractEmailFromIdToken(newIdToken) } else if let existingEmail { - logger.debug("No new ID token, preserving existing email: \(existingEmail)") + logger.debug("No new ID token, preserving existing email") customerEmail = existingEmail } else if let existingIdToken { logger.debug("No stored email but have original ID token, extracting...") @@ -261,15 +328,7 @@ final class CustomerAccountManager: ObservableObject { func logout() { logger.debug("Logging out...") let idToken = KeychainHelper.shared.getTokens()?.idToken - - KeychainHelper.shared.clearTokens() - isAuthenticated = false - customerEmail = nil - tokenExpiresAt = nil - codeVerifier = nil - savedState = nil - - CartManager.shared.resetCart() + clearLocalSession() if let idToken { logger.debug("Sending logout request to server") @@ -280,6 +339,44 @@ final class CustomerAccountManager: ObservableObject { logger.debug("Logout complete") } + func logout(from presentationAnchor: ASPresentationAnchor) async throws { + let idToken = KeychainHelper.shared.getTokens()?.idToken + isLoading = true + defer { + clearLocalSession() + isLoading = false + } + + guard let idToken, + let logoutURL = buildLogoutURL(idTokenHint: idToken), + let callbackScheme + else { + return + } + + do { + _ = try await presentAuthenticationSession( + url: logoutURL, + callbackScheme: callbackScheme, + from: presentationAnchor + ) + } catch CustomerAccountError.authorizationCancelled { + // The local session is already cleared. Surface cancellation so the + // app can explain that the browser session may still be active. + throw CustomerAccountError.authorizationCancelled + } + } + + private func buildLogoutURL(idTokenHint: String) -> URL? { + guard let logoutEndpoint, let redirectUri else { return nil } + guard var components = URLComponents(string: logoutEndpoint) else { return nil } + components.queryItems = [ + URLQueryItem(name: "id_token_hint", value: idTokenHint), + URLQueryItem(name: "post_logout_redirect_uri", value: redirectUri) + ] + return components.url + } + private func performLogoutRequest(idTokenHint: String) async { guard let logoutEndpoint else { return } guard var components = URLComponents(string: logoutEndpoint) else { return } @@ -295,6 +392,18 @@ final class CustomerAccountManager: ObservableObject { _ = try? await URLSession.shared.data(for: request) } + private func clearLocalSession() { + KeychainHelper.shared.clearTokens() + isAuthenticated = false + customerEmail = nil + tokenExpiresAt = nil + codeVerifier = nil + savedState = nil + + CartManager.shared.resetCart() + ShopifyCheckoutKit.invalidate() + } + func checkExistingSession() { logger.debug("Checking existing session...") @@ -304,7 +413,6 @@ final class CustomerAccountManager: ObservableObject { } customerEmail = KeychainHelper.shared.getEmail() - logger.debug("Loaded email from keychain: \(customerEmail ?? "nil")") if !tokens.isExpired { logger.debug("Tokens valid, expires at: \(tokens.expiresAt)") @@ -341,7 +449,8 @@ final class CustomerAccountManager: ObservableObject { request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - let bodyString = body.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" } + let bodyString = body.sorted(by: { $0.key < $1.key }) + .map { "\(formEncode($0.key))=\(formEncode($0.value))" } .joined(separator: "&") request.httpBody = bodyString.data(using: .utf8) @@ -378,6 +487,11 @@ final class CustomerAccountManager: ObservableObject { ) } + private func formEncode(_ value: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + private func extractEmailFromIdToken(_ idToken: String?) { guard let idToken else { logger.debug("extractEmailFromIdToken called with nil ID token") @@ -405,55 +519,62 @@ final class CustomerAccountManager: ObservableObject { return } - logger.debug("ID Token payload claims:") - for (key, value) in json.sorted(by: { $0.key < $1.key }) { - logger.debug(" \(key): \(value)") - } - guard let email = json["email"] as? String else { logger.error("No 'email' claim found in ID token") return } - logger.debug("Extracted email from ID token: \(email)") customerEmail = email KeychainHelper.shared.saveEmail(email) } - func handleCallback(url: URL) -> Bool { - guard let callbackScheme, - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - components.scheme == callbackScheme, - components.host == "callback" - else { - return false + private func presentAuthenticationSession( + url: URL, + callbackScheme: String, + from presentationAnchor: ASPresentationAnchor + ) async throws -> URL { + guard authenticationSession == nil else { + throw CustomerAccountError.authorizationInProgress } - guard let queryItems = components.queryItems else { - return false - } - - let queryDict = Dictionary(uniqueKeysWithValues: queryItems.compactMap { item -> (String, String)? in - guard let value = item.value else { return nil } - return (item.name, value) - }) - - guard let state = queryDict["state"], validateState(state) else { - return false - } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let contextProvider = CustomerAccountPresentationContextProvider(anchor: presentationAnchor) + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { [weak self] callbackURL, error in + Task { @MainActor in + self?.authenticationSession = nil + self?.presentationContextProvider = nil + + if let authenticationError = error as? ASWebAuthenticationSessionError, + authenticationError.code == .canceledLogin + { + continuation.resume(throwing: CustomerAccountError.authorizationCancelled) + } else if let error { + continuation.resume(throwing: CustomerAccountError.authorizationFailed(error.localizedDescription)) + } else if let callbackURL { + continuation.resume(returning: callbackURL) + } else { + continuation.resume(throwing: CustomerAccountError.invalidCallback) + } + } + } + session.presentationContextProvider = contextProvider + session.prefersEphemeralWebBrowserSession = false - guard let code = queryDict["code"] else { - return false - } + authenticationSession = session + presentationContextProvider = contextProvider - Task { - do { - try await exchangeCodeForTokens(code: code) - } catch { - logger.error("Failed to exchange code for tokens: \(error)") + guard session.start() else { + authenticationSession = nil + presentationContextProvider = nil + continuation.resume(throwing: CustomerAccountError.authorizationFailed("Unable to start the browser session")) + return + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.authenticationSession?.cancel() } } - - return true } }