TM-SGNL-iOS/SignalServiceKit/Subscriptions/Donations/Paypal+WebAuthentication.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

347 lines
14 KiB
Swift

//
// 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 {
/// We are required to hold a strong reference to any
/// in-progress ``ASWebAuthenticationSession``s.
private static let _runningAuthSession: AtomicOptional<ASWebAuthenticationSession> = AtomicOptional(nil, lock: .sharedGlobal)
/// Creates and presents a new auth session. Only one auth session should
/// be able to exist at once.
static func presentExpectingApprovalParams<ApprovalParams: FromApprovedPaypalWebAuthFinalUrlQueryItems>(
approvalUrl: URL,
withPresentationContext presentationContext: ASWebAuthenticationPresentationContextProviding
) -> Promise<ApprovalParams> {
let (session, promise): (AuthSession<ApprovalParams>, Promise<ApprovalParams>) = makeNewAuthSession(
approvalUrl: approvalUrl
)
session.presentationContextProvider = presentationContext
owsPrecondition(
session.start(),
"[Donations] Failed to start PayPal authentication session. Was it set up correctly?"
)
return promise
}
private static func makeNewAuthSession<ApprovalParams: FromApprovedPaypalWebAuthFinalUrlQueryItems>(
approvalUrl: URL
) -> (AuthSession<ApprovalParams>, Promise<ApprovalParams>) {
let (promise, future) = Promise<ApprovalParams>.pending()
let newSession = AuthSession<ApprovalParams>(approvalUrl: approvalUrl) { approvalResult in
_runningAuthSession.set(nil)
switch approvalResult {
case let .approved(params):
future.resolve(params)
case .canceled:
future.reject(AuthError.userCanceled)
case let .error(error):
future.reject(error)
}
}
guard _runningAuthSession.tryToSetIfNil(newSession) else {
owsFail("Unexpectedly found existing auth session while creating a new one! This should be impossible.")
}
return (newSession, promise)
}
}
// 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>: ASWebAuthenticationSession {
enum AuthResult {
case approved(ApprovalParams)
case canceled
case error(Error)
}
typealias CompletionHandler = (AuthResult) -> Void
/// Create a new auth session starting at the given URL, with the given
/// completion handler. A `nil` value passed to the completion handler
/// indicates that the user canceled the auth flow.
init(approvalUrl: URL, completion: @escaping CompletionHandler) {
super.init(
url: approvalUrl,
callbackURLScheme: Paypal.authSessionScheme
) { finalUrl, error in
Self.onCompleted(finalUrl: finalUrl, error: error, completion: completion)
}
}
/// 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.
private static func onCompleted(
finalUrl: URL?,
error: Error?,
completion: CompletionHandler
) {
if let finalUrl {
owsAssertDebug(error == nil)
complete(withFinalUrl: finalUrl, completion: completion)
} else if let error {
complete(withError: error, completion: completion)
} else {
owsFail("Unexpectedly had neither a final URL nor error!")
}
}
private static func complete(withFinalUrl finalUrl: URL, completion: CompletionHandler) {
guard let callbackUrlComponents = URLComponents(url: finalUrl, resolvingAgainstBaseURL: true) else {
completion(.error(OWSAssertionError("[Donations] Malformed callback URL!")))
return
}
guard
callbackUrlComponents.scheme == Paypal.authSessionScheme,
callbackUrlComponents.user == nil,
callbackUrlComponents.password == nil,
callbackUrlComponents.host == Paypal.authSessionHost,
callbackUrlComponents.port == nil
else {
completion(.error(OWSAssertionError("[Donations] Callback URL did not match expected!")))
return
}
let authResult: AuthResult = {
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 .approved(approvalParams)
} else {
return .error(OWSAssertionError("[Donations] Unexpectedly failed to extract approval params from approved callback URL!"))
}
case Paypal.paymentCanceledPathComponent:
Logger.info("[Donations] Received PayPal cancel.")
return .canceled
default:
return .error(OWSAssertionError("[Donations] Encountered URL that looked like a PayPal callback URL but had an unrecognized path!"))
}
}()
completion(authResult)
}
private static func complete(withError error: Error, completion: CompletionHandler) {
guard let authSessionError = error as? ASWebAuthenticationSessionError else {
completion(.error(OWSAssertionError("Unexpected error from auth session: \(error)!")))
return
}
switch authSessionError.code {
case .canceledLogin:
completion(.canceled)
case
.presentationContextNotProvided,
.presentationContextInvalid:
owsFail("Unexpected issue with presentation context. Was the auth session set up correctly?")
@unknown default:
completion(.error(OWSAssertionError("Unexpected auth sesion error code: \(authSessionError.code)")))
}
}
}
}