Skip to content

Commit c61f42e

Browse files
committed
Retry IAM fetch after JWT refresh on 401/403 response
When an IAM fetch returns an unauthorized response (401 or 403), the SDK now saves the pending fetch state and automatically retries once the JWT is refreshed for the same user. Switching users clears any stale retry. Made-with: Cursor
1 parent 098a4f7 commit c61f42e

6 files changed

Lines changed: 212 additions & 4 deletions

File tree

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import com.onesignal.common.putSafe
88
import com.onesignal.common.safeBool
99
import com.onesignal.common.safeDouble
1010
import com.onesignal.common.safeInt
11-
import com.onesignal.common.safeLong
1211
import com.onesignal.common.safeJSONObject
12+
import com.onesignal.common.safeLong
1313
import com.onesignal.common.safeString
1414
import com.onesignal.common.toMap
1515
import com.onesignal.user.internal.backend.CreateUserResponse

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package com.onesignal.user.internal.identity
22

3+
import com.onesignal.common.events.EventProducer
34
import com.onesignal.core.internal.preferences.IPreferencesService
45
import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys
56
import com.onesignal.core.internal.preferences.PreferenceStores
67
import com.onesignal.debug.internal.logging.Logging
78
import org.json.JSONException
89
import org.json.JSONObject
910

11+
fun interface IJwtUpdateListener {
12+
fun onJwtUpdated(externalId: String)
13+
}
14+
1015
/**
1116
* Persistent store mapping externalId -> JWT token. Supports multiple users simultaneously
1217
* so that queued operations for a previous user can still resolve their JWT at execution time.
@@ -20,6 +25,7 @@ class JwtTokenStore(
2025
) {
2126
private val tokens: MutableMap<String, String> = mutableMapOf()
2227
private var isLoaded = false
28+
private val jwtUpdateNotifier = EventProducer<IJwtUpdateListener>()
2329

2430
/** Not thread-safe; callers must hold `synchronized(tokens)`. */
2531
private fun ensureLoaded() {
@@ -61,6 +67,14 @@ class JwtTokenStore(
6167
}
6268
}
6369

