Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Subscription oauth v2 #1033

Open
wants to merge 163 commits into
base: main
Choose a base branch
from

Conversation

federicocappelli
Copy link
Member

@federicocappelli federicocappelli commented Oct 25, 2024

Task/Issue URL: https://app.asana.com/0/1205842942115003/1207991044706235/f
iOS PR: duckduckgo/iOS#3480
macOS PR: duckduckgo/macos-browser#3580
What kind of version bump will this require?: Major
CC: @miasma13

iOS PR: duckduckgo/iOS#3480
macOS PR: duckduckgo/macos-browser#3580

Description:

This PR introduces the use of OAuth V2 authentication in Privacy Pro Subscription.
The code changes are comprehensive due to the paradigm changes between the old access token lifecycle and the new JWT lifecycle.
The Subscription UI and UX should be unchanged.

Steps to test this PR:
Test all Privacy Pro Subscription features and UX, more details here


Internal references:

Software Engineering Expectations
Technical Design Template

# Conflicts:
#	Sources/Subscription/API/SubscriptionEndpointService.swift
#	Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift
#	Sources/Subscription/Managers/SubscriptionManager.swift
#	Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift
@diegoreymendez
Copy link
Contributor

Note: I've edited the description to add links to the related PRs for easy of navigation.

@diegoreymendez diegoreymendez self-requested a review January 14, 2025 15:55
Copy link
Contributor

@diegoreymendez diegoreymendez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some additional comments, still going through all this.

@@ -1,7 +1,7 @@
//
// KeychainType.swift
//
// Copyright © 2023 DuckDuckGo. All rights reserved.
// Copyright © 2024 DuckDuckGo. All rights reserved.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is actually not right. The date in copyright notices is the date when the work was first published.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KeychainType.swift existed in both bsk and mac app, I just moved it, reverting it back just to avoid noise

@@ -123,7 +123,7 @@ final class NetworkProtectionConnectionTester {
}

