Skip to content

Commit 0b9c140

Browse files
authored
Use auth v2 in subscription activation flow (#6366)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1205648422731273/task/1210723642420067?focus=true ### Description ### Steps to test this PR - [x] Build with staging patch from https://app.asana.com/1/137249556945/project/1209991789468715/task/1210448620621729?focus=true - [x] Go to settings -> I have a subscription - [x] Click "Add via Email" - [x] Verify that the app made request to api/auth/v2/.well-known/jwks.json - [x] Proceed with resotoring the subscription until the success screen is shown - [x] Verify that there were no requests to api/auth/access-token - [x] Verify that there were no more requests to api/auth/v2/.well-known/jwks.json - [x] Verify that the app settings show subscription in active state, VPN can be enabled, etc. ### No UI changes
1 parent b78c713 commit 0b9c140

File tree

8 files changed

+285
-4
lines changed

8 files changed

+285
-4
lines changed

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,14 @@ interface PrivacyProFeature {
182182
* This flag will be used to select FE subscription messaging mode.
183183
* The value is added into GetFeatureConfig to allow FE to select the mode.
184184
*/
185-
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
185+
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
186186
fun enableSubscriptionFlowsV2(): Toggle
187+
188+
/**
189+
* Kill-switch for in-memory caching of auth v2 JWKs.
190+
*/
191+
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
192+
fun authApiV2JwksCache(): Toggle
187193
}
188194

189195
@ContributesBinding(AppScope::class)

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,11 @@ interface SubscriptionsManager {
210210
*/
211211
suspend fun signInV1(authToken: String)
212212

213+
/**
214+
* Signs the user in using the provided v2 access and refresh tokens
215+
*/
216+
suspend fun signInV2(accessToken: String, refreshToken: String)
217+
213218
/**
214219
* Signs the user out and deletes all the data from the device
215220
*/
@@ -382,6 +387,21 @@ class RealSubscriptionsManager @Inject constructor(
382387
}
383388
}
384389

390+
override suspend fun signInV2(
391+
accessToken: String,
392+
refreshToken: String,
393+
) {
394+
val tokens = TokenPair(accessToken, refreshToken)
395+
val jwks = authClient.getJwks()
396+
saveTokens(validateTokens(tokens, jwks))
397+
authRepository.purchaseToWaitingStatus()
398+
try {
399+
refreshSubscriptionData()
400+
} catch (e: Exception) {
401+
logcat { "Subs: error when refreshing subscription on v2 sign in" }
402+
}
403+
}
404+
385405
override suspend fun signOut() {
386406
authRepository.getAccessTokenV2()?.run {
387407
coroutineScope.launch { authClient.tryLogout(accessTokenV2 = jwt) }

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/auth2/AuthClient.kt

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,17 @@ package com.duckduckgo.subscriptions.impl.auth2
1818

1919
import android.net.Uri
2020
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
21+
import com.duckduckgo.common.utils.CurrentTimeProvider
22+
import com.duckduckgo.common.utils.DispatcherProvider
2123
import com.duckduckgo.di.scopes.AppScope
24+
import com.duckduckgo.subscriptions.impl.PrivacyProFeature
2225
import com.squareup.anvil.annotations.ContributesBinding
26+
import dagger.Lazy
27+
import dagger.SingleInstanceIn
28+
import java.time.Duration
29+
import java.time.Instant
2330
import javax.inject.Inject
31+
import kotlinx.coroutines.withContext
2432
import logcat.logcat
2533
import retrofit2.HttpException
2634
import retrofit2.Response
@@ -112,11 +120,17 @@ data class TokenPair(
112120
)
113121

114122
@ContributesBinding(AppScope::class)
123+
@SingleInstanceIn(AppScope::class)
115124
class AuthClientImpl @Inject constructor(
116125
private val authService: AuthService,
117126
private val appBuildConfig: AppBuildConfig,
127+
private val timeProvider: CurrentTimeProvider,
128+
private val privacyProFeature: Lazy<PrivacyProFeature>,
129+
private val dispatchers: DispatcherProvider,
118130
) : AuthClient {
119131

132+
private var cachedJwks: CachedJwks? = null
133+
120134
override suspend fun authorize(codeChallenge: String): String {
121135
val response = authService.authorize(
122136
responseType = AUTH_V2_RESPONSE_TYPE,
@@ -183,8 +197,20 @@ class AuthClientImpl @Inject constructor(
183197
)
184198
}
185199

186-
override suspend fun getJwks(): String =
187-
authService.jwks().string()
200+
override suspend fun getJwks(): String {
201+
val useCache = withContext(dispatchers.io()) {
202+
privacyProFeature.get().authApiV2JwksCache().isEnabled()
203+
}
204+
205+
return if (useCache) {
206+
val cachedResult = cachedJwks?.takeIf { it.timestamp + JWKS_CACHE_DURATION > getCurrentTime() }?.jwks
207+
208+
cachedResult ?: authService.jwks().string()
209+
.also { cachedJwks = CachedJwks(jwks = it, timestamp = getCurrentTime()) }
210+
} else {
211+
authService.jwks().string()
212+
}
213+
}
188214

189215
override suspend fun storeLogin(
190216
sessionId: String,
@@ -242,6 +268,13 @@ class AuthClientImpl @Inject constructor(
242268
}
243269
}
244270

271+
private fun getCurrentTime(): Instant = Instant.ofEpochMilli(timeProvider.currentTimeMillis())
272+
273+
private data class CachedJwks(
274+
val jwks: String,
275+
val timestamp: Instant,
276+
)
277+
245278
private companion object {
246279
const val AUTH_V2_CLIENT_ID = "f4311287-0121-40e6-8bbd-85c36daf1837"
247280
const val AUTH_V2_REDIRECT_URI = "com.duckduckgo:/authcb"
@@ -250,5 +283,6 @@ class AuthClientImpl @Inject constructor(
250283
const val AUTH_V2_RESPONSE_TYPE = "code"
251284
const val GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
252285
const val GRANT_TYPE_REFRESH_TOKEN = "refresh_token"
286+
val JWKS_CACHE_DURATION: Duration = Duration.ofHours(1)
253287
}
254288
}

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class SubscriptionMessagingInterface @Inject constructor(
6969
SubscriptionsHandler(),
7070
GetSubscriptionMessage(subscriptionsManager, dispatcherProvider),
7171
SetSubscriptionMessage(subscriptionsManager, appCoroutineScope, dispatcherProvider, pixelSender, subscriptionsChecker),
72+
SetAuthTokensMessage(subscriptionsManager, appCoroutineScope, dispatcherProvider, pixelSender, subscriptionsChecker),
7273
InformationalEventsMessage(subscriptionsManager, appCoroutineScope, pixelSender),
7374
GetAccessTokenMessage(subscriptionsManager),
7475
GetAuthAccessTokenMessage(subscriptionsManager),
@@ -222,6 +223,43 @@ class SubscriptionMessagingInterface @Inject constructor(
222223
override val methods: List<String> = listOf("setSubscription")
223224
}
224225

226+
inner class SetAuthTokensMessage(
227+
private val subscriptionsManager: SubscriptionsManager,
228+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
229+
private val dispatcherProvider: DispatcherProvider,
230+
private val pixelSender: SubscriptionPixelSender,
231+
private val subscriptionsChecker: SubscriptionsChecker,
232+
) : JsMessageHandler {
233+
234+
override fun process(
235+
jsMessage: JsMessage,
236+
secret: String,
237+
jsMessageCallback: JsMessageCallback?,
238+
) {
239+
val (accessToken, refreshToken) = try {
240+
with(jsMessage.params) { getString("accessToken") to getString("refreshToken") }
241+
} catch (e: Exception) {
242+
logcat { "Error parsing the tokens" }
243+
return
244+
}
245+
246+
appCoroutineScope.launch(dispatcherProvider.io()) {
247+
try {
248+
subscriptionsManager.signInV2(accessToken, refreshToken)
249+
subscriptionsChecker.runChecker()
250+
pixelSender.reportRestoreUsingEmailSuccess()
251+
pixelSender.reportSubscriptionActivated()
252+
} catch (e: Exception) {
253+
logcat { "Failed to set auth tokens" }
254+
}
255+
}
256+
}
257+
258+
override val allowedDomains: List<String> = emptyList()
259+
override val featureName: String = "useSubscription"
260+
override val methods: List<String> = listOf("setAuthTokens")
261+
}
262+
225263
private class InformationalEventsMessage(
226264
private val subscriptionsManager: SubscriptionsManager,
227265
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ package com.duckduckgo.subscriptions.impl.ui
1919
import androidx.lifecycle.ViewModel
2020
import androidx.lifecycle.viewModelScope
2121
import com.duckduckgo.anvil.annotations.ContributesViewModel
22+
import com.duckduckgo.app.di.AppCoroutineScope
2223
import com.duckduckgo.common.utils.DispatcherProvider
2324
import com.duckduckgo.di.scopes.ActivityScope
2425
import com.duckduckgo.subscriptions.api.SubscriptionStatus
2526
import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.Companion.SUBSCRIPTION_NOT_FOUND_ERROR
2627
import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.RecoverSubscriptionResult
2728
import com.duckduckgo.subscriptions.impl.SubscriptionsChecker
2829
import com.duckduckgo.subscriptions.impl.SubscriptionsManager
30+
import com.duckduckgo.subscriptions.impl.auth2.AuthClient
2931
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
3032
import com.duckduckgo.subscriptions.impl.repository.isExpired
3133
import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Error
@@ -35,6 +37,7 @@ import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command
3537
import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.SubscriptionNotFound
3638
import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Success
3739
import javax.inject.Inject
40+
import kotlinx.coroutines.CoroutineScope
3841
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
3942
import kotlinx.coroutines.channels.Channel
4043
import kotlinx.coroutines.flow.Flow
@@ -44,13 +47,16 @@ import kotlinx.coroutines.flow.launchIn
4447
import kotlinx.coroutines.flow.onEach
4548
import kotlinx.coroutines.flow.receiveAsFlow
4649
import kotlinx.coroutines.launch
50+
import logcat.logcat
4751

4852
@ContributesViewModel(ActivityScope::class)
4953
class RestoreSubscriptionViewModel @Inject constructor(
5054
private val subscriptionsManager: SubscriptionsManager,
5155
private val subscriptionsChecker: SubscriptionsChecker,
5256
private val dispatcherProvider: DispatcherProvider,
5357
private val pixelSender: SubscriptionPixelSender,
58+
private val authClient: AuthClient,
59+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
5460
) : ViewModel() {
5561

5662
private val command = Channel<Command>(1, DROP_OLDEST)
@@ -106,6 +112,7 @@ class RestoreSubscriptionViewModel @Inject constructor(
106112
viewModelScope.launch {
107113
command.send(RestoreFromEmail)
108114
}
115+
warmUpJwksCache()
109116
}
110117

111118
fun onSubscriptionRestoredFromEmail() = viewModelScope.launch {
@@ -116,6 +123,20 @@ class RestoreSubscriptionViewModel @Inject constructor(
116123
}
117124
}
118125

126+
/*
127+
We'll need JWKs to validate auth tokens returned by FE after the user completes activation flow using email.
128+
Prefetching them is optional, but it reduces the risk of failure when the network connection is unstable.
129+
*/
130+
private fun warmUpJwksCache() {
131+
appCoroutineScope.launch {
132+
try {
133+
authClient.getJwks()
134+
} catch (e: Exception) {
135+
logcat { "Failed to warm-up JWKs cache, e: ${e.stackTraceToString()}" }
136+
}
137+
}
138+
}
139+
119140
sealed class Command {
120141
data object RestoreFromEmail : Command()
121142
data object Success : Command()

subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/auth2/AuthClientImplTest.kt

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
package com.duckduckgo.subscriptions.impl.auth2
22

3+
import android.annotation.SuppressLint
34
import androidx.test.ext.junit.runners.AndroidJUnit4
45
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
6+
import com.duckduckgo.common.test.CoroutineTestRule
7+
import com.duckduckgo.common.utils.CurrentTimeProvider
8+
import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory
9+
import com.duckduckgo.feature.toggles.api.Toggle.State
10+
import com.duckduckgo.subscriptions.impl.PrivacyProFeature
11+
import java.time.Duration
12+
import java.time.Instant
13+
import java.time.LocalDateTime
514
import kotlinx.coroutines.test.runTest
615
import okhttp3.Headers
716
import okhttp3.MediaType.Companion.toMediaTypeOrNull
817
import okhttp3.ResponseBody.Companion.toResponseBody
918
import org.junit.Assert.assertEquals
1019
import org.junit.Assert.fail
20+
import org.junit.Rule
1121
import org.junit.Test
1222
import org.junit.runner.RunWith
23+
import org.mockito.Mockito.times
1324
import org.mockito.kotlin.any
1425
import org.mockito.kotlin.anyOrNull
1526
import org.mockito.kotlin.doReturn
@@ -22,11 +33,23 @@ import retrofit2.Response
2233
@RunWith(AndroidJUnit4::class)
2334
class AuthClientImplTest {
2435

36+
@get:Rule
37+
var coroutinesTestRule = CoroutineTestRule()
38+
2539
private val authService: AuthService = mock()
2640
private val appBuildConfig: AppBuildConfig = mock { config ->
2741
whenever(config.applicationId).thenReturn("com.duckduckgo.android")
2842
}
29-
private val authClient = AuthClientImpl(authService, appBuildConfig)
43+
private val timeProvider = FakeTimeProvider()
44+
private val privacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java)
45+
46+
private val authClient = AuthClientImpl(
47+
authService = authService,
48+
appBuildConfig = appBuildConfig,
49+
timeProvider = timeProvider,
50+
privacyProFeature = { privacyProFeature },
51+
dispatchers = coroutinesTestRule.testDispatcherProvider,
52+
)
3053

3154
@Test
3255
fun `when authorize success then returns sessionId parsed from Set-Cookie header`() = runTest {
@@ -264,4 +287,90 @@ class AuthClientImplTest {
264287

265288
authClient.tryLogout("fake v2 access token")
266289
}
290+
291+
@Test
292+
fun `when JWKS not cached then fetches from network`() = runTest {
293+
val jwksJson = """{"keys": [{"kty": "RSA", "kid": "networkKey"}]}"""
294+
val responseBody = jwksJson.toResponseBody("application/json".toMediaTypeOrNull())
295+
296+
whenever(authService.jwks()).thenReturn(responseBody)
297+
298+
val result = authClient.getJwks()
299+
300+
assertEquals(jwksJson, result)
301+
verify(authService).jwks()
302+
}
303+
304+
@Test
305+
fun `when JWKS is cached and not expired then returns cached value`() = runTest {
306+
val jwksJson = """{"keys": [{"kty": "RSA", "kid": "cachedKey"}]}"""
307+
val responseBody = jwksJson.toResponseBody("application/json".toMediaTypeOrNull())
308+
309+
whenever(authService.jwks()).thenReturn(responseBody)
310+
311+
// Initial request
312+
val first = authClient.getJwks()
313+
assertEquals(jwksJson, first)
314+
315+
// Advance time just before expiration
316+
timeProvider.currentTime += Duration.ofMinutes(59)
317+
318+
val second = authClient.getJwks()
319+
assertEquals(jwksJson, second)
320+
321+
// Verify network call happened only once
322+
verify(authService).jwks()
323+
}
324+
325+
@Test
326+
fun `when JWKS cache is expired then fetches new value`() = runTest {
327+
val oldJwks = """{"keys": [{"kty": "RSA", "kid": "oldKey"}]}"""
328+
val newJwks = """{"keys": [{"kty": "RSA", "kid": "newKey"}]}"""
329+
330+
whenever(authService.jwks())
331+
.thenReturn(oldJwks.toResponseBody("application/json".toMediaTypeOrNull()))
332+
.thenReturn(newJwks.toResponseBody("application/json".toMediaTypeOrNull()))
333+
334+
// Initial call → old value cached
335+
val first = authClient.getJwks()
336+
assertEquals(oldJwks, first)
337+
338+
// Advance time past expiration
339+
timeProvider.currentTime += Duration.ofMinutes(61)
340+
341+
// Call again → should return new JWKS
342+
val second = authClient.getJwks()
343+
assertEquals(newJwks, second)
344+
345+
verify(authService, times(2)).jwks()
346+
}
347+
348+
@SuppressLint("DenyListedApi")
349+
@Test
350+
fun `when JWKS cache is disabled then always fetches from network`() = runTest {
351+
privacyProFeature.authApiV2JwksCache().setRawStoredState(State(false))
352+
353+
val jwks1 = """{"keys": [{"kty": "RSA", "kid": "key1"}]}"""
354+
val jwks2 = """{"keys": [{"kty": "RSA", "kid": "key2"}]}"""
355+
356+
whenever(authService.jwks())
357+
.thenReturn(jwks1.toResponseBody("application/json".toMediaTypeOrNull()))
358+
.thenReturn(jwks2.toResponseBody("application/json".toMediaTypeOrNull()))
359+
360+
val first = authClient.getJwks()
361+
val second = authClient.getJwks()
362+
363+
assertEquals(jwks1, first)
364+
assertEquals(jwks2, second)
365+
366+
verify(authService, times(2)).jwks()
367+
}
368+
369+
private class FakeTimeProvider : CurrentTimeProvider {
370+
var currentTime: Instant = Instant.parse("2024-10-28T00:00:00Z")
371+
372+
override fun elapsedRealtime(): Long = throw UnsupportedOperationException()
373+
override fun currentTimeMillis(): Long = currentTime.toEpochMilli()
374+
override fun localDateTimeNow(): LocalDateTime = throw UnsupportedOperationException()
375+
}
267376
}

0 commit comments

Comments
 (0)