Skip to content

Commit

Permalink
subscripti token background refresh added
Browse files Browse the repository at this point in the history
  • Loading branch information
federicocappelli committed Nov 5, 2024
1 parent edfdfbb commit 54182e2
Show file tree
Hide file tree
Showing 10 changed files with 129 additions and 74 deletions.
15 changes: 9 additions & 6 deletions Sources/Networking/OAuth/OAuthClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,15 +161,18 @@ final public class DefaultOAuthClient: OAuthClient {
return (codeVerifier, codeChallenge)
}

#if DEBUG
internal var testingDecodedTokenContainer: TokenContainer?
#endif
private func decode(accessToken: String, refreshToken: String) async throws -> TokenContainer {
#if canImport(XCTest)
return TokenContainer(accessToken: accessToken,
refreshToken: refreshToken,
decodedAccessToken: JWTAccessToken.mock,
decodedRefreshToken: JWTRefreshToken.mock)
Logger.OAuthClient.log("Decoding tokens")

Check failure on line 169 in Sources/Networking/OAuth/OAuthClient.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
#if DEBUG
if let testingDecodedTokenContainer {
return testingDecodedTokenContainer
}
#endif

Logger.OAuthClient.log("Decoding tokens")
let jwtSigners = try await authService.getJWTSigners()
let decodedAccessToken = try jwtSigners.verify(accessToken, as: JWTAccessToken.self)
let decodedRefreshToken = try jwtSigners.verify(refreshToken, as: JWTRefreshToken.self)
Expand Down
59 changes: 40 additions & 19 deletions Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ public final class DefaultAppStorePurchaseFlow: AppStorePurchaseFlow {
do {
let newAccountExternalID = try await subscriptionManager.getTokenContainer(policy: .createIfNeeded).decodedAccessToken.externalID
externalID = newAccountExternalID
} catch OAuthClientError.deadToken {
if let transactionJWS = await recoverSubscriptionFromDeadToken() {
return .success(transactionJWS)
} else {
return .failure(.purchaseFailed(OAuthClientError.deadToken))
}
} catch {
Logger.subscriptionStripePurchaseFlow.error("Failed to create a new account: \(error.localizedDescription, privacy: .public), the operation is unrecoverable")
return .failure(.internalError)
Expand Down Expand Up @@ -142,41 +148,56 @@ public final class DefaultAppStorePurchaseFlow: AppStorePurchaseFlow {
// Removing all traces of the subscription and the account
return .failure(.purchaseFailed(AppStoreRestoreFlowError.subscriptionExpired))
}
} catch OAuthClientError.deadToken {
let transactionJWS = await recoverSubscriptionFromDeadToken()
if transactionJWS != nil {
return .success(PurchaseUpdate.completed)
} else {
return .failure(.purchaseFailed(OAuthClientError.deadToken))
}
} catch {
Logger.subscriptionAppStorePurchaseFlow.error("Purchase Failed: \(error)")
return .failure(.purchaseFailed(error))
}
}

private func callWithRetries(retry retryCount: Int, wait waitTime: Double, conditionToCheck: () async -> Bool) async -> Bool {
var count = 0
var successful = false

repeat {
successful = await conditionToCheck()

if successful {
break
} else {
count += 1
try? await Task.sleep(interval: waitTime)
}
} while !successful && count < retryCount

return successful
}

private func getExpiredSubscriptionID() async -> String? {
do {
let subscription = try await subscriptionManager.currentSubscription(refresh: true)
// Only return an externalID if the subscription is expired so to prevent creating multiple subscriptions in the same account
if !subscription.isActive,
subscription.platform != .apple {
return try? await subscriptionManager.getTokenContainer(policy: .localValid).decodedAccessToken.externalID
return try await subscriptionManager.getTokenContainer(policy: .localValid).decodedAccessToken.externalID
}
return nil
} catch OAuthClientError.deadToken {
let transactionJWS = await recoverSubscriptionFromDeadToken()
if transactionJWS != nil {
return try? await subscriptionManager.getTokenContainer(policy: .localValid).decodedAccessToken.externalID
} else {
return nil
}
} catch {
return nil
}
}

private func recoverSubscriptionFromDeadToken() async -> String? {

// TODO: SEND PIXEL

Check failure on line 187 in Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

TODOs should be resolved (SEND PIXEL) (todo)

Logger.subscriptionAppStorePurchaseFlow.log("Recovering Subscription From Dead Token")

// Clear everything, the token is unrecoverable
await subscriptionManager.signOut()

switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() {
case .success(let transactionJWS):
Logger.subscriptionAppStorePurchaseFlow.log("Subscription recovered")
return transactionJWS
case .failure(let error):
Logger.subscriptionAppStorePurchaseFlow.log("Failed to recover Apple subscription: \(error.localizedDescription, privacy: .public)")
return nil
}
}
}
6 changes: 3 additions & 3 deletions Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public enum AppStoreRestoreFlowError: LocalizedError, Equatable {

@available(macOS 12.0, iOS 15.0, *)
public protocol AppStoreRestoreFlow {
@discardableResult func restoreAccountFromPastPurchase() async -> Result<Void, AppStoreRestoreFlowError>
@discardableResult func restoreAccountFromPastPurchase() async -> Result<String, AppStoreRestoreFlowError>
}

@available(macOS 12.0, iOS 15.0, *)
Expand All @@ -64,7 +64,7 @@ public final class DefaultAppStoreRestoreFlow: AppStoreRestoreFlow {
}

@discardableResult
public func restoreAccountFromPastPurchase() async -> Result<Void, AppStoreRestoreFlowError> {
public func restoreAccountFromPastPurchase() async -> Result<String, AppStoreRestoreFlowError> {
Logger.subscriptionAppStoreRestoreFlow.log("Restoring account from past purchase")

// Clear subscription Cache
Expand All @@ -78,7 +78,7 @@ public final class DefaultAppStoreRestoreFlow: AppStoreRestoreFlow {
do {
let subscription = try await subscriptionManager.getSubscriptionFrom(lastTransactionJWSRepresentation: lastTransactionJWSRepresentation)
if subscription.isActive {
return .success(())
return .success(lastTransactionJWSRepresentation)
} else {
Logger.subscriptionAppStoreRestoreFlow.error("Subscription expired")

Expand Down
26 changes: 13 additions & 13 deletions Sources/Subscription/Managers/StorePurchaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
await updatePurchasedProducts()
await updateAvailableProducts()
} catch {
Logger.subscription.error("[StorePurchaseManager] Error: \(String(reflecting: error), privacy: .public) (\(error.localizedDescription, privacy: .public))")
Logger.subscriptionStorePurchaseManager.error("[StorePurchaseManager] Error: \(String(reflecting: error), privacy: .public) (\(error.localizedDescription, privacy: .public))")
throw error
}
}
Expand All @@ -104,7 +104,7 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
let monthly = products.first(where: { $0.subscription?.subscriptionPeriod.unit == .month && $0.subscription?.subscriptionPeriod.value == 1 })
let yearly = products.first(where: { $0.subscription?.subscriptionPeriod.unit == .year && $0.subscription?.subscriptionPeriod.value == 1 })
guard let monthly, let yearly else {
Logger.subscription.error("[AppStorePurchaseFlow] No products found")
Logger.subscriptionStorePurchaseManager.error("[AppStorePurchaseFlow] No products found")
return nil
}

Expand All @@ -125,23 +125,23 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM

@MainActor
public func updateAvailableProducts() async {
Logger.subscription.log("Update available products")
Logger.subscriptionStorePurchaseManager.log("Update available products")

do {
let availableProducts = try await Product.products(for: productIdentifiers)
Logger.subscription.log("\(availableProducts.count) products available")
Logger.subscriptionStorePurchaseManager.log("\(availableProducts.count) products available")

if self.availableProducts != availableProducts {
self.availableProducts = availableProducts
}
} catch {
Logger.subscription.error("Failed to fetch available products: \(String(reflecting: error), privacy: .public)")
Logger.subscriptionStorePurchaseManager.error("Failed to fetch available products: \(String(reflecting: error), privacy: .public)")
}
}

@MainActor
public func updatePurchasedProducts() async {
Logger.subscription.log("Update purchased products")
Logger.subscriptionStorePurchaseManager.log("Update purchased products")

var purchasedSubscriptions: [String] = []

Expand All @@ -157,10 +157,10 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
}
}
} catch {
Logger.subscription.error("Failed to update purchased products: \(String(reflecting: error), privacy: .public)")
Logger.subscriptionStorePurchaseManager.error("Failed to update purchased products: \(String(reflecting: error), privacy: .public)")
}

Logger.subscription.log("UpdatePurchasedProducts fetched \(purchasedSubscriptions.count) active subscriptions")
Logger.subscriptionStorePurchaseManager.log("UpdatePurchasedProducts fetched \(purchasedSubscriptions.count) active subscriptions")

if self.purchasedProductIDs != purchasedSubscriptions {
self.purchasedProductIDs = purchasedSubscriptions
Expand Down Expand Up @@ -194,7 +194,7 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM

guard let product = availableProducts.first(where: { $0.id == identifier }) else { return .failure(StorePurchaseManagerError.productNotFound) }

Logger.subscription.info("Purchasing Subscription \(product.displayName, privacy: .public) (\(externalID, privacy: .public))")
Logger.subscriptionStorePurchaseManager.log("Purchasing Subscription \(product.displayName, privacy: .public) (\(externalID, privacy: .public))")

purchaseQueue.append(product.id)

Expand All @@ -203,27 +203,27 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
if let token = UUID(uuidString: externalID) {
options.insert(.appAccountToken(token))
} else {
Logger.subscription.error("[StorePurchaseManager] Error: Failed to create UUID")
Logger.subscriptionStorePurchaseManager.error("Failed to create UUID from \(externalID, privacy: .public)")
return .failure(StorePurchaseManagerError.externalIDisNotAValidUUID)
}

let purchaseResult: Product.PurchaseResult
do {
purchaseResult = try await product.purchase(options: options)
} catch {
Logger.subscription.error("[StorePurchaseManager] Error: \(String(reflecting: error), privacy: .public)")
Logger.subscriptionStorePurchaseManager.error("Error: \(String(reflecting: error), privacy: .public)")
return .failure(StorePurchaseManagerError.purchaseFailed)
}

Logger.subscriptionStorePurchaseManager.log("purchaseSubscription complete")
Logger.subscriptionStorePurchaseManager.log("PurchaseSubscription complete")

purchaseQueue.removeAll()

switch purchaseResult {
case let .success(verificationResult):
switch verificationResult {
case let .verified(transaction):
Logger.subscriptionStorePurchaseManager.log("purchaseSubscription result: success")
Logger.subscriptionStorePurchaseManager.log("PurchaseSubscription result: success")
// Successful purchase
await transaction.finish()
await self.updatePurchasedProducts()
Expand Down
58 changes: 35 additions & 23 deletions Sources/Subscription/Managers/SubscriptionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ import Common
import os.log
import Networking

enum SubscriptionManagerError: Error {
case unsupportedSubscription
public enum SubscriptionManagerError: Error {
case tokenUnavailable
case confirmationHasInvalidSubscription
}

public enum SubscriptionPixelType {
case deadToken
}

public protocol SubscriptionManager {

// Environment
Expand All @@ -42,7 +45,6 @@ public protocol SubscriptionManager {
func currentSubscription(refresh: Bool) async throws -> PrivacyProSubscription
func getSubscriptionFrom(lastTransactionJWSRepresentation: String) async throws -> PrivacyProSubscription
var canPurchase: Bool { get }
func clearSubscriptionCache()

@available(macOS 12.0, iOS 15.0, *) func storePurchaseManager() -> StorePurchaseManager
func url(for type: SubscriptionURL) -> URL
Expand All @@ -54,14 +56,26 @@ public protocol SubscriptionManager {
var userEmail: String? { get }
var entitlements: [SubscriptionEntitlement] { get }

/// Get a token container accordingly to the policy
/// - Parameter policy: The policy that will be used to get the token, it effects the tokens source and validity
/// - Returns: The TokenContainer
/// - Throws: OAuthClientError.deadToken if the token is unrecoverable. SubscriptionEndpointServiceError.noData if the token is not available.
@discardableResult func getTokenContainer(policy: TokensCachePolicy) async throws -> TokenContainer

func getTokenContainerSynchronously(policy: TokensCachePolicy) -> TokenContainer?
func exchange(tokenV1: String) async throws -> TokenContainer

// func signOut(skipNotification: Bool)
/// Sign out the user and clear all the tokens and subscription cache
func signOut() async
func signOut(skipNotification: Bool) async

func clearSubscriptionCache()

/// Confirm a purchase with a platform signature
func confirmPurchase(signature: String) async throws -> PrivacyProSubscription

// Pixels
typealias PixelHandler = (SubscriptionPixelType) -> Void
}

/// Single entry point for everything related to Subscription. This manager is disposable, every time something related to the environment changes this need to be recreated.
Expand All @@ -70,18 +84,20 @@ public final class DefaultSubscriptionManager: SubscriptionManager {
private let oAuthClient: any OAuthClient
private let _storePurchaseManager: StorePurchaseManager?
private let subscriptionEndpointService: SubscriptionEndpointService

private let pixelHandler: PixelHandler
public let currentEnvironment: SubscriptionEnvironment
public private(set) var canPurchase: Bool = false

public init(storePurchaseManager: StorePurchaseManager? = nil,
oAuthClient: any OAuthClient,
subscriptionEndpointService: SubscriptionEndpointService,
subscriptionEnvironment: SubscriptionEnvironment) {
subscriptionEnvironment: SubscriptionEnvironment,
pixelHandler: @escaping PixelHandler) {
self._storePurchaseManager = storePurchaseManager
self.oAuthClient = oAuthClient
self.subscriptionEndpointService = subscriptionEndpointService
self.currentEnvironment = subscriptionEnvironment
self.pixelHandler = pixelHandler
switch currentEnvironment.purchasePlatform {
case .appStore:
if #available(macOS 12.0, iOS 15.0, *) {
Expand Down Expand Up @@ -217,19 +233,16 @@ public final class DefaultSubscriptionManager: SubscriptionManager {

/// If the client succeeds in making a refresh request but does not get the response, then the second refresh request will fail with `invalidTokenRequest` and the stored token will become unusable and un-refreshable.
private func throwAppropriateDeadTokenError() async throws -> TokenContainer {
Logger.subscription.log("Dead token detected")
Logger.subscription.warning("Dead token detected")
do {
let subscription = try await subscriptionEndpointService.getSubscription(accessToken: "some", cachePolicy: .returnCacheDataDontLoad)
let subscription = try await subscriptionEndpointService.getSubscription(accessToken: "", // Token is unused
cachePolicy: .returnCacheDataDontLoad)
switch subscription.platform {
case .apple:
Logger.subscription.log("Recovering Apple App Store subscription")
// TODO: how do we handle this?
pixelHandler(.deadToken)
throw OAuthClientError.deadToken
case .stripe:
Logger.subscription.error("Trying to recover a Stripe subscription is unsupported")
throw SubscriptionManagerError.unsupportedSubscription
default:
throw SubscriptionManagerError.unsupportedSubscription
throw SubscriptionManagerError.tokenUnavailable
}
} catch {
throw SubscriptionManagerError.tokenUnavailable
Expand All @@ -252,19 +265,18 @@ public final class DefaultSubscriptionManager: SubscriptionManager {
try await oAuthClient.exchange(accessTokenV1: tokenV1)
}

// public func signOut(skipNotification: Bool = false) {
// Task {
// await signOut()
// if !skipNotification {
// NotificationCenter.default.post(name: .accountDidSignOut, object: self, userInfo: nil)
// }
// }
// }

public func signOut() async {
Logger.subscription.log("Removing all traces of the subscription and auth tokens")
try? await oAuthClient.logout()
subscriptionEndpointService.clearSubscription()
NotificationCenter.default.post(name: .accountDidSignOut, object: self, userInfo: nil)
}

public func signOut(skipNotification: Bool) async {
await signOut()
if !skipNotification {
NotificationCenter.default.post(name: .accountDidSignOut, object: self, userInfo: nil)
}
}

public func confirmPurchase(signature: String) async throws -> PrivacyProSubscription {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public final class SubscriptionCookieManager: SubscriptionCookieManaging {
else { return }

do {
let accessToken = try await subscriptionManager.getTokenContainer(policy: .localValid).accessToken
let accessToken = try await subscriptionManager.getTokenContainer(policy: .local).accessToken
Logger.subscriptionCookieManager.info("Handle .accountDidSignIn - setting cookie")
try await cookieStore.setSubscriptionCookie(for: accessToken)
updateLastRefreshDateToNow()
Expand Down Expand Up @@ -124,7 +124,7 @@ public final class SubscriptionCookieManager: SubscriptionCookieManaging {
Logger.subscriptionCookieManager.info("Refresh subscription cookie")
updateLastRefreshDateToNow()

let accessToken: String? = try? await subscriptionManager.getTokenContainer(policy: .localValid).accessToken
let accessToken: String? = try? await subscriptionManager.getTokenContainer(policy: .local).accessToken
let subscriptionCookie = await cookieStore.fetchCurrentSubscriptionCookie()

let noCookieOrWithUnexpectedValue = (accessToken ?? "") != subscriptionCookie?.value
Expand Down
Loading

0 comments on commit 54182e2

Please sign in to comment.