70+
fun subscribe(listener: IJwtUpdateListener) {
71+
jwtUpdateNotifier.subscribe(listener)
72+
}
73+
74+
fun unsubscribe(listener: IJwtUpdateListener) {
75+
jwtUpdateNotifier.unsubscribe(listener)
76+
}
77+
6478
/**
6579
* Stores (or replaces) the JWT for [externalId]. Passing a null [jwt] is a no-op;
6680
* use [invalidateJwt] to remove a token.
@@ -75,6 +89,7 @@ class JwtTokenStore(
7589
tokens[externalId] = jwt
7690
persist()
7791
}
92+
jwtUpdateNotifier.fire { it.onJwtUpdated(externalId) }
7893
}
7994

8095
/**

OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.app.AlertDialog
44
import com.onesignal.common.AndroidUtils
55
import com.onesignal.common.IDManager
66
import com.onesignal.common.JSONUtils
7+
import com.onesignal.common.NetworkUtils
78
import com.onesignal.common.consistency.IamFetchReadyCondition
89
import com.onesignal.common.consistency.RywData
910
import com.onesignal.common.consistency.models.IConsistencyManager
@@ -46,6 +47,7 @@ import com.onesignal.session.internal.session.ISessionLifecycleHandler
4647
import com.onesignal.session.internal.session.ISessionService
4748
import com.onesignal.user.IUserManager
4849
import com.onesignal.user.internal.backend.IdentityConstants
50+
import com.onesignal.user.internal.identity.IJwtUpdateListener
4951
import com.onesignal.user.internal.identity.IdentityModel
5052
import com.onesignal.user.internal.identity.IdentityModelStore
5153
import com.onesignal.user.internal.identity.JwtTokenStore
@@ -85,7 +87,8 @@ internal class InAppMessagesManager(
8587
IInAppLifecycleEventHandler,
8688
ITriggerHandler,
8789
ISessionLifecycleHandler,
88-
IApplicationLifecycleHandler {
90+
IApplicationLifecycleHandler,
91+
IJwtUpdateListener {
8992
private val lifecycleCallback = EventProducer<IInAppMessageLifecycleListener>()
9093
private val messageClickCallback = EventProducer<IInAppMessageClickListener>()
9194

@@ -124,12 +127,20 @@ internal class InAppMessagesManager(
124127
// Tracks trigger keys added early on cold start (before first fetch completes), for redisplay logic
125128
private val earlySessionTriggers: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
126129

130+
// Pending IAM retry state for 401 (expired JWT) responses.
131+
// Stores the externalId and rywData from the failed fetch so we can retry after JWT refresh.
132+
private var pendingJwtRetryExternalId: String? = null
133+
private var pendingJwtRetryRywData: RywData? = null
134+
127135
private val identityModelChangeHandler =
128136
object : ISingletonModelStoreChangeHandler<IdentityModel> {
129137
override fun onModelReplaced(
130138
model: IdentityModel,
131139
tag: String,
132-
) { }
140+
) {
141+
pendingJwtRetryExternalId = null
142+
pendingJwtRetryRywData = null
143+
}
133144

134145
override fun onModelUpdated(
135146
args: ModelChangedArgs,
@@ -192,6 +203,7 @@ internal class InAppMessagesManager(
192203
_sessionService.subscribe(this)
193204
_applicationService.addApplicationLifecycleHandler(this)
194205
_identityModelStore.subscribe(identityModelChangeHandler)
206+
_jwtTokenStore.subscribe(this)
195207

196208
suspendifyOnIO {
197209
_repository.cleanCachedInAppMessages()
@@ -326,7 +338,17 @@ internal class InAppMessagesManager(
326338

327339
// lambda so that it is updated on each potential retry
328340
val sessionDurationProvider = { _time.currentTimeMillis - _sessionService.startTime }
329-
val newMessages = _backend.listInAppMessages(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt)
341+
val newMessages =
342+
try {
343+
_backend.listInAppMessages(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt)
344+
} catch (ex: BackendException) {
345+
if (NetworkUtils.getResponseStatusType(ex.statusCode) == NetworkUtils.ResponseStatusType.UNAUTHORIZED) {
346+
Logging.debug("InAppMessagesManager.fetchMessages: ${ex.statusCode} response. Will retry after JWT refresh for externalId=$externalId")
347+
pendingJwtRetryExternalId = externalId
348+
pendingJwtRetryRywData = rywData
349+
}
350+
null
351+
}
330352

331353
if (newMessages != null) {
332354
this.messages = newMessages as MutableList<InAppMessage>
@@ -1017,6 +1039,21 @@ internal class InAppMessagesManager(
10171039
.show()
10181040
}
10191041

1042+
override fun onJwtUpdated(externalId: String) {
1043+
val retryExternalId = pendingJwtRetryExternalId ?: return
1044+
val retryRywData = pendingJwtRetryRywData ?: return
1045+
1046+
if (externalId != retryExternalId) return
1047+
1048+
Logging.debug("InAppMessagesManager.onJwtUpdated: JWT refreshed for $externalId, retrying IAM fetch")
1049+
pendingJwtRetryExternalId = null
1050+
pendingJwtRetryRywData = null
1051+
1052+
suspendifyOnIO {
1053+
fetchMessages(retryRywData)
1054+
}
1055+
}
1056+
10201057
override fun onFocus(firedOnSubscribe: Boolean) { }
10211058

10221059
override fun onUnfocused() { }

OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,8 @@ internal class InAppBackendService(
239239
response.retryAfterSeconds?.let {
240240
delay(it * 1_000L)
241241
}
242+
} else if (NetworkUtils.getResponseStatusType(response.statusCode) == NetworkUtils.ResponseStatusType.UNAUTHORIZED) {
243+
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
242244
} else if (response.statusCode in 500..599) {
243245
return null
244246
} else {

OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.onesignal.common.consistency.IamFetchReadyCondition
66
import com.onesignal.common.consistency.RywData
77
import com.onesignal.common.consistency.models.IConsistencyManager
88
import com.onesignal.common.exceptions.BackendException
9+
import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler
910
import com.onesignal.common.modeling.ModelChangedArgs
1011
import com.onesignal.core.internal.config.ConfigModel
1112
import com.onesignal.debug.LogLevel
@@ -31,6 +32,7 @@ import com.onesignal.session.internal.influence.IInfluenceManager
3132
import com.onesignal.session.internal.outcomes.IOutcomeEventsController
3233
import com.onesignal.session.internal.session.ISessionService
3334
import com.onesignal.user.IUserManager
35+
import com.onesignal.user.internal.identity.IdentityModel
3436
import com.onesignal.user.internal.identity.JwtTokenStore
3537
import com.onesignal.user.internal.subscriptions.ISubscriptionManager
3638
import com.onesignal.user.internal.subscriptions.SubscriptionModel
@@ -45,6 +47,7 @@ import io.mockk.just
4547
import io.mockk.mockk
4648
import io.mockk.mockkObject
4749
import io.mockk.runs
50+
import io.mockk.slot
4851
import io.mockk.spyk
4952
import io.mockk.unmockkObject
5053
import io.mockk.verify
@@ -169,6 +172,20 @@ private class Mocks {
169172
return property.get(manager) as Boolean
170173
}
171174

175+
fun getPendingJwtRetryExternalId(manager: InAppMessagesManager): String? {
176+
val property = InAppMessagesManager::class.memberProperties
177+
.first { it.name == "pendingJwtRetryExternalId" }
178+
property.isAccessible = true
179+
return property.get(manager) as String?
180+
}
181+
182+
fun getPendingJwtRetryRywData(manager: InAppMessagesManager): RywData? {
183+
val property = InAppMessagesManager::class.memberProperties
184+
.first { it.name == "pendingJwtRetryRywData" }
185+
property.isAccessible = true
186+
return property.get(manager) as RywData?
187+
}
188+
172189
// Helper function to create InAppMessagesManager with all dependencies
173190
val inAppMessagesManager = InAppMessagesManager(
174191
applicationService,
@@ -1418,4 +1435,123 @@ class InAppMessagesManagerTests : FunSpec({
14181435
messageAfterClear.isTriggerChanged shouldBe false
14191436
}
14201437
}
1438+
1439+
context("JWT 401 Retry") {
1440+
test("fetchMessages stores pending retry state on 401 BackendException") {
1441+
// Given
1442+
every { mocks.userManager.onesignalId } returns "onesignal-id"
1443+
every { mocks.applicationService.isInForeground } returns true
1444+
every { mocks.pushSubscription.id } returns "subscription-id"
1445+
mocks.identityModelStore.model.externalId = "test-external-id"
1446+
coEvery {
1447+
mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any())
1448+
} throws BackendException(401, "Unauthorized")
1449+
1450+
// When
1451+
mocks.inAppMessagesManager.onSessionStarted()
1452+
awaitIO()
1453+
1454+
// Then
1455+
mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe "test-external-id"
1456+
mocks.getPendingJwtRetryRywData(mocks.inAppMessagesManager) shouldBe mocks.rywData
1457+
}
1458+
1459+
test("onJwtUpdated retries fetch when externalId matches pending retry") {
1460+
// Given
1461+
every { mocks.userManager.onesignalId } returns "onesignal-id"
1462+
every { mocks.applicationService.isInForeground } returns true
1463+
every { mocks.pushSubscription.id } returns "subscription-id"
1464+
mocks.identityModelStore.model.externalId = "test-external-id"
1465+
mocks.configModelStore.model.fetchIAMMinInterval = 0L
1466+
1467+
// First call throws 401, second call succeeds
1468+
coEvery {
1469+
mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any())
1470+
} throws BackendException(401, "Unauthorized") andThen listOf(mocks.createInAppMessage())
1471+
1472+
// Trigger the initial fetch that will 401
1473+
mocks.inAppMessagesManager.onSessionStarted()
1474+
awaitIO()
1475+
1476+
// Verify pending state was set
1477+
mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe "test-external-id"
1478+
1479+
// When - JWT is updated for the same external ID
1480+
mocks.inAppMessagesManager.onJwtUpdated("test-external-id")
1481+
awaitIO()
1482+
1483+
// Then - should have retried and cleared the pending state
1484+
coVerify(exactly = 2) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) }
1485+
mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe null
1486+
mocks.getPendingJwtRetryRywData(mocks.inAppMessagesManager) shouldBe null
1487+
}
1488+
1489+
test("onJwtUpdated does not retry when externalId does not match pending retry") {
1490+
// Given
1491+
every { mocks.userManager.onesignalId } returns "onesignal-id"
1492+
every { mocks.applicationService.isInForeground } returns true
1493+
every { mocks.pushSubscription.id } returns "subscription-id"
1494+
mocks.identityModelStore.model.externalId = "test-external-id"
1495+
coEvery {
1496+
mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any())
1497+
} throws BackendException(401, "Unauthorized")
1498+
1499+
// Trigger the initial fetch that will 401
1500+
mocks.inAppMessagesManager.onSessionStarted()
1501+
awaitIO()
1502+
1503+
// When - JWT is updated for a DIFFERENT external ID
1504+
mocks.inAppMessagesManager.onJwtUpdated("different-external-id")
1505+
awaitIO()
1506+
1507+
// Then - should NOT have retried, pending state remains
1508+
coVerify(exactly = 1) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) }
1509+
mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe "test-external-id"
1510+
}
1511+
1512+
test("onJwtUpdated does nothing when no pending retry") {
1513+
// Given - no 401 has happened, so no pending retry
1514+
1515+
// When
1516+
mocks.inAppMessagesManager.onJwtUpdated("any-external-id")
1517+
awaitIO()
1518+
1519+
// Then
1520+
coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) }
1521+
}
1522+
1523+
test("pending retry state is cleared on user switch (identity model replaced)") {
1524+
// Given
1525+
every { mocks.userManager.onesignalId } returns "onesignal-id"
1526+
every { mocks.applicationService.isInForeground } returns true
1527+
every { mocks.pushSubscription.id } returns "subscription-id"
1528+
mocks.identityModelStore.model.externalId = "test-external-id"
1529+
coEvery {
1530+
mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any())
1531+
} throws BackendException(401, "Unauthorized")
1532+
1533+
// Capture the handler passed to identityModelStore.subscribe
1534+
val handlerSlot = slot<ISingletonModelStoreChangeHandler<IdentityModel>>()
1535+
every { mocks.identityModelStore.subscribe(capture(handlerSlot)) } just runs
1536+
1537+
// Start the manager to subscribe
1538+
val mockRepository = mocks.repository
1539+
coEvery { mockRepository.cleanCachedInAppMessages() } just runs
1540+
coEvery { mockRepository.listInAppMessages() } returns emptyList()
1541+
mocks.inAppMessagesManager.start()
1542+
awaitIO()
1543+
1544+
// Trigger 401
1545+
mocks.inAppMessagesManager.onSessionStarted()
1546+
awaitIO()
1547+
mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe "test-external-id"
1548+
1549+
// When - simulate user switch via the captured handler
1550+
handlerSlot.captured.onModelReplaced(IdentityModel(), "test")
1551+
1552+
// Then - pending retry state should be cleared
1553+
mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe null
1554+
mocks.getPendingJwtRetryRywData(mocks.inAppMessagesManager) shouldBe null
1555+
}
1556+
}
14211557
})

OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,24 @@ class InAppBackendServiceTests :
8787
coVerify(exactly = 1) { mockHttpClient.get("apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", any()) }
8888
}
8989

90+
test("listInAppMessages throws BackendException on 401 response") {
91+
// Given
92+
val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore())
93+
val mockHttpClient = mockk<IHttpClient>()
94+
coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(401, "{\"errors\":[\"Invalid token\"]}")
95+
96+
val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator)
97+
98+
// When / Then
99+
val exception =
100+
shouldThrowUnit<BackendException> {
101+
inAppBackendService.listInAppMessages("appId", "onesignal_id", "user123", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider, "expired-jwt")
102+
}
103+
104+
exception.statusCode shouldBe 401
105+
coVerify(exactly = 1) { mockHttpClient.get("apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", any()) }
106+
}
107+
90108
test("listInAppMessages returns null when non-success response") {
91109
// Given
92110
val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore())

0 commit comments

Comments
 (0)