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 132 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

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

@@ -29,6 +29,7 @@ extension APIClient {
extension APIClient: APIClient.Mockable {}

public protocol APIClientEnvironment {
func queryItems(for requestType: APIRequestType) -> QueryItems
Copy link
Member Author

Choose a reason for hiding this comment

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

Adopting the improved Networking V2

@@ -1,7 +1,7 @@
//
// DecodableHelper.swift
// HTTPURLResponse+Cookie.swift
Copy link
Member Author

Choose a reason for hiding this comment

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

Note: github is mistaken here, not the same file at all

@federicocappelli federicocappelli marked this pull request as ready for review December 12, 2024 17:42
}
}

public typealias DecodableHelper = CodableHelper
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this required?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes but just because the branch is long-living, 90% of the "dumb" merge conflicts I get are for this name change, I'll remove it after we merge this.

/// The available features in the subscription based on the country and feature flags. Not based on user entitlements
let features: [SubscriptionOptions.Feature]

public init(platform: SubscriptionPlatformName, options: [SubscriptionOption], availableEntitlements: [SubscriptionEntitlement]) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the reason for changing the naming here from features to availableEntitlements. This is confusing and against the naming conventions we agreed upon:

  • entitlements are permissions granted to user's account for accessing features (e.g. account has entitlement to use VPN)
  • features are services bundled together as a subscription plan, set of features defines the complete set of entitlements user is granted when subscribing

https://app.asana.com/0/1208524871249522/1208561749459707/f
https://app.asana.com/0/1208524871249522/1208561749459707/f

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 reasons are the following:

The new source of truth for both user entitlements and subscription entitlements is a single function in SubscriptionManager, this function needs to return "something" that explains what features are available and if they are available for the specific user, this something natural name is Feature (entitlement + enabled or not), I tried to invent something different but the result was confusing.
How we obtain entitlements and features has changed a lot, getting stuck on the previous naming convention didn't make much sense.

The data we get from the backend is identical between user entitlement and subscription entitlement, they are all entitlement so I maintained the same approach, the moment we merge the two concept we get a SubscriptionFeature

The old Feature and Entitlement struct with var product was verbose for no reason, the only thingh we care of is the entitlement

The important concept here is that all of these entitlements and where they come from are internal implementation details, the only externally visible object is

public struct SubscriptionFeature: Equatable, CustomDebugStringConvertible {
    public var entitlement: SubscriptionEntitlement
    public var enabled: Bool
    ...
}

