Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Subscriptions/Donations/Paypal+WebAuthentication.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import AuthenticationServices
import Foundation

// MARK: - Present a new auth session

/// PayPal donations are authorized by a user via PayPal's web interface,
/// presented in an ``ASWebAuthenticationSession``.
public extension Paypal {
    /// Creates and presents a new auth session.
    @MainActor
    static func presentExpectingApprovalParams<ApprovalParams: FromApprovedPaypalWebAuthFinalUrlQueryItems>(
        approvalUrl: URL,
        withPresentationContext presentationContext: ASWebAuthenticationPresentationContextProviding,
    ) async throws -> ApprovalParams {
        let authSession = AuthSession<ApprovalParams>(approvalUrl: approvalUrl)
        return try await authSession.start(presentationContextProvider: presentationContext)
    }
}

// MARK: - Callback URLs

/// We present an auth session beginning at a PayPal URL we fetch from our
/// service. However, the callback URLs that PayPal will complete our session
/// with do not use a custom URL scheme (and instead use `https`), due to
/// restrictions from PayPal. Consequently, the callback URLs we give to PayPal
/// should redirect to custom-scheme URLs, which will complete the session.
extension Paypal {
    /// The scheme used in PayPal callback URLs. Required by PayPal to be
    /// `https`.
    private static let redirectUrlScheme: String = "https"

    /// The host used in PayPal callback URLs.
    private static let redirectUrlHost: String = "signaldonations.org"

    /// A path component used in PayPal callback URLs that will tell the server
    /// to redirect us to the `sgnl://` custom scheme with all path and query
    /// components after `/redirect`.
    ///
    /// For example, the URL `https://signaldonations.org/redirect/whatever?foo=bar`
    /// will be redirected by the server to `sgnl://whatever?foo=bar`.
    private static let paymentRedirectPathComponent: String = "/redirect/\(authSessionHost)"

    /// A path component used in PayPal callback URLs to indicate that the user
    /// approved payment.
    private static let paymentApprovalPathComponent: String = "/approved"

    /// A path component used in PayPal callback URLs to indicate that the user
    /// canceled payment.
    private static let paymentCanceledPathComponent: String = "/canceled"

    /// The URL PayPal will redirect the user to after a successful web
    /// authentication and payment approval. Passed while setting up web
    /// authentication.
    ///
    /// This URL is expected to redirect to a custom scheme URL. See
    /// ``paymentRedirectPathComponent`` for more.
    static let webAuthReturnUrl: URL = {
        var components = URLComponents()
        components.scheme = redirectUrlScheme
        components.host = redirectUrlHost
        components.path = paymentRedirectPathComponent + paymentApprovalPathComponent

        return components.url!
    }()

    /// The URL PayPal will redirect the user to after a canceled web
    /// authentication. Passed while setting up web authentication.
    ///
    /// This URL is expected to redirect to a custom scheme URL. See
    /// ``paymentRedirectPathComponent`` for more.
    static let webAuthCancelUrl: URL = {
        var components = URLComponents()
        components.scheme = redirectUrlScheme
        components.host = redirectUrlHost
        components.path = paymentRedirectPathComponent + paymentCanceledPathComponent

        return components.url!
    }()

    /// The scheme for the URL we expect to be redirected to by our `https`
    /// PayPal callback URLs.
    ///
    /// See ``paymentRedirectPathComponent`` for more.
    ///
    /// Per ``ASWebAuthenticationSession``'s documentation [here][0], even if
    /// other apps register this scheme the auth session will ensure that they
    /// are not invoked (so we are the only ones to capture the callback).
    ///
    /// [0]: https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession
    private static let authSessionScheme: String = "sgnl"

    /// The host for the URL we expect to be redirected to by our `https`
    /// PayPal callback URLs. See ``paymentRedirectPathComponent``.
    private static let authSessionHost: String = "paypal-payment"

    /// The URL we expect to be redirected to by ``approvalUrl``.
    ///
    /// See ``paymentRedirectPathComponent`` for more.
    private static let authSessionApprovalCallbackUrl: URL = {
        var components = URLComponents()
        components.scheme = authSessionScheme
        components.host = authSessionHost
        components.path = paymentApprovalPathComponent

        return components.url!
    }()

    /// The URL we expect to be redirected to by ``cancelUrl``.
    ///
    /// See ``paymentRedirectPathComponent`` for more.
    private static let authSessionCancelCallbackUrl: URL = {
        var components = URLComponents()
        components.scheme = authSessionScheme
        components.host = authSessionHost
        components.path = paymentCanceledPathComponent

        return components.url!
    }()
}

// MARK: - Auth results

/// Represents a type that may be constructable from a given set of URL query
/// items retrieved from the final URL from a successfully-completed PayPal web
/// auth session.
public protocol FromApprovedPaypalWebAuthFinalUrlQueryItems {
    /// Init using the given URL query items, retrieved from the final URL from
    /// a successfully-completed PayPal web auth session. Returns `nil` if
    /// initialization is not possible, e.g. a required query item is missing.
    init?(fromFinalUrlQueryItems queryItems: [URLQueryItem])
}

public extension Paypal {
    /// Represents parameters returned to us after an approved PayPal
    /// authentication for a one-time payment, which are used to confirm
    /// the payment. These fields are opaque to us.
    struct OneTimePaymentWebAuthApprovalParams: FromApprovedPaypalWebAuthFinalUrlQueryItems {
        let payerId: String
        let paymentToken: String