func stop() {
Logger.networkProtectionConnectionTester.log("🔴 Stopping connection tester")
Logger.networkProtectionConnectionTester.log("⚫️ Stopping connection tester")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, roll back the bullet color change.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all the other stopping colours are black, this is the only one red and confusing, but I reverted it

@@ -20,7 +20,7 @@ import Foundation
import Common
import os.log

enum NetworkProtectionKeychainStoreError: Error, NetworkProtectionErrorConvertible {
public enum NetworkProtectionKeychainStoreError: Error, NetworkProtectionErrorConvertible {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be public? I don't see it used anywhere outside the NetworkProtection module and for the most part I don't expect external code to be doing anything about these, other than just firing pixels with code and domain.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct, reverted

@@ -189,7 +188,7 @@ public actor NetworkProtectionDeviceManager: NetworkProtectionDeviceManagement {
selectionMethod: NetworkProtectionServerSelectionMethod) async throws -> (server: NetworkProtectionServer,
newExpiration: Date?) {

guard let token = try? tokenStore.fetchToken() else { throw NetworkProtectionError.noAuthTokenFound }
let token = try await VPNAuthTokenBuilder.getVPNAuthToken(from: tokenProvider, policy: .localValid)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you wrap this in a NetworkProtectionError with underlying error data?

It'll make it much easier to debug issues if we know where this error is coming from.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have modified Networking.OAuthClientError.missingTokens by adding an underlying error and used in here

guard let token = try? tokenStore.fetchToken() else {
throw NetworkProtectionError.noAuthTokenFound
}
let token = try await VPNAuthTokenBuilder.getVPNAuthToken(from: tokenProvider, policy: .localValid)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you wrap this in a NetworkProtectionError with underlying error data?

It'll make it much easier to debug issues if we know where this error is coming from.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have modified Networking.OAuthClientError.missingTokens by adding an underlying error and used in here

}
} catch {
Logger.subscription.error("Failed to migrate V1 token: \(error, privacy: .public)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this error throw? The VPN calls this at startup: what happens if the migration fails?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the migration fails there's nothing we can do, we just don't have a token and we stop and just send a pixel (added now in the vpn too)

}
} catch {
Logger.subscription.error("Failed to load initial subscription data: \(error, privacy: .public)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this error throw? The VPN calls this at startup: what happens if the user is not authenticated? Can they still connect?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not authenticated = no auth token, so nothing done there would make sense
Not having a token is a valid state and nothing the vpn can do to recover

Copy link
Contributor

@THISISDINOSAUR THISISDINOSAUR left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's almost nothing PIR related in BSK so I have no comments here

Copy link
Contributor

@miasma13 miasma13 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First batch of comments

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be deleted

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have updates to this?

Comment on lines +29 to +41
assertionFailure("Failed to retrieve auth token: \(error)")
}
return nil
}
set(newValue) {
do {
guard let newValue else {
try removeAccessToken()
return
}
try store(accessToken: newValue)
} catch {
assertionFailure("Failed set token: \(error)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we also need error handling for the legacy token store. Having pixel for failures to read/write at point where we for example attempt to migrate the token would be an important signal.

func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result<AppStorePurchaseFlow.TransactionJWS, AppStorePurchaseFlowError>
@discardableResult
func completeSubscriptionPurchase(with transactionJWS: AppStorePurchaseFlow.TransactionJWS) async -> Result<PurchaseUpdate, AppStorePurchaseFlowError>
func purchaseSubscription(with subscriptionIdentifier: String) async -> Result<TransactionJWS, AppStorePurchaseFlowError>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: The API changed, you now omit the optional emailAccessToken which was passed to account creation. When creating a new account with that token we link the account with user's email protection address. Can we double check if and how this functionality is ported to Auth API v2?

accountManager.signOut(skipNotification: true)
Logger.subscriptionAppStorePurchaseFlow.error("purchaseSubscription error: \(String(reflecting: error), privacy: .public)")

await subscriptionManager.signOut(notifyUI: true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong param, in the old implementation it was skipNotification: true but then notifyUI should be false.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old behaviour is very different from the current one, I re-tested this and notifying the Ui at logout here is needed in case the user has multiple windows and the subscription settings app is visible

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

// Clear everything, the token is unrecoverable
await subscriptionManager.signOut(notifyUI: true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't notifyUI be false here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not really, we want to log out the user if every recovery attempt fails and we want to notify the UI accordingly

Comment on lines 116 to 121
do {
let transactionJWS = try await recoverSubscriptionFromDeadToken()
return .success(transactionJWS)
} catch {
return .failure(.purchaseFailed(OAuthClientError.deadToken))
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handling seems to be incorrect:

  • in L:117 you attempt to recoverSubscriptionFromDeadToken()
  • inside this function you call appStoreRestoreFlow.restoreAccountFromPastPurchase()
  • this does not makes sense as this call already failed in L:107 (scenario for no subscription to be recovered via past App Store purchases)

Copy link
Member Author

@federicocappelli federicocappelli Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

restoreAccountFromPastPurchase fails because the token is dead, the first action of recoverSubscriptionFromDeadToken is to sign out the user (aka deleting everything, including the dead token) and re-run restoreAccountFromPastPurchase. The assumption is that the user had a token (dead) so a subscription was present, so the same subscription is recoverable with a new token.


do {
let subscription = try await subscriptionManager.confirmPurchase(signature: transactionJWS)
if subscription.isActive {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this check. If the subscription after completing the purchase ends up in wrong state, it is a BE issue and there is little we can do about it, cannot recover from it nor we don't special handle it.

I would only keep the refetch and check if entitlements were granted to the account.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how to handle this but:

  • The request has a 3 max retries with a 4s delay between retries APIRequestV2.RetryPolicy(maxRetries: 3, delay: 4.0) but the call itself doesn't fail if the subscription is inactive
  • If the subscription is inactive we log it and return a failure to the script page with the correct error, so I would like to detect it still. I have never seen a failure here, I'm just trying to fail gracefully in case of BE errors.
  • I moved the token refresh out of the if so it is done no matter what.

subscription.platform != .apple {
return externalID
@discardableResult
private func recoverSubscriptionFromDeadToken() async throws -> String {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this is being called 3x from different scenarios but none of them seems for me to be likely to encounter dead token as the account was either just restored or created. In my opinion that while dead token is possible option during the purchase flow it should be treated as other "default" errors but is not recoverable here. We should just fail at given step.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, this is confusing, so I did the following:

  • removed the recovery function call from every AppStorePurchaseFlow call and returned the dead token error normally
  • made recoverSubscriptionFromDeadToken() public and generic, so can be used from averyone receiving a dead token error, I'll update the clients accordingly

# Conflicts:
#	Package.swift
#	Sources/Subscription/API/SubscriptionEndpointService.swift
#	Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift
#	Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift
#	Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift
Copy link
Contributor

@miasma13 miasma13 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second part of the review

Sources/Networking/OAuth/OAuthClient.swift Outdated Show resolved Hide resolved
Comment on lines +22 to +26
public final class SessionDelegate: NSObject, URLSessionTaskDelegate {

/// Disable automatic redirection, in our specific OAuth implementation we manage the redirection, not the user
public func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest) async -> URLRequest? {
return nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like it is used only in one test. Please confirm if this is relevant and move accordingly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean the entire SessionDelegate? no, it's used in the OAuthClient init in the main apps

let urlSession = URLSession(configuration: configuration,
                                    delegate: SessionDelegate(),
                                    delegateQueue: nil)

}

/// The sole entity responsible of obtaining, storing and refreshing an OAuth Token
public protocol SubscriptionTokenProvider {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a big file I would split out these additional protocols and enums not directly related to SubscriptionManager to separate files for improved readability.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved SubscriptionFeature and SubscriptionTokenProvider to dedicated files

Comment on lines 418 to 424
public func isFeatureActive(_ entitlement: SubscriptionEntitlement) async -> Bool {
guard isUserAuthenticated else { return false }

let currentFeatures = await currentSubscriptionFeatures(forceRefresh: false)
return currentFeatures.contains { feature in
feature.entitlement == entitlement && feature.enabled
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this naming confusing. Let's go one by one, currentSubscriptionFeatures above is clear and well defined, features that are provided by current subscription. But when you move to isFeatureActive and SubscriptionFeature.enabled it can be interpreted in multiple ways (e.g. isFeatureActive(.networkProtection), does it mean that VPN is running? etc.).

  • isFeatureActive - means if the feature is provided within the current subscription and if current user has entitlements to use it, previously we sticked to established vocabulary and checked if used has entitlements to a feature. If you don't like this maybe something along the line isFeatureAvailableForUser?
  • SubscriptionFeature.enabled - this is the underlying property storing Bool for the above so I would align them together

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree:

  • isFeatureActive renamed isFeatureAvailableForUser
  • SubscriptionFeature.enabled renamed availableForUser

cacheSerialQueue.sync {
subscriptionCache.reset()
}
// NotificationCenter.default.post(name: .subscriptionDidChange, object: self, userInfo: nil)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please double check if this is needed.

Copy link
Member Author

@federicocappelli federicocappelli Jan 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed for now, anything related to the subscription cancellation is handled by the signOut that sends the proper notification. I'm keeping this commented to remind me where it COULD be if I need it, not to be merged anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants