diff --git a/Example/Example/Sources/UI/Modules/AlternativePayment/Data/AlternativePaymentDataBuilder.swift b/Example/Example/Sources/UI/Modules/AlternativePayment/Data/AlternativePaymentDataBuilder.swift index 61022a4c4..42fd1bce2 100644 --- a/Example/Example/Sources/UI/Modules/AlternativePayment/Data/AlternativePaymentDataBuilder.swift +++ b/Example/Example/Sources/UI/Modules/AlternativePayment/Data/AlternativePaymentDataBuilder.swift @@ -7,6 +7,7 @@ import UIKit +@MainActor final class AlternativePaymentDataBuilder { init(completion: @escaping ([String: String]) -> Void) { diff --git a/Example/Example/Sources/UI/Modules/AlternativePayment/Data/ViewModel/AlternativePaymentDataViewModelType.swift b/Example/Example/Sources/UI/Modules/AlternativePayment/Data/ViewModel/AlternativePaymentDataViewModelType.swift index 6df0b8d3f..66df9bfbc 100644 --- a/Example/Example/Sources/UI/Modules/AlternativePayment/Data/ViewModel/AlternativePaymentDataViewModelType.swift +++ b/Example/Example/Sources/UI/Modules/AlternativePayment/Data/ViewModel/AlternativePaymentDataViewModelType.swift @@ -7,6 +7,7 @@ import Foundation +@MainActor protocol AlternativePaymentDataViewModelType: ViewModelType { /// Submits items and continues payment. diff --git a/Example/Example/Sources/UI/Modules/AlternativePayment/DataEntry/AlternativePaymentDataEntryBuilder.swift b/Example/Example/Sources/UI/Modules/AlternativePayment/DataEntry/AlternativePaymentDataEntryBuilder.swift index 68c30560c..57ec95f7e 100644 --- a/Example/Example/Sources/UI/Modules/AlternativePayment/DataEntry/AlternativePaymentDataEntryBuilder.swift +++ b/Example/Example/Sources/UI/Modules/AlternativePayment/DataEntry/AlternativePaymentDataEntryBuilder.swift @@ -7,6 +7,7 @@ import UIKit +@MainActor final class AlternativePaymentDataEntryBuilder { init(completion: @escaping (_ key: String, _ value: String) -> Void) { diff --git a/Example/Example/Sources/UI/Modules/AlternativePayment/Methods/AlternativePaymentMethodsBuilder.swift b/Example/Example/Sources/UI/Modules/AlternativePayment/Methods/AlternativePaymentMethodsBuilder.swift index 27adb2590..0e015c28d 100644 --- a/Example/Example/Sources/UI/Modules/AlternativePayment/Methods/AlternativePaymentMethodsBuilder.swift +++ b/Example/Example/Sources/UI/Modules/AlternativePayment/Methods/AlternativePaymentMethodsBuilder.swift @@ -8,6 +8,7 @@ import UIKit import ProcessOut +@MainActor final class AlternativePaymentMethodsBuilder { init(filter: POAllGatewayConfigurationsRequest.Filter) { diff --git a/Example/Example/Sources/UI/Modules/AuthorizationAmount/AuthorizationAmountBuilder.swift b/Example/Example/Sources/UI/Modules/AuthorizationAmount/AuthorizationAmountBuilder.swift index 8419d723c..df7a28cf2 100644 --- a/Example/Example/Sources/UI/Modules/AuthorizationAmount/AuthorizationAmountBuilder.swift +++ b/Example/Example/Sources/UI/Modules/AuthorizationAmount/AuthorizationAmountBuilder.swift @@ -7,6 +7,7 @@ import UIKit +@MainActor final class AuthorizationAmountBuilder { init(completion: @escaping (_ amount: Decimal, _ currencyCode: String) -> Void) { diff --git a/Example/Example/Sources/UI/Modules/CardPayment/CardPaymentBuilder.swift b/Example/Example/Sources/UI/Modules/CardPayment/CardPaymentBuilder.swift index c5db320d5..7ed7a2a50 100644 --- a/Example/Example/Sources/UI/Modules/CardPayment/CardPaymentBuilder.swift +++ b/Example/Example/Sources/UI/Modules/CardPayment/CardPaymentBuilder.swift @@ -10,6 +10,7 @@ import ProcessOut import ProcessOutUI import ProcessOutCheckout3DS +@MainActor final class CardPaymentBuilder { init(threeDSService: CardPayment3DSService = .test, completion: @escaping (Result) -> Void) { @@ -21,12 +22,9 @@ final class CardPaymentBuilder { let threeDSService: PO3DSService switch self.threeDSService { case .test: - threeDSService = POTest3DSService(returnUrl: Constants.returnUrl) + threeDSService = POTest3DSService() case .checkout: - threeDSService = POCheckout3DSServiceBuilder() - .with(delegate: CardPaymentCheckout3DSServiceDelegate()) - .with(environment: .sandbox) - .build() + threeDSService = POCheckout3DSService(environment: .sandbox) } let delegate = CardPaymentDelegate( invoicesService: ProcessOut.shared.invoices, threeDSService: threeDSService diff --git a/Example/Example/Sources/UI/Modules/CardPayment/CardPaymentCheckout3DSServiceDelegate.swift b/Example/Example/Sources/UI/Modules/CardPayment/CardPaymentCheckout3DSServiceDelegate.swift deleted file mode 100644 index 1c2aacee5..000000000 --- a/Example/Example/Sources/UI/Modules/CardPayment/CardPaymentCheckout3DSServiceDelegate.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// CardPaymentCheckout3DSServiceDelegate.swift -// Example -// -// Created by Andrii Vysotskyi on 31.01.2024. -// - -import UIKit -import ProcessOut -import ProcessOutUI -import ProcessOutCheckout3DS - -final class CardPaymentCheckout3DSServiceDelegate: POCheckout3DSServiceDelegate { - - func handle(redirect: PO3DSRedirect, completion: @escaping (Result) -> Void) { - Task { @MainActor in - let session = POWebAuthenticationSession( - redirect: redirect, returnUrl: Constants.returnUrl, completion: completion - ) - if await session.start() { - return - } - let failure = POFailure(message: "Unable to process redirect", code: .generic(.mobile)) - completion(.failure(failure)) - } - } -} diff --git a/Example/Example/Sources/UI/Modules/Features/FeaturesBuilder.swift b/Example/Example/Sources/UI/Modules/Features/FeaturesBuilder.swift index 98c8d1369..03aee7aff 100644 --- a/Example/Example/Sources/UI/Modules/Features/FeaturesBuilder.swift +++ b/Example/Example/Sources/UI/Modules/Features/FeaturesBuilder.swift @@ -8,6 +8,7 @@ import UIKit import ProcessOut +@MainActor final class FeaturesBuilder { func build() -> UIViewController { diff --git a/Example/Example/Sources/UI/Modules/Features/ViewModel/FeaturesViewModel.swift b/Example/Example/Sources/UI/Modules/Features/ViewModel/FeaturesViewModel.swift index 69c22dda5..3ed8d4511 100644 --- a/Example/Example/Sources/UI/Modules/Features/ViewModel/FeaturesViewModel.swift +++ b/Example/Example/Sources/UI/Modules/Features/ViewModel/FeaturesViewModel.swift @@ -148,7 +148,7 @@ extension FeaturesViewModel: PODynamicCheckoutDelegate { func dynamicCheckout( willAuthorizeInvoiceWith request: inout POInvoiceAuthorizationRequest ) async -> any PO3DSService { - POTest3DSService(returnUrl: Constants.returnUrl) + POTest3DSService() } func dynamicCheckout(willAuthorizeInvoiceWith request: PKPaymentRequest) async { diff --git a/Example/Example/Sources/UI/Shared/Architecture/Router/RouterType.swift b/Example/Example/Sources/UI/Shared/Architecture/Router/RouterType.swift index 27e13e9d3..a756517fd 100644 --- a/Example/Example/Sources/UI/Shared/Architecture/Router/RouterType.swift +++ b/Example/Example/Sources/UI/Shared/Architecture/Router/RouterType.swift @@ -5,6 +5,7 @@ // Created by Andrii Vysotskyi on 23.10.2022. // +@MainActor protocol RouterType: AnyObject { /// `RouteType` defines which routes can be triggered in a certain implementation. diff --git a/Example/Example/Sources/UI/Shared/Architecture/ViewModel/ViewModelType.swift b/Example/Example/Sources/UI/Shared/Architecture/ViewModel/ViewModelType.swift index 5af09e2f6..946473048 100644 --- a/Example/Example/Sources/UI/Shared/Architecture/ViewModel/ViewModelType.swift +++ b/Example/Example/Sources/UI/Shared/Architecture/ViewModel/ViewModelType.swift @@ -5,6 +5,7 @@ // Created by Andrii Vysotskyi on 21.10.2022. // +@MainActor protocol ViewModelType: AnyObject { associatedtype State diff --git a/Example/Example/Sources/UI/Shared/View/ViewController.swift b/Example/Example/Sources/UI/Shared/View/ViewController.swift index e558dcfe9..1b8ffa276 100644 --- a/Example/Example/Sources/UI/Shared/View/ViewController.swift +++ b/Example/Example/Sources/UI/Shared/View/ViewController.swift @@ -40,7 +40,9 @@ class ViewController: UIViewController { // as a workaround, configuration is postponed to a point when tracking ends. if RunLoop.current.currentMode == .tracking { RunLoop.current.perform { - self.configure(with: self.viewModel.state) + MainActor.assumeIsolated { + self.configure(with: self.viewModel.state) + } } } else { configure(with: viewModel.state) diff --git a/Package.swift b/Package.swift index e98b56b8b..4c5ebd5a3 100644 --- a/Package.swift +++ b/Package.swift @@ -3,6 +3,7 @@ import PackageDescription let swiftSettings: [SwiftSetting] = [ + .enableExperimentalFeature("IsolatedAny"), .enableUpcomingFeature("StrictConcurrency") ] @@ -27,7 +28,6 @@ let package = Package( dependencies: [ .target(name: "cmark") ], - exclude: ["swiftgen.yml"], resources: [ .process("Resources") ], diff --git a/Sources/ProcessOut/Sources/Api/ProcessOut.swift b/Sources/ProcessOut/Sources/Api/ProcessOut.swift index 61ea1139d..a6d80fa7e 100644 --- a/Sources/ProcessOut/Sources/Api/ProcessOut.swift +++ b/Sources/ProcessOut/Sources/Api/ProcessOut.swift @@ -154,12 +154,13 @@ public final class ProcessOut: @unchecked Sendable { } private static func create3DSService() -> DefaultThreeDSService { + let webSession = WebAuthenticationSession() let decoder = JSONDecoder() decoder.keyDecodingStrategy = .useDefaultKeys let encoder = JSONEncoder() encoder.dataEncodingStrategy = .base64 encoder.keyEncodingStrategy = .useDefaultKeys - return DefaultThreeDSService(decoder: decoder, encoder: encoder) + return DefaultThreeDSService(decoder: decoder, encoder: encoder, webSession: webSession) } private func createConnector( diff --git a/Sources/ProcessOut/Sources/Core/Utils/AsyncUtils.swift b/Sources/ProcessOut/Sources/Core/Utils/AsyncUtils.swift index 73521219e..996983b0a 100644 --- a/Sources/ProcessOut/Sources/Core/Utils/AsyncUtils.swift +++ b/Sources/ProcessOut/Sources/Core/Utils/AsyncUtils.swift @@ -12,8 +12,8 @@ import Foundation /// - Warning: operation should support cancellation, otherwise calling this method has no effect. func withTimeout( _ timeout: TimeInterval, - error timeoutError: @autoclosure () -> Error, - perform operation: @escaping @Sendable () async throws -> T + error timeoutError: @autoclosure @Sendable () -> Error, + perform operation: @escaping @Sendable @isolated(any) () async throws -> T ) async throws -> T { let isTimedOut = POUnfairlyLocked(wrappedValue: false) let task = Task(operation: operation) @@ -48,10 +48,10 @@ func withTimeout( // MARK: - Retry func retry( - operation: @escaping @Sendable () async throws -> T, + operation: @escaping @Sendable @isolated(any) () async throws -> T, while condition: @escaping @Sendable (Result) -> Bool, timeout: TimeInterval, - timeoutError: @autoclosure () -> Error, + timeoutError: @autoclosure @Sendable () -> Error, retryStrategy: RetryStrategy? = nil ) async throws -> T { let operationBox = { @Sendable in @@ -67,7 +67,7 @@ func retry( } private func retry( - operation: @escaping @Sendable () async throws -> T, + operation: @escaping @Sendable @isolated(any) () async throws -> T, after result: Result, while condition: @escaping @Sendable (Result) -> Bool, retryStrategy: RetryStrategy?, @@ -91,11 +91,13 @@ private func retry( ) } -extension Result where Failure == Error { +extension Result where Failure == Error, Success: Sendable { /// Creates a new result by evaluating a throwing closure, capturing the /// returned value as a success, or any thrown error as a failure. - fileprivate init(catching body: () async throws -> Success) async { // swiftlint:disable:this strict_fileprivate + fileprivate init( // swiftlint:disable:this strict_fileprivate + catching body: @isolated(any) () async throws -> Success + ) async { do { let success = try await body() self = .success(success) diff --git a/Sources/ProcessOut/Sources/Core/Utils/Batcher.swift b/Sources/ProcessOut/Sources/Core/Utils/Batcher.swift index 7899ad5fd..6166469fd 100644 --- a/Sources/ProcessOut/Sources/Core/Utils/Batcher.swift +++ b/Sources/ProcessOut/Sources/Core/Utils/Batcher.swift @@ -7,9 +7,9 @@ import Foundation -final class Batcher: Sendable { +final class Batcher: Sendable { - typealias Executor = @Sendable (Array) async -> Bool + typealias Executor = @Sendable @isolated(any) (Array) async -> Bool init(executionInterval: TimeInterval = 10, executor: @escaping Executor) { self.executionInterval = executionInterval diff --git a/Sources/ProcessOut/Sources/Core/WebAuthenticationSession/WebAuthenticationSession.swift b/Sources/ProcessOut/Sources/Core/WebAuthenticationSession/WebAuthenticationSession.swift new file mode 100644 index 000000000..7ccbbf187 --- /dev/null +++ b/Sources/ProcessOut/Sources/Core/WebAuthenticationSession/WebAuthenticationSession.swift @@ -0,0 +1,120 @@ +// +// WebAuthenticationSession.swift +// ProcessOut +// +// Created by Andrii Vysotskyi on 01.08.2024. +// + +import AuthenticationServices + +@MainActor +final class WebAuthenticationSession: NSObject, Sendable, ASWebAuthenticationPresentationContextProviding { + + override nonisolated init() { + // Ignored + } + + func authenticate( + using url: URL, + callbackScheme: String? = nil, + additionalHeaderFields: [String: String]? = nil + ) async throws -> URL { + let sessionProxy = WebAuthenticationSessionProxy() + return try await withTaskCancellationHandler( + operation: { + try await withCheckedThrowingContinuation { continuation in + guard !Task.isCancelled else { + let failure = POFailure(message: "Authentication session was cancelled.", code: .cancelled) + continuation.resume(throwing: failure) + return + } + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: callbackScheme, + completionHandler: { url, error in + sessionProxy.invalidate() + if let error { + continuation.resume(throwing: Self.converted(error: error)) + } else if let url { + continuation.resume(returning: url) + } else { + preconditionFailure("Unexpected ASWebAuthenticationSession completion result.") + } + } + ) + session.prefersEphemeralWebBrowserSession = true + session.presentationContextProvider = self + if #available(iOS 17.4, *) { + session.additionalHeaderFields = additionalHeaderFields + } + sessionProxy.setSession(session, continuation: continuation) + session.start() + } + }, + onCancel: { + sessionProxy.cancel() + } + ) + } + + // MARK: - ASWebAuthenticationPresentationContextProviding + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + let application = UIApplication.shared + let scene = application.connectedScenes.first { $0 is UIWindowScene } as? UIWindowScene + let window = scene?.windows.first(where: \.isKeyWindow) + return window ?? ASPresentationAnchor() + } + + // MARK: - Private Methods + + private static func converted(error: Error) -> POFailure { + guard let error = error as? ASWebAuthenticationSessionError else { + return POFailure(code: .generic(.mobile), underlyingError: error) + } + let poCode: POFailure.Code + switch error.code { + case .canceledLogin: + poCode = .cancelled + case .presentationContextNotProvided, .presentationContextInvalid: + poCode = .internal(.mobile) + @unknown default: + poCode = .generic(.mobile) + } + return POFailure(code: poCode, underlyingError: error) + } +} + +@MainActor +private final class WebAuthenticationSessionProxy: Sendable { + + func setSession(_ session: ASWebAuthenticationSession, continuation: CheckedContinuation) { + self.session = session + self.continuation = continuation + } + + func invalidate() { + session = nil + continuation = nil + } + + nonisolated func cancel() { + Task { @MainActor in + _cancel() + } + } + + // MARK: - Private Properties + + private var session: ASWebAuthenticationSession? + private var continuation: CheckedContinuation? + + // MARK: - Private Methods + + private func _cancel() { + let failure = POFailure(message: "Authentication session was cancelled.", code: .cancelled) + session?.cancel() + continuation?.resume(throwing: failure) + invalidate() + } +} diff --git a/Sources/ProcessOut/Sources/Services/3DS/DefaultThreeDSService.swift b/Sources/ProcessOut/Sources/Services/3DS/DefaultThreeDSService.swift index 966605091..a8cf84259 100644 --- a/Sources/ProcessOut/Sources/Services/3DS/DefaultThreeDSService.swift +++ b/Sources/ProcessOut/Sources/Services/3DS/DefaultThreeDSService.swift @@ -7,28 +7,39 @@ import Foundation +@MainActor final class DefaultThreeDSService: ThreeDSService { - init(decoder: JSONDecoder, encoder: JSONEncoder, jsonWritingOptions: JSONSerialization.WritingOptions = []) { + nonisolated init( + decoder: JSONDecoder, + encoder: JSONEncoder, + jsonWritingOptions: JSONSerialization.WritingOptions = [], + webSession: WebAuthenticationSession + ) { self.decoder = decoder self.encoder = encoder self.jsonWritingOptions = jsonWritingOptions + self.webSession = webSession } // MARK: - ThreeDSService func handle(action: ThreeDSCustomerAction, delegate: Delegate) async throws -> String { - // todo(andrii-vysotskyi): when async delegate methods are publicly available ensure - // that thrown errors are mapped to POFailure if needed. - switch action.type { - case .fingerprintMobile: - return try await fingerprint(encodedConfiguration: action.value, delegate: delegate) - case .challengeMobile: - return try await challenge(encodedChallenge: action.value, delegate: delegate) - case .fingerprint: - return try await fingerprint(url: action.value, delegate: delegate) - case .redirect, .url: - return try await redirect(url: action.value, delegate: delegate) + do { + switch action.type { + case .fingerprintMobile: + return try await fingerprint(encodedConfiguration: action.value, delegate: delegate) + case .challengeMobile: + return try await challenge(encodedChallenge: action.value, delegate: delegate) + case .fingerprint: + return try await fingerprint(url: action.value) + case .redirect, .url: + return try await redirect(url: action.value) + } + } catch let error as POFailure { + throw error + } catch { + throw POFailure(code: .generic(.mobile), underlyingError: error) } } @@ -37,8 +48,6 @@ final class DefaultThreeDSService: ThreeDSService { private enum Constants { static let deviceChannel = "app" static let tokenPrefix = "gway_req_" - static let challengeSuccessEncodedResponse = "eyJib2R5IjoieyBcInRyYW5zU3RhdHVzXCI6IFwiWVwiIH0ifQ==" - static let challengeFailureEncodedResponse = "eyJib2R5IjoieyBcInRyYW5zU3RhdHVzXCI6IFwiTlwiIH0ifQ==" static let fingerprintTimeoutResponseBody = #"{ "threeDS2FingerprintTimeout": true }"# static let webFingerprintTimeout: TimeInterval = 10 } @@ -53,33 +62,48 @@ final class DefaultThreeDSService: ThreeDSService { private let decoder: JSONDecoder private let encoder: JSONEncoder private let jsonWritingOptions: JSONSerialization.WritingOptions + private let webSession: WebAuthenticationSession - // MARK: - Private Methods + // MARK: - Native 3DS private func fingerprint(encodedConfiguration: String, delegate: Delegate) async throws -> String { let configuration = try decode(PO3DS2Configuration.self, from: encodedConfiguration) - let request = try await delegate.authenticationRequest(configuration: configuration) - let response = AuthenticationResponse(url: nil, body: try self.encode(request: request)) + let requestParameters = try await delegate.authenticationRequestParameters(configuration: configuration) + let response = AuthenticationResponse(url: nil, body: try self.encode(requestParameters: requestParameters)) return try encode(authenticationResponse: response) } private func challenge(encodedChallenge: String, delegate: Delegate) async throws -> String { - let challenge = try decode(PO3DS2Challenge.self, from: encodedChallenge) - let success = try await delegate.handle(challenge: challenge) - let encodedResponse = success - ? Constants.challengeSuccessEncodedResponse - : Constants.challengeFailureEncodedResponse - return Constants.tokenPrefix + encodedResponse + let parameters = try decode(PO3DS2ChallengeParameters.self, from: encodedChallenge) + let result = try await delegate.performChallenge(with: parameters) + let encodedChallengeResult: String + do { + encodedChallengeResult = try String(decoding: encoder.encode(result), as: UTF8.self) + } catch { + let message = "Did fail to encode CRES result." + throw POFailure(message: message, code: .internal(.mobile), underlyingError: error) + } + let response = AuthenticationResponse(url: nil, body: encodedChallengeResult) + return try Constants.tokenPrefix + encode(authenticationResponse: response) } - private func fingerprint(url: String, delegate: Delegate) async throws -> String { + // MARK: - Web Based 3DS + + private func fingerprint(url: String) async throws -> String { guard let url = URL(string: url) else { let message = "Unable to create URL from string: \(url)." throw POFailure(message: message, code: .internal(.mobile), underlyingError: nil) } - let context = PO3DSRedirect(url: url, timeout: Constants.webFingerprintTimeout) do { - return try await delegate.handle(redirect: context) + let returnUrl = try await withTimeout( + Constants.webFingerprintTimeout, + error: POFailure(code: .timeout(.mobile)), + perform: { + try await self.webSession.authenticate(using: url) + } + ) + let queryItems = URLComponents(string: returnUrl.absoluteString)?.queryItems + return queryItems?.first { $0.name == "token" }?.value ?? "" } catch let failure as POFailure where failure.code == .timeout(.mobile) { // Fingerprinting timeout is treated differently from other errors. let response = AuthenticationResponse(url: url, body: Constants.fingerprintTimeoutResponseBody) @@ -87,13 +111,14 @@ final class DefaultThreeDSService: ThreeDSService { } } - private func redirect(url: String, delegate: Delegate) async throws -> String { + private func redirect(url: String) async throws -> String { guard let url = URL(string: url) else { let message = "Unable to create URL from string: \(url)." throw POFailure(message: message, code: .internal(.mobile), underlyingError: nil) } - let context = PO3DSRedirect(url: url, timeout: nil) - return try await delegate.handle(redirect: context) + let returnUrl = try await self.webSession.authenticate(using: url) + let queryItems = URLComponents(string: returnUrl.absoluteString)?.queryItems + return queryItems?.first { $0.name == "token" }?.value ?? "" } // MARK: - Coding @@ -113,20 +138,20 @@ final class DefaultThreeDSService: ThreeDSService { } } - private func encode(request: PO3DS2AuthenticationRequest) throws -> String { + private func encode(requestParameters parameters: PO3DS2AuthenticationRequestParameters) throws -> String { do { // Using JSONSerialization helps avoid creating boilerplate objects for JSON Web Key for coding. // Implementation doesn't validate JWK correctness and simply re-encodes given value. let sdkEphemeralPublicKey = try JSONSerialization.jsonObject( - with: Data(request.sdkEphemeralPublicKey.utf8) + with: Data(parameters.sdkEphemeralPublicKey.utf8) ) let requestParameters = [ "deviceChannel": Constants.deviceChannel, - "sdkAppID": request.sdkAppId, + "sdkAppID": parameters.sdkAppId, "sdkEphemPubKey": sdkEphemeralPublicKey, - "sdkReferenceNumber": request.sdkReferenceNumber, - "sdkTransID": request.sdkTransactionId, - "sdkEncData": request.deviceData + "sdkReferenceNumber": parameters.sdkReferenceNumber, + "sdkTransID": parameters.sdkTransactionId, + "sdkEncData": parameters.deviceData ] let requestParametersData = try JSONSerialization.data( withJSONObject: requestParameters, options: jsonWritingOptions @@ -138,14 +163,12 @@ final class DefaultThreeDSService: ThreeDSService { } } - // MARK: - Utils - /// Encodes given response and creates token with it. private func encode(authenticationResponse: AuthenticationResponse) throws -> String { do { return try Constants.tokenPrefix + encoder.encode(authenticationResponse).base64EncodedString() } catch { - let message = "Did fail to encode AREQ parameters." + let message = "Did fail to encode authentication result." throw POFailure(message: message, code: .internal(.mobile), underlyingError: error) } } diff --git a/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2AuthenticationRequest.swift b/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2AuthenticationRequestParameters.swift similarity index 79% rename from Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2AuthenticationRequest.swift rename to Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2AuthenticationRequestParameters.swift index f42d1fa36..b19f4afe9 100644 --- a/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2AuthenticationRequest.swift +++ b/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2AuthenticationRequestParameters.swift @@ -1,12 +1,15 @@ // -// PO3DS2AuthenticationRequest.swift +// PO3DS2AuthenticationRequestParameters.swift // ProcessOut // // Created by Andrii Vysotskyi on 03.11.2022. // +@available(*, deprecated, renamed: "PO3DS2AuthenticationRequestParameters") +public typealias PO3DS2AuthenticationRequest = PO3DS2AuthenticationRequestParameters + /// Holds transaction data that the 3DS Server requires to create the AReq. -public struct PO3DS2AuthenticationRequest: Hashable, Sendable { +public struct PO3DS2AuthenticationRequestParameters: Hashable, Sendable { /// Encrypted device data as a JWE string. public let deviceData: String diff --git a/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2Challenge.swift b/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2ChallengeParameters.swift similarity index 80% rename from Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2Challenge.swift rename to Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2ChallengeParameters.swift index 2764a221e..cba716637 100644 --- a/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2Challenge.swift +++ b/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2ChallengeParameters.swift @@ -1,13 +1,16 @@ // -// PO3DS2Challenge.swift +// PO3DS2ChallengeParameters.swift // ProcessOut // // Created by Andrii Vysotskyi on 02.11.2022. // +@available(*, deprecated, renamed: "PO3DS2ChallengeParameters") +public typealias PO3DS2Challenge = PO3DS2ChallengeParameters + /// Information from the 3DS Server's authentication response that could be used by the 3DS2 SDK to initiate /// the challenge flow. -public struct PO3DS2Challenge: Decodable, Hashable, Sendable { +public struct PO3DS2ChallengeParameters: Decodable, Hashable, Sendable { /// Unique transaction identifier assigned by the ACS. public let acsTransactionId: String diff --git a/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2ChallengeResult.swift b/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2ChallengeResult.swift new file mode 100644 index 000000000..ef327e5ca --- /dev/null +++ b/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2ChallengeResult.swift @@ -0,0 +1,23 @@ +// +// PO3DS2ChallengeResult.swift +// ProcessOut +// +// Created by Andrii Vysotskyi on 01.08.2024. +// + +/// Contains information about completion of the challenge process. +public struct PO3DS2ChallengeResult: Encodable, Sendable { + + /// The transaction status that was received in the final challenge response. + public let transactionStatus: String + + public init(transactionStatus: String) { + self.transactionStatus = transactionStatus + } + + // MARK: - Private Nested Types + + private enum CodingKeys: String, CodingKey { + case transactionStatus = "transStatus" + } +} diff --git a/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2Configuration.swift b/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2Configuration.swift index 0c6d7384b..85b2af2ff 100644 --- a/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2Configuration.swift +++ b/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2Configuration.swift @@ -21,8 +21,7 @@ public struct PO3DS2Configuration: Decodable, Hashable, Sendable { public let directoryServerTransactionId: String /// Card scheme from the card used to initiate the payment. - @POTypedRepresentation - public private(set) var scheme: PO3DS2ConfigurationCardScheme? + public let scheme: POCardScheme? /// 3DS protocol version identifier. public let messageVersion: String diff --git a/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2ConfigurationCardScheme.swift b/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2ConfigurationCardScheme.swift deleted file mode 100644 index a2792de03..000000000 --- a/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DS2ConfigurationCardScheme.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// PO3DS2ConfigurationCardScheme.swift -// ProcessOut -// -// Created by Andrii Vysotskyi on 27.03.2023. -// - -// todo(andrii-vysotskyi): remove when updating to 5.0.0 - -/// Available card schemes. -public enum PO3DS2ConfigurationCardScheme: RawRepresentable, Decodable, Hashable, Sendable { - - /// Known card schemes. - case visa, mastercard, europay, carteBancaire, jcb, diners, discover, unionpay, americanExpress - - /// Used for schemes unknown to sdk. - case unknown(String) - - public init(rawValue: String) { - self = Self.knownSchemes[rawValue] ?? .unknown(rawValue) - } - - public var rawValue: String { - switch self { - case .visa: - return Constants.visa - case .mastercard: - return Constants.mastercard - case .europay: - return Constants.europay - case .carteBancaire: - return Constants.carteBancaire - case .jcb: - return Constants.jcb - case .diners: - return Constants.diners - case .discover: - return Constants.discover - case .unionpay: - return Constants.unionpay - case .americanExpress: - return Constants.americanExpress - case .unknown(let rawValue): - return rawValue - } - } - - // MARK: - Private Nested Types - - private enum Constants { - static let visa = "visa" - static let mastercard = "mastercard" - static let europay = "europay" - static let carteBancaire = "carte bancaire" - static let jcb = "jcb" - static let diners = "diners" - static let discover = "discover" - static let unionpay = "unionpay" - static let americanExpress = "american express" - } - - // MARK: - Private Properties - - private static let knownSchemes: [String: Self] = [ - Constants.visa: .visa, - Constants.mastercard: .mastercard, - Constants.europay: .europay, - Constants.carteBancaire: .carteBancaire, - Constants.jcb: .jcb, - Constants.diners: .diners, - Constants.discover: .discover, - Constants.unionpay: .unionpay, - Constants.americanExpress: .americanExpress - ] -} diff --git a/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DSRedirect.swift b/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DSRedirect.swift deleted file mode 100644 index 8dc9ef9bf..000000000 --- a/Sources/ProcessOut/Sources/Services/3DS/Models/PO3DSRedirect.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// PO3DSRedirect.swift -// ProcessOut -// -// Created by Andrii Vysotskyi on 17.01.2023. -// - -import Foundation - -/// Holds information about 3DS redirect. -public struct PO3DSRedirect: Hashable, Sendable { - - /// Redirect url. - public let url: URL - - /// Boolean value that indicates whether a given URL can be handled in headless mode, meaning - /// without showing any UI for the user. - @available(*, deprecated) - public let isHeadlessModeAllowed = false - - /// Optional timeout interval. - public let timeout: TimeInterval? -} diff --git a/Sources/ProcessOut/Sources/Services/3DS/PO3DSService.swift b/Sources/ProcessOut/Sources/Services/3DS/PO3DSService.swift index 527ad59f6..15352d77a 100644 --- a/Sources/ProcessOut/Sources/Services/3DS/PO3DSService.swift +++ b/Sources/ProcessOut/Sources/Services/3DS/PO3DSService.swift @@ -12,53 +12,10 @@ public typealias PO3DSServiceType = PO3DSService public protocol PO3DSService: AnyObject, Sendable { /// Asks implementation to create request that will be passed to 3DS Server to create the AReq. - func authenticationRequest( - configuration: PO3DS2Configuration, - completion: @escaping @Sendable (Result) -> Void - ) + func authenticationRequestParameters( + configuration: PO3DS2Configuration + ) async throws -> PO3DS2AuthenticationRequestParameters - /// Implementation must handle given 3DS2 challenge and call completion with result. Use `true` if challenge - /// was handled successfully, if transaction was denied, pass `false`. In all other cases, call completion - /// with failure indicating what went wrong. - func handle(challenge: PO3DS2Challenge, completion: @escaping @Sendable (Result) -> Void) - - /// Asks implementation to handle redirect. If value of ``PO3DSRedirect/timeout`` is present it must be - /// respected, meaning if timeout is reached `completion` should be called with instance of ``POFailure`` with - /// ``POFailure/code-swift.property`` set to ``POFailure/TimeoutCode/mobile``. - func handle(redirect: PO3DSRedirect, completion: @escaping @Sendable (Result) -> Void) -} - -@MainActor -extension PO3DSService { - - /// Asks implementation to create request that will be passed to 3DS Server to create the AReq. - func authenticationRequest(configuration: PO3DS2Configuration) async throws -> PO3DS2AuthenticationRequest { - try await withUnsafeThrowingContinuation { continuation in - authenticationRequest(configuration: configuration) { result in - continuation.resume(with: result) - } - } - } - - /// Implementation must handle given 3DS2 challenge and call completion with result. Use `true` if challenge - /// was handled successfully, if transaction was denied, pass `false`. In all other cases, call completion - /// with failure indicating what went wrong. - func handle(challenge: PO3DS2Challenge) async throws -> Bool { - try await withUnsafeThrowingContinuation { continuation in - handle(challenge: challenge) { result in - continuation.resume(with: result) - } - } - } - - /// Asks implementation to handle redirect. If value of ``PO3DSRedirect/timeout`` is present it must be - /// respected, meaning if timeout is reached `completion` should be called with instance of ``POFailure`` with - /// ``POFailure/code-swift.property`` set to ``POFailure/TimeoutCode/mobile``. - func handle(redirect: PO3DSRedirect) async throws -> String { - try await withUnsafeThrowingContinuation { continuation in - handle(redirect: redirect) { result in - continuation.resume(with: result) - } - } - } + /// Implementation must handle given 3DS2 challenge. + func performChallenge(with parameters: PO3DS2ChallengeParameters) async throws -> PO3DS2ChallengeResult } diff --git a/Sources/ProcessOutCheckout3DS/Sources/Builder/POCheckout3DSServiceBuilder.swift b/Sources/ProcessOutCheckout3DS/Sources/Builder/POCheckout3DSServiceBuilder.swift deleted file mode 100644 index 06712ca72..000000000 --- a/Sources/ProcessOutCheckout3DS/Sources/Builder/POCheckout3DSServiceBuilder.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// POCheckout3DSServiceBuilder.swift -// ProcessOutCheckout3DS -// -// Created by Andrii Vysotskyi on 01.03.2023. -// - -import Foundation -import ProcessOut -import Checkout3DS - -/// Builder to configure and create service capable of handling 3DS challenges using Checkout3DS SDK. -public final class POCheckout3DSServiceBuilder { - - /// - NOTE: Delegate will be strongly referenced by created service. - @available(*, deprecated, message: "Use non static method instead.") - public static func with(delegate: POCheckout3DSServiceDelegate) -> POCheckout3DSServiceBuilder { - POCheckout3DSServiceBuilder().with(delegate: delegate) - } - - /// Creates builder instance. - public init() { - environment = .production - } - - /// - NOTE: Delegate will be strongly referenced by created service. - public func with(delegate: POCheckout3DSServiceDelegate) -> Self { - self.delegate = delegate - return self - } - - /// Sets environment used to initialize `Standalone3DSService`. Default value is `production`. - public func with(environment: Checkout3DS.Environment) -> Self { - self.environment = environment - return self - } - - /// Creates service instance. - public func build() -> PO3DSService { - guard let delegate else { - preconditionFailure("Delegate must be set.") - } - let service = Checkout3DSService( - errorMapper: DefaultAuthenticationErrorMapper(), - configurationMapper: DefaultConfigurationMapper(), - delegate: delegate, - environment: environment - ) - return service - } - - // MARK: - Private Properties - - private var delegate: POCheckout3DSServiceDelegate? - private var environment: Checkout3DS.Environment -} diff --git a/Sources/ProcessOutCheckout3DS/Sources/Extensions/Checkout3DSTransaction+Async.swift b/Sources/ProcessOutCheckout3DS/Sources/Extensions/Checkout3DSTransaction+Async.swift new file mode 100644 index 000000000..c8357f327 --- /dev/null +++ b/Sources/ProcessOutCheckout3DS/Sources/Extensions/Checkout3DSTransaction+Async.swift @@ -0,0 +1,27 @@ +// +// Checkout3DSTransaction+Async.swift +// ProcessOutCheckout3DS +// +// Created by Andrii Vysotskyi on 01.08.2024. +// + +import Checkout3DS + +extension Checkout3DS.Transaction { + + /// Returns device and 3DS SDK information to the 3DS Requestor App. + func getAuthenticationRequestParameters() async throws -> AuthenticationRequestParameters { + try await withCheckedThrowingContinuation { continuation in + getAuthenticationRequestParameters(completion: continuation.resume) + } + } + + /// Initiates the challenge process. + func doChallenge( + challengeParameters: ChallengeParameters + ) async throws -> AuthenticationResult { + try await withCheckedThrowingContinuation { continuation in + doChallenge(challengeParameters: challengeParameters, completion: continuation.resume) + } + } +} diff --git a/Sources/ProcessOutCheckout3DS/Sources/Mappers/AuthenticationError/AuthenticationErrorMapper.swift b/Sources/ProcessOutCheckout3DS/Sources/Mappers/AuthenticationError/AuthenticationErrorMapper.swift index e0f45c197..b1fc5199f 100644 --- a/Sources/ProcessOutCheckout3DS/Sources/Mappers/AuthenticationError/AuthenticationErrorMapper.swift +++ b/Sources/ProcessOutCheckout3DS/Sources/Mappers/AuthenticationError/AuthenticationErrorMapper.swift @@ -8,8 +8,8 @@ import ProcessOut import Checkout3DS -protocol AuthenticationErrorMapper { +protocol AuthenticationErrorMapper: Sendable { /// Converts given authentication error to ProcessOut error. - func convert(error: Checkout3DS.AuthenticationError) -> POFailure + func convert(error: AuthenticationError) -> POFailure } diff --git a/Sources/ProcessOutCheckout3DS/Sources/Mappers/AuthenticationError/DefaultAuthenticationErrorMapper.swift b/Sources/ProcessOutCheckout3DS/Sources/Mappers/AuthenticationError/DefaultAuthenticationErrorMapper.swift index f58aff81c..08933491a 100644 --- a/Sources/ProcessOutCheckout3DS/Sources/Mappers/AuthenticationError/DefaultAuthenticationErrorMapper.swift +++ b/Sources/ProcessOutCheckout3DS/Sources/Mappers/AuthenticationError/DefaultAuthenticationErrorMapper.swift @@ -8,7 +8,7 @@ import ProcessOut import Checkout3DS -final class DefaultAuthenticationErrorMapper: AuthenticationErrorMapper { +struct DefaultAuthenticationErrorMapper: AuthenticationErrorMapper { func convert(error: AuthenticationError) -> POFailure { let code: POFailure.Code diff --git a/Sources/ProcessOutCheckout3DS/Sources/Mappers/Configuration/ConfigurationMapper.swift b/Sources/ProcessOutCheckout3DS/Sources/Mappers/Configuration/ConfigurationMapper.swift index ed30d47b0..2c390ec9d 100644 --- a/Sources/ProcessOutCheckout3DS/Sources/Mappers/Configuration/ConfigurationMapper.swift +++ b/Sources/ProcessOutCheckout3DS/Sources/Mappers/Configuration/ConfigurationMapper.swift @@ -8,7 +8,7 @@ import ProcessOut import Checkout3DS -protocol ConfigurationMapper { +protocol ConfigurationMapper: Sendable { /// Converts given ProcessOut configuration to Checkout config parameters. func convert(configuration: PO3DS2Configuration) -> ThreeDS2ServiceConfiguration.ConfigParameters diff --git a/Sources/ProcessOutCheckout3DS/Sources/Mappers/Configuration/DefaultConfigurationMapper.swift b/Sources/ProcessOutCheckout3DS/Sources/Mappers/Configuration/DefaultConfigurationMapper.swift index ed4ef74d4..df970b7cd 100644 --- a/Sources/ProcessOutCheckout3DS/Sources/Mappers/Configuration/DefaultConfigurationMapper.swift +++ b/Sources/ProcessOutCheckout3DS/Sources/Mappers/Configuration/DefaultConfigurationMapper.swift @@ -8,7 +8,7 @@ import ProcessOut import Checkout3DS -final class DefaultConfigurationMapper: ConfigurationMapper { +struct DefaultConfigurationMapper: ConfigurationMapper { func convert(configuration: PO3DS2Configuration) -> ThreeDS2ServiceConfiguration.ConfigParameters { let directoryServerData = ThreeDS2ServiceConfiguration.DirectoryServerData( @@ -19,7 +19,7 @@ final class DefaultConfigurationMapper: ConfigurationMapper { let configParameters = ThreeDS2ServiceConfiguration.ConfigParameters( directoryServerData: directoryServerData, messageVersion: configuration.messageVersion, - scheme: configuration.$scheme.typed().map(self.convert) ?? "" + scheme: configuration.scheme.map(self.convert) ?? "" ) return configParameters } diff --git a/Sources/ProcessOutCheckout3DS/Sources/Service/Checkout3DSService.swift b/Sources/ProcessOutCheckout3DS/Sources/Service/Checkout3DSService.swift index 309e1b8e8..af408c01f 100644 --- a/Sources/ProcessOutCheckout3DS/Sources/Service/Checkout3DSService.swift +++ b/Sources/ProcessOutCheckout3DS/Sources/Service/Checkout3DSService.swift @@ -1,5 +1,5 @@ // -// Checkout3DSService.swift +// POCheckout3DSService.swift // ProcessOutCheckout3DS // // Created by Andrii Vysotskyi on 28.02.2023. @@ -8,170 +8,125 @@ import ProcessOut import Checkout3DS -final class Checkout3DSService: PO3DSService { +public actor POCheckout3DSService: PO3DSService, Sendable { - init( - errorMapper: AuthenticationErrorMapper, - configurationMapper: ConfigurationMapper, - delegate: POCheckout3DSServiceDelegate, - environment: Checkout3DS.Environment - ) { - self.errorMapper = errorMapper - self.configurationMapper = configurationMapper + public init(delegate: POCheckout3DSServiceDelegate? = nil, environment: Environment = .production) { + errorMapper = DefaultAuthenticationErrorMapper() + configurationMapper = DefaultConfigurationMapper() self.delegate = delegate self.environment = environment - queue = DispatchQueue.global() - state = .idle } deinit { - clean() + service?.cleanUp() } + /// Service's delegate. + public weak var delegate: POCheckout3DSServiceDelegate? + // MARK: - PO3DSService - // swiftlint:disable:next function_body_length - func authenticationRequest( - configuration: PO3DS2Configuration, - completion: @escaping (Result) -> Void - ) { - delegate.willCreateAuthenticationRequest(configuration: configuration) - switch state { - case .idle, .fingerprinted: - clean() - default: - let failure = POFailure(code: .generic(.mobile)) - delegate.didCreateAuthenticationRequest(result: .failure(failure)) - completion(.failure(failure)) - return - } - let configurationParameters = configurationMapper.convert(configuration: configuration) - let configuration = delegate.configuration(with: configurationParameters) + public func authenticationRequestParameters( + configuration: PO3DS2Configuration + ) async throws -> PO3DS2AuthenticationRequestParameters { + invalidate() do { - let service = try Standalone3DSService.initialize(with: configuration, environment: environment) - let context = State.Context(service: service, transaction: service.createTransaction()) - state = .fingerprinting(context) - queue.async { [unowned self, errorMapper] in - let warnings = service.getWarnings() - DispatchQueue.main.async { - self.delegate.shouldContinue(with: warnings) { shouldContinue in - assert(Thread.isMainThread, "Completion must be called on main thread.") - if shouldContinue { - context.transaction.getAuthenticationRequestParameters { [unowned self] result in - let mappedResult = result - .mapError(errorMapper.convert) - .map(self.convertToAuthenticationRequest) - switch mappedResult { - case .success: - self.state = .fingerprinted(context) - case .failure: - self.setIdleStateUnchecked() - } - self.delegate.didCreateAuthenticationRequest(result: mappedResult) - completion(mappedResult) - } - } else { - self.setIdleStateUnchecked() - let failure = POFailure(code: .cancelled) - self.delegate.didCreateAuthenticationRequest(result: .failure(failure)) - completion(.failure(failure)) - } - } - } + let service = try Standalone3DSService.initialize( + with: await serviceConfiguration(with: configuration), environment: environment + ) + self.service = service + guard await delegate?.checkout3DSService(self, shouldContinueWith: service.getWarnings()) ?? true else { + throw POFailure(code: .cancelled) } - } catch let error as AuthenticationError { - let failure = errorMapper.convert(error: error) - delegate.didCreateAuthenticationRequest(result: .failure(failure)) - completion(.failure(failure)) + let authenticationRequest = authenticationRequest( + with: try await service.createTransaction().getAuthenticationRequestParameters() + ) + await delegate?.checkout3DSService(self, didCreateFingerprintWith: .success(authenticationRequest)) + return authenticationRequest } catch { - let failure = POFailure(code: .generic(.mobile), underlyingError: error) - delegate.didCreateAuthenticationRequest(result: .failure(failure)) - completion(.failure(failure)) + invalidate() + let failure = failure(with: error) + await delegate?.checkout3DSService(self, didCreateFingerprintWith: .failure(failure)) + throw failure } } - func handle(challenge: PO3DS2Challenge, completion: @escaping (Result) -> Void) { - delegate.willHandle(challenge: challenge) - guard case let .fingerprinted(context) = state else { - let failure = POFailure(code: .generic(.mobile)) - delegate.didHandle3DS2Challenge(result: .failure(failure)) - completion(.failure(failure)) - return + public func performChallenge(with parameters: PO3DS2ChallengeParameters) async throws -> PO3DS2ChallengeResult { + defer { + invalidate() } - state = .challenging(context) - let parameters = convertToChallengeParameters(data: challenge) - context.transaction.doChallenge(challengeParameters: parameters) { [unowned self, errorMapper] result in - self.setIdleStateUnchecked() - let mappedResult = result.map(extractStatus(authenticationResult:)).mapError(errorMapper.convert) - delegate.didHandle3DS2Challenge(result: mappedResult) - completion(mappedResult) + do { + await delegate?.checkout3DSService(self, willPerformChallengeWith: parameters) + guard let transaction = service?.createTransaction() else { + throw POFailure(code: .generic(.mobile)) + } + let authenticationResult = try await transaction.doChallenge( + challengeParameters: challengeParameters(with: parameters) + ) + let challengeResult = PO3DS2ChallengeResult( + transactionStatus: authenticationResult.transactionStatus ?? "N" + ) + await delegate?.checkout3DSService(self, didPerformChallenge: .success(challengeResult)) + return challengeResult + } catch { + let failure = failure(with: error) + await delegate?.checkout3DSService(self, didPerformChallenge: .failure(failure)) + throw failure } } - func handle(redirect: PO3DSRedirect, completion: @escaping (Result) -> Void) { - // Redirection is simply forwarded to delegate without additional validations. - delegate.handle(redirect: redirect, completion: completion) - } - - // MARK: - Private Nested Types - - private typealias State = Checkout3DSServiceState - // MARK: - Private Properties private let errorMapper: AuthenticationErrorMapper private let configurationMapper: ConfigurationMapper - private let queue: DispatchQueue - private let delegate: POCheckout3DSServiceDelegate private let environment: Checkout3DS.Environment - private var state: State + private var service: ThreeDS2Service? // MARK: - Private Methods - private func setIdleStateUnchecked() { - clean() - state = .idle - } - - private func clean() { - let currentContext: Checkout3DSServiceState.Context - switch state { - case let .fingerprinting(context), let .fingerprinted(context), let .challenging(context): - currentContext = context - default: - return - } - currentContext.transaction.close() - currentContext.service.cleanUp() + private func invalidate() { + service?.cleanUp() + service = nil } // MARK: - Utils - private func convertToAuthenticationRequest( - request: AuthenticationRequestParameters - ) -> PO3DS2AuthenticationRequest { - let authenticationRequest = PO3DS2AuthenticationRequest( + private func serviceConfiguration(with configuration: PO3DS2Configuration) async -> ThreeDS2ServiceConfiguration { + let configParameters = configurationMapper.convert(configuration: configuration) + var serviceConfiguration = ThreeDS2ServiceConfiguration(configParameters: configParameters) + await delegate?.checkout3DSService(self, willCreateAuthenticationRequestParametersWith: &serviceConfiguration) + return serviceConfiguration + } + + private func authenticationRequest( + with request: AuthenticationRequestParameters + ) -> PO3DS2AuthenticationRequestParameters { + PO3DS2AuthenticationRequestParameters( deviceData: request.deviceData, sdkAppId: request.sdkAppID, sdkEphemeralPublicKey: request.sdkEphemeralPublicKey, sdkReferenceNumber: request.sdkReferenceNumber, sdkTransactionId: request.sdkTransactionID ) - return authenticationRequest } - private func convertToChallengeParameters(data: PO3DS2Challenge) -> ChallengeParameters { - let challengeParameters = ChallengeParameters( - threeDSServerTransactionID: data.threeDSServerTransactionId, - acsTransactionID: data.acsTransactionId, - acsRefNumber: data.acsReferenceNumber, - acsSignedContent: data.acsSignedContent + private func challengeParameters(with parameters: PO3DS2ChallengeParameters) -> ChallengeParameters { + ChallengeParameters( + threeDSServerTransactionID: parameters.threeDSServerTransactionId, + acsTransactionID: parameters.acsTransactionId, + acsRefNumber: parameters.acsReferenceNumber, + acsSignedContent: parameters.acsSignedContent ) - return challengeParameters } - private func extractStatus(authenticationResult: AuthenticationResult) -> Bool { - authenticationResult.transactionStatus?.uppercased() == "Y" + private func failure(with error: Error) -> POFailure { + if let failure = error as? POFailure { + return failure + } + if let error = error as? AuthenticationError { + return errorMapper.convert(error: error) + } + return POFailure(code: .generic(.mobile), underlyingError: error) } } diff --git a/Sources/ProcessOutCheckout3DS/Sources/Service/Checkout3DSServiceState.swift b/Sources/ProcessOutCheckout3DS/Sources/Service/Checkout3DSServiceState.swift deleted file mode 100644 index c5f528948..000000000 --- a/Sources/ProcessOutCheckout3DS/Sources/Service/Checkout3DSServiceState.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Checkout3DSServiceState.swift -// ProcessOutCheckout3DS -// -// Created by Andrii Vysotskyi on 01.03.2023. -// - -import Foundation -import Checkout3DS - -enum Checkout3DSServiceState { - - struct Context { - - /// Service. - let service: ThreeDS2Service - - /// Transaction. - let transaction: Transaction - } - - /// Idle state. - case idle - - /// Fingerprinting. - case fingerprinting(Context) - - /// Fingerprinting is completed and implementation is now ready for 3DS2 challenge. - case fingerprinted(Context) - - /// Challenge is currently in progress. - case challenging(Context) -} diff --git a/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSServiceDelegate.swift b/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSServiceDelegate.swift index bb4194ac4..cfc2a7187 100644 --- a/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSServiceDelegate.swift +++ b/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSServiceDelegate.swift @@ -9,60 +9,78 @@ import ProcessOut import Checkout3DS /// Checkout 3DS service delegate. -public protocol POCheckout3DSServiceDelegate: AnyObject { - - /// Notifies delegate that service is about to fingerprint device. - func willCreateAuthenticationRequest(configuration: PO3DS2Configuration) - - /// Asks implementation to create `ThreeDS2ServiceConfiguration` using `configParameters`. This method - /// could be used to customize underlying 3DS SDK appearance and behavior. - func configuration( - with parameters: Checkout3DS.ThreeDS2ServiceConfiguration.ConfigParameters - ) -> Checkout3DS.ThreeDS2ServiceConfiguration +public protocol POCheckout3DSServiceDelegate: AnyObject, Sendable { /// Asks delegate whether service should continue with given warnings. Default implementation - /// ignores warnings and completes with `true`. - func shouldContinue(with warnings: Set, completion: @escaping (Bool) -> Void) - - /// Notifies delegate that service did complete device fingerprinting. - func didCreateAuthenticationRequest(result: Result) - - /// Notifies delegate that implementation is about to handle 3DS2 challenge. - func willHandle(challenge: PO3DS2Challenge) - - /// Notifies delegate that service did end handling 3DS2 challenge with given result. - func didHandle3DS2Challenge(result: Result) + /// ignores warnings and returns `true`. + func checkout3DSService(_ service: POCheckout3DSService, shouldContinueWith warnings: Set) async -> Bool - /// Asks delegate to handle 3DS redirect. See documentation of `PO3DSService/handle(redirect:completion:)` - /// for more details. - func handle(redirect: PO3DSRedirect, completion: @escaping (Result) -> Void) + /// Notifies delegate that service is about to fingerprint device. + /// + /// Your implementation could change given `configuration` in case you want to + /// customize underlying 3DS SDK appearance and behavior. Please note that + /// `configParameters` should remain unchanged. + @MainActor + func checkout3DSService( + _ service: POCheckout3DSService, + willCreateAuthenticationRequestParametersWith configuration: inout ThreeDS2ServiceConfiguration + ) + + /// Notifies delegate that service failed to produce device fingerprint. + @MainActor + func checkout3DSService( + _ service: POCheckout3DSService, + didCreateAuthenticationRequestParameters result: Result + ) + + /// Notifies delegate that implementation is about to proceed with 3DS2 challenge. + @MainActor + func checkout3DSService( + _ service: POCheckout3DSService, willPerformChallengeWith parameters: PO3DS2ChallengeParameters + ) + + /// Notifies delegate that service did fail to handle 3DS2 challenge. + @MainActor + func checkout3DSService( + _ service: POCheckout3DSService, didPerformChallenge result: Result + ) } extension POCheckout3DSServiceDelegate { - public func willCreateAuthenticationRequest(configuration: PO3DS2Configuration) { - // Ignored + public func checkout3DSService( + _ service: POCheckout3DSService, shouldContinueWith warnings: Set + ) async -> Bool { + true } - public func configuration( - with parameters: Checkout3DS.ThreeDS2ServiceConfiguration.ConfigParameters - ) -> Checkout3DS.ThreeDS2ServiceConfiguration { - ThreeDS2ServiceConfiguration(configParameters: parameters) - } - - public func didCreateAuthenticationRequest(result: Result) { + @MainActor + public func checkout3DSService( + _ service: POCheckout3DSService, + willCreateAuthenticationRequestParametersWith configuration: inout ThreeDS2ServiceConfiguration + ) { // Ignored } - public func willHandle(challenge: PO3DS2Challenge) { + @MainActor + public func checkout3DSService( + _ service: POCheckout3DSService, + didCreateAuthenticationRequestParameters result: Result + ) { // Ignored } - public func didHandle3DS2Challenge(result: Result) { + @MainActor + public func checkout3DSService( + _ service: POCheckout3DSService, willPerformChallengeWith parameters: PO3DS2ChallengeParameters + ) { // Ignored } - public func shouldContinue(with warnings: Set, completion: @escaping (Bool) -> Void) { - completion(true) + @MainActor + public func checkout3DSService( + _ service: POCheckout3DSService, didPerformChallenge result: Result + ) { + // Ignored } } diff --git a/Sources/ProcessOutCoreUI/Sources/Backports/Task/View+Task.swift b/Sources/ProcessOutCoreUI/Sources/Backports/Task/View+Task.swift index 2da36731f..48200ceae 100644 --- a/Sources/ProcessOutCoreUI/Sources/Backports/Task/View+Task.swift +++ b/Sources/ProcessOutCoreUI/Sources/Backports/Task/View+Task.swift @@ -16,7 +16,7 @@ extension POBackport where Wrapped: View { public func task( id value: T, priority: TaskPriority = .userInitiated, - @_inheritActorContext _ action: @escaping @Sendable () async -> Void + _ action: @escaping @Sendable @isolated(any) () async -> Void ) -> some View where T: Equatable { if #available(iOS 15, *) { wrapped.task(id: value, priority: priority, action) @@ -29,7 +29,7 @@ extension POBackport where Wrapped: View { @available(iOS 14, *) @ViewBuilder public func task( - priority: TaskPriority = .userInitiated, @_inheritActorContext _ action: @escaping @Sendable () async -> Void + priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable @isolated(any) () async -> Void ) -> some View { task(id: 0, priority: priority, action) } diff --git a/Sources/ProcessOutCoreUI/Sources/DesignSystem/AsyncImage/POAsyncImage.swift b/Sources/ProcessOutCoreUI/Sources/DesignSystem/AsyncImage/POAsyncImage.swift index 7ed2f7e67..d7dcc44c9 100644 --- a/Sources/ProcessOutCoreUI/Sources/DesignSystem/AsyncImage/POAsyncImage.swift +++ b/Sources/ProcessOutCoreUI/Sources/DesignSystem/AsyncImage/POAsyncImage.swift @@ -16,7 +16,7 @@ public struct POAsyncImage: View { /// and returns the view to display for the specified phase. public init( id: AnyHashable, - image: @Sendable @escaping () async throws -> Image?, + image: @Sendable @escaping @isolated(any) () async throws -> Image?, transaction: Transaction, @ViewBuilder content: @escaping (POAsyncImagePhase) -> Content ) { @@ -35,7 +35,7 @@ public struct POAsyncImage: View { phase = .empty } } - .backport.task(id: id, priority: .userInitiated, resolveImage) + .backport.task(id: id, priority: .userInitiated) { await resolveImage() } } // MARK: - Private Properties @@ -54,7 +54,6 @@ public struct POAsyncImage: View { // MARK: - Private Methods /// Implementation resolves image and updates phase. - @Sendable @MainActor private func resolveImage() async { guard !Task.isCancelled, case .empty = phase else { diff --git a/Sources/ProcessOutUI/Sources/Api/Test3DS/POTest3DSService.swift b/Sources/ProcessOutUI/Sources/Api/Test3DS/POTest3DSService.swift index e4d898a2b..b1741150d 100644 --- a/Sources/ProcessOutUI/Sources/Api/Test3DS/POTest3DSService.swift +++ b/Sources/ProcessOutUI/Sources/Api/Test3DS/POTest3DSService.swift @@ -12,60 +12,40 @@ import ProcessOut /// Control Server (ACS). Should be used only for testing purposes in sandbox environment. public final class POTest3DSService: PO3DSService { - /// Creates service instance. - public init(returnUrl: URL) { - self.returnUrl = returnUrl + public init() { + // Ignored } - // MARK: - PO3DSService - - public func authenticationRequest( - configuration: PO3DS2Configuration, - completion: @escaping @Sendable (Result) -> Void - ) { - let request = PO3DS2AuthenticationRequest( + public func authenticationRequestParameters( + configuration: PO3DS2Configuration + ) async throws -> PO3DS2AuthenticationRequestParameters { + PO3DS2AuthenticationRequestParameters( deviceData: "", sdkAppId: "", sdkEphemeralPublicKey: "{}", sdkReferenceNumber: "", sdkTransactionId: "" ) - completion(.success(request)) } - public func handle(challenge: PO3DS2Challenge, completion: @escaping @Sendable (Result) -> Void) { - MainActor.assumeIsolated { - guard let presentingViewController = PresentingViewControllerProvider.find() else { - completion(.success(false)) - return - } + @MainActor + public func performChallenge(with parameters: PO3DS2ChallengeParameters) async throws -> PO3DS2ChallengeResult { + guard let presentingViewController = PresentingViewControllerProvider.find() else { + throw POFailure(code: .generic(.mobile)) + } + return await withCheckedContinuation { continuation in let alertController = UIAlertController( title: String(resource: .Test3DS.title), message: "", preferredStyle: .alert ) let acceptAction = UIAlertAction(title: String(resource: .Test3DS.accept), style: .default) { _ in - completion(.success(true)) + continuation.resume(returning: PO3DS2ChallengeResult(transactionStatus: "Y")) } alertController.addAction(acceptAction) let rejectAction = UIAlertAction(title: String(resource: .Test3DS.reject), style: .default) { _ in - completion(.success(false)) + continuation.resume(returning: PO3DS2ChallengeResult(transactionStatus: "N")) } alertController.addAction(rejectAction) presentingViewController.present(alertController, animated: true) } } - - public func handle(redirect: PO3DSRedirect, completion: @escaping @Sendable (Result) -> Void) { - Task { @MainActor in - let session = POWebAuthenticationSession(redirect: redirect, returnUrl: returnUrl, completion: completion) - if await session.start() { - return - } - let failure = POFailure(message: "Unable to process redirect", code: .generic(.mobile)) - completion(.failure(failure)) - } - } - - // MARK: - Private Properties - - private let returnUrl: URL } diff --git a/Sources/ProcessOutUI/Sources/Modules/3DSRedirect/PO3DSRedirectController.swift b/Sources/ProcessOutUI/Sources/Modules/3DSRedirect/PO3DSRedirectController.swift deleted file mode 100644 index 61538fbe1..000000000 --- a/Sources/ProcessOutUI/Sources/Modules/3DSRedirect/PO3DSRedirectController.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// PO3DSRedirectController.swift -// ProcessOutUI -// -// Created by Andrii Vysotskyi on 17.11.2023. -// - -import Foundation -import SafariServices -import ProcessOut - -/// An object that presents a screen that allows to handle 3DS redirect. -/// -/// - Important: The PO3DSRedirectController class performs the same role as the SFSafariViewController -/// class initialized with 3DSRedirect, but it does not depend on the UIKit framework. This means that -/// the controller can be used in places where a view controller cannot (for example, in SwiftUI applications). -@available(*, deprecated, message: "Use POWebAuthenticationSession instead.") -@MainActor -public final class PO3DSRedirectController: Sendable { - - /// - Parameters: - /// - redirect: redirect to handle. - /// - returnUrl: Return URL specified when creating invoice or customer token. - /// - safariConfiguration: The configuration for the new view controller. - public init( - redirect: PO3DSRedirect, - returnUrl: URL, - safariConfiguration: SFSafariViewController.Configuration = SFSafariViewController.Configuration() - ) { - self.redirect = redirect - self.returnUrl = returnUrl - self.safariConfiguration = safariConfiguration - } - - /// Presents the Redirect UI modally over your app. You are responsible for dismissal. - /// - /// - Parameters: - /// - completion: A block that is called after the screen is presented. - /// - success: A Boolean value that indicates whether the screen was successfully presented. - /// - /// - NOTE: Redirect controller is retained for the duration of presentation. - public func present(completion: ((_ success: Bool) -> Void)? = nil) { - guard safariViewController == nil else { - preconditionFailure("Controller is already presented.") - } - if let presentingViewController = PresentingViewControllerProvider.find() { - let safariViewController = SFSafariViewController( - redirect: redirect, - returnUrl: returnUrl, - safariConfiguration: safariConfiguration, - completion: self.completion ?? { _ in } - ) - safariViewController.preferredBarTintColor = preferredBarTintColor - safariViewController.preferredControlTintColor = preferredControlTintColor - safariViewController.dismissButtonStyle = .cancel - presentingViewController.present(safariViewController, animated: true) { - completion?(true) - } - objc_setAssociatedObject( - safariViewController, &AssociatedKeys.redirectController, self, .OBJC_ASSOCIATION_RETAIN - ) - self.safariViewController = safariViewController - } else { - completion?(false) - let failure = POFailure(message: "Unable to present redirect UI.", code: .generic(.mobile)) - self.completion?(.failure(failure)) - } - } - - /// Dismisses the Redirect UI. - public func dismiss(completion: (() -> Void)? = nil) { - // todo(andrii-vysotskyi): automatically dismiss controller so behavior - // matches `POAlternativePaymentMethodController`. - if let safariViewController, safariViewController.presentingViewController != nil { - self.safariViewController = nil - safariViewController.dismiss(animated: true, completion: completion) - } else { - completion?() - } - } - - /// Completion to invoke when redirect handling ends. - public var completion: (@Sendable (Result) -> Void)? - - /// The preferred color to tint the background of the navigation bar and toolbar. - public var preferredBarTintColor: UIColor? - - /// The preferred color to tint the control buttons on the navigation bar and toolbar. - public var preferredControlTintColor: UIColor? - - // MARK: - Private Nested Types - - private enum AssociatedKeys { - nonisolated(unsafe) static var redirectController: UInt8 = 0 - } - - // MARK: - Private Properties - - private let redirect: PO3DSRedirect - private let returnUrl: URL - private let safariConfiguration: SFSafariViewController.Configuration - - private weak var safariViewController: SFSafariViewController? -} diff --git a/Sources/ProcessOutUI/Sources/Modules/3DSRedirect/POWebAuthenticationSession+3DSRedirect.swift b/Sources/ProcessOutUI/Sources/Modules/3DSRedirect/POWebAuthenticationSession+3DSRedirect.swift deleted file mode 100644 index 2542194e6..000000000 --- a/Sources/ProcessOutUI/Sources/Modules/3DSRedirect/POWebAuthenticationSession+3DSRedirect.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// POWebAuthenticationSession+3DSRedirect.swift -// ProcessOutUI -// -// Created by Andrii Vysotskyi on 29.05.2024. -// - -import Foundation -import ProcessOut - -extension POWebAuthenticationSession { - - /// Creates POWebAuthenticationSession that is able to handle 3DS redirects. - /// - /// - Parameters: - /// - redirect: redirect to handle. - /// - returnUrl: Return URL specified when creating invoice or customer token. - /// - completion: Completion to invoke when redirect handling ends. - public convenience init( - redirect: PO3DSRedirect, - returnUrl: URL, - completion: @escaping @Sendable (Result) -> Void - ) { - let completionBox: Completion = { result in - completion(result.map(Self.token(with:))) - } - let callback = POWebAuthenticationSessionCallback.customScheme(returnUrl.scheme ?? "") - self.init(url: redirect.url, callback: callback, timeout: redirect.timeout, completion: completionBox) - } - - // MARK: - Private Methods - - private static nonisolated func token(with url: URL) -> String { - let components = URLComponents(url: url, resolvingAgainstBaseURL: true) - return components?.queryItems?.first { $0.name == "token" }?.value ?? "" - } -} diff --git a/Sources/ProcessOutUI/Sources/Modules/3DSRedirect/SFSafariViewController+3DSRedirect.swift b/Sources/ProcessOutUI/Sources/Modules/3DSRedirect/SFSafariViewController+3DSRedirect.swift deleted file mode 100644 index 1d3d81607..000000000 --- a/Sources/ProcessOutUI/Sources/Modules/3DSRedirect/SFSafariViewController+3DSRedirect.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// SFSafariViewController+3DSRedirect.swift -// ProcessOutUI -// -// Created by Andrii Vysotskyi on 17.11.2023. -// - -import SafariServices -@_spi(PO) import ProcessOut - -extension SFSafariViewController { - - /// Creates view controller that can handle 3DS Redirects. - /// - /// - Note: Caller should dismiss view controller after completion is called. - /// - Note: Object's delegate shouldn't be modified. - /// - /// - Parameters: - /// - redirect: redirect to handle. - /// - returnUrl: Return URL specified when creating invoice or customer token. - /// - safariConfiguration: The configuration for the new view controller. - /// - completion: Completion to invoke when redirect handling ends. - public convenience init( - redirect: PO3DSRedirect, - returnUrl: URL, - safariConfiguration: SFSafariViewController.Configuration = .init(), - completion: @escaping @Sendable (Result) -> Void - ) { - self.init(url: redirect.url, configuration: safariConfiguration) - let api: ProcessOut = ProcessOut.shared // swiftlint:disable:this redundant_type_annotation - let viewModel = DefaultSafariViewModel( - callback: .customScheme(returnUrl.scheme ?? ""), - timeout: redirect.timeout, - eventEmitter: api.eventEmitter, - logger: api.logger, - completion: { result in - completion(result.map(Self.token)) - } - ) - setViewModel(viewModel) - viewModel.start() - } - - // MARK: - Private Methods - - private static nonisolated func token(with url: URL) -> String { - let components = URLComponents(url: url, resolvingAgainstBaseURL: true) - return components?.queryItems?.first { $0.name == "token" }?.value ?? "" - } -}