From e5620393a05687f8b6596fd394c968eadbd9a450 Mon Sep 17 00:00:00 2001 From: Lukasz Macionczyk Date: Wed, 9 Jul 2025 11:54:13 +0200 Subject: [PATCH 1/5] Handle SetAuthTokens message in subscriptions pages --- .../impl/SubscriptionsManager.kt | 20 ++++++++++ .../SubscriptionMessagingInterface.kt | 38 +++++++++++++++++++ .../SubscriptionMessagingInterfaceTest.kt | 31 +++++++++++++++ 3 files changed, 89 insertions(+) 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 7f5903442073..9e7719e8f414 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 @@ -210,6 +210,11 @@ interface SubscriptionsManager { */ suspend fun signInV1(authToken: String) + /** + * Signs the user in using the provided v2 access and refresh tokens + */ + suspend fun signInV2(accessToken: String, refreshToken: String) + /** * Signs the user out and deletes all the data from the device */ @@ -382,6 +387,21 @@ class RealSubscriptionsManager @Inject constructor( } } + override suspend fun signInV2( + accessToken: String, + refreshToken: String, + ) { + val tokens = TokenPair(accessToken, refreshToken) + val jwks = authClient.getJwks() + saveTokens(validateTokens(tokens, jwks)) + authRepository.purchaseToWaitingStatus() + try { + refreshSubscriptionData() + } catch (e: Exception) { + logcat { "Subs: error when refreshing subscription on v2 sign in" } + } + } + override suspend fun signOut() { authRepository.getAccessTokenV2()?.run { coroutineScope.launch { authClient.tryLogout(accessTokenV2 = jwt) } 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 86aba30ca4d0..6c5f5c7b027a 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 @@ -69,6 +69,7 @@ class SubscriptionMessagingInterface @Inject constructor( SubscriptionsHandler(), GetSubscriptionMessage(subscriptionsManager, dispatcherProvider), SetSubscriptionMessage(subscriptionsManager, appCoroutineScope, dispatcherProvider, pixelSender, subscriptionsChecker), + SetAuthTokensMessage(subscriptionsManager, appCoroutineScope, dispatcherProvider, pixelSender, subscriptionsChecker), InformationalEventsMessage(subscriptionsManager, appCoroutineScope, pixelSender), GetAccessTokenMessage(subscriptionsManager), GetAuthAccessTokenMessage(subscriptionsManager), @@ -222,6 +223,43 @@ class SubscriptionMessagingInterface @Inject constructor( override val methods: List = listOf("setSubscription") } + inner class SetAuthTokensMessage( + private val subscriptionsManager: SubscriptionsManager, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val pixelSender: SubscriptionPixelSender, + private val subscriptionsChecker: SubscriptionsChecker, + ) : JsMessageHandler { + + override fun process( + jsMessage: JsMessage, + secret: String, + jsMessageCallback: JsMessageCallback?, + ) { + val (accessToken, refreshToken) = try { + with(jsMessage.params) { getString("accessToken") to getString("refreshToken") } + } catch (e: Exception) { + logcat { "Error parsing the tokens" } + return + } + + appCoroutineScope.launch(dispatcherProvider.io()) { + try { + subscriptionsManager.signInV2(accessToken, refreshToken) + subscriptionsChecker.runChecker() + pixelSender.reportRestoreUsingEmailSuccess() + pixelSender.reportSubscriptionActivated() + } catch (e: Exception) { + logcat { "Failed to set auth tokens" } + } + } + } + + override val allowedDomains: List = emptyList() + override val featureName: String = "useSubscription" + override val methods: List = listOf("setAuthTokens") + } + private class InformationalEventsMessage( private val subscriptionsManager: SubscriptionsManager, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, 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 f4601997d4d0..31c65e81f0c1 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 @@ -403,6 +403,37 @@ class SubscriptionMessagingInterfaceTest { verifyNoInteractions(pixelSender) } + @Test + fun `when process and setAuthTokens message then authenticate`() = runTest { + givenInterfaceIsRegistered() + + val params = """{"accessToken":"accessToken","refreshToken":"refreshToken"}""" + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"setAuthTokens","params":$params} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verify(subscriptionsManager).signInV2(accessToken = "accessToken", refreshToken = "refreshToken") + verify(pixelSender).reportRestoreUsingEmailSuccess() + verify(pixelSender).reportSubscriptionActivated() + assertEquals(0, callback.counter) + } + + @Test + fun `when process and setAuthTokens message and no tokens then do nothing`() = runTest { + givenInterfaceIsRegistered() + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"setAuthTokens","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verifyNoInteractions(subscriptionsManager) + verifyNoInteractions(pixelSender) + } + @Test fun `when process and get subscription options message if feature name does not match do nothing`() = runTest { givenInterfaceIsRegistered() From 0a477d648b157c1c4cca5647681182c2b16235d8 Mon Sep 17 00:00:00 2001 From: Lukasz Macionczyk Date: Wed, 9 Jul 2025 16:55:17 +0200 Subject: [PATCH 2/5] enable auth v2 subscription flow in internal builds --- .../java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f9a114c11d0f..0a76242d5da0 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 @@ -182,7 +182,7 @@ interface PrivacyProFeature { * This flag will be used to select FE subscription messaging mode. * The value is added into GetFeatureConfig to allow FE to select the mode. */ - @Toggle.DefaultValue(DefaultFeatureValue.FALSE) + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) fun enableSubscriptionFlowsV2(): Toggle } From 8ffa417b3eddf06f3aa78be6cff036a217db86aa Mon Sep 17 00:00:00 2001 From: Lukasz Macionczyk Date: Wed, 9 Jul 2025 17:44:35 +0200 Subject: [PATCH 3/5] add in-memory cache for jwks --- .../subscriptions/impl/auth2/AuthClient.kt | 24 +++++- .../impl/auth2/AuthClientImplTest.kt | 73 ++++++++++++++++++- 2 files changed, 94 insertions(+), 3 deletions(-) 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 index c5516938985b..363bdea0fc24 100644 --- 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 @@ -18,8 +18,12 @@ package com.duckduckgo.subscriptions.impl.auth2 import android.net.Uri import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import java.time.Duration +import java.time.Instant import javax.inject.Inject import logcat.logcat import retrofit2.HttpException @@ -112,11 +116,15 @@ data class TokenPair( ) @ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) class AuthClientImpl @Inject constructor( private val authService: AuthService, private val appBuildConfig: AppBuildConfig, + private val timeProvider: CurrentTimeProvider, ) : AuthClient { + private var cachedJwks: CachedJwks? = null + override suspend fun authorize(codeChallenge: String): String { val response = authService.authorize( responseType = AUTH_V2_RESPONSE_TYPE, @@ -183,8 +191,12 @@ class AuthClientImpl @Inject constructor( ) } - override suspend fun getJwks(): String = - authService.jwks().string() + override suspend fun getJwks(): String { + val cachedResult = cachedJwks?.takeIf { it.timestamp + JWKS_CACHE_DURATION > getCurrentTime() }?.jwks + + return cachedResult ?: authService.jwks().string() + .also { cachedJwks = CachedJwks(jwks = it, timestamp = getCurrentTime()) } + } override suspend fun storeLogin( sessionId: String, @@ -242,6 +254,13 @@ class AuthClientImpl @Inject constructor( } } + private fun getCurrentTime(): Instant = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) + + private data class CachedJwks( + val jwks: String, + val timestamp: Instant, + ) + private companion object { const val AUTH_V2_CLIENT_ID = "f4311287-0121-40e6-8bbd-85c36daf1837" const val AUTH_V2_REDIRECT_URI = "com.duckduckgo:/authcb" @@ -250,5 +269,6 @@ class AuthClientImpl @Inject constructor( const val AUTH_V2_RESPONSE_TYPE = "code" const val GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" const val GRANT_TYPE_REFRESH_TOKEN = "refresh_token" + val JWKS_CACHE_DURATION: Duration = Duration.ofHours(1) } } 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 index 812a5c42b58b..3f49ecebba7a 100644 --- 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 @@ -2,6 +2,10 @@ package com.duckduckgo.subscriptions.impl.auth2 import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.utils.CurrentTimeProvider +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime import kotlinx.coroutines.test.runTest import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -10,6 +14,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.times import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn @@ -26,7 +31,8 @@ class AuthClientImplTest { private val appBuildConfig: AppBuildConfig = mock { config -> whenever(config.applicationId).thenReturn("com.duckduckgo.android") } - private val authClient = AuthClientImpl(authService, appBuildConfig) + private val timeProvider = FakeTimeProvider() + private val authClient = AuthClientImpl(authService, appBuildConfig, timeProvider) @Test fun `when authorize success then returns sessionId parsed from Set-Cookie header`() = runTest { @@ -264,4 +270,69 @@ class AuthClientImplTest { authClient.tryLogout("fake v2 access token") } + + @Test + fun `when JWKS not cached then fetches from network`() = runTest { + val jwksJson = """{"keys": [{"kty": "RSA", "kid": "networkKey"}]}""" + val responseBody = jwksJson.toResponseBody("application/json".toMediaTypeOrNull()) + + whenever(authService.jwks()).thenReturn(responseBody) + + val result = authClient.getJwks() + + assertEquals(jwksJson, result) + verify(authService).jwks() + } + + @Test + fun `when JWKS is cached and not expired then returns cached value`() = runTest { + val jwksJson = """{"keys": [{"kty": "RSA", "kid": "cachedKey"}]}""" + val responseBody = jwksJson.toResponseBody("application/json".toMediaTypeOrNull()) + + whenever(authService.jwks()).thenReturn(responseBody) + + // Initial request + val first = authClient.getJwks() + assertEquals(jwksJson, first) + + // Advance time just before expiration + timeProvider.currentTime += Duration.ofMinutes(59) + + val second = authClient.getJwks() + assertEquals(jwksJson, second) + + // Verify network call happened only once + verify(authService).jwks() + } + + @Test + fun `when JWKS cache is expired then fetches new value`() = runTest { + val oldJwks = """{"keys": [{"kty": "RSA", "kid": "oldKey"}]}""" + val newJwks = """{"keys": [{"kty": "RSA", "kid": "newKey"}]}""" + + whenever(authService.jwks()) + .thenReturn(oldJwks.toResponseBody("application/json".toMediaTypeOrNull())) + .thenReturn(newJwks.toResponseBody("application/json".toMediaTypeOrNull())) + + // Initial call → old value cached + val first = authClient.getJwks() + assertEquals(oldJwks, first) + + // Advance time past expiration + timeProvider.currentTime += Duration.ofMinutes(61) + + // Call again → should return new JWKS + val second = authClient.getJwks() + assertEquals(newJwks, second) + + verify(authService, times(2)).jwks() + } + + 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() + } } From fdd7dd142bdc47c850683284deae5e534d34e144 Mon Sep 17 00:00:00 2001 From: Lukasz Macionczyk Date: Wed, 9 Jul 2025 18:59:38 +0200 Subject: [PATCH 4/5] warm up jwks cache when user enters activation flow --- .../impl/ui/RestoreSubscriptionViewModel.kt | 21 ++++++++++++++++++ .../ui/RestoreSubscriptionViewModelTest.kt | 22 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt index 6418ece3a51b..e9b138225f10 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt @@ -19,6 +19,7 @@ package com.duckduckgo.subscriptions.impl.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.subscriptions.api.SubscriptionStatus @@ -26,6 +27,7 @@ import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.Companion.SUBS import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.RecoverSubscriptionResult import com.duckduckgo.subscriptions.impl.SubscriptionsChecker import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.auth2.AuthClient import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.isExpired import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Error @@ -35,6 +37,7 @@ import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.SubscriptionNotFound import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Success import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -44,6 +47,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import logcat.logcat @ContributesViewModel(ActivityScope::class) class RestoreSubscriptionViewModel @Inject constructor( @@ -51,6 +55,8 @@ class RestoreSubscriptionViewModel @Inject constructor( private val subscriptionsChecker: SubscriptionsChecker, private val dispatcherProvider: DispatcherProvider, private val pixelSender: SubscriptionPixelSender, + private val authClient: AuthClient, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : ViewModel() { private val command = Channel(1, DROP_OLDEST) @@ -106,6 +112,7 @@ class RestoreSubscriptionViewModel @Inject constructor( viewModelScope.launch { command.send(RestoreFromEmail) } + warmUpJwksCache() } fun onSubscriptionRestoredFromEmail() = viewModelScope.launch { @@ -116,6 +123,20 @@ class RestoreSubscriptionViewModel @Inject constructor( } } + /* + We'll need JWKs to validate auth tokens returned by FE after the user completes activation flow using email. + Prefetching them is optional, but it reduces the risk of failure when the network connection is unstable. + */ + private fun warmUpJwksCache() { + appCoroutineScope.launch { + try { + authClient.getJwks() + } catch (e: Exception) { + logcat { "Failed to warm-up JWKs cache, e: ${e.stackTraceToString()}" } + } + } + } + sealed class Command { data object RestoreFromEmail : Command() data object Success : Command() diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt index 09e2928656fb..92b6f5eed4af 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt @@ -10,6 +10,7 @@ import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.Companion.SUBS import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.RecoverSubscriptionResult import com.duckduckgo.subscriptions.impl.SubscriptionsChecker import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.auth2.AuthClient import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.Subscription import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Error @@ -37,6 +38,7 @@ class RestoreSubscriptionViewModelTest { private val subscriptionsManager: SubscriptionsManager = mock() private val pixelSender: SubscriptionPixelSender = mock() private val subscriptionsChecker: SubscriptionsChecker = mock() + private val authClient: AuthClient = mock() private lateinit var viewModel: RestoreSubscriptionViewModel @Before @@ -46,6 +48,8 @@ class RestoreSubscriptionViewModelTest { dispatcherProvider = coroutineTestRule.testDispatcherProvider, pixelSender = pixelSender, subscriptionsChecker = subscriptionsChecker, + authClient = authClient, + appCoroutineScope = coroutineTestRule.testScope, ) } @@ -190,6 +194,24 @@ class RestoreSubscriptionViewModelTest { } } + @Test + fun whenRestoreFromEmailThenJwksCacheIsWarmedUp() = runTest { + viewModel.restoreFromEmail() + verify(authClient).getJwks() + } + + @Test + fun whenWarmUpJwksFailsThenNoCrashOccurs() = runTest { + whenever(authClient.getJwks()).thenThrow(RuntimeException("Network error")) + + viewModel.restoreFromEmail() + + viewModel.commands().test { + assertTrue(awaitItem() is RestoreFromEmail) + } + verify(pixelSender).reportActivateSubscriptionEnterEmailClick() + } + private fun subscriptionActive(): Subscription { return Subscription( productId = "productId", From 7d938b8420697eafbf20746fc1fda5f73a826508 Mon Sep 17 00:00:00 2001 From: Lukasz Macionczyk Date: Fri, 11 Jul 2025 00:24:33 +0200 Subject: [PATCH 5/5] add kill switch to JWKs cache --- .../subscriptions/impl/RealSubscriptions.kt | 6 +++ .../subscriptions/impl/auth2/AuthClient.kt | 20 ++++++++-- .../impl/auth2/AuthClientImplTest.kt | 40 ++++++++++++++++++- 3 files changed, 62 insertions(+), 4 deletions(-) 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 0a76242d5da0..6925fd2a5816 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 @@ -184,6 +184,12 @@ interface PrivacyProFeature { */ @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) fun enableSubscriptionFlowsV2(): Toggle + + /** + * Kill-switch for in-memory caching of auth v2 JWKs. + */ + @Toggle.DefaultValue(DefaultFeatureValue.TRUE) + fun authApiV2JwksCache(): Toggle } @ContributesBinding(AppScope::class) 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 index 363bdea0fc24..6b3dd90257ae 100644 --- 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 @@ -19,12 +19,16 @@ package com.duckduckgo.subscriptions.impl.auth2 import android.net.Uri import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.impl.PrivacyProFeature import com.squareup.anvil.annotations.ContributesBinding +import dagger.Lazy import dagger.SingleInstanceIn import java.time.Duration import java.time.Instant import javax.inject.Inject +import kotlinx.coroutines.withContext import logcat.logcat import retrofit2.HttpException import retrofit2.Response @@ -121,6 +125,8 @@ class AuthClientImpl @Inject constructor( private val authService: AuthService, private val appBuildConfig: AppBuildConfig, private val timeProvider: CurrentTimeProvider, + private val privacyProFeature: Lazy, + private val dispatchers: DispatcherProvider, ) : AuthClient { private var cachedJwks: CachedJwks? = null @@ -192,10 +198,18 @@ class AuthClientImpl @Inject constructor( } override suspend fun getJwks(): String { - val cachedResult = cachedJwks?.takeIf { it.timestamp + JWKS_CACHE_DURATION > getCurrentTime() }?.jwks + val useCache = withContext(dispatchers.io()) { + privacyProFeature.get().authApiV2JwksCache().isEnabled() + } + + return if (useCache) { + val cachedResult = cachedJwks?.takeIf { it.timestamp + JWKS_CACHE_DURATION > getCurrentTime() }?.jwks - return cachedResult ?: authService.jwks().string() - .also { cachedJwks = CachedJwks(jwks = it, timestamp = getCurrentTime()) } + cachedResult ?: authService.jwks().string() + .also { cachedJwks = CachedJwks(jwks = it, timestamp = getCurrentTime()) } + } else { + authService.jwks().string() + } } override suspend fun storeLogin( 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 index 3f49ecebba7a..21e269b7ec6b 100644 --- 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 @@ -1,8 +1,13 @@ package com.duckduckgo.subscriptions.impl.auth2 +import android.annotation.SuppressLint import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.appbuildconfig.api.AppBuildConfig +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.impl.PrivacyProFeature import java.time.Duration import java.time.Instant import java.time.LocalDateTime @@ -12,6 +17,7 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals import org.junit.Assert.fail +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.times @@ -27,12 +33,23 @@ import retrofit2.Response @RunWith(AndroidJUnit4::class) class AuthClientImplTest { + @get:Rule + var coroutinesTestRule = CoroutineTestRule() + private val authService: AuthService = mock() private val appBuildConfig: AppBuildConfig = mock { config -> whenever(config.applicationId).thenReturn("com.duckduckgo.android") } private val timeProvider = FakeTimeProvider() - private val authClient = AuthClientImpl(authService, appBuildConfig, timeProvider) + private val privacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java) + + private val authClient = AuthClientImpl( + authService = authService, + appBuildConfig = appBuildConfig, + timeProvider = timeProvider, + privacyProFeature = { privacyProFeature }, + dispatchers = coroutinesTestRule.testDispatcherProvider, + ) @Test fun `when authorize success then returns sessionId parsed from Set-Cookie header`() = runTest { @@ -328,6 +345,27 @@ class AuthClientImplTest { verify(authService, times(2)).jwks() } + @SuppressLint("DenyListedApi") + @Test + fun `when JWKS cache is disabled then always fetches from network`() = runTest { + privacyProFeature.authApiV2JwksCache().setRawStoredState(State(false)) + + val jwks1 = """{"keys": [{"kty": "RSA", "kid": "key1"}]}""" + val jwks2 = """{"keys": [{"kty": "RSA", "kid": "key2"}]}""" + + whenever(authService.jwks()) + .thenReturn(jwks1.toResponseBody("application/json".toMediaTypeOrNull())) + .thenReturn(jwks2.toResponseBody("application/json".toMediaTypeOrNull())) + + val first = authClient.getJwks() + val second = authClient.getJwks() + + assertEquals(jwks1, first) + assertEquals(jwks2, second) + + verify(authService, times(2)).jwks() + } + private class FakeTimeProvider : CurrentTimeProvider { var currentTime: Instant = Instant.parse("2024-10-28T00:00:00Z")