Comment on lines 404 to 439
/// Returns the features available for the current subscription, a feature is enabled only if the user has the corresponding entitlement
/// - Parameter forceRefresh: ignore subscription and token cache and re-download everything
/// - Returns: An Array of SubscriptionFeature where each feature is enabled or disabled based on the user entitlements
public func currentSubscriptionFeatures(forceRefresh: Bool) async -> [SubscriptionFeature] {
guard isUserAuthenticated else { return [] }

if let subscriptionFeatureFlagger,
subscriptionFeatureFlagger.isFeatureOn(.isLaunchedROW) || subscriptionFeatureFlagger.isFeatureOn(.isLaunchedROWOverride) {
do {
let currentSubscription = try await getSubscription(cachePolicy: .returnCacheDataDontLoad)
let tokenContainer = try await getTokenContainer(policy: forceRefresh ? .localForceRefresh : .local)
let userEntitlements = tokenContainer.decodedAccessToken.subscriptionEntitlements
let availableFeatures = currentSubscription.features ?? [] // await subscriptionFeatureMappingCache.subscriptionFeatures(for: subscription.productId)

// Filter out the features that are not available because the user doesn't have the right entitlements
let result = availableFeatures.map({ featureEntitlement in
let enabled = userEntitlements.contains(featureEntitlement)
return SubscriptionFeature(entitlement: featureEntitlement, enabled: enabled)
})
Logger.subscription.log("""
User entitlements: \(userEntitlements)
Available Features: \(availableFeatures)
Subscription features: \(result)
""")
return result
} catch {
return []
}
} else {
return [.networkProtection, .dataBrokerProtection, .identityTheftRestoration]
let result = [SubscriptionFeature(entitlement: .networkProtection, enabled: true),
SubscriptionFeature(entitlement: .dataBrokerProtection, enabled: true),
SubscriptionFeature(entitlement: .identityTheftRestoration, enabled: true)]
Logger.subscription.debug("Default Subscription features: \(result)")
return result
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I do not fully agree with this method. Subscription feature as a concept cannot be enabled/disabled - it is either present or not. A subscription is defined as being a bundle of features, e.g. "US Privacy Pro" has VPN, PIR, ITR, "ROW Privacy PRO" has VPN, ITR-ROW.

So where this implementation diverges from the old one in SubscriptionFeatureMappingCache it should not filter it through entitlements. It is the set of features as defined per subscription ID and fetched from BE API.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also what is worth, this method is to be used by client apps to determine which parts of the UI are to be shown for the currently active subscription.

Copy link
Member Author

Choose a reason for hiding this comment

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

| A subscription is defined as being a bundle of features
yes, in the backend. We enhance the "subscription features" by adding additional info (available or not to the user)

I'm not sure where's the problem here, everything you described is right and it's how is currently implemented, in fact, the number/type of features returned by this function is dictated by the entitlement (backend name) present in the subscription and they are marked enabled/disabled by the user capabilities.

As you say this function user is the client's UI that needs a way to decide which features displaying (here is the name) and if they are disabled or not :) Exactly what this function provides.

Note that SubscriptionManager (in this function in particular) is the only entity that has the right to manipulate entitlements and create Features, nobody else touches them so the joining of user entitlements and subscription entitlements needs to happen here.

I think the misunderstanding here is that you are not separating the internal implementation from the Subscription framework interface. The models' name and functions' contract should be dictated by the need of the user (in this case the App's UI) not from some internal implementation ideas.

Comment on lines +441 to +444
public func isFeatureActive(_ entitlement: SubscriptionEntitlement) async -> Bool {
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.

Similar as comment above. I strongly would encourage to stick to naming conventions already established and rename it to hasEntitlement(_:). Also there should be no logic crosschecking it with the entitlements here, just simple check for entitlements array if one is present and return true or false.
I think I see what you tried to achieve here but the whole quite simple concept becomes convoluted without reason. Features in the subscription define what UI-wise is part of current subscription, and Entitlements define which parts user currently has access to within the scope of current subscription feature set.

Copy link
Member Author

Choose a reason for hiding this comment

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

As explained above hasEntitlement(_:) doesn't make sense anymore, nobody outside SubscriptionManager can or should know/care about internal implementation details (The S of SOLID principles) so it's the duty of currentSubscriptionFeatures to provide the right information needed from the UI, we can't ask the users (main apps) to check if the use entitlement is present, get the subscription entitlements, compare them and then update the UI.

@THISISDINOSAUR
Copy link
Contributor

There's basically no PIR stuff in BSK at the moment

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.

Adding some comments. Still going through the code.

Sources/Common/KeychainType.swift Outdated Show resolved Hide resolved
let logger = { Logger(subsystem: Bundle.main.bundleIdentifier ?? "DuckDuckGo", category: "UserDefaultsCache") }()
let logger = { Logger(subsystem: "UserDefaultsCache", category: "") }()
Copy link
Contributor

Choose a reason for hiding this comment

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

This will remove these entries from VPN log export. Is there a need to make this 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.

I didn't know the exported was actively filtering for this, the reason for changing it is that, As for Apple's documentation: The subsystem string identifies a large functional area within your app or apps. this can be interpreted in several ways but all other Loggers' subsystems we declare are stuff like "Subscription", "Content Blocking" etc.
Additionally, logs already have the app bundle identifier/app name, so this field was a duplication.
Is it easy to change the log export? if not I can revert this

Sources/NetworkProtection/PacketTunnelProvider.swift Outdated Show resolved Hide resolved
Comment on lines -680 to +684
try load(options: startupOptions)

if (try? tokenStore.fetchToken()) == nil {
throw TunnelError.startingTunnelWithoutAuthToken
}
try await load(options: startupOptions)
Logger.networkProtection.log("Startup options loaded correctly")
Copy link
Contributor

Choose a reason for hiding this comment

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

The error handling needs to be improved here. Previously we could only have startingTunnelWithoutAuthToken, but now we can get a number of different possible errors from tokenProvider.adopt and tokenProvider.getTokenContainer.

These errors should ideally implement CustomNSError and include underlying error information. See PacketTunnelProvider.TunnelError for reference.

The reason this is important is because it's what'll allow us to debug tunnel start issues coming from these changes, and understand what's going on.

Copy link
Member Author

Choose a reason for hiding this comment

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

Understood, atm getTokenContainer can fail for a variety of reasons, from storage to API, decoding and decryption errors, we don't have a single set Error so implementing this is quite difficult, should I wrap any possible error into a higher level TunnelError?

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