diff --git a/app/build.gradle b/app/build.gradle index f1eb13ba7376..73b0892762e6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -390,9 +390,6 @@ dependencies { implementation project(':breakage-reporting-impl') - implementation project(":auth-jwt-api") - implementation project(":auth-jwt-impl") - // Deprecated. TODO: Stop using this artifact. implementation "androidx.legacy:legacy-support-v4:_" debugImplementation Square.leakCanary.android diff --git a/auth-jwt/auth-jwt-api/.gitignore b/auth-jwt/auth-jwt-api/.gitignore deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/auth-jwt/auth-jwt-impl/build.gradle b/auth-jwt/auth-jwt-impl/build.gradle deleted file mode 100644 index 81d42735b010..000000000000 --- a/auth-jwt/auth-jwt-impl/build.gradle +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2021 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -plugins { - id 'com.android.library' - id 'kotlin-android' - id 'com.squareup.anvil' - id 'com.google.devtools.ksp' -} - -apply from: "$rootProject.projectDir/gradle/android-library.gradle" - -dependencies { - implementation project(":auth-jwt-api") - - anvil project(path: ':anvil-compiler') - implementation project(path: ':anvil-annotations') - implementation project(path: ':di') - implementation project(path: ':common-utils') - - implementation Google.dagger - - implementation "com.squareup.logcat:logcat:_" - - implementation "io.jsonwebtoken:jjwt-api:_" - runtimeOnly "io.jsonwebtoken:jjwt-impl:_" - runtimeOnly("io.jsonwebtoken:jjwt-orgjson:_") { - exclude(group: 'org.json', module: 'json') // provided by Android natively - } - - testImplementation Testing.junit4 - testImplementation "org.mockito.kotlin:mockito-kotlin:_" - testImplementation Testing.robolectric - testImplementation AndroidX.test.ext.junit - testImplementation project(path: ':common-test') - - coreLibraryDesugaring Android.tools.desugarJdkLibs -} - -android { - namespace "com.duckduckgo.authjwt.impl" - anvil { - generateDaggerFactories = true // default is false - } - testOptions { - unitTests { - includeAndroidResources = true - } - } - compileOptions { - coreLibraryDesugaringEnabled = true - } -} - diff --git a/auth-jwt/readme.md b/auth-jwt/readme.md deleted file mode 100644 index e6fbcf22cd06..000000000000 --- a/auth-jwt/readme.md +++ /dev/null @@ -1,9 +0,0 @@ -# Feature Name - -This module provides API for parsing and validating JWT (JWS) tokens issued by Auth API v2. - -## Who can help you better understand this feature? -- Łukasz Macionczyk - -## More information -N/A \ No newline at end of file diff --git a/subscriptions/subscriptions-impl/build.gradle b/subscriptions/subscriptions-impl/build.gradle index a4f27febe3b8..ea120b157bc6 100644 --- a/subscriptions/subscriptions-impl/build.gradle +++ b/subscriptions/subscriptions-impl/build.gradle @@ -78,6 +78,12 @@ dependencies { implementation Square.okHttp3.okHttp implementation AndroidX.security.crypto + implementation "io.jsonwebtoken:jjwt-api:_" + runtimeOnly "io.jsonwebtoken:jjwt-impl:_" + runtimeOnly("io.jsonwebtoken:jjwt-orgjson:_") { + exclude(group: 'org.json', module: 'json') // provided by Android natively + } + // Testing dependencies testImplementation project(':feature-toggles-test') testImplementation project(path: ':common-test') diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt index 4aec27224a3f..6daff9eb71d1 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt @@ -142,6 +142,9 @@ interface PrivacyProFeature { @Toggle.DefaultValue(false) fun serpPromoCookie(): Toggle + + @Toggle.DefaultValue(false) + fun authApiV2(): Toggle } @ContributesBinding(AppScope::class) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsChecker.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsChecker.kt index bacbfe7c038c..61b5c52eebdc 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsChecker.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsChecker.kt @@ -23,6 +23,7 @@ import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters @@ -35,8 +36,8 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.impl.RealSubscriptionsChecker.Companion.TAG_WORKER_SUBSCRIPTION_CHECK import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding -import java.util.concurrent.TimeUnit.HOURS -import java.util.concurrent.TimeUnit.MINUTES +import java.time.Duration +import java.time.Instant import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -66,21 +67,13 @@ class RealSubscriptionsChecker @Inject constructor( } override suspend fun runChecker() { - if (subscriptionsManager.subscriptionStatus() != UNKNOWN) { - PeriodicWorkRequestBuilder(1, HOURS) - .addTag(TAG_WORKER_SUBSCRIPTION_CHECK) - .setConstraints( - Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), - ) - .setBackoffCriteria(BackoffPolicy.LINEAR, 10, MINUTES) - .build().run { - workManager.enqueueUniquePeriodicWork( - TAG_WORKER_SUBSCRIPTION_CHECK, - ExistingPeriodicWorkPolicy.REPLACE, - this, - ) - } - } + if (!subscriptionsManager.isSignedIn()) return + + workManager.enqueueUniquePeriodicWork( + TAG_WORKER_SUBSCRIPTION_CHECK, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + buildSubscriptionCheckPeriodicWorkRequest(), + ) } companion object { @@ -101,14 +94,31 @@ class SubscriptionsCheckWorker( override suspend fun doWork(): Result { return try { - if (subscriptionsManager.subscriptionStatus() != UNKNOWN) { - subscriptionsManager.fetchAndStoreAllData() - val subscription = subscriptionsManager.getSubscription() - if (subscription?.status == null || subscription.status == UNKNOWN) { - workManager.cancelAllWorkByTag(TAG_WORKER_SUBSCRIPTION_CHECK) + if (subscriptionsManager.isSignedIn()) { + if (subscriptionsManager.isSignedInV2()) { + subscriptionsManager.refreshSubscriptionData() + val subscription = subscriptionsManager.getSubscription() + if (subscription?.isActive() == true) { + // No need to refresh active subscription in the background. Delay next refresh to the expiry/renewal time. + // It will still get refreshed when the app goes to the foreground. + val expiresOrRenewsAt = Instant.ofEpochMilli(subscription.expiresOrRenewsAt) + if (expiresOrRenewsAt > Instant.now()) { + workManager.enqueueUniquePeriodicWork( + TAG_WORKER_SUBSCRIPTION_CHECK, + ExistingPeriodicWorkPolicy.UPDATE, + buildSubscriptionCheckPeriodicWorkRequest(nextScheduleTimeOverride = expiresOrRenewsAt), + ) + } + } + } else { + subscriptionsManager.fetchAndStoreAllData() + val subscription = subscriptionsManager.getSubscription() + if (subscription?.status == null || subscription.status == UNKNOWN) { + workManager.cancelUniqueWork(TAG_WORKER_SUBSCRIPTION_CHECK) + } } } else { - workManager.cancelAllWorkByTag(TAG_WORKER_SUBSCRIPTION_CHECK) + workManager.cancelUniqueWork(TAG_WORKER_SUBSCRIPTION_CHECK) } Result.success() } catch (e: Exception) { @@ -116,3 +126,16 @@ class SubscriptionsCheckWorker( } } } + +private fun buildSubscriptionCheckPeriodicWorkRequest(nextScheduleTimeOverride: Instant? = null): PeriodicWorkRequest = + PeriodicWorkRequestBuilder(Duration.ofHours(1)) + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), + ) + .setBackoffCriteria(BackoffPolicy.LINEAR, Duration.ofMinutes(10)) + .apply { + if (nextScheduleTimeOverride != null) { + setNextScheduleTimeOverride(nextScheduleTimeOverride.toEpochMilli()) + } + } + .build() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index 94fb34b83895..743837d21935 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -20,6 +20,7 @@ import android.app.Activity import android.content.Context import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.subscriptions.api.Product @@ -35,15 +36,23 @@ import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.RecoverSubscri import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN +import com.duckduckgo.subscriptions.impl.auth2.AccessTokenClaims +import com.duckduckgo.subscriptions.impl.auth2.AuthClient +import com.duckduckgo.subscriptions.impl.auth2.AuthJwtValidator +import com.duckduckgo.subscriptions.impl.auth2.BackgroundTokenRefresh +import com.duckduckgo.subscriptions.impl.auth2.PkceGenerator +import com.duckduckgo.subscriptions.impl.auth2.RefreshTokenClaims +import com.duckduckgo.subscriptions.impl.auth2.TokenPair import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import com.duckduckgo.subscriptions.impl.billing.PurchaseState import com.duckduckgo.subscriptions.impl.billing.RetryPolicy import com.duckduckgo.subscriptions.impl.billing.retry import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender +import com.duckduckgo.subscriptions.impl.repository.AccessToken import com.duckduckgo.subscriptions.impl.repository.Account import com.duckduckgo.subscriptions.impl.repository.AuthRepository +import com.duckduckgo.subscriptions.impl.repository.RefreshToken import com.duckduckgo.subscriptions.impl.repository.Subscription -import com.duckduckgo.subscriptions.impl.repository.isActive import com.duckduckgo.subscriptions.impl.repository.isActiveOrWaiting import com.duckduckgo.subscriptions.impl.repository.isExpired import com.duckduckgo.subscriptions.impl.repository.toProductList @@ -58,7 +67,10 @@ import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.JsonDataException import com.squareup.moshi.JsonEncodingException import com.squareup.moshi.Moshi +import dagger.Lazy import dagger.SingleInstanceIn +import java.time.Duration +import java.time.Instant import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope @@ -100,6 +112,7 @@ interface SubscriptionsManager { * * @return [true] if successful, [false] otherwise */ + @Deprecated("This method will be removed after migrating to auth v2") suspend fun fetchAndStoreAllData(): Boolean /** @@ -107,6 +120,17 @@ interface SubscriptionsManager { */ suspend fun getSubscription(): Subscription? + /** + * Fetches subscription information from BE and saves it in internal storage + */ + suspend fun refreshSubscriptionData() + + /** + * Gets new access token from BE and saves it in internal storage. + * This operation also updates account email and entitlements. + */ + suspend fun refreshAccessToken() + /** * Gets the account details from internal storage */ @@ -115,11 +139,13 @@ interface SubscriptionsManager { /** * Exchanges the auth token for an access token and stores both tokens */ + @Deprecated("This method will be removed after migrating to auth v2") suspend fun exchangeAuthToken(authToken: String): String /** * Returns the auth token and if expired, tries to refresh irt */ + @Deprecated("This method will be removed after migrating to auth v2") suspend fun getAuthToken(): AuthTokenResult /** @@ -133,10 +159,15 @@ interface SubscriptionsManager { suspend fun subscriptionStatus(): SubscriptionStatus /** - * Checks if user is signed in or not + * Checks if user is signed in or not (using either auth API v1 or v2) */ suspend fun isSignedIn(): Boolean + /** + * Checks if user is signed in or not using auth API v2 + */ + suspend fun isSignedInV2(): Boolean + /** * Flow to know if a user is signed in or not */ @@ -182,6 +213,12 @@ class RealSubscriptionsManager @Inject constructor( @AppCoroutineScope private val coroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, private val pixelSender: SubscriptionPixelSender, + private val privacyProFeature: Lazy, + private val authClient: AuthClient, + private val authJwtValidator: AuthJwtValidator, + private val pkceGenerator: PkceGenerator, + private val timeProvider: CurrentTimeProvider, + private val backgroundTokenRefresh: BackgroundTokenRefresh, ) : SubscriptionsManager { private val adapter = Moshi.Builder().build().adapter(ResponseError::class.java) @@ -211,9 +248,21 @@ class RealSubscriptionsManager @Inject constructor( private var removeExpiredSubscriptionOnCancelledPurchase: Boolean = false override suspend fun isSignedIn(): Boolean { + return isSignedInV1() || isSignedInV2() + } + + private suspend fun isSignedInV1(): Boolean { return !authRepository.getAuthToken().isNullOrBlank() && !authRepository.getAccessToken().isNullOrBlank() } + override suspend fun isSignedInV2(): Boolean { + return authRepository.getRefreshTokenV2() != null + } + + private suspend fun shouldUseAuthV2(): Boolean { + return privacyProFeature.get().authApiV2().isEnabled() || isSignedInV2() + } + private fun emitEntitlementsValues() { coroutineScope.launch(dispatcherProvider.io()) { val entitlements = if (authRepository.getSubscription()?.status?.isActiveOrWaiting() == true) { @@ -277,10 +326,16 @@ class RealSubscriptionsManager @Inject constructor( } override suspend fun signOut() { + authRepository.getAccessTokenV2()?.run { + coroutineScope.launch { authClient.tryLogout(accessTokenV2 = jwt) } + } + authRepository.setAccessTokenV2(null) + authRepository.setRefreshTokenV2(null) authRepository.setAuthToken(null) authRepository.setAccessToken(null) authRepository.setAccount(null) authRepository.setSubscription(null) + authRepository.setEntitlements(emptyList()) _isSignedIn.emit(false) _subscriptionStatus.emit(UNKNOWN) _entitlements.emit(emptyList()) @@ -322,38 +377,42 @@ class RealSubscriptionsManager @Inject constructor( purchaseToken: String, ): Boolean { return try { - subscriptionsService.confirm( + val confirmationResponse = subscriptionsService.confirm( ConfirmationBody( packageName = packageName, purchaseToken = purchaseToken, ), - ).also { confirmationResponse -> + ) + + val subscription = Subscription( + productId = confirmationResponse.subscription.productId, + startedAt = confirmationResponse.subscription.startedAt, + expiresOrRenewsAt = confirmationResponse.subscription.expiresOrRenewsAt, + status = confirmationResponse.subscription.status.toStatus(), + platform = confirmationResponse.subscription.platform, + ) + + authRepository.setSubscription(subscription) + + if (shouldUseAuthV2()) { + // existing access token has to be invalidated after the purchase, because it doesn't have up-to-date entitlements + authRepository.setAccessTokenV2(null) + refreshAccessToken() + } else { authRepository.getAccount() ?.copy(email = confirmationResponse.email) ?.let { authRepository.setAccount(it) } - val subscriptionStatus = confirmationResponse.subscription.status.toStatus() - - authRepository.setSubscription( - Subscription( - productId = confirmationResponse.subscription.productId, - startedAt = confirmationResponse.subscription.startedAt, - expiresOrRenewsAt = confirmationResponse.subscription.expiresOrRenewsAt, - status = subscriptionStatus, - platform = confirmationResponse.subscription.platform, - ), - ) - authRepository.setEntitlements(confirmationResponse.entitlements.toEntitlements()) + } - if (subscriptionStatus.isActive()) { - pixelSender.reportPurchaseSuccess() - pixelSender.reportSubscriptionActivated() - emitEntitlementsValues() - _currentPurchaseState.emit(CurrentPurchase.Success) - } else { - handlePurchaseFailed() - } + if (subscription.isActive()) { + pixelSender.reportPurchaseSuccess() + pixelSender.reportSubscriptionActivated() + emitEntitlementsValues() + _currentPurchaseState.emit(CurrentPurchase.Success) + } else { + handlePurchaseFailed() } _subscriptionStatus.emit(authRepository.getStatus()) @@ -379,6 +438,7 @@ class RealSubscriptionsManager @Inject constructor( } } + @Deprecated("This method will be removed after migrating to auth v2") override suspend fun exchangeAuthToken(authToken: String): String { val accessToken = authService.accessToken("Bearer $authToken").accessToken authRepository.setAccessToken(accessToken) @@ -386,9 +446,11 @@ class RealSubscriptionsManager @Inject constructor( return accessToken } + @Deprecated("This method will be removed after migrating to auth v2") override suspend fun fetchAndStoreAllData(): Boolean { try { - if (!isSignedIn()) return false + if (!isSignedInV1()) return false + val subscription = try { subscriptionsService.subscription() } catch (e: HttpException) { @@ -427,6 +489,72 @@ class RealSubscriptionsManager @Inject constructor( } } + override suspend fun refreshAccessToken() { + val refreshToken = checkNotNull(authRepository.getRefreshTokenV2()) + + val newTokens = try { + val tokens = authClient.getTokens(refreshToken.jwt) + validateTokens(tokens) + } catch (e: HttpException) { + if (e.code() == 401) { + // refresh token is invalid / expired -> try to get a new pair of tokens using store login + val account = checkNotNull(authRepository.getAccount()) { "Missing account info when refreshing access token" } + + when (val storeLoginResult = storeLogin(account.externalId)) { + is StoreLoginResult.Success -> storeLoginResult.tokens + StoreLoginResult.Failure.AccountExternalIdMismatch, + StoreLoginResult.Failure.PurchaseHistoryNotAvailable, + StoreLoginResult.Failure.AuthenticationError, + -> { + signOut() + throw e + } + + StoreLoginResult.Failure.Other -> throw e + } + } else { + throw e + } + } + + saveTokens(newTokens) + } + + override suspend fun refreshSubscriptionData() { + val subscription = subscriptionsService.subscription() + + authRepository.setSubscription( + Subscription( + productId = subscription.productId, + startedAt = subscription.startedAt, + expiresOrRenewsAt = subscription.expiresOrRenewsAt, + status = subscription.status.toStatus(), + platform = subscription.platform, + ), + ) + + _subscriptionStatus.emit(subscription.status.toStatus()) + } + + private suspend fun validateTokens(tokens: TokenPair): ValidatedTokenPair { + val jwks = authClient.getJwks() + + return ValidatedTokenPair( + accessToken = tokens.accessToken, + accessTokenClaims = authJwtValidator.validateAccessToken(tokens.accessToken, jwks), + refreshToken = tokens.refreshToken, + refreshTokenClaims = authJwtValidator.validateRefreshToken(tokens.refreshToken, jwks), + ) + } + + private suspend fun saveTokens(tokens: ValidatedTokenPair) = with(tokens) { + authRepository.setAccessTokenV2(AccessToken(accessToken, accessTokenClaims.expiresAt)) + authRepository.setRefreshTokenV2(RefreshToken(refreshToken, refreshTokenClaims.expiresAt)) + authRepository.setEntitlements(accessTokenClaims.entitlements) + authRepository.setAccount(Account(email = accessTokenClaims.email, externalId = accessTokenClaims.accountExternalId)) + backgroundTokenRefresh.schedule() + } + private fun extractError(e: Exception): String { return if (e is HttpException) { parseError(e)?.error ?: "An error happened" @@ -435,31 +563,76 @@ class RealSubscriptionsManager @Inject constructor( } } - override suspend fun recoverSubscriptionFromStore(externalId: String?): RecoverSubscriptionResult { + private suspend fun storeLogin(accountExternalId: String? = null): StoreLoginResult { return try { val purchase = playBillingManager.purchaseHistory.lastOrNull() - if (purchase != null) { - val signature = purchase.signature - val body = purchase.originalJson - val storeLoginBody = StoreLoginBody(signature = signature, signedData = body, packageName = context.packageName) - val response = authService.storeLogin(storeLoginBody) - if (externalId != null && externalId != response.externalId) return RecoverSubscriptionResult.Failure("") - authRepository.setAccount(Account(externalId = response.externalId, email = null)) - authRepository.setAuthToken(response.authToken) - exchangeAuthToken(response.authToken) - if (fetchAndStoreAllData()) { - logcat(LogPriority.DEBUG) { "Subs: store login succeeded" } - val subscription = authRepository.getSubscription() - if (subscription?.isActive() == true) { - RecoverSubscriptionResult.Success(subscription) + ?: return StoreLoginResult.Failure.PurchaseHistoryNotAvailable + + val codeVerifier = pkceGenerator.generateCodeVerifier() + val codeChallenge = pkceGenerator.generateCodeChallenge(codeVerifier) + val sessionId = authClient.authorize(codeChallenge) + val authorizationCode = authClient.storeLogin(sessionId, purchase.signature, purchase.originalJson) + val tokens = authClient.getTokens(sessionId, authorizationCode, codeVerifier) + val validatedTokens = validateTokens(tokens) + + if (accountExternalId != null && accountExternalId != validatedTokens.accessTokenClaims.accountExternalId) { + return StoreLoginResult.Failure.AccountExternalIdMismatch + } + + StoreLoginResult.Success(validatedTokens) + } catch (e: Exception) { + if (e is HttpException && e.code() == 400) { + StoreLoginResult.Failure.AuthenticationError + } else { + StoreLoginResult.Failure.Other + } + } + } + + override suspend fun recoverSubscriptionFromStore(externalId: String?): RecoverSubscriptionResult { + return try { + if (shouldUseAuthV2()) { + require(externalId == null) { "Use storeLogin() directly to re-authenticate using existing externalId" } + when (val storeLoginResult = storeLogin()) { + is StoreLoginResult.Success -> { + saveTokens(storeLoginResult.tokens) + refreshSubscriptionData() + val subscription = getSubscription() + if (subscription?.isActive() == true) { + RecoverSubscriptionResult.Success(subscription) + } else { + RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR) + } + } + is StoreLoginResult.Failure -> { + RecoverSubscriptionResult.Failure("") + } + } + } else { + val purchase = playBillingManager.purchaseHistory.lastOrNull() + if (purchase != null) { + val signature = purchase.signature + val body = purchase.originalJson + val storeLoginBody = StoreLoginBody(signature = signature, signedData = body, packageName = context.packageName) + val response = authService.storeLogin(storeLoginBody) + if (externalId != null && externalId != response.externalId) return RecoverSubscriptionResult.Failure("") + authRepository.setAccount(Account(externalId = response.externalId, email = null)) + authRepository.setAuthToken(response.authToken) + exchangeAuthToken(response.authToken) + if (fetchAndStoreAllData()) { + logcat(LogPriority.DEBUG) { "Subs: store login succeeded" } + val subscription = authRepository.getSubscription() + if (subscription?.isActive() == true) { + RecoverSubscriptionResult.Success(subscription) + } else { + RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR) + } } else { - RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR) + RecoverSubscriptionResult.Failure("") } } else { - RecoverSubscriptionResult.Failure("") + RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR) } - } else { - RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR) } } catch (e: Exception) { logcat(LogPriority.DEBUG) { "Subs: Exception!" } @@ -495,7 +668,10 @@ class RealSubscriptionsManager @Inject constructor( _currentPurchaseState.emit(CurrentPurchase.PreFlowInProgress) // refresh any existing account / subscription data - fetchAndStoreAllData() + when { + isSignedInV2() -> refreshSubscriptionData() + isSignedInV1() -> fetchAndStoreAllData() + } if (!isSignedIn()) { recoverSubscriptionFromStore() @@ -522,7 +698,9 @@ class RealSubscriptionsManager @Inject constructor( if (subscription == null && !isSignedIn()) { createAccount() - exchangeAuthToken(authRepository.getAuthToken()!!) + if (!shouldUseAuthV2()) { + exchangeAuthToken(authRepository.getAuthToken()!!) + } } logcat(LogPriority.DEBUG) { "Subs: external id is ${authRepository.getAccount()!!.externalId}" } @@ -540,9 +718,17 @@ class RealSubscriptionsManager @Inject constructor( } } + @Deprecated("This method will be removed after migrating to auth v2") override suspend fun getAuthToken(): AuthTokenResult { + if (isSignedInV2()) { + return when (val accessToken = getAccessToken()) { + is AccessTokenResult.Failure -> AuthTokenResult.Failure.UnknownError + is AccessTokenResult.Success -> AuthTokenResult.Success(accessToken.accessToken) + } + } + try { - return if (isSignedIn()) { + return if (isSignedInV1()) { logcat { "Subs auth token is ${authRepository.getAuthToken()}" } validateToken(authRepository.getAuthToken()!!) AuthTokenResult.Success(authRepository.getAuthToken()!!) @@ -568,25 +754,80 @@ class RealSubscriptionsManager @Inject constructor( } override suspend fun getAccessToken(): AccessTokenResult { - return if (isSignedIn()) { - AccessTokenResult.Success(authRepository.getAccessToken()!!) + return when { + isSignedIn() && shouldUseAuthV2() -> try { + AccessTokenResult.Success(getValidAccessTokenV2()) + } catch (e: Exception) { + AccessTokenResult.Failure("Token not found") + } + isSignedInV1() -> AccessTokenResult.Success(authRepository.getAccessToken()!!) + else -> AccessTokenResult.Failure("Token not found") + } + } + + private suspend fun getValidAccessTokenV2(): String { + check(isSignedIn()) + check(shouldUseAuthV2()) + + if (!isSignedInV2() && isSignedInV1()) { + migrateToAuthV2() + } + + val accessToken = authRepository.getAccessTokenV2() + ?.takeIf { isAccessTokenUsable(it) } + + return if (accessToken != null) { + accessToken.jwt } else { - AccessTokenResult.Failure("Token not found") + refreshAccessToken() + + // Rotating auth credentials didn't throw an exception, so a valid access token is expected to be available + val newAccessToken = authRepository.getAccessTokenV2() + checkNotNull(newAccessToken) + check(isAccessTokenUsable(newAccessToken)) + + newAccessToken.jwt } } + private suspend fun migrateToAuthV2() { + val accessTokenV1 = checkNotNull(authRepository.getAccessToken()) + val codeVerifier = pkceGenerator.generateCodeVerifier() + val codeChallenge = pkceGenerator.generateCodeChallenge(codeVerifier) + val sessionId = authClient.authorize(codeChallenge) + val authorizationCode = authClient.exchangeV1AccessToken(accessTokenV1, sessionId) + val tokens = authClient.getTokens(sessionId, authorizationCode, codeVerifier) + saveTokens(validateTokens(tokens)) + authRepository.setAccessToken(null) + authRepository.setAuthToken(null) + } + + private fun isAccessTokenUsable(accessToken: AccessToken): Boolean { + val currentTime = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) + return accessToken.expiresAt > currentTime + Duration.ofMinutes(1) + } + private suspend fun validateToken(token: String): ValidateTokenResponse { return authService.validateToken("Bearer $token") } private suspend fun createAccount() { try { - val account = authService.createAccount("Bearer ${emailManager.getToken()}") - if (account.authToken.isEmpty()) { - pixelSender.reportPurchaseFailureAccountCreation() + if (shouldUseAuthV2()) { + val codeVerifier = pkceGenerator.generateCodeVerifier() + val codeChallenge = pkceGenerator.generateCodeChallenge(codeVerifier) + val sessionId = authClient.authorize(codeChallenge) + val authorizationCode = authClient.createAccount(sessionId) + val tokens = authClient.getTokens(sessionId, authorizationCode, codeVerifier) + saveTokens(validateTokens(tokens)) } else { - authRepository.setAccount(Account(externalId = account.externalId, email = null)) - authRepository.setAuthToken(account.authToken) + val account = authService.createAccount("Bearer ${emailManager.getToken()}") + if (account.authToken.isEmpty()) { + pixelSender.reportPurchaseFailureAccountCreation() + } else { + authRepository.setAccount(Account(externalId = account.externalId, email = null)) + authRepository.setAuthToken(account.authToken) + } } } catch (e: Exception) { when (e) { @@ -607,6 +848,16 @@ class RealSubscriptionsManager @Inject constructor( } } + private sealed class StoreLoginResult { + data class Success(val tokens: ValidatedTokenPair) : StoreLoginResult() + sealed class Failure : StoreLoginResult() { + data object PurchaseHistoryNotAvailable : Failure() + data object AccountExternalIdMismatch : Failure() + data object AuthenticationError : Failure() + data object Other : Failure() + } + } + companion object { const val SUBSCRIPTION_NOT_FOUND_ERROR = "SubscriptionNotFound" } @@ -654,3 +905,10 @@ data class SubscriptionOffer( val yearlyPlanId: String, val yearlyFormattedPrice: String, ) + +data class ValidatedTokenPair( + val accessToken: String, + val accessTokenClaims: AccessTokenClaims, + val refreshToken: String, + val refreshTokenClaims: RefreshTokenClaims, +) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthClient.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthClient.kt new file mode 100644 index 000000000000..c5516938985b --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthClient.kt @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.auth2 + +import android.net.Uri +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import logcat.logcat +import retrofit2.HttpException +import retrofit2.Response + +interface AuthClient { + /** + * Starts authorization session. + * + * @param codeChallenge code challenge derived from code verifier as described in RFC 7636 section 4.2 + * @return authorization session id + */ + suspend fun authorize(codeChallenge: String): String + + /** + * Creates an account linked to the active authorization session. + * + * @param sessionId authorization session id + * @return authorization code required to fetch access token + */ + suspend fun createAccount(sessionId: String): String + + /** + * Obtains new access and refresh tokens from BE. + * + * @param sessionId authorization session id + * @param authorizationCode authorization code obtained when creating account + * @param codeVerifier code verifier generated as described in RFC 7636 section 4.1 + * @return [TokenPair] instance containing access and refresh tokens + */ + suspend fun getTokens( + sessionId: String, + authorizationCode: String, + codeVerifier: String, + ): TokenPair + + /** + * Obtains new access and refresh tokens from BE. + * + * @param refreshToken refresh token + * @return [TokenPair] instance containing access and refresh tokens + */ + suspend fun getTokens(refreshToken: String): TokenPair + + /** + * Obtains set of public JWKs used to validate JWTs (JWEs) generated by Auth API V2. + * + * @return [String] containing JWK set in JSON format (RFC 7517 section 5) + */ + suspend fun getJwks(): String + + /** + * Attempts to sign in using Play Store purchase history data. + * + * @param sessionId authorization session id + * @param signature cryptographically signed string that can be verified by the Auth API with public keys published by the Play Store + * @param googleSignedData signed data that produced the cryptographic signature in the signature param + * @return authorization code required to fetch access token + */ + suspend fun storeLogin( + sessionId: String, + signature: String, + googleSignedData: String, + ): String + + /** + * Attempts to sign in using V1 access token. + * + * @param accessTokenV1 access token obtained from auth API v1 + * @param sessionId authorization session id + * @return authorization code required to fetch access token + */ + suspend fun exchangeV1AccessToken( + accessTokenV1: String, + sessionId: String, + ): String + + /** + * Invalidates the current access token + refresh token pair based on the access token provided. + * This is meant to work on a best-effort basis, so this method does not throw if the request fails. + * + * @param accessTokenV2 access token obtained from auth API v2 + */ + suspend fun tryLogout(accessTokenV2: String) +} + +data class TokenPair( + val accessToken: String, + val refreshToken: String, +) + +@ContributesBinding(AppScope::class) +class AuthClientImpl @Inject constructor( + private val authService: AuthService, + private val appBuildConfig: AppBuildConfig, +) : AuthClient { + + override suspend fun authorize(codeChallenge: String): String { + val response = authService.authorize( + responseType = AUTH_V2_RESPONSE_TYPE, + codeChallenge = codeChallenge, + codeChallengeMethod = AUTH_V2_CODE_CHALLENGE_METHOD, + clientId = AUTH_V2_CLIENT_ID, + redirectUri = AUTH_V2_REDIRECT_URI, + scope = AUTH_V2_SCOPE, + ) + + if (response.code() == 302) { + val sessionId = response.headers() + .values("Set-Cookie") + .firstOrNull { it.startsWith("ddg_auth_session_id=") } + ?.substringBefore(';') + ?.substringAfter('=') + + if (sessionId == null) { + throw RuntimeException("Failed to extract sessionId") + } + + return sessionId + } else { + throw HttpException(response) + } + } + + override suspend fun createAccount(sessionId: String): String { + val response = authService.createAccount("ddg_auth_session_id=$sessionId") + return response.getAuthorizationCode() + } + + override suspend fun getTokens( + sessionId: String, + authorizationCode: String, + codeVerifier: String, + ): TokenPair { + val tokensResponse = authService.token( + grantType = GRANT_TYPE_AUTHORIZATION_CODE, + clientId = AUTH_V2_CLIENT_ID, + codeVerifier = codeVerifier, + code = authorizationCode, + redirectUri = AUTH_V2_REDIRECT_URI, + refreshToken = null, + ) + return TokenPair( + accessToken = tokensResponse.accessToken, + refreshToken = tokensResponse.refreshToken, + ) + } + + override suspend fun getTokens(refreshToken: String): TokenPair { + val tokensResponse = authService.token( + grantType = GRANT_TYPE_REFRESH_TOKEN, + clientId = AUTH_V2_CLIENT_ID, + codeVerifier = null, + code = null, + redirectUri = null, + refreshToken = refreshToken, + ) + return TokenPair( + accessToken = tokensResponse.accessToken, + refreshToken = tokensResponse.refreshToken, + ) + } + + override suspend fun getJwks(): String = + authService.jwks().string() + + override suspend fun storeLogin( + sessionId: String, + signature: String, + googleSignedData: String, + ): String { + val response = authService.login( + cookie = "ddg_auth_session_id=$sessionId", + body = StoreLoginBody( + method = "signature", + signature = signature, + source = "google_play_store", + googleSignedData = googleSignedData, + googlePackageName = appBuildConfig.applicationId, + ), + ) + + return response.getAuthorizationCode() + } + + override suspend fun exchangeV1AccessToken( + accessTokenV1: String, + sessionId: String, + ): String { + val response = authService.exchange( + authorization = "Bearer $accessTokenV1", + cookie = "ddg_auth_session_id=$sessionId", + ) + return response.getAuthorizationCode() + } + + override suspend fun tryLogout(accessTokenV2: String) { + try { + authService.logout(authorization = "Bearer $accessTokenV2") + } catch (e: Exception) { + logcat { "Logout request failed" } + } + } + + private fun Response.getAuthorizationCode(): String { + if (code() == 302) { + val authorizationCode = headers() + .values("Location") + .firstOrNull() + ?.let { Uri.parse(it) } + ?.getQueryParameter("code") + + if (authorizationCode == null) { + throw RuntimeException("Failed to extract authorization code") + } + + return authorizationCode + } else { + throw HttpException(this) + } + } + + private companion object { + const val AUTH_V2_CLIENT_ID = "f4311287-0121-40e6-8bbd-85c36daf1837" + const val AUTH_V2_REDIRECT_URI = "com.duckduckgo:/authcb" + const val AUTH_V2_SCOPE = "privacypro" + const val AUTH_V2_CODE_CHALLENGE_METHOD = "S256" + const val AUTH_V2_RESPONSE_TYPE = "code" + const val GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" + const val GRANT_TYPE_REFRESH_TOKEN = "refresh_token" + } +} diff --git a/auth-jwt/auth-jwt-api/src/main/java/com/duckduckgo/authjwt/api/AuthJwtValidator.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidator.kt similarity index 92% rename from auth-jwt/auth-jwt-api/src/main/java/com/duckduckgo/authjwt/api/AuthJwtValidator.kt rename to subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidator.kt index fd7f90c53048..edb1a0fcb04d 100644 --- a/auth-jwt/auth-jwt-api/src/main/java/com/duckduckgo/authjwt/api/AuthJwtValidator.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidator.kt @@ -14,8 +14,9 @@ * limitations under the License. */ -package com.duckduckgo.authjwt.api +package com.duckduckgo.subscriptions.impl.auth2 +import com.duckduckgo.subscriptions.impl.model.Entitlement import java.time.Instant /** @@ -76,15 +77,3 @@ data class RefreshTokenClaims( */ val accountExternalId: String, ) - -data class Entitlement( - /** - * Name of the product represented by this entitlement. - */ - val product: String, - - /** - * Name of the entitlement. - */ - val name: String, -) diff --git a/auth-jwt/auth-jwt-impl/src/main/java/com/duckduckgo/authjwt/impl/AuthJwtValidatorImpl.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidatorImpl.kt similarity index 88% rename from auth-jwt/auth-jwt-impl/src/main/java/com/duckduckgo/authjwt/impl/AuthJwtValidatorImpl.kt rename to subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidatorImpl.kt index 4929de35198f..7d1e35461e02 100644 --- a/auth-jwt/auth-jwt-impl/src/main/java/com/duckduckgo/authjwt/impl/AuthJwtValidatorImpl.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidatorImpl.kt @@ -14,20 +14,14 @@ * limitations under the License. */ -package com.duckduckgo.authjwt.impl +package com.duckduckgo.subscriptions.impl.auth2 -import com.duckduckgo.authjwt.api.AccessTokenClaims -import com.duckduckgo.authjwt.api.AuthJwtValidator -import com.duckduckgo.authjwt.api.Entitlement -import com.duckduckgo.authjwt.api.RefreshTokenClaims import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.squareup.anvil.annotations.ContributesBinding import io.jsonwebtoken.Claims -import io.jsonwebtoken.JwsHeader -import io.jsonwebtoken.Jwts -import io.jsonwebtoken.security.Jwks -import java.util.Date +import java.util.* import javax.inject.Inject @ContributesBinding(AppScope::class) @@ -85,14 +79,14 @@ class AuthJwtValidatorImpl @Inject constructor( requiredAudience: String, requiredScope: String, ): Claims { - val jwks = Jwks.setParser() + val jwks = io.jsonwebtoken.security.Jwks.setParser() .build() .parse(jwkSet) .getKeys() - return Jwts.parser() + return io.jsonwebtoken.Jwts.parser() .keyLocator { header -> - val keyId = (header as JwsHeader).keyId + val keyId = (header as io.jsonwebtoken.JwsHeader).keyId jwks.first { it.id == keyId }.toKey() } .clock { Date(timeProvider.currentTimeMillis()) } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthService.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthService.kt new file mode 100644 index 000000000000..45490b534f90 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthService.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.auth2 + +import com.squareup.moshi.Json +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Query + +interface AuthService { + @GET("https://quack.duckduckgo.com/api/auth/v2/authorize") + suspend fun authorize( + @Query("response_type") responseType: String, + @Query("code_challenge") codeChallenge: String, + @Query("code_challenge_method") codeChallengeMethod: String, + @Query("client_id") clientId: String, + @Query("redirect_uri") redirectUri: String, + @Query("scope") scope: String, + ): Response + + @POST("https://quack.duckduckgo.com/api/auth/v2/account/create") + suspend fun createAccount( + @Header("Cookie") cookie: String, + ): Response + + @GET("https://quack.duckduckgo.com/api/auth/v2/token") + suspend fun token( + @Query("grant_type") grantType: String, + @Query("client_id") clientId: String, + @Query("code_verifier") codeVerifier: String?, + @Query("code") code: String?, + @Query("redirect_uri") redirectUri: String?, + @Query("refresh_token") refreshToken: String?, + ): TokensResponse + + @GET("https://quack.duckduckgo.com/api/auth/v2/.well-known/jwks.json") + suspend fun jwks(): ResponseBody + + @POST("https://quack.duckduckgo.com/api/auth/v2/login") + suspend fun login( + @Header("Cookie") cookie: String, + @Body body: StoreLoginBody, + ): Response + + @POST("https://quack.duckduckgo.com/api/auth/v2/exchange") + suspend fun exchange( + @Header("Authorization") authorization: String, + @Header("Cookie") cookie: String, + ): Response + + @POST("https://quack.duckduckgo.com/api/auth/v2/logout") + suspend fun logout(@Header("Authorization") authorization: String) +} + +data class TokensResponse( + @field:Json(name = "access_token") val accessToken: String, + @field:Json(name = "refresh_token") val refreshToken: String, +) + +data class StoreLoginBody( + @field:Json(name = "method") val method: String, + @field:Json(name = "signature") val signature: String, + @field:Json(name = "source") val source: String, + @field:Json(name = "google_signed_data") val googleSignedData: String, + @field:Json(name = "google_package_name") val googlePackageName: String, +) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthServiceModule.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthServiceModule.kt new file mode 100644 index 000000000000..c8bb4eba6369 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthServiceModule.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.auth2 + +import android.annotation.SuppressLint +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import dagger.SingleInstanceIn +import javax.inject.Named +import javax.inject.Provider +import okhttp3.OkHttpClient +import retrofit2.Retrofit + +@Module +@ContributesTo(AppScope::class) +object AuthServiceModule { + @SuppressLint("NoRetrofitCreateMethodCallDetector") + @Provides + @SingleInstanceIn(AppScope::class) + fun provideAuthService( + @Named("nonCaching") okHttpClient: Provider, + @Named("nonCaching") retrofit: Retrofit, + ): AuthService { + val okHttpClientWithoutRedirects = lazy { + okHttpClient.get().newBuilder() + .followRedirects(false) + .build() + } + + return retrofit.newBuilder() + .callFactory { okHttpClientWithoutRedirects.value.newCall(it) } + .build() + .create(AuthService::class.java) + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/BackgroundTokenRefresh.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/BackgroundTokenRefresh.kt new file mode 100644 index 000000000000..e57d85508a75 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/BackgroundTokenRefresh.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.auth2 + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.duckduckgo.anvil.annotations.ContributesWorker +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.impl.AccessTokenResult.Success +import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.auth2.TokenRefreshWorker.Companion.REFRESH_WORKER_NAME +import com.squareup.anvil.annotations.ContributesBinding +import java.time.Duration +import java.util.concurrent.TimeUnit.MINUTES +import javax.inject.Inject + +/** + * The refresh token is valid for 1 month. We should ensure it doesn’t expire, as that could result in the user being signed out. + * In practice, if the app is actively used, tokens will be refreshed more frequently since the access token is only valid for 4 hours. + * Otherwise, this worker ensures that we obtain a new refresh token before the current one expires. + */ +interface BackgroundTokenRefresh { + fun schedule() +} + +@ContributesBinding(AppScope::class) +class BackgroundTokenRefreshImpl @Inject constructor( + val workManager: WorkManager, +) : BackgroundTokenRefresh { + override fun schedule() { + val workRequest = PeriodicWorkRequestBuilder(repeatInterval = Duration.ofDays(7)) + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), + ) + .setInitialDelay(duration = Duration.ofDays(7)) + .setBackoffCriteria(BackoffPolicy.LINEAR, 10, MINUTES) + .build() + + workManager.enqueueUniquePeriodicWork( + REFRESH_WORKER_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + workRequest, + ) + } +} + +@ContributesWorker(AppScope::class) +class TokenRefreshWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + @Inject + lateinit var workManager: WorkManager + + @Inject + lateinit var subscriptionsManager: SubscriptionsManager + + override suspend fun doWork(): Result { + return try { + if (subscriptionsManager.isSignedInV2()) { + /* + Access tokens are valid for only a few hours. + Calling getAccessToken() will refresh the tokens if they haven’t been refreshed recently. + */ + val result = subscriptionsManager.getAccessToken() + check(result is Success) + } else { + workManager.cancelUniqueWork(REFRESH_WORKER_NAME) + } + Result.success() + } catch (e: Exception) { + Result.failure() + } + } + + companion object { + const val REFRESH_WORKER_NAME = "WORKER_TOKEN_REFRESH" + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/PkceGenerator.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/PkceGenerator.kt new file mode 100644 index 000000000000..7c57cf0c0146 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/PkceGenerator.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.auth2 + +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.security.MessageDigest +import java.security.SecureRandom +import javax.inject.Inject +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +interface PkceGenerator { + fun generateCodeVerifier(): String + fun generateCodeChallenge(codeVerifier: String): String +} + +@ContributesBinding(AppScope::class) +class PkceGeneratorImpl @Inject constructor() : PkceGenerator { + + override fun generateCodeVerifier(): String { + val code = ByteArray(32) + .apply { SecureRandom().nextBytes(this) } + + return code.encodeBase64() + } + + override fun generateCodeChallenge(codeVerifier: String): String { + return MessageDigest.getInstance("SHA-256") + .digest(codeVerifier.toByteArray(Charsets.US_ASCII)) + .encodeBase64() + } + + @OptIn(ExperimentalEncodingApi::class) + private fun ByteArray.encodeBase64(): String { + return Base64.UrlSafe.encode(this).trimEnd('=') + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt index fd4aba335146..d2bdc8b8396a 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt @@ -67,7 +67,7 @@ class SubscriptionMessagingInterface @Inject constructor( SubscriptionsHandler(), GetSubscriptionMessage(subscriptionsManager, dispatcherProvider), SetSubscriptionMessage(subscriptionsManager, appCoroutineScope, dispatcherProvider, pixelSender, subscriptionsChecker), - InformationalEventsMessage(appCoroutineScope, pixelSender), + InformationalEventsMessage(subscriptionsManager, appCoroutineScope, pixelSender), GetAccessTokenMessage(subscriptionsManager), ) @@ -220,6 +220,7 @@ class SubscriptionMessagingInterface @Inject constructor( } private class InformationalEventsMessage( + private val subscriptionsManager: SubscriptionsManager, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val pixelSender: SubscriptionPixelSender, ) : JsMessageHandler { @@ -232,7 +233,11 @@ class SubscriptionMessagingInterface @Inject constructor( when (jsMessage.method) { "subscriptionsMonthlyPriceClicked" -> pixelSender.reportMonthlyPriceClick() "subscriptionsYearlyPriceClicked" -> pixelSender.reportYearlyPriceClick() - "subscriptionsAddEmailSuccess" -> pixelSender.reportAddEmailSuccess() + "subscriptionsAddEmailSuccess" -> { + pixelSender.reportAddEmailSuccess() + subscriptionsManager.tryRefreshAccessToken() + } + "subscriptionsEditEmailSuccess" -> subscriptionsManager.tryRefreshAccessToken() "subscriptionsWelcomeAddEmailClicked", "subscriptionsWelcomeFaqClicked", -> { @@ -244,6 +249,16 @@ class SubscriptionMessagingInterface @Inject constructor( } } + private suspend fun SubscriptionsManager.tryRefreshAccessToken() { + try { + if (subscriptionsManager.isSignedInV2()) { + refreshAccessToken() + } + } catch (e: Exception) { + logcat { e.stackTraceToString() } + } + } + override val allowedDomains: List = emptyList() override val featureName: String = "useSubscription" override val methods: List = listOf( @@ -251,6 +266,7 @@ class SubscriptionMessagingInterface @Inject constructor( "subscriptionsYearlyPriceClicked", "subscriptionsUnknownPriceClicked", "subscriptionsAddEmailSuccess", + "subscriptionsEditEmailSuccess", "subscriptionsWelcomeAddEmailClicked", "subscriptionsWelcomeFaqClicked", ) diff --git a/auth-jwt/auth-jwt-api/build.gradle b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/model/Entitlement.kt similarity index 66% rename from auth-jwt/auth-jwt-api/build.gradle rename to subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/model/Entitlement.kt index af7750526bff..a5b99bf7a1c1 100644 --- a/auth-jwt/auth-jwt-api/build.gradle +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/model/Entitlement.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 DuckDuckGo + * Copyright (c) 2024 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,18 +14,16 @@ * limitations under the License. */ -plugins { - id 'java-library' - id 'kotlin' -} +package com.duckduckgo.subscriptions.impl.model -apply from: "$rootProject.projectDir/code-formatting.gradle" +data class Entitlement( + /** + * Name of the entitlement. + */ + val name: String, -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -kotlin { - jvmToolchain(17) -} + /** + * Name of the product represented by this entitlement. + */ + val product: String, +) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt index a589b1b1114a..31df7e6af201 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt @@ -28,6 +28,7 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE import com.duckduckgo.subscriptions.api.SubscriptionStatus.NOT_AUTO_RENEWABLE import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.serp_promo.SerpPromo import com.duckduckgo.subscriptions.impl.store.SubscriptionsDataStore import com.duckduckgo.subscriptions.impl.store.SubscriptionsEncryptedDataStore @@ -39,10 +40,14 @@ import com.squareup.moshi.Types import dagger.Module import dagger.Provides import dagger.SingleInstanceIn +import java.time.Instant import kotlinx.coroutines.withContext interface AuthRepository { - suspend fun getExternalID(): String? + suspend fun setAccessTokenV2(accessToken: AccessToken?) + suspend fun getAccessTokenV2(): AccessToken? + suspend fun setRefreshTokenV2(refreshToken: RefreshToken?) + suspend fun getRefreshTokenV2(): RefreshToken? suspend fun setAccessToken(accessToken: String?) suspend fun getAccessToken(): String? suspend fun setAuthToken(authToken: String?) @@ -87,6 +92,29 @@ internal class RealAuthRepository constructor( return adapter>(Types.newParameterizedType(List::class.java, T::class.java)).fromJson(jsonString) } + override suspend fun setAccessTokenV2(accessToken: AccessToken?) = withContext(dispatcherProvider.io()) { + subscriptionsDataStore.accessTokenV2 = accessToken?.jwt + subscriptionsDataStore.accessTokenV2ExpiresAt = accessToken?.expiresAt + updateSerpPromoCookie() + } + + override suspend fun getAccessTokenV2(): AccessToken? { + val jwt = subscriptionsDataStore.accessTokenV2 ?: return null + val expiresAt = subscriptionsDataStore.accessTokenV2ExpiresAt ?: return null + return AccessToken(jwt, expiresAt) + } + + override suspend fun setRefreshTokenV2(refreshToken: RefreshToken?) { + subscriptionsDataStore.refreshTokenV2 = refreshToken?.jwt + subscriptionsDataStore.refreshTokenV2ExpiresAt = refreshToken?.expiresAt + } + + override suspend fun getRefreshTokenV2(): RefreshToken? { + val jwt = subscriptionsDataStore.refreshTokenV2 ?: return null + val expiresAt = subscriptionsDataStore.refreshTokenV2ExpiresAt ?: return null + return RefreshToken(jwt, expiresAt) + } + override suspend fun setEntitlements(entitlements: List) = withContext(dispatcherProvider.io()) { subscriptionsDataStore.entitlements = moshi.listToJson(entitlements) } @@ -95,13 +123,9 @@ internal class RealAuthRepository constructor( return subscriptionsDataStore.entitlements?.let { moshi.parseList(it) } ?: emptyList() } - override suspend fun getExternalID(): String? = withContext(dispatcherProvider.io()) { - return@withContext subscriptionsDataStore.externalId - } - override suspend fun setAccessToken(accessToken: String?) = withContext(dispatcherProvider.io()) { subscriptionsDataStore.accessToken = accessToken - serpPromo.injectCookie(accessToken) + updateSerpPromoCookie() } override suspend fun setAuthToken(authToken: String?) = withContext(dispatcherProvider.io()) { @@ -167,8 +191,23 @@ internal class RealAuthRepository constructor( override suspend fun canSupportEncryption(): Boolean = withContext(dispatcherProvider.io()) { subscriptionsDataStore.canUseEncryption() } + + private suspend fun updateSerpPromoCookie() { + val accessToken = subscriptionsDataStore.run { accessTokenV2 ?: accessToken } + serpPromo.injectCookie(accessToken) + } } +data class AccessToken( + val jwt: String, + val expiresAt: Instant, +) + +data class RefreshToken( + val jwt: String, + val expiresAt: Instant, +) + data class Account( val email: String?, val externalId: String, @@ -207,8 +246,3 @@ fun List.toProductList(): List { emptyList() } } - -data class Entitlement( - val name: String, - val product: String, -) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/AuthService.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/AuthService.kt index bdb8911b66fc..235737b96cf0 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/AuthService.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/AuthService.kt @@ -18,7 +18,7 @@ package com.duckduckgo.subscriptions.impl.services import com.duckduckgo.anvil.annotations.ContributesNonCachingServiceApi import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.subscriptions.impl.repository.Entitlement +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.squareup.moshi.Json import retrofit2.http.Body import retrofit2.http.GET @@ -44,16 +44,8 @@ interface AuthService { */ @GET("https://quack.duckduckgo.com/api/auth/access-token") suspend fun accessToken(@Header("Authorization") authorization: String): AccessTokenResponse - - /** - * Deletes an account - */ - @POST("https://quack.duckduckgo.com/api/auth/account/delete") - suspend fun delete(@Header("Authorization") authorization: String): DeleteAccountResponse } -data class DeleteAccountResponse(val status: String) - data class StoreLoginBody( val signature: String, @field:Json(name = "signed_data") val signedData: String, diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt index ca6f293ba80e..7b5b7c7f4c29 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt @@ -19,7 +19,7 @@ package com.duckduckgo.subscriptions.impl.services import com.duckduckgo.anvil.annotations.ContributesNonCachingServiceApi import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.subscriptions.impl.auth.AuthRequired -import com.duckduckgo.subscriptions.impl.repository.Entitlement +import com.duckduckgo.subscriptions.impl.model.Entitlement import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt index 826007ef63ac..03fafd0c3aef 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt @@ -20,10 +20,15 @@ import android.content.SharedPreferences import androidx.core.content.edit import com.duckduckgo.data.store.api.SharedPreferencesProvider import com.duckduckgo.subscriptions.impl.repository.AuthRepository +import java.time.Instant interface SubscriptionsDataStore { // Auth + var accessTokenV2: String? + var accessTokenV2ExpiresAt: Instant? + var refreshTokenV2: String? + var refreshTokenV2ExpiresAt: Instant? var accessToken: String? var authToken: String? var email: String? @@ -52,6 +57,46 @@ internal class SubscriptionsEncryptedDataStore constructor( return sharedPreferencesProvider.getEncryptedSharedPreferences(FILENAME, multiprocess = true) } + override var accessTokenV2: String? + get() = encryptedPreferences?.getString(KEY_ACCESS_TOKEN_V2, null) + set(value) { + encryptedPreferences?.edit(commit = true) { putString(KEY_ACCESS_TOKEN_V2, value) } + } + + override var accessTokenV2ExpiresAt: Instant? + get() = encryptedPreferences?.getLong(KEY_ACCESS_TOKEN_V2_EXPIRES_AT, 0) + ?.takeIf { it != 0L } + ?.let { Instant.ofEpochMilli(it) } + set(value) { + encryptedPreferences?.edit(commit = true) { + if (value == null) { + remove(KEY_ACCESS_TOKEN_V2_EXPIRES_AT) + } else { + putLong(KEY_ACCESS_TOKEN_V2_EXPIRES_AT, value.toEpochMilli()) + } + } + } + + override var refreshTokenV2: String? + get() = encryptedPreferences?.getString(KEY_REFRESH_TOKEN_V2, null) + set(value) { + encryptedPreferences?.edit(commit = true) { putString(KEY_REFRESH_TOKEN_V2, value) } + } + + override var refreshTokenV2ExpiresAt: Instant? + get() = encryptedPreferences?.getLong(KEY_REFRESH_TOKEN_V2_EXPIRES_AT, 0) + ?.takeIf { it != 0L } + ?.let { Instant.ofEpochMilli(it) } + set(value) { + encryptedPreferences?.edit(commit = true) { + if (value == null) { + remove(KEY_REFRESH_TOKEN_V2_EXPIRES_AT) + } else { + putLong(KEY_REFRESH_TOKEN_V2_EXPIRES_AT, value.toEpochMilli()) + } + } + } + override var productId: String? get() = encryptedPreferences?.getString(KEY_PRODUCT_ID, null) set(value) { @@ -147,6 +192,10 @@ internal class SubscriptionsEncryptedDataStore constructor( companion object { const val FILENAME = "com.duckduckgo.subscriptions.store" + const val KEY_ACCESS_TOKEN_V2 = "KEY_ACCESS_TOKEN_V2" + const val KEY_ACCESS_TOKEN_V2_EXPIRES_AT = "KEY_ACCESS_TOKEN_V2_EXPIRES_AT" + const val KEY_REFRESH_TOKEN_V2 = "KEY_REFRESH_TOKEN_V2" + const val KEY_REFRESH_TOKEN_V2_EXPIRES_AT = "KEY_REFRESH_TOKEN_V2_EXPIRES_AT" const val KEY_ACCESS_TOKEN = "KEY_ACCESS_TOKEN" const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN" const val KEY_PLATFORM = "KEY_PLATFORM" diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index 92e57134260c..33cf232cc0be 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -1,7 +1,7 @@ package com.duckduckgo.subscriptions.impl +import android.annotation.SuppressLint import android.content.Context -import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetails.PricingPhase @@ -9,14 +9,26 @@ import com.android.billingclient.api.ProductDetails.PricingPhases import com.android.billingclient.api.PurchaseHistoryRecord import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.subscriptions.api.Product.NetP import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.api.SubscriptionStatus.* import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.RecoverSubscriptionResult import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN +import com.duckduckgo.subscriptions.impl.auth2.AccessTokenClaims +import com.duckduckgo.subscriptions.impl.auth2.AuthClient +import com.duckduckgo.subscriptions.impl.auth2.AuthJwtValidator +import com.duckduckgo.subscriptions.impl.auth2.BackgroundTokenRefresh +import com.duckduckgo.subscriptions.impl.auth2.PkceGenerator +import com.duckduckgo.subscriptions.impl.auth2.PkceGeneratorImpl +import com.duckduckgo.subscriptions.impl.auth2.RefreshTokenClaims +import com.duckduckgo.subscriptions.impl.auth2.TokenPair import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import com.duckduckgo.subscriptions.impl.billing.PurchaseState +import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.AuthRepository import com.duckduckgo.subscriptions.impl.repository.FakeSubscriptionsDataStore @@ -36,6 +48,9 @@ import com.duckduckgo.subscriptions.impl.services.SubscriptionsService import com.duckduckgo.subscriptions.impl.services.ValidateTokenResponse import com.duckduckgo.subscriptions.impl.store.SubscriptionsDataStore import java.lang.Exception +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf @@ -44,10 +59,13 @@ import kotlinx.coroutines.test.runTest import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.* +import org.junit.Assume.assumeFalse +import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.junit.runners.Parameterized import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -58,8 +76,8 @@ import org.mockito.kotlin.whenever import retrofit2.HttpException import retrofit2.Response -@RunWith(AndroidJUnit4::class) -class RealSubscriptionsManagerTest { +@RunWith(Parameterized::class) +class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { @get:Rule val coroutineRule = CoroutineTestRule() @@ -73,6 +91,15 @@ class RealSubscriptionsManagerTest { private val playBillingManager: PlayBillingManager = mock() private val context: Context = mock() private val pixelSender: SubscriptionPixelSender = mock() + + @SuppressLint("DenyListedApi") + private val privacyProFeature: PrivacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java) + .apply { authApiV2().setRawStoredState(State(authApiV2Enabled)) } + private val authClient: AuthClient = mock() + private val pkceGenerator: PkceGenerator = PkceGeneratorImpl() + private val authJwtValidator: AuthJwtValidator = mock() + private val timeProvider = FakeTimeProvider() + private val backgroundTokenRefresh: BackgroundTokenRefresh = mock() private lateinit var subscriptionsManager: SubscriptionsManager @Before @@ -89,6 +116,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) } @@ -108,11 +141,18 @@ class RealSubscriptionsManagerTest { givenStoreLoginSucceeds() givenSubscriptionSucceedsWithEntitlements() givenAccessTokenSucceeds() + givenV2AccessTokenRefreshSucceeds() subscriptionsManager.recoverSubscriptionFromStore() as RecoverSubscriptionResult.Success - verify(authService).storeLogin(any()) - assertEquals("authToken", authDataStore.authToken) + if (authApiV2Enabled) { + verify(authClient).storeLogin(any(), any(), any()) + assertEquals(FAKE_ACCESS_TOKEN_V2, authDataStore.accessTokenV2) + assertEquals(FAKE_REFRESH_TOKEN_V2, authDataStore.refreshTokenV2) + } else { + verify(authService).storeLogin(any()) + assertEquals("authToken", authDataStore.authToken) + } assertTrue(authRepository.getEntitlements().firstOrNull { it.product == NetP.value } != null) } @@ -144,6 +184,7 @@ class RealSubscriptionsManagerTest { givenStoreLoginSucceeds() givenSubscriptionSucceedsWithEntitlements() givenAccessTokenSucceeds() + givenV2AccessTokenRefreshSucceeds() subscriptionsManager.recoverSubscriptionFromStore() as RecoverSubscriptionResult.Success @@ -184,7 +225,12 @@ class RealSubscriptionsManagerTest { subscriptionsManager.recoverSubscriptionFromStore() subscriptionsManager.isSignedIn.test { assertTrue(awaitItem()) - assertEquals("accessToken", authDataStore.accessToken) + if (authApiV2Enabled) { + assertEquals(FAKE_ACCESS_TOKEN_V2, authDataStore.accessTokenV2) + assertEquals(FAKE_REFRESH_TOKEN_V2, authDataStore.refreshTokenV2) + } else { + assertEquals("accessToken", authDataStore.accessToken) + } cancelAndConsumeRemainingEvents() } } @@ -200,6 +246,8 @@ class RealSubscriptionsManagerTest { @Test fun whenFetchAndStoreAllDataIfTokenIsValidThenReturnSubscription() = runTest { + assumeFalse(authApiV2Enabled) // fetchAndStoreAllData() is deprecated and won't be used with auth v2 enabled + givenUserIsSignedIn() givenSubscriptionSucceedsWithEntitlements() @@ -210,6 +258,8 @@ class RealSubscriptionsManagerTest { @Test fun whenFetchAndStoreAllDataIfTokenIsValidThenReturnEmitEntitlements() = runTest { + assumeFalse(authApiV2Enabled) // fetchAndStoreAllData() is deprecated and won't be used with auth v2 enabled + givenUserIsSignedIn() givenSubscriptionSucceedsWithEntitlements() @@ -230,6 +280,8 @@ class RealSubscriptionsManagerTest { @Test fun whenFetchAndStoreAllDataIfSubscriptionFailsWith401ThenSignOutAndReturnNull() = runTest { + assumeFalse(authApiV2Enabled) // fetchAndStoreAllData() is deprecated and won't be used with auth v2 enabled + givenUserIsSignedIn() givenSubscriptionFails(httpResponseCode = 401) @@ -246,14 +298,23 @@ class RealSubscriptionsManagerTest { @Test fun whenPurchaseFlowIfUserNotSignedInAndNotPurchaseStoredThenCreateAccount() = runTest { givenUserIsNotSignedIn() + givenCreateAccountSucceeds() subscriptionsManager.purchase(mock(), planId = "") - verify(authService).createAccount(any()) + if (authApiV2Enabled) { + verify(authClient).authorize(any()) + verify(authClient).createAccount(any()) + verify(authClient).getTokens(any(), any(), any()) + } else { + verify(authService).createAccount(any()) + } } @Test fun whenPurchaseFlowIfUserNotSignedInAndNotPurchaseStoredAndSignedInEmailThenCreateAccountWithEmailToken() = runTest { + assumeFalse(authApiV2Enabled) // passing email token when creating account is no longer a thing in api v2 + whenever(emailManager.getToken()).thenReturn("emailToken") givenUserIsNotSignedIn() @@ -334,6 +395,8 @@ class RealSubscriptionsManagerTest { @Test fun whenPurchaseFlowIfUserSignedInThenValidateToken() = runTest { + assumeFalse(authApiV2Enabled) // there is no /validate-token endpoint in v2 API + givenUserIsSignedIn() subscriptionsManager.purchase(mock(), planId = "") @@ -386,8 +449,17 @@ class RealSubscriptionsManagerTest { givenAccessTokenSucceeds() subscriptionsManager.purchase(mock(), planId = "") - assertEquals("accessToken", authDataStore.accessToken) - assertEquals("authToken", authDataStore.authToken) + if (authApiV2Enabled) { + assertEquals(FAKE_ACCESS_TOKEN_V2, authDataStore.accessTokenV2) + assertEquals(FAKE_REFRESH_TOKEN_V2, authDataStore.refreshTokenV2) + assertNull(authDataStore.accessToken) + assertNull(authDataStore.authToken) + } else { + assertNull(authDataStore.accessTokenV2) + assertNull(authDataStore.refreshTokenV2) + assertEquals("accessToken", authDataStore.accessToken) + assertEquals("authToken", authDataStore.authToken) + } } @Test @@ -401,8 +473,17 @@ class RealSubscriptionsManagerTest { subscriptionsManager.purchase(mock(), planId = "") subscriptionsManager.isSignedIn.test { assertTrue(awaitItem()) - assertEquals("accessToken", authDataStore.accessToken) - assertEquals("authToken", authDataStore.authToken) + if (authApiV2Enabled) { + assertEquals(FAKE_ACCESS_TOKEN_V2, authDataStore.accessTokenV2) + assertEquals(FAKE_REFRESH_TOKEN_V2, authDataStore.refreshTokenV2) + assertNull(authDataStore.accessToken) + assertNull(authDataStore.authToken) + } else { + assertNull(authDataStore.accessTokenV2) + assertNull(authDataStore.refreshTokenV2) + assertEquals("accessToken", authDataStore.accessToken) + assertEquals("authToken", authDataStore.authToken) + } cancelAndConsumeRemainingEvents() } } @@ -435,6 +516,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.subscriptionStatus.test { @@ -457,6 +544,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.subscriptionStatus.test { @@ -469,6 +562,7 @@ class RealSubscriptionsManagerTest { fun whenPurchaseSuccessfulThenPurchaseCheckedAndSuccessEmit() = runTest { givenUserIsSignedIn() givenConfirmPurchaseSucceeds() + givenV2AccessTokenRefreshSucceeds() val flowTest: MutableSharedFlow = MutableSharedFlow() whenever(playBillingManager.purchaseState).thenReturn(flowTest) @@ -483,6 +577,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.currentPurchaseState.test { @@ -524,6 +624,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.currentPurchaseState.test { @@ -555,6 +661,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.currentPurchaseState.test { @@ -571,7 +683,9 @@ class RealSubscriptionsManagerTest { val result = subscriptionsManager.getAccessToken() assertTrue(result is AccessTokenResult.Success) - assertEquals("accessToken", (result as AccessTokenResult.Success).accessToken) + val actualAccessToken = (result as AccessTokenResult.Success).accessToken + val expectedAccessToken = if (authApiV2Enabled) FAKE_ACCESS_TOKEN_V2 else "accessToken" + assertEquals(expectedAccessToken, actualAccessToken) } @Test @@ -583,6 +697,87 @@ class RealSubscriptionsManagerTest { assertTrue(result is AccessTokenResult.Failure) } + @Test + fun whenGetAccessTokenIfAccessTokenIsExpiredThenGetNewTokenAndReturnSuccess() = runTest { + assumeTrue(authApiV2Enabled) + + givenUserIsSignedIn() + givenAccessTokenIsExpired() + givenV2AccessTokenRefreshSucceeds(newAccessToken = "new access token") + + val result = subscriptionsManager.getAccessToken() + + assertTrue(result is AccessTokenResult.Success) + assertEquals("new access token", (result as AccessTokenResult.Success).accessToken) + } + + @Test + fun whenGetAccessTokenIfAccessTokenIsExpiredAndRefreshFailsThenGetNewTokenAndReturnFailure() = runTest { + assumeTrue(authApiV2Enabled) + + givenUserIsSignedIn() + givenAccessTokenIsExpired() + givenV2AccessTokenRefreshFails() + + val result = subscriptionsManager.getAccessToken() + + assertTrue(result is AccessTokenResult.Failure) + } + + @Test + fun whenGetAccessTokenIfAccessTokenIsExpiredAndRefreshFailsWithAuthErrorThenGetNewTokenUsingStoreLoginAndReturnSuccess() = runTest { + assumeTrue(authApiV2Enabled) + + givenUserIsSignedIn() + givenAccessTokenIsExpired() + givenV2AccessTokenRefreshFails(authenticationError = true) + givenPurchaseStored() + givenStoreLoginSucceeds(newAccessToken = "new access token") + + val result = subscriptionsManager.getAccessToken() + + assertTrue(result is AccessTokenResult.Success) + assertEquals("new access token", (result as AccessTokenResult.Success).accessToken) + } + + @Test + fun whenGetAccessTokenIfAccessTokenIsExpiredAndRefreshFailsWithAuthErrorAndStoreRecoveryNotPossibleThenSignOutAndReturnFailure() = runTest { + assumeTrue(authApiV2Enabled) + + givenUserIsSignedIn() + givenSubscriptionExists() + givenAccessTokenIsExpired() + givenV2AccessTokenRefreshFails(authenticationError = true) + givenPurchaseStored() + givenStoreLoginFails() + + val result = subscriptionsManager.getAccessToken() + + assertTrue(result is AccessTokenResult.Failure) + assertFalse(subscriptionsManager.isSignedIn()) + assertNull(authRepository.getAccessTokenV2()) + assertNull(authRepository.getRefreshTokenV2()) + assertNull(authRepository.getAccount()) + assertNull(authRepository.getSubscription()) + } + + @Test + fun whenGetAccessTokenIfSignedInWithV1ThenExchangesTokenForV2AndReturnsTrue() = runTest { + assumeTrue(authApiV2Enabled) + + givenUserIsSignedIn(useAuthV2 = false) + givenV1AccessTokenExchangeSuccess() + + val result = subscriptionsManager.getAccessToken() + + assertTrue(result is AccessTokenResult.Success) + assertEquals(FAKE_ACCESS_TOKEN_V2, (result as AccessTokenResult.Success).accessToken) + assertEquals(FAKE_ACCESS_TOKEN_V2, authRepository.getAccessTokenV2()?.jwt) + assertEquals(FAKE_REFRESH_TOKEN_V2, authRepository.getRefreshTokenV2()?.jwt) + assertNull(authRepository.getAccessToken()) + assertNull(authRepository.getAuthToken()) + } + @Test fun whenGetAuthTokenIfUserSignedInAndValidTokenThenReturnSuccess() = runTest { givenUserIsSignedIn() @@ -591,7 +786,10 @@ class RealSubscriptionsManagerTest { val result = subscriptionsManager.getAuthToken() assertTrue(result is AuthTokenResult.Success) - assertEquals("authToken", (result as AuthTokenResult.Success).authToken) + + val actualAuthToken = (result as AuthTokenResult.Success).authToken + val expectedAuthToken = if (authApiV2Enabled) FAKE_ACCESS_TOKEN_V2 else "authToken" + assertEquals(expectedAuthToken, actualAuthToken) } @Test @@ -605,6 +803,8 @@ class RealSubscriptionsManagerTest { @Test fun whenGetAuthTokenIfUserSignedInWithSubscriptionAndTokenExpiredAndEntitlementsExistsThenReturnSuccess() = runTest { + assumeFalse(authApiV2Enabled) // getAuthToken() is deprecated and with auth v2 enabled will just delegate to getAccessToken() + authDataStore.externalId = "1234" givenUserIsSignedIn() givenSubscriptionSucceedsWithEntitlements() @@ -622,6 +822,8 @@ class RealSubscriptionsManagerTest { @Test fun whenGetAuthTokenIfUserSignedInWithSubscriptionAndTokenExpiredAndEntitlementsExistsAndExternalIdDifferentThenReturnFailure() = runTest { + assumeFalse(authApiV2Enabled) // getAuthToken() is deprecated and with auth v2 enabled will just delegate to getAccessToken() + authDataStore.externalId = "test" givenUserIsSignedIn() givenSubscriptionSucceedsWithEntitlements() @@ -639,6 +841,8 @@ class RealSubscriptionsManagerTest { @Test fun whenGetAuthTokenIfUserSignedInWithSubscriptionAndTokenExpiredAndEntitlementsDoNotExistThenReturnFailure() = runTest { + assumeFalse(authApiV2Enabled) // getAuthToken() is deprecated and with auth v2 enabled will just delegate to getAccessToken() + givenUserIsSignedIn() givenValidateTokenSucceedsNoEntitlements() givenValidateTokenFailsAndThenSucceedsWithNoEntitlements("""{ "error": "expired_token" }""") @@ -655,6 +859,8 @@ class RealSubscriptionsManagerTest { @Test fun whenGetAuthTokenIfUserSignedInAndTokenExpiredAndNoPurchaseInTheStoreThenReturnFailure() = runTest { + assumeFalse(authApiV2Enabled) // getAuthToken() is deprecated and with auth v2 enabled will just delegate to getAccessToken() + givenUserIsSignedIn() givenValidateTokenFailsAndThenSucceeds("""{ "error": "expired_token" }""") @@ -667,6 +873,8 @@ class RealSubscriptionsManagerTest { @Test fun whenGetAuthTokenIfUserSignedInAndTokenExpiredAndPurchaseNotValidThenReturnFailure() = runTest { + assumeFalse(authApiV2Enabled) // getAuthToken() is deprecated and with auth v2 enabled will just delegate to getAccessToken() + givenUserIsSignedIn() givenValidateTokenFailsAndThenSucceeds("""{ "error": "expired_token" }""") givenStoreLoginFails() @@ -681,6 +889,8 @@ class RealSubscriptionsManagerTest { @Test fun whenGetSubscriptionThenReturnCorrectStatus() = runTest { + assumeFalse(authApiV2Enabled) // fetchAndStoreAllData() is deprecated and won't be used with auth v2 enabled + givenUserIsSignedIn() givenValidateTokenSucceedsWithEntitlements() @@ -745,12 +955,21 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.signOut() verify(mockRepo).setSubscription(null) verify(mockRepo).setAccount(null) verify(mockRepo).setAuthToken(null) verify(mockRepo).setAccessToken(null) + verify(mockRepo).setEntitlements(emptyList()) + verify(mockRepo).setAccessTokenV2(null) + verify(mockRepo).setRefreshTokenV2(null) } @Test @@ -781,6 +1000,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) manager.subscriptionStatus.test { @@ -809,6 +1034,7 @@ class RealSubscriptionsManagerTest { givenUserIsSignedIn() givenValidateTokenSucceedsWithEntitlements() givenConfirmPurchaseSucceeds() + givenV2AccessTokenRefreshSucceeds() whenever(playBillingManager.purchaseState).thenReturn(flowOf(PurchaseState.Purchased("any", "any"))) @@ -941,6 +1167,12 @@ class RealSubscriptionsManagerTest { TestScope(), coroutineRule.testDispatcherProvider, pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, ) assertFalse(subscriptionsManager.canSupportEncryption()) @@ -1014,16 +1246,31 @@ class RealSubscriptionsManagerTest { private fun givenUserIsNotSignedIn() { authDataStore.accessToken = null authDataStore.authToken = null - } - - private fun givenUserIsSignedIn() { - authDataStore.accessToken = "accessToken" - authDataStore.authToken = "authToken" + authDataStore.accessTokenV2 = null + authDataStore.accessTokenV2ExpiresAt = null + authDataStore.refreshTokenV2 = null + authDataStore.refreshTokenV2ExpiresAt = null + } + + private fun givenUserIsSignedIn(useAuthV2: Boolean = authApiV2Enabled) { + if (useAuthV2) { + authDataStore.accessTokenV2 = FAKE_ACCESS_TOKEN_V2 + authDataStore.accessTokenV2ExpiresAt = timeProvider.currentTime + Duration.ofHours(4) + authDataStore.refreshTokenV2 = FAKE_REFRESH_TOKEN_V2 + authDataStore.refreshTokenV2ExpiresAt = timeProvider.currentTime + Duration.ofDays(30) + authDataStore.externalId = "1234" + } else { + authDataStore.accessToken = "accessToken" + authDataStore.authToken = "authToken" + } } private suspend fun givenCreateAccountFails() { val exception = "account_failure".toResponseBody("text/json".toMediaTypeOrNull()) whenever(authService.createAccount(any())).thenThrow(HttpException(Response.error(400, exception))) + + whenever(authClient.authorize(any())).thenThrow(HttpException(Response.error(400, exception))) + whenever(authClient.createAccount(any())).thenThrow(HttpException(Response.error(400, exception))) } private suspend fun givenCreateAccountSucceeds() { @@ -1034,6 +1281,13 @@ class RealSubscriptionsManagerTest { status = "ok", ), ) + + whenever(authClient.authorize(any())).thenReturn("fake session id") + whenever(authClient.createAccount(any())).thenReturn("fake authorization code") + whenever(authClient.getTokens(any(), any(), any())) + .thenReturn(TokenPair(FAKE_ACCESS_TOKEN_V2, FAKE_REFRESH_TOKEN_V2)) + + givenValidateV2TokensSucceeds() } private fun givenSubscriptionExists(status: SubscriptionStatus = AUTO_RENEWABLE) { @@ -1080,6 +1334,9 @@ class RealSubscriptionsManagerTest { private suspend fun givenStoreLoginFails() { val exception = "failure".toResponseBody("text/json".toMediaTypeOrNull()) whenever(authService.storeLogin(any())).thenThrow(HttpException(Response.error(400, exception))) + + whenever(authClient.authorize(any())).thenThrow(HttpException(Response.error(400, exception))) + whenever(authClient.storeLogin(any(), any(), any())).thenThrow(HttpException(Response.error(400, exception))) } private suspend fun givenValidateTokenSucceedsWithEntitlements() { @@ -1124,7 +1381,7 @@ class RealSubscriptionsManagerTest { whenever(playBillingManager.purchaseHistory).thenReturn(listOf(purchaseRecord)) } - private suspend fun givenStoreLoginSucceeds() { + private suspend fun givenStoreLoginSucceeds(newAccessToken: String = FAKE_ACCESS_TOKEN_V2) { whenever(authService.storeLogin(any())).thenReturn( StoreLoginResponse( authToken = "authToken", @@ -1133,6 +1390,22 @@ class RealSubscriptionsManagerTest { status = "ok", ), ) + + whenever(authClient.authorize(any())).thenReturn("fake session id") + whenever(authClient.storeLogin(any(), any(), any())).thenReturn("fake authorization code") + whenever(authClient.getTokens(any(), any(), any())) + .thenReturn(TokenPair(newAccessToken, FAKE_REFRESH_TOKEN_V2)) + whenever(authClient.getJwks()).thenReturn("fake jwks") + + givenValidateV2TokensSucceeds() + } + + private suspend fun givenV1AccessTokenExchangeSuccess() { + whenever(authClient.authorize(any())).thenReturn("fake session id") + whenever(authClient.exchangeV1AccessToken(any(), any())).thenReturn("fake authorization code") + whenever(authClient.getTokens(any(), any(), any())).thenReturn(TokenPair(FAKE_ACCESS_TOKEN_V2, FAKE_REFRESH_TOKEN_V2)) + whenever(authClient.getJwks()).thenReturn("fake jwks") + givenValidateV2TokensSucceeds() } private suspend fun givenAccessTokenSucceeds() { @@ -1166,4 +1439,67 @@ class RealSubscriptionsManagerTest { ), ) } + + private suspend fun givenV2AccessTokenRefreshSucceeds( + newAccessToken: String = FAKE_ACCESS_TOKEN_V2, + newRefreshToken: String = FAKE_REFRESH_TOKEN_V2, + ) { + whenever(authClient.getTokens(any())) + .thenReturn(TokenPair(newAccessToken, newRefreshToken)) + whenever(authClient.getJwks()).thenReturn("fake jwks") + + givenValidateV2TokensSucceeds() + } + + private suspend fun givenV2AccessTokenRefreshFails(authenticationError: Boolean = false) { + val exception = if (authenticationError) { + val responseBody = "failure".toResponseBody("text/json".toMediaTypeOrNull()) + HttpException(Response.error(401, responseBody)) + } else { + RuntimeException() + } + whenever(authClient.getTokens(any())).thenThrow(exception) + } + + private suspend fun givenValidateV2TokensSucceeds() { + whenever(authClient.getJwks()).thenReturn("fake jwks") + + whenever(authJwtValidator.validateAccessToken(any(), any())).thenReturn( + AccessTokenClaims( + expiresAt = Instant.now() + Duration.ofHours(4), + accountExternalId = "1234", + email = null, + entitlements = listOf(Entitlement(product = NetP.value, name = "subscriber")), + ), + ) + + whenever(authJwtValidator.validateRefreshToken(any(), any())).thenReturn( + RefreshTokenClaims( + expiresAt = Instant.now() + Duration.ofDays(30), + accountExternalId = "1234", + ), + ) + } + + private suspend fun givenAccessTokenIsExpired() { + val accessToken = authRepository.getAccessTokenV2() ?: return + authRepository.setAccessTokenV2(accessToken.copy(expiresAt = timeProvider.currentTime - Duration.ofHours(1))) + } + + private class FakeTimeProvider : CurrentTimeProvider { + var currentTime: Instant = Instant.parse("2024-10-28T00:00:00Z") + + override fun elapsedRealtime(): Long = throw UnsupportedOperationException() + override fun currentTimeMillis(): Long = currentTime.toEpochMilli() + override fun localDateTimeNow(): LocalDateTime = throw UnsupportedOperationException() + } + + private companion object { + @JvmStatic + @Parameterized.Parameters(name = "authApiV2Enabled={0}") + fun data(): Collection> = listOf(arrayOf(true), arrayOf(false)) + + const val FAKE_ACCESS_TOKEN_V2 = "fake access token" + const val FAKE_REFRESH_TOKEN_V2 = "fake refresh token" + } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthClientImplTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthClientImplTest.kt new file mode 100644 index 000000000000..812a5c42b58b --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthClientImplTest.kt @@ -0,0 +1,267 @@ +package com.duckduckgo.subscriptions.impl.auth2 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import kotlinx.coroutines.test.runTest +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import retrofit2.HttpException +import retrofit2.Response + +@RunWith(AndroidJUnit4::class) +class AuthClientImplTest { + + private val authService: AuthService = mock() + private val appBuildConfig: AppBuildConfig = mock { config -> + whenever(config.applicationId).thenReturn("com.duckduckgo.android") + } + private val authClient = AuthClientImpl(authService, appBuildConfig) + + @Test + fun `when authorize success then returns sessionId parsed from Set-Cookie header`() = runTest { + val sessionId = "fake auth session id" + val codeChallenge = "fake code challenge" + + val mockResponse: Response = mock { + on { code() } doReturn 302 + on { headers() } doReturn Headers.headersOf("Set-Cookie", "ddg_auth_session_id=$sessionId; Path=/") + on { isSuccessful } doReturn false // Retrofit treats non-2xx responses as unsuccessful + } + + whenever(authService.authorize(any(), any(), any(), any(), any(), any())) + .thenReturn(mockResponse) + + val receivedSessionId = authClient.authorize(codeChallenge) + assertEquals(sessionId, receivedSessionId) + + verify(authService).authorize( + responseType = "code", + codeChallenge = codeChallenge, + codeChallengeMethod = "S256", + clientId = "f4311287-0121-40e6-8bbd-85c36daf1837", + redirectUri = "com.duckduckgo:/authcb", + scope = "privacypro", + ) + } + + @Test + fun `when authorize responds with non-302 code then throws HttpException`() = runTest { + val errorResponse = Response.error( + 400, + "{}".toResponseBody("application/json".toMediaTypeOrNull()), + ) + + whenever(authService.authorize(any(), any(), any(), any(), any(), any())) + .thenReturn(errorResponse) + + try { + authClient.authorize("fake code challenge") + fail("Expected HttpException to be thrown") + } catch (e: HttpException) { + assertEquals(400, e.code()) + } + } + + @Test + fun `when createAccount success then returns authorization code parsed from Location header`() = runTest { + val authorizationCode = "fake_authorization_code" + + val mockResponse: Response = mock { + on { code() } doReturn 302 + on { headers() } doReturn Headers.headersOf("Location", "https://example.com?code=$authorizationCode") + on { isSuccessful } doReturn false // Retrofit treats non-2xx responses as unsuccessful + } + + whenever(authService.createAccount(any())).thenReturn(mockResponse) + + assertEquals(authorizationCode, authClient.createAccount("fake auth session id")) + } + + @Test + fun `when createAccount HTTP error then throws HttpException`() = runTest { + val errorResponse = Response.error( + 400, + "{}".toResponseBody("application/json".toMediaTypeOrNull()), + ) + + whenever(authService.createAccount(any())).thenReturn(errorResponse) + + try { + authClient.createAccount("fake auth session id") + fail("Expected HttpException to be thrown") + } catch (e: HttpException) { + assertEquals(400, e.code()) + } + } + + @Test + fun `when getTokens success then returns AuthenticationCredentials`() = runTest { + val sessionId = "fake auth session id" + val authorizationCode = "fake authorization code" + val codeVerifier = "fake code verifier" + val accessToken = "fake access token" + val refreshToken = "fake refresh token" + + whenever(authService.token(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(TokensResponse(accessToken, refreshToken)) + + val tokens = authClient.getTokens(sessionId, authorizationCode, codeVerifier) + + assertEquals(accessToken, tokens.accessToken) + assertEquals(refreshToken, tokens.refreshToken) + + verify(authService).token( + grantType = "authorization_code", + clientId = "f4311287-0121-40e6-8bbd-85c36daf1837", + codeVerifier = codeVerifier, + code = authorizationCode, + redirectUri = "com.duckduckgo:/authcb", + refreshToken = null, + ) + } + + @Test + fun `when getTokens with refresh token then return AuthenticationCredentials`() = runTest { + val accessToken = "fake access token" + val refreshToken = "fake refresh token" + + whenever(authService.token(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(TokensResponse(accessToken, refreshToken)) + + val credentials = authClient.getTokens("fake refresh token") + + assertEquals(accessToken, credentials.accessToken) + assertEquals(refreshToken, credentials.refreshToken) + + verify(authService).token( + grantType = "refresh_token", + clientId = "f4311287-0121-40e6-8bbd-85c36daf1837", + codeVerifier = null, + code = null, + redirectUri = null, + refreshToken = refreshToken, + ) + } + + @Test + fun `when getJwks then return JWK set in JSON format`() = runTest { + val jwks = """{"keys": [{"kty": "RSA", "kid": "fakeKeyId"}]}""" + val jwksResponse = jwks.toResponseBody("application/json".toMediaTypeOrNull()) + + whenever(authService.jwks()).thenReturn(jwksResponse) + + assertEquals(jwks, authClient.getJwks()) + } + + @Test + fun `when login success then returns authorization code`() = runTest { + val sessionId = "fake auth session id" + val authorizationCode = "fake_authorization_code" + val signature = "fake signature" + val googleSignedData = "fake signed data" + + val mockResponse: Response = mock { + on { code() } doReturn 302 + on { headers() } doReturn Headers.headersOf("Location", "https://example.com?code=$authorizationCode") + on { isSuccessful } doReturn false // Retrofit treats non-2xx responses as unsuccessful + } + + whenever(authService.login(any(), any())).thenReturn(mockResponse) + + val storeLoginResponse = authClient.storeLogin(sessionId, signature, googleSignedData) + + assertEquals(authorizationCode, storeLoginResponse) + + verify(authService).login( + cookie = "ddg_auth_session_id=$sessionId", + body = StoreLoginBody( + method = "signature", + signature = signature, + source = "google_play_store", + googleSignedData = googleSignedData, + googlePackageName = appBuildConfig.applicationId, + ), + ) + } + + @Test + fun `when login HTTP error then throws HttpException`() = runTest { + val errorResponse = Response.error( + 400, + "{}".toResponseBody("application/json".toMediaTypeOrNull()), + ) + + whenever(authService.login(any(), any())).thenReturn(errorResponse) + + try { + authClient.storeLogin("fake auth session id", "fake signature", "fake signed data") + fail("Expected HttpException to be thrown") + } catch (e: HttpException) { + assertEquals(400, e.code()) + } + } + + @Test + fun `when exchange token success then returns authorization code`() = runTest { + val sessionId = "fake auth session id" + val authorizationCode = "fake_authorization_code" + val accessTokenV1 = "fake v1 access token" + + val mockResponse: Response = mock { + on { code() } doReturn 302 + on { headers() } doReturn Headers.headersOf("Location", "https://example.com?code=$authorizationCode") + on { isSuccessful } doReturn false // Retrofit treats non-2xx responses as unsuccessful + } + + whenever(authService.exchange(any(), any())).thenReturn(mockResponse) + + val response = authClient.exchangeV1AccessToken(accessTokenV1, sessionId) + + assertEquals(authorizationCode, response) + + verify(authService).exchange( + authorization = "Bearer $accessTokenV1", + cookie = "ddg_auth_session_id=$sessionId", + ) + } + + @Test + fun `when exchange token HTTP error then throws HttpException`() = runTest { + val errorResponse = Response.error( + 400, + "{}".toResponseBody("application/json".toMediaTypeOrNull()), + ) + + whenever(authService.exchange(any(), any())).thenReturn(errorResponse) + + try { + authClient.exchangeV1AccessToken("fake v1 access token", "fake auth session id") + fail("Expected HttpException to be thrown") + } catch (e: HttpException) { + assertEquals(400, e.code()) + } + } + + @Test + fun `when logout error then does not throw any exception`() = runTest { + val errorResponse = Response.error( + 400, + "{}".toResponseBody("application/json".toMediaTypeOrNull()), + ) + + whenever(authService.logout(any())).thenThrow(HttpException(errorResponse)) + + authClient.tryLogout("fake v2 access token") + } +} diff --git a/auth-jwt/auth-jwt-impl/src/test/java/com/duckduckgo/authjwt/impl/AuthJwtValidatorImplTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidatorImplTest.kt similarity index 95% rename from auth-jwt/auth-jwt-impl/src/test/java/com/duckduckgo/authjwt/impl/AuthJwtValidatorImplTest.kt rename to subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidatorImplTest.kt index 2477ba741833..b4da57135b22 100644 --- a/auth-jwt/auth-jwt-impl/src/test/java/com/duckduckgo/authjwt/impl/AuthJwtValidatorImplTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthJwtValidatorImplTest.kt @@ -1,8 +1,24 @@ -package com.duckduckgo.authjwt.impl +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.auth2 import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.duckduckgo.authjwt.api.Entitlement import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.subscriptions.impl.model.Entitlement import java.time.Instant import java.time.LocalDateTime import org.junit.Assert.assertEquals diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/PkceGeneratorImplTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/PkceGeneratorImplTest.kt new file mode 100644 index 000000000000..4ad13c3f873c --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/PkceGeneratorImplTest.kt @@ -0,0 +1,17 @@ +package com.duckduckgo.subscriptions.impl.auth2 + +import org.junit.Assert.* +import org.junit.Test + +class PkceGeneratorImplTest { + + @Test + fun `should generate correct code challenge`() { + val codeVerifier = "oas6Ov1EcKzKjM-w9Q97cs6bYDU1cCI_hQwhAt0mLiE" + + assertEquals( + "JP1GpQFSca0OUo-5Xxe5fzu2K_Sa84q2yCeHb-bw1zM", + PkceGeneratorImpl().generateCodeChallenge(codeVerifier), + ) + } +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt index 100463820058..3c055c0d2b5f 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt @@ -628,6 +628,62 @@ class SubscriptionMessagingInterfaceTest { verify(jsMessageCallback).process(eq("useSubscription"), eq("subscriptionsWelcomeAddEmailClicked"), any(), any()) } + @Test + fun whenProcessAndAddEmailSuccessAnIsSignedInUsingAuthV2AThenAccessTokenIsRefreshed() = runTest { + givenInterfaceIsRegistered() + whenever(subscriptionsManager.isSignedInV2()).thenReturn(true) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"subscriptionsAddEmailSuccess","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verify(subscriptionsManager).refreshAccessToken() + } + + @Test + fun whenProcessAndEditEmailSuccessAnIsSignedInUsingAuthV2AThenAccessTokenIsRefreshed() = runTest { + givenInterfaceIsRegistered() + whenever(subscriptionsManager.isSignedInV2()).thenReturn(true) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"subscriptionsEditEmailSuccess","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verify(subscriptionsManager).refreshAccessToken() + } + + @Test + fun whenProcessAndAddEmailSuccessAnIsNotSignedInUsingAuthV2AThenAccessTokenIsNotRefreshed() = runTest { + givenInterfaceIsRegistered() + whenever(subscriptionsManager.isSignedInV2()).thenReturn(false) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"subscriptionsAddEmailSuccess","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verify(subscriptionsManager, never()).refreshAccessToken() + } + + @Test + fun whenProcessAndEditEmailSuccessAnIsNotSignedInUsingAuthV2AThenAccessTokenIsNotRefreshed() = runTest { + givenInterfaceIsRegistered() + whenever(subscriptionsManager.isSignedInV2()).thenReturn(false) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"subscriptionsEditEmailSuccess","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verify(subscriptionsManager, never()).refreshAccessToken() + } + private fun givenInterfaceIsRegistered() { messagingInterface.register(webView, callback) whenever(webView.url).thenReturn("https://duckduckgo.com/test") diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt index 6f98a57226ff..9d5f862ce3e5 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt @@ -17,10 +17,15 @@ package com.duckduckgo.subscriptions.impl.repository import com.duckduckgo.subscriptions.impl.store.SubscriptionsDataStore +import java.time.Instant class FakeSubscriptionsDataStore(private val supportEncryption: Boolean = true) : SubscriptionsDataStore { // Auth + override var accessTokenV2: String? = null + override var accessTokenV2ExpiresAt: Instant? = null + override var refreshTokenV2: String? = null + override var refreshTokenV2ExpiresAt: Instant? = null override var accessToken: String? = null override var authToken: String? = null override var email: String? = null diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt index 1cc33824a8b2..a7ba0f85104d 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt @@ -9,6 +9,7 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.NOT_AUTO_RENEWABLE import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING import com.duckduckgo.subscriptions.impl.serp_promo.FakeSerpPromo +import java.time.Instant import kotlinx.coroutines.test.runTest import org.junit.Assert.* import org.junit.Rule @@ -80,12 +81,23 @@ class RealAuthRepositoryTest { fun whenTokensThenReturnTokens() = runTest { assertNull(authStore.authToken) assertNull(authStore.accessToken) + assertNull(authStore.accessTokenV2) + assertNull(authStore.accessTokenV2ExpiresAt) + assertNull(authStore.refreshTokenV2) + assertNull(authStore.refreshTokenV2ExpiresAt) authStore.accessToken = "accessToken" authStore.authToken = "authToken" + val accessTokenV2 = AccessToken(jwt = "jwt-access", expiresAt = Instant.parse("2024-10-21T10:15:30.00Z")) + val refreshTokenV2 = RefreshToken(jwt = "jwt-refresh", expiresAt = Instant.parse("2024-10-21T10:15:30.00Z")) + authRepository.setAccessTokenV2(accessTokenV2) + authRepository.setRefreshTokenV2(refreshTokenV2) + assertEquals("authToken", authRepository.getAuthToken()) assertEquals("accessToken", authRepository.getAccessToken()) + assertEquals(accessTokenV2, authRepository.getAccessTokenV2()) + assertEquals(refreshTokenV2, authRepository.getRefreshTokenV2()) } @Test diff --git a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/AccountDeletionHandler.kt b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/AccountDeletionHandler.kt deleted file mode 100644 index 98b0e45d3322..000000000000 --- a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/AccountDeletionHandler.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2024 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.subscriptions.internal.settings - -import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.subscriptions.impl.AuthTokenResult -import com.duckduckgo.subscriptions.impl.SubscriptionsManager -import com.duckduckgo.subscriptions.impl.services.AuthService -import com.squareup.anvil.annotations.ContributesBinding -import javax.inject.Inject - -interface AccountDeletionHandler { - suspend fun deleteAccountAndSignOut(): Boolean -} - -@ContributesBinding(AppScope::class) -class AccountDeletionHandlerImpl @Inject constructor( - private val subscriptionsManager: SubscriptionsManager, - private val authService: AuthService, -) : AccountDeletionHandler { - - override suspend fun deleteAccountAndSignOut(): Boolean { - val accountDeleted = deleteAccount() - if (accountDeleted) { - subscriptionsManager.signOut() - } - return accountDeleted - } - - private suspend fun deleteAccount(): Boolean { - return try { - val token = subscriptionsManager.getAuthToken() - if (token is AuthTokenResult.Success) { - val state = authService.delete("Bearer ${token.authToken}") - (state.status == "deleted") - } else { - false - } - } catch (e: Exception) { - false - } - } -} diff --git a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/CopySubscriptionDataView.kt b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/CopySubscriptionDataView.kt index 9b6fcc793b31..c104f1d402a9 100644 --- a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/CopySubscriptionDataView.kt +++ b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/CopySubscriptionDataView.kt @@ -23,6 +23,8 @@ import android.util.AttributeSet import android.view.View import android.widget.FrameLayout import android.widget.Toast +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.findViewTreeLifecycleOwner import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.common.ui.viewbinding.viewBinding @@ -35,7 +37,9 @@ import com.duckduckgo.subscriptions.internal.databinding.SubsSimpleViewBinding import com.squareup.anvil.annotations.ContributesMultibinding import dagger.android.support.AndroidSupportInjection import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -62,8 +66,14 @@ class CopySubscriptionDataView @JvmOverloads constructor( AndroidSupportInjection.inject(this) super.onAttachedToWindow() - binding.root.setPrimaryText("Copy subscriptions data") - binding.root.setSecondaryText("Copies your data to the clipboard") + binding.root.setPrimaryText("Subscriptions data") + + findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(dispatcherProvider.main()) { + while (true) { + binding.root.setSecondaryText(getData()) + delay(1.seconds) + } + } binding.root.setClickListener { copyDataToClipboard() @@ -74,35 +84,27 @@ class CopySubscriptionDataView @JvmOverloads constructor( val clipboardManager = context.getSystemService(ClipboardManager::class.java) appCoroutineScope.launch(dispatcherProvider.io()) { - val auth = authRepository.getAuthToken() - val authToken = if (auth.isNullOrBlank()) { - "No auth token found" - } else { - auth - } - - val access = authRepository.getAccessToken() - val accessToken = if (access.isNullOrBlank()) { - "No access token found" - } else { - access - } - - val external = authRepository.getExternalID() - val externalId = if (external.isNullOrBlank()) { - "No external id found" - } else { - external - } - val text = "Auth token is $authToken || Access token is $accessToken || External id is $externalId" - - clipboardManager.setPrimaryClip(ClipData.newPlainText("", text)) + clipboardManager.setPrimaryClip(ClipData.newPlainText("", getData())) withContext(dispatcherProvider.main()) { Toast.makeText(context, "Data copied to clipboard", Toast.LENGTH_SHORT).show() } } } + + private suspend fun getData(): String { + val textParts = listOf( + "Account: ${authRepository.getAccount()}", + "Subscription: ${authRepository.getSubscription()}", + "Entitlements: ${authRepository.getEntitlements()}", + "Access token (V2): ${authRepository.getAccessTokenV2()}", + "Refresh token (V2): ${authRepository.getRefreshTokenV2()}", + "Auth token (V1): ${authRepository.getAuthToken()}", + "Access token (V1): ${authRepository.getAccessToken()}", + ) + + return textParts.joinToString(separator = "\n---\n") + } } @ContributesMultibinding(ActivityScope::class) diff --git a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/DeleteSubscriptionView.kt b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/DeleteSubscriptionView.kt deleted file mode 100644 index 52979e910499..000000000000 --- a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/DeleteSubscriptionView.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (c) 2024 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.subscriptions.internal.settings - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.widget.FrameLayout -import android.widget.Toast -import com.duckduckgo.anvil.annotations.InjectWith -import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.common.ui.viewbinding.viewBinding -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.ActivityScope -import com.duckduckgo.di.scopes.ViewScope -import com.duckduckgo.subscriptions.internal.SubsSettingPlugin -import com.duckduckgo.subscriptions.internal.databinding.SubsSimpleViewBinding -import com.squareup.anvil.annotations.ContributesMultibinding -import dagger.android.support.AndroidSupportInjection -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@InjectWith(ViewScope::class) -class DeleteSubscriptionView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0, -) : FrameLayout(context, attrs, defStyle) { - - @Inject - lateinit var dispatcherProvider: DispatcherProvider - - @Inject - @AppCoroutineScope - lateinit var appCoroutineScope: CoroutineScope - - @Inject - lateinit var accountDeletionHandler: AccountDeletionHandler - - private val binding: SubsSimpleViewBinding by viewBinding() - - override fun onAttachedToWindow() { - AndroidSupportInjection.inject(this) - super.onAttachedToWindow() - - binding.root.setPrimaryText("Delete Subscription Account") - binding.root.setSecondaryText("Deletes your subscription account") - - binding.root.setClickListener { - deleteSubscription() - } - } - - private fun deleteSubscription() { - appCoroutineScope.launch(dispatcherProvider.io()) { - val message = if (accountDeletionHandler.deleteAccountAndSignOut()) { - "Account deleted" - } else { - "We could not delete your account" - } - withContext(dispatcherProvider.main()) { - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } - } -} - -@ContributesMultibinding(ActivityScope::class) -class DeleteSubscriptionViewPlugin @Inject constructor() : SubsSettingPlugin { - override fun getView(context: Context): View { - return DeleteSubscriptionView(context) - } -} diff --git a/subscriptions/subscriptions-internal/src/test/java/com/duckduckgo/subscriptions/internal/settings/AccountDeletionHandlerImplTest.kt b/subscriptions/subscriptions-internal/src/test/java/com/duckduckgo/subscriptions/internal/settings/AccountDeletionHandlerImplTest.kt deleted file mode 100644 index ba8edbbe873e..000000000000 --- a/subscriptions/subscriptions-internal/src/test/java/com/duckduckgo/subscriptions/internal/settings/AccountDeletionHandlerImplTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.duckduckgo.subscriptions.internal.settings - -import com.duckduckgo.subscriptions.impl.AuthTokenResult -import com.duckduckgo.subscriptions.impl.SubscriptionsManager -import com.duckduckgo.subscriptions.impl.services.AuthService -import com.duckduckgo.subscriptions.impl.services.DeleteAccountResponse -import kotlinx.coroutines.test.runTest -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.ResponseBody.Companion.toResponseBody -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import retrofit2.HttpException -import retrofit2.Response - -class AccountDeletionHandlerImplTest { - - private val subscriptionsManager: SubscriptionsManager = mock() - private val authService: AuthService = mock() - - private val subject = AccountDeletionHandlerImpl(subscriptionsManager, authService) - - @Test - fun whenDeleteAccountIfUserAuthenticatedAndValidTokenThenReturnTrue() = runTest { - givenUserIsAuthenticated() - givenDeleteAccountSucceeds() - - val accountDeleted = subject.deleteAccountAndSignOut() - - assertTrue(accountDeleted) - verify(authService).delete(eq("Bearer token")) - verify(subscriptionsManager).signOut() - } - - @Test - fun whenDeleteAccountIfUserNotAuthenticatedThenReturnFalse() = runTest { - givenUserIsNotAuthenticated() - givenDeleteAccountFails() - - val accountDeleted = subject.deleteAccountAndSignOut() - - assertFalse(accountDeleted) - verify(subscriptionsManager).getAuthToken() - verify(subscriptionsManager, never()).signOut() - verify(authService, never()).delete(any()) - } - - @Test - fun whenDeleteAccountFailsThenReturnFalse() = runTest { - givenUserIsAuthenticated() - givenDeleteAccountFails() - - val accountDeleted = subject.deleteAccountAndSignOut() - - assertFalse(accountDeleted) - verify(subscriptionsManager).getAuthToken() - verify(authService).delete(eq("Bearer token")) - verify(subscriptionsManager, never()).signOut() - } - - private suspend fun givenUserIsAuthenticated() { - whenever(subscriptionsManager.getAuthToken()).thenReturn(AuthTokenResult.Success("token")) - } - - private suspend fun givenUserIsNotAuthenticated() { - whenever(subscriptionsManager.getAuthToken()).thenReturn(null) - } - - private suspend fun givenDeleteAccountSucceeds() { - whenever(authService.delete(any())).thenReturn(DeleteAccountResponse(status = "deleted")) - } - - private suspend fun givenDeleteAccountFails() { - val exception = "failure".toResponseBody("text/json".toMediaTypeOrNull()) - whenever(authService.delete(any())).thenThrow(HttpException(Response.error(400, exception))) - } -}