        /// Represents the query-string keys for the elements we expect to be
        /// present in the callback URL from a successful PayPal authentication.
        private enum QueryKey: String {
            case payerId = "PayerID"
            case paymentToken = "token"
        }

        public init(payerId: String, paymentToken: String) {
            self.payerId = payerId
            self.paymentToken = paymentToken
        }

        public init?(fromFinalUrlQueryItems queryItems: [URLQueryItem]) {
            let queryItemMap: [QueryKey: String] = queryItems.reduce(into: [:]) { partialResult, queryItem in
                guard let queryKey = QueryKey(rawValue: queryItem.name) else {
                    Logger.warn("[Donations] Unexpected query item: \(queryItem.name)")
                    return
                }

                guard partialResult[queryKey] == nil else {
                    owsFailDebug("[Donations] Unexpectedly had duplicate known query items: \(queryKey)")
                    return
                }

                guard let value = queryItem.value else {
                    owsFailDebug("[Donations] Unexpectedly missing value for known query item: \(queryKey)")
                    return
                }

                partialResult[queryKey] = value
            }

            self.init(queryItemMap: queryItemMap)
        }

        private init?(queryItemMap: [QueryKey: String]) {
            guard
                let payerId = queryItemMap[.payerId],
                let paymentToken = queryItemMap[.paymentToken]
            else {
                return nil
            }

            self.payerId = payerId
            self.paymentToken = paymentToken
        }
    }

    /// Represents parameters returned to us after an approved PayPal
    /// authentication for a monthly payment.
    ///
    /// In practice, we do not need any data returned from the PayPal
    /// authentication.
    struct MonthlyPaymentWebAuthApprovalParams: FromApprovedPaypalWebAuthFinalUrlQueryItems {
        public init?(fromFinalUrlQueryItems queryItems: [URLQueryItem]) {}
    }

    /// Represents an error returned from web authentication.
    enum AuthError: Error {
        case userCanceled
    }
}

// MARK: - AuthSession

private extension Paypal {
    private class AuthSession<ApprovalParams: FromApprovedPaypalWebAuthFinalUrlQueryItems> {
        private let approvalUrl: URL

        /// Create a new auth session starting at the given URL.
        init(approvalUrl: URL) {
            self.approvalUrl = approvalUrl
        }

        func start(presentationContextProvider: ASWebAuthenticationPresentationContextProviding) async throws -> ApprovalParams {
            var authSession: ASWebAuthenticationSession!
            defer {
                // Stop retaining it once its completion handler is invoked.
                authSession = nil
            }
            return try await withCheckedThrowingContinuation { continuation in
                authSession = ASWebAuthenticationSession(
                    url: approvalUrl,
                    callbackURLScheme: Paypal.authSessionScheme,
                ) { finalUrl, error in
                    /// Our auth session should only complete on its own if the user cancels
                    /// it interactively, since it can only auto-complete if we are using a
                    /// custom URL scheme, which our callback URLs do not.
                    let result: Result<ApprovalParams, any Error>
                    if let finalUrl {
                        owsAssertDebug(error == nil)
                        result = Result(catching: { try Self.complete(withFinalUrl: finalUrl) })
                    } else if let error {
                        result = .failure(Self.complete(withError: error))
                    } else {
                        owsFail("Unexpectedly had neither a final URL nor error!")
                    }
                    continuation.resume(with: result)
                }
                authSession.presentationContextProvider = presentationContextProvider
                let result = authSession.start()
                owsPrecondition(result, "[Donations] Failed to start PayPal authentication session.")
            }
        }

        private static func complete(withFinalUrl finalUrl: URL) throws -> ApprovalParams {
            guard let callbackUrlComponents = URLComponents(url: finalUrl, resolvingAgainstBaseURL: true) else {
                throw OWSAssertionError("[Donations] Malformed callback URL!")
            }

            guard
                callbackUrlComponents.scheme == Paypal.authSessionScheme,
                callbackUrlComponents.user == nil,
                callbackUrlComponents.password == nil,
                callbackUrlComponents.host == Paypal.authSessionHost,
                callbackUrlComponents.port == nil
            else {
                throw OWSAssertionError("[Donations] Callback URL did not match expected!")
            }

            switch callbackUrlComponents.path {
            case Paypal.paymentApprovalPathComponent:
                if
                    let queryItems = callbackUrlComponents.queryItems,
                    let approvalParams = ApprovalParams(fromFinalUrlQueryItems: queryItems)
                {
                    Logger.info("[Donations] Received PayPal approval params, moving forward.")
                    return approvalParams
                } else {
                    throw OWSAssertionError("[Donations] Unexpectedly failed to extract approval params from approved callback URL!")
                }
            case Paypal.paymentCanceledPathComponent:
                Logger.info("[Donations] Received PayPal cancel.")
                throw AuthError.userCanceled
            default:
                throw OWSAssertionError("[Donations] Encountered URL that looked like a PayPal callback URL but had an unrecognized path!")
            }
        }

        private static func complete(withError error: Error) -> any Error {
            guard let authSessionError = error as? ASWebAuthenticationSessionError else {
                return OWSAssertionError("Unexpected error from auth session: \(error)!")
            }

            switch authSessionError.code {
            case .canceledLogin:
                return AuthError.userCanceled
            case
                .presentationContextNotProvided,
                .presentationContextInvalid:
                owsFail("Unexpected issue with presentation context. Was the auth session set up correctly?")
            @unknown default:
                return OWSAssertionError("Unexpected auth sesion error code: \(authSessionError.code)")
            }
        }
    }
}