Skip to content

Commit c23fa23

Browse files
authored
feat: Identity Verification release (#2640)
1 parent 41a4a88 commit c23fa23

104 files changed

Lines changed: 3129 additions & 276 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

OneSignalSDK/detekt/detekt-baseline-core.xml

Lines changed: 42 additions & 18 deletions
Large diffs are not rendered by default.

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,34 @@ interface IOneSignal {
131131
*/
132132
fun logout()
133133

134+
/**
135+
* Update the JWT bearer token associated with [externalId]. Use this when your backend
136+
* has issued a new JWT for an already-logged-in user (e.g. in response to a previous
137+
* [IUserJwtInvalidatedListener.onUserJwtInvalidated] callback). Stores the JWT and
138+
* wakes the operation queue so any deferred ops can dispatch with the fresh token.
139+
*
140+
* @param externalId The external ID the JWT belongs to.
141+
* @param token The new JWT bearer token issued by your backend.
142+
*/
143+
fun updateUserJwt(
144+
externalId: String,
145+
token: String,
146+
)
147+
148+
/**
149+
* Subscribe a listener for JWT-invalidated events. Fires on a background thread when
150+
* the SDK detects that the stored JWT for a user is no longer valid (typically after
151+
* a 401 from the OneSignal backend). Apps should respond by fetching a fresh JWT from
152+
* their backend and supplying it via [updateUserJwt].
153+
*
154+
* Pure pub/sub: only listeners subscribed at the time of the invalidation receive the
155+
* event. Subscribe early (e.g. in `Application.onCreate`) to avoid missing events.
156+
*/
157+
fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener)
158+
159+
/** Unsubscribe a listener previously registered via [addUserJwtInvalidatedListener]. */
160+
fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener)
161+
134162
// Suspend versions of property accessors and methods to avoid blocking threads
135163

136164
/**
@@ -226,4 +254,16 @@ interface IOneSignal {
226254
* Logout the current user (suspend version).
227255
*/
228256
suspend fun logoutSuspend()
257+
258+
/**
259+
* Update the JWT bearer token associated with [externalId] (suspend version). Suspends
260+
* until SDK initialization is complete, then stores the JWT and wakes the operation queue.
261+
*
262+
* @param externalId The external ID the JWT belongs to.
263+
* @param token The new JWT bearer token issued by your backend.
264+
*/
265+
suspend fun updateUserJwtSuspend(
266+
externalId: String,
267+
token: String,
268+
)
229269
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.onesignal
2+
3+
/**
4+
* Implement this interface and provide an instance to
5+
* [IOneSignal.addUserJwtInvalidatedListener] to be notified when the SDK has
6+
* detected that the JWT for a user is no longer valid (typically a 401 from
7+
* the OneSignal backend on a request signed with that JWT).
8+
*
9+
* Threading: delivered on a background dispatcher
10+
* (`OneSignalDispatchers.launchOnDefault`). Implementations should not assume a
11+
* specific thread and should re-dispatch to the UI thread if needed.
12+
*
13+
* Pure pub/sub: only listeners subscribed at the time of the invalidation
14+
* receive the event. Subscribe early (e.g. in `Application.onCreate`) to avoid
15+
* missing cold-start 401s.
16+
*/
17+
fun interface IUserJwtInvalidatedListener {
18+
/**
19+
* Called when the JWT is invalidated for [UserJwtInvalidatedEvent.externalId].
20+
* Apps should use this signal to fetch a fresh JWT from their backend and
21+
* supply it via [IOneSignal.updateUserJwt].
22+
*
23+
* @param event Describes which user's JWT was invalidated.
24+
*/
25+
fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent)
26+
}

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,39 @@ object OneSignal {
343343
@JvmStatic
344344
fun logout() = oneSignal.logout()
345345

346+
/**
347+
* Update the JWT bearer token associated with [externalId]. Use this when your backend
348+
* has issued a new JWT for an already-logged-in user (e.g. in response to a previous
349+
* [IUserJwtInvalidatedListener.onUserJwtInvalidated] callback). Stores the JWT and
350+
* wakes the operation queue so any deferred ops can dispatch with the fresh token.
351+
*
352+
* @param externalId The external ID the JWT belongs to.
353+
* @param token The new JWT bearer token issued by your backend.
354+
*/
355+
@JvmStatic
356+
fun updateUserJwt(
357+
externalId: String,
358+
token: String,
359+
) = oneSignal.updateUserJwt(externalId, token)
360+
361+
/**
362+
* Subscribe a listener for JWT-invalidated events. Fires on a background thread when
363+
* the SDK detects that the stored JWT for a user is no longer valid (typically after
364+
* a 401 from the OneSignal backend). Apps should respond by fetching a fresh JWT from
365+
* their backend and supplying it via [updateUserJwt].
366+
*
367+
* Pure pub/sub: only listeners subscribed at the time of the invalidation receive the
368+
* event. Subscribe early (e.g. in `Application.onCreate`) to avoid missing events.
369+
*/
370+
@JvmStatic
371+
fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) =
372+
oneSignal.addUserJwtInvalidatedListener(listener)
373+
374+
/** Unsubscribe a listener previously registered via [addUserJwtInvalidatedListener]. */
375+
@JvmStatic
376+
fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) =
377+
oneSignal.removeUserJwtInvalidatedListener(listener)
378+
346379
private val oneSignal: IOneSignal by lazy {
347380
OneSignalImp()
348381
}
@@ -405,6 +438,21 @@ object OneSignal {
405438
oneSignal.logoutSuspend()
406439
}
407440

441+
/**
442+
* Update the JWT bearer token associated with [externalId] without blocking the calling
443+
* thread. Suspend-safe version of [updateUserJwt].
444+
*
445+
* @param externalId The external ID the JWT belongs to.
446+
* @param token The new JWT bearer token issued by your backend.
447+
*/
448+
@JvmStatic
449+
suspend fun updateUserJwtSuspend(
450+
externalId: String,
451+
token: String,
452+
) {
453+
oneSignal.updateUserJwtSuspend(externalId, token)
454+
}
455+
408456
/**
409457
* Used to retrieve services from the SDK when constructor dependency injection is not an
410458
* option.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.onesignal
2+
3+
/**
4+
* The event passed into [IUserJwtInvalidatedListener.onUserJwtInvalidated].
5+
* Delivery occurs on a background thread.
6+
*/
7+
class UserJwtInvalidatedEvent(
8+
val externalId: String,
9+
)

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.onesignal.core.internal.background.impl.BackgroundManager
1313
import com.onesignal.core.internal.config.ConfigModelStore
1414
import com.onesignal.core.internal.config.impl.ConfigModelStoreListener
1515
import com.onesignal.core.internal.config.impl.FeatureFlagsRefreshService
16+
import com.onesignal.core.internal.config.impl.IdentityVerificationService
1617
import com.onesignal.core.internal.database.IDatabaseProvider
1718
import com.onesignal.core.internal.database.impl.DatabaseProvider
1819
import com.onesignal.core.internal.device.IDeviceService
@@ -45,6 +46,7 @@ import com.onesignal.location.ILocationManager
4546
import com.onesignal.location.internal.MisconfiguredLocationManager
4647
import com.onesignal.notifications.INotificationsManager
4748
import com.onesignal.notifications.internal.MisconfiguredNotificationsManager
49+
import com.onesignal.user.internal.jwt.JwtTokenStore
4850

4951
internal class CoreModule : IModule {
5052
override fun register(builder: ServiceBuilder) {
@@ -68,6 +70,11 @@ internal class CoreModule : IModule {
6870
builder.register<ConfigModelStoreListener>().provides<IStartableService>()
6971
builder.register<FeatureFlagsRefreshService>().provides<IStartableService>()
7072

73+
builder.register<JwtTokenStore>().provides<JwtTokenStore>()
74+
builder.register<IdentityVerificationService>()
75+
.provides<IdentityVerificationService>()
76+
.provides<IStartableService>()
77+
7178
// Operations
7279
builder.register<OperationModelStore>().provides<OperationModelStore>()
7380
builder.register<OperationRepo>()

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,7 @@ internal class ParamsBackendService(
8888
return ParamsObject(
8989
googleProjectNumber = responseJson.safeString("android_sender_id"),
9090
enterprise = responseJson.safeBool("enterp"),
91-
// TODO: New
92-
useIdentityVerification = responseJson.safeBool("require_ident_auth"),
91+
useIdentityVerification = responseJson.safeBool("jwt_required"),
9392
notificationChannels = responseJson.optJSONArray("chnl_lst"),
9493
firebaseAnalytics = responseJson.safeBool("fba"),
9594
restoreTTLFilter = responseJson.safeBool("restore_ttl_filter"),

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.onesignal.core.internal.config
22

33
import com.onesignal.common.modeling.Model
44
import com.onesignal.core.internal.http.OneSignalService.ONESIGNAL_API_BASE_URL
5+
import com.onesignal.user.internal.jwt.JwtRequirement
56
import org.json.JSONArray
67
import org.json.JSONObject
78

@@ -236,13 +237,18 @@ class ConfigModel : Model() {
236237
setBooleanProperty(::enterprise.name, value)
237238
}
238239

239-
/**
240-
* Whether SMS auth hash should be used.
241-
*/
242-
var useIdentityVerification: Boolean
243-
get() = getBooleanProperty(::useIdentityVerification.name) { false }
240+
/** Mirrors backend `jwt_required`. Pre-HYDRATE callers see [JwtRequirement.UNKNOWN]. */
241+
internal var useIdentityVerification: JwtRequirement
242+
get() = JwtRequirement.fromBoolean(getOptBooleanProperty(::useIdentityVerification.name))
244243
set(value) {
245-
setBooleanProperty(::useIdentityVerification.name, value)
244+
setOptBooleanProperty(
245+
::useIdentityVerification.name,
246+
when (value) {
247+
JwtRequirement.UNKNOWN -> null
248+
JwtRequirement.NOT_REQUIRED -> false
249+
JwtRequirement.REQUIRED -> true
250+
},
251+
)
246252
}
247253

248254
/**

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.onesignal.core.internal.config.ConfigModel
1010
import com.onesignal.core.internal.config.ConfigModelStore
1111
import com.onesignal.core.internal.startup.IStartableService
1212
import com.onesignal.debug.internal.logging.Logging
13+
import com.onesignal.user.internal.jwt.JwtRequirement
1314
import com.onesignal.user.internal.subscriptions.ISubscriptionManager
1415
import kotlinx.coroutines.delay
1516
import java.net.HttpURLConnection
@@ -82,10 +83,10 @@ internal class ConfigModelStoreListener(
8283
config.fcmParams.projectId = params.fcmParams.projectId
8384
config.fcmParams.appId = params.fcmParams.appId
8485
config.fcmParams.apiKey = params.fcmParams.apiKey
86+
config.useIdentityVerification = JwtRequirement.fromBoolean(params.useIdentityVerification ?: false)
8587

8688
// these are only copied from the backend params when the backend has set them.
8789
params.enterprise?.let { config.enterprise = it }
88-
params.useIdentityVerification?.let { config.useIdentityVerification = it }
8990
params.firebaseAnalytics?.let { config.firebaseAnalytics = it }
9091
params.restoreTTLFilter?.let { config.restoreTTLFilter = it }
9192
params.clearGroupOnSummaryClick?.let { config.clearGroupOnSummaryClick = it }
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.onesignal.core.internal.config.impl
2+
3+
import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler
4+
import com.onesignal.common.modeling.ModelChangeTags
5+
import com.onesignal.common.modeling.ModelChangedArgs
6+
import com.onesignal.core.internal.config.ConfigModel
7+
import com.onesignal.core.internal.config.ConfigModelStore
8+
import com.onesignal.core.internal.features.FeatureFlag
9+
import com.onesignal.core.internal.features.IFeatureManager
10+
import com.onesignal.core.internal.startup.IStartableService
11+
import com.onesignal.user.internal.jwt.JwtRequirement
12+
13+
/**
14+
* Single source of truth for Identity Verification gating, and for forwarding HYDRATE events to
15+
* the [com.onesignal.core.internal.operations.IOperationRepo] post-HYDRATE choreography.
16+
*
17+
* Gate state is derived on read from the injected [IFeatureManager] (rollout flag) and
18+
* [ConfigModelStore] (customer `jwt_required`); nothing is duplicated here. UNKNOWN
19+
* (pre-HYDRATE) reads as `false` for both gates, which is the safe default.
20+
*
21+
* Invariant `ivBehaviorActive == true ⇒ newCodePathsRun == true` holds because both are derived
22+
* from the same `useIdentityVerification` field.
23+
*
24+
* Consumers (e.g. OperationRepo) wire post-HYDRATE behavior via [setOnJwtConfigHydratedHandler];
25+
* the handler fires once per HYDRATE with `ivRequired = useIdentityVerification == REQUIRED`.
26+
*/
27+
class IdentityVerificationService(
28+
private val featureManager: IFeatureManager,
29+
private val configModelStore: ConfigModelStore,
30+
) : IStartableService, ISingletonModelStoreChangeHandler<ConfigModel> {
31+
/** Whether IV-specific behavior (JWT attachment, auth error handling) applies. UNKNOWN reads as `false`. */
32+
val ivBehaviorActive: Boolean
33+
get() = configModelStore.model.useIdentityVerification == JwtRequirement.REQUIRED
34+
35+
/** Whether new IV-related code paths should run. `featureFlag_IV_ON || jwt_required == REQUIRED`. */
36+
val newCodePathsRun: Boolean
37+
get() = featureManager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) || ivBehaviorActive
38+
39+
private val handlerLock = Any()
40+
private var onJwtConfigHydrated: ((ivRequired: Boolean) -> Unit)? = null
41+
42+
/**
43+
* Register a handler invoked once per HYDRATE of the config model. Used by OperationRepo to
44+
* release pre-HYDRATE deferral and (when IV is required) purge anonymous queued ops.
45+
* Pass `null` to clear.
46+
*/
47+
fun setOnJwtConfigHydratedHandler(handler: ((ivRequired: Boolean) -> Unit)?) {
48+
synchronized(handlerLock) {
49+
onJwtConfigHydrated = handler
50+
}
51+
}
52+
53+
override fun start() {
54+
configModelStore.subscribe(this)
55+
}
56+
57+
override fun onModelReplaced(
58+
model: ConfigModel,
59+
tag: String,
60+
) {
61+
if (tag != ModelChangeTags.HYDRATE) return
62+
// Snapshot the handler under the lock, then invoke outside — never hold the lock
63+
// across user-supplied code.
64+
val handler = synchronized(handlerLock) { onJwtConfigHydrated }
65+
handler?.invoke(model.useIdentityVerification == JwtRequirement.REQUIRED)
66+
}
67+
68+
override fun onModelUpdated(
69+
args: ModelChangedArgs,
70+
tag: String,
71+
) {
72+
// Remote params arrive as full-model replacements (HYDRATE); individual property
73+
// updates are not expected for useIdentityVerification.
74+
}
75+
}

0 commit comments

Comments
 (0)