Skip to content

feat: identity verification 5.8#2599

Open
nan-li wants to merge 29 commits intomainfrom
feat/identity_verification_5.8
Open

feat: identity verification 5.8#2599
nan-li wants to merge 29 commits intomainfrom
feat/identity_verification_5.8

Conversation

@nan-li
Copy link
Copy Markdown
Contributor

@nan-li nan-li commented Mar 30, 2026

Description

One Line Summary

Add Identity Verification (JWT-based) support to the Android SDK, gating API operations behind server-issued JWTs when enabled.

Details

Motivation

Identity Verification allows app developers to authenticate users with JWTs before the SDK sends operations to the OneSignal backend. This prevents unauthorized API calls by requiring a valid JWT for all user-scoped operations when the feature is enabled server-side via the jwt_required remote param.

Scope

Public API additions:

  • OneSignal.login(externalId, jwtBearerToken) — accepts an optional JWT on login
  • OneSignal.updateUserJwt(externalId, token) — supply a fresh JWT when the current one expires
  • OneSignal.addUserJwtInvalidatedListener(listener) / removeUserJwtInvalidatedListener(listener) — get notified when a JWT is invalidated (e.g. 401 from backend) so the app can provide a new one
  • IUserJwtInvalidatedListener / UserJwtInvalidatedEvent — listener interface and event class

Internal changes:

  • JwtTokenStore — persistent store mapping externalId → JWT, backed by SharedPreferences
  • IdentityVerificationService — reacts to the jwt_required remote param arriving via config HYDRATE; purges anonymous operations when IV is enabled; wires JWT invalidation callbacks
  • OperationRepo — gates operation execution on valid JWT when IV is enabled (hasValidJwtIfRequired); handles FAIL_UNAUTHORIZED by invalidating the JWT, re-queuing operations, and notifying the developer; stamps externalId synchronously at enqueue time to avoid race conditions
  • LogoutHelper — when IV is enabled, disables the push subscription internally and suppresses the backend login operation (the device-scoped user is local-only)
  • ConfigModelStoreListener / ParamsBackendService — reads jwt_required from remote params, defaults to false when absent
  • HTTP layer — attaches Authorization: Bearer <jwt> header to requests when IV is enabled
  • In-App Messaging — uses alias-based fetch endpoint and includes JWT in IAM backend calls
  • Operation executors — propagate externalId to result operations and attach JWT to backend requests

What is NOT affected:

  • Apps without Identity Verification enabled behave exactly as before — jwt_required defaults to false and no JWT gating occurs
  • Notification display, open handling, push processing, and confirm deliveries are unchanged

Testing

Unit testing

WIP Adding more testing

  • OperationRepoTests — added tests for FAIL_UNAUTHORIZED handling (identified user fires handler + retries; anonymous user drops operations), and synchronous externalId stamping before async dispatch
  • LoginUserOperationExecutorTests — updated for identity verification parameters
  • LogoutHelperTests — covers IV-enabled logout flow
  • LoginHelperTests — covers IV-enabled login flow with JWT

Manual testing

Tested with the demo app on an Android emulator (Pixel 7, API 35):

  • Login with valid JWT → operations execute normally
  • Login with expired JWT → 401 triggers IUserJwtInvalidatedListener, call updateUserJwt → operations resume
  • Logout with IV enabled → push subscription disabled, new anonymous user created locally
  • App without IV enabled → SDK operates normally, no regression

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

nan-li added 16 commits March 26, 2026 22:52
…ication nullable, OptionalHeaders.jwt

Foundational models and infrastructure for identity verification:

- Create JwtTokenStore: persistent Map<externalId, JWT> backed by SharedPreferences, supporting multi-user JWT storage with getJwt/putJwt/invalidateJwt/pruneToExternalIds
- Add var externalId to Operation base class so OperationRepo can stamp and gate operations per-user; remove redundant externalId from LoginUserOperation and TrackCustomEventOperation (same Model data-map key, no migration needed)
- Change ConfigModel.useIdentityVerification from Boolean to Boolean? (null = unknown, false = off, true = on) to eliminate race between operation processing and remote params
- Add jwt field to OptionalHeaders for passing Bearer tokens through HTTP layer
- Add PREFS_OS_JWT_TOKENS key to PreferenceOneSignalKeys

Made-with: Cursor
…ion Bearer header to HttpClient

Identity verification: plumb JWT through the HTTP and backend layer.

- HttpClient: set Authorization: Bearer header when OptionalHeaders.jwt is non-null
- IIdentityBackendService + impl: add jwt param to setAlias, deleteAlias
- ISubscriptionBackendService + impl: add jwt param to createSubscription,
  updateSubscription, deleteSubscription, transferSubscription, getIdentityFromSubscription
- IUserBackendService + impl: add jwt param to createUser, updateUser, getUser
- All jwt params default to null so existing callers are unaffected
…D handling to OperationRepo

Identity verification: OperationRepo becomes JWT-aware.

- Add JwtTokenStore and IdentityModelStore as constructor dependencies
- Centralized externalId stamping: internalEnqueue() auto-sets op.externalId from the current identity model for new operations (not loaded from persistence, not already set by the operation's constructor)
- IV gating in getNextOps(): when IV=null (unknown), hold ALL operations; when IV=true, skip operations without a valid JWT; when IV=false, proceed normally
- FAIL_UNAUTHORIZED: invalidate the per-user JWT in JwtTokenStore and re-queue operations to front (held by JWT gating until a new JWT is provided)
- Cold-start cleanup: prune JwtTokenStore to externalIds from pending operations plus the current identity model's externalId
- New removeOperationsWithoutExternalId() method on IOperationRepo for IdentityVerificationService to purge anonymous operations when IV is enabled
…tity verification

- Add resolveIdentityAlias() helper to dynamically choose external_id vs onesignal_id for API paths
- Each executor now looks up JWT from JwtTokenStore using operation's externalId and passes it to backend calls
- LoginUserFromSubscriptionOperationExecutor returns FAIL_NORETRY when IV is enabled (v4 migration safety net)

Made-with: Cursor
- Add jwt param to IInAppBackendService.listInAppMessages and pass through OptionalHeaders
- Skip IAM fetch when identity verification is enabled and user is anonymous
- Look up JWT from JwtTokenStore for authenticated IAM requests

Made-with: Cursor
…subscriptions/:subscription_id/iams

Made-with: Cursor
… and subscription listeners

- LoginHelper: store JWT unconditionally on login, handle same-externalId
  re-login (store + forceExecute), set existingOneSignalId to null when IV=ON
- LogoutHelper: IV=ON branch opts out push before user switch so backend is
  notified, then creates local-only anonymous user with suppressBackendOperation
- UserManager: add jwtInvalidatedNotifier EventProducer for JWT callbacks
- OneSignalImp: wire updateUserJwt, addUserJwtInvalidatedListener,
  removeUserJwtInvalidatedListener; pass JwtTokenStore/SubscriptionModelStore
  to LoginHelper/LogoutHelper
- SubscriptionModelStoreListener, IdentityModelStoreListener,
  PropertiesModelStoreListener: suppress ops for anonymous users when IV=ON

Made-with: Cursor
- New IdentityVerificationService: listens for config HYDRATE events,
  purges anonymous operations when IV=true, wakes OperationRepo when IV
  resolves from null, fires UserJwtInvalidatedEvent for beta migration
  (externalId present but no JWT)
- CoreModule: register JwtTokenStore and IdentityVerificationService
- ParamsBackendService: remove leftover TODO comments

Made-with: Cursor
Register for JWT invalidation events, log a warning and show a
toast when triggered.
Optional headers (ETag, RYW-Token, Retry-Count, Session-Duration,
Authorization) were set after the body write, which opens the
connection. This caused IllegalStateException on POST requests
with a JWT. Move all setRequestProperty calls before outputStream
write to prevent the error.

Made-with: Cursor
Cache the JWT token on login and updateUserJwt calls. When
Identity Verification is enabled, include the Authorization
Bearer header in the demo app's fetch user request.
When Identity Verification is ON and logout is called, the SDK now
sets isDisabledInternally=true instead of optedIn=false. This
preserves the real opt-in preference while telling the backend the
subscription is disabled. On the next login, UserSwitcher creates
a fresh SubscriptionModel that defaults isDisabledInternally=false,
restoring the real state automatically.

Made-with: Cursor
Add addJwtInvalidatedListener, removeJwtInvalidatedListener, and
fireJwtInvalidated methods to UserManager. Callers no longer need
to cast IUserManager to UserManager to access the notifier directly.
Register UserManager as a concrete DI service so
IdentityVerificationService can depend on it directly.

Made-with: Cursor
@nan-li nan-li changed the title Feat/identity verification 5.8 Feat: identity verification 5.8 Mar 30, 2026
@nan-li nan-li changed the title Feat: identity verification 5.8 feat: identity verification 5.8 Mar 30, 2026
nan-li added 2 commits March 30, 2026 02:25
Add KDoc to public JWT API functions, update detekt baseline
@nan-li nan-li force-pushed the feat/identity_verification_5.8 branch from 4509a8f to 457b745 Compare March 30, 2026 09:35
@nan-li nan-li force-pushed the feat/identity_verification_5.8 branch 3 times, most recently from 22fe4f9 to 113e96b Compare March 30, 2026 19:49
@nan-li nan-li force-pushed the feat/identity_verification_5.8 branch from 113e96b to 00d3799 Compare March 30, 2026 23:05
nan-li added 3 commits March 30, 2026 23:36
When OperationRepo handled FAIL_UNAUTHORIZED it invalidated the JWT
and re-queued operations but never fired IUserJwtInvalidatedListener,
leaving the queue permanently stuck. Wire a callback from OperationRepo
through IdentityVerificationService to UserManager.fireJwtInvalidated()
so the developer is notified and can supply a fresh token.

Made-with: Cursor
Follow-up operations returned by executors (e.g. RefreshUserOperation)
were inserted directly into the queue without externalId, causing them
to be permanently blocked when identity verification is enabled.

Made-with: Cursor
nan-li added 3 commits March 31, 2026 00:12
The externalId was being stamped inside internalEnqueue which runs on
the OperationRepo coroutine thread. When LogoutHelper set
isDisabledInternally=true (triggering an UpdateSubscriptionOperation)
then immediately called createAndSwitchToNewUser(), the identity model's
externalId could be cleared before the coroutine ran, leaving the
operation with externalId=null and permanently blocked.

Move stamping to enqueue()/enqueueAndWait() on the caller's thread so
the externalId is captured at the moment the operation is created.

Made-with: Cursor
Wrap JSONObject parsing in ensureLoaded() with try-catch so that
corrupted persisted data (e.g. from a process kill mid-write) does
not permanently break JWT lookups and stall the operation queue.

Made-with: Cursor
When the server omits jwt_required from the params response,
safeBool() returned null, leaving useIdentityVerification unset.
getNextOps() treated null as "not yet known" and returned null
permanently, silently blocking every SDK operation for the session.

Default to false when the field is absent so non-IV apps are
unaffected by the identity verification gating logic.

Made-with: Cursor
@nan-li
Copy link
Copy Markdown
Contributor Author

nan-li commented Mar 31, 2026

@claude re-review

@claude
Copy link
Copy Markdown

claude bot commented Mar 31, 2026

Claude finished @nan-li's task in 3m 55s —— View job


Re-review: feat/identity_verification_5.8

  • Read current state of files related to previous review findings
  • Verify fixes for the 4 critical issues flagged
  • Look for any remaining or new issues
  • Post updated review

All four critical issues flagged in the previous review have been addressed. Here's a status breakdown:

Previously Critical — Now Fixed ✅

1. Runtime 401 not firing IUserJwtInvalidatedListener (OperationRepo.kt:292-297)

Fixed correctly. OperationRepo now holds a _jwtInvalidatedHandler: ((String) -> Unit)? that is set in IdentityVerificationService.start():

_operationRepo.setJwtInvalidatedHandler { externalId ->
    _userManager.fireJwtInvalidated(externalId)
}

And in the FAIL_UNAUTHORIZED branch, _jwtInvalidatedHandler?.invoke(externalId) is called after _jwtTokenStore.invalidateJwt(externalId). The queue correctly re-enqueues the operations, and the developer receives the callback to provide a fresh JWT. When updateUserJwt is called, forceExecuteOperations() wakes the queue — the full round trip is wired.

2. Logout race condition — UpdateSubscriptionOperation stamped with null externalId (OperationRepo.kt:165-168)

Fixed correctly. stampExternalId(operation) is now called synchronously in enqueue() before scope.launch { internalEnqueue(...) }. Since SubscriptionModelStoreListener.getUpdateOperation() fires synchronously when isDisabledInternally = true is set — and createAndSwitchToNewUser() has not yet run at that point — _identityModelStore.model.externalId is still the old user's ID when it's captured.

3. JwtTokenStore.ensureLoaded() throws uncaught JSONException (JwtTokenStore.kt:32-42)

Fixed correctly. The JSON parsing is wrapped in try { } catch (e: Exception) and isLoaded = true is placed unconditionally at the end of ensureLoaded(). Corrupted SharedPreferences data is now logged and discarded gracefully instead of causing an infinite throw loop.

4. useIdentityVerification stays null for non-IV apps, permanently blocking the queue (ParamsBackendService.kt:87)

Fixed correctly. The field now defaults to false when jwt_required is absent from the backend response:

useIdentityVerification = responseJson.safeBool("jwt_required") ?: false

Non-IV apps get false after HYDRATE, getNextOps() proceeds, and IdentityVerificationService.onModelReplaced() still calls forceExecuteOperations() to wake the queue.


Remaining Observations (non-blocking)

A. _jwtInvalidatedHandler is not @Volatile (OperationRepo.kt:47)

private var _jwtInvalidatedHandler: ((String) -> Unit)? = null

This field is written from IdentityVerificationService.start() (likely the main thread) and read from executeOperations() running on OSOperationRepoScope. In practice this is safe because start() runs before any operations execute, but the JMM doesn't guarantee visibility without @Volatile or a synchronisation point. Worth annotating for correctness. Fix this →

B. Expired JWT at logout time blocks the unsubscribe operation (LogoutHelper.kt)

When IV=true and a 401 has already invalidated the JWT before the user calls logout(), the UpdateSubscriptionOperation (to disable the push subscription) gets a null result from _jwtTokenStore.getJwt(externalId) and is permanently held by hasValidJwtIfRequired. The developer would need to call updateUserJwt with a fresh token to unblock it — but if they're logging out, they likely won't. The backend push subscription stays enabled for the logged-out user until the next cold start (when pruneToExternalIds cleans up the orphaned operation).

This is an edge case (JWT expired + user logs out before providing a refresh) and the overall security posture of the IV feature prevents worse outcomes, but it may be worth documenting or handling explicitly — for example, by skipping the JWT gate for the unsubscribe-on-logout operation. Not blocking the PR.


The implementation is solid. All critical issues are resolved and the approach is well-structured. The IdentityVerificationService / OperationRepo / JwtTokenStore responsibilities are cleanly separated.

The field is written from the main thread in
IdentityVerificationService.start() and read on the OperationRepo
coroutine thread. @volatile guarantees cross-thread visibility.

Made-with: Cursor
@nan-li
Copy link
Copy Markdown
Contributor Author

nan-li commented Mar 31, 2026

@claude re-review

@claude
Copy link
Copy Markdown

claude bot commented Mar 31, 2026

Claude finished @nan-li's task in 2m 39s —— View job


Re-review: feat/identity_verification_5.8

  • Read current state of key files
  • Verify fixes for the 2 observations from previous review
  • Look for any remaining or new issues
  • Post updated review

Both remaining observations from the previous re-review have been addressed. The PR is clean.

Previously Non-blocking — Now Fixed ✅

A. _jwtInvalidatedHandler not @Volatile (OperationRepo.kt:47)

Fixed in commit 76dd958. The field is now correctly annotated:

@Volatile
private var _jwtInvalidatedHandler: ((String) -> Unit)? = null

B. Expired JWT at logout blocks the push subscription disable (UpdateSubscriptionOperation.kt:89)

Fixed in commit 130aa16. The first approach (skip in LogoutHelper) was tried and reverted; the final solution is cleaner — it adds Operation.requiresJwt: Boolean = true as an open property on the base class and overrides it in UpdateSubscriptionOperation:

// Operation.kt:69
open val requiresJwt: Boolean get() = true

// UpdateSubscriptionOperation.kt:89
override val requiresJwt: Boolean get() = false

OperationRepo.hasValidJwtIfRequired() short-circuits on !op.requiresJwt, so subscription updates always proceed regardless of JWT state. This is backend-aligned — the subscription endpoint doesn't enforce JWT auth — and the executor still passes the JWT opportunistically when one is available (val jwt = lastOperation.externalId?.let { _jwtTokenStore.getJwt(it) }).


Minor Observation (no action needed)

The requiresJwt = false behavior on UpdateSubscriptionOperation has no dedicated test in OperationRepoTests. The behavior is implicitly covered by LogoutHelperTests, but a unit test verifying that an operation with requiresJwt = false executes when hasValidJwtIfRequired would otherwise block could add confidence. Given the simplicity of the change, this is not blocking.


The implementation is solid. All six issues identified across both review passes are resolved. The architecture is clean: IdentityVerificationService / OperationRepo / JwtTokenStore responsibilities are well-separated, the requiresJwt flag gives per-operation control over JWT gating, and the developer-facing callback round-trip is fully wired. Good to merge.

nan-li added 2 commits March 31, 2026 01:49
When IV is enabled and the JWT has already been invalidated (e.g. by
a prior 401), the UpdateSubscriptionOperation to disable the push
subscription would be permanently blocked by hasValidJwtIfRequired.
Since the backend call would fail with 401 anyway, skip it entirely
and just switch to the new anonymous user.

Made-with: Cursor

Revert "Skip push subscription disable on logout when JWT is already expired"

This reverts commit 5ce284257b6e03b8c862745ff58fcf4293299577.

Exempt UpdateSubscriptionOperation from JWT gating

The subscription update endpoint does not require a JWT on the
backend. Add Operation.requiresJwt (default true) and override it
to false in UpdateSubscriptionOperation so these operations are not
blocked by hasValidJwtIfRequired when the JWT is expired or missing.

This fixes the edge case where logging out with an expired JWT would
permanently block the push subscription disable operation.

Made-with: Cursor
@nan-li nan-li force-pushed the feat/identity_verification_5.8 branch from 972eace to f6e4227 Compare March 31, 2026 08:49
@nan-li nan-li requested a review from a team March 31, 2026 16:01
}

fun setUseIdentityVerification(enabled: Boolean) {
SharedPreferenceUtil.cacheIdentityVerification(getApplication(), enabled)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_privacyConsentGiven.value = repository.getPrivacyConsent()
_inAppMessagesPaused.value = repository.isInAppMessagesPaused()
_locationShared.value = repository.isLocationShared()
_useIdentityVerification.value = SharedPreferenceUtil.getCachedIdentityVerification(context)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should run on a background thread

<ID>ConstructorParameterNaming:NewRecordsState.kt$NewRecordsState$private val _time: ITime</ID>
<ID>ConstructorParameterNaming:OSDatabase.kt$OSDatabase$private val _outcomeTableProvider: OutcomeTableProvider</ID>
<ID>ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _configModelStore: ConfigModelStore</ID>
<ID>ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _identityModelStore: IdentityModelStore</ID>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nan-li can we try to address as many detekt issues as possible instead of adding this to the baseline?

<CurrentIssues>
<ID>ComplexCondition:InAppMessagesManager.kt$InAppMessagesManager$!message.isTriggerChanged &amp;&amp; isMessageDisplayed &amp;&amp; (isTriggerOnMessage || isNewTriggerAdded &amp;&amp; isOnlyDynamicTriggers)</ID>
<ID>ComplexMethod:TriggerController.kt$TriggerController$private fun evaluateTrigger(trigger: Trigger): Boolean</ID>
<ID>ComplexMethod:InAppMessagesManager.kt$InAppMessagesManager$private suspend fun fetchMessages(rywData: RywData)</ID>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should break it down if its a complex method.

private val _userManager: UserManager,
) : IStartableService, ISingletonModelStoreChangeHandler<ConfigModel> {
override fun start() {
_configModelStore.subscribe(this)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be in a background thread?

/**
* The optional external ID of this newly logged-in user. Must be unique for the [appId].
*/
var externalId: String?
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have something similar in the Operation.kt now? did we move this from here?


// Create new device-scoped user (clears external ID)
userSwitcher.createAndSwitchToNewUser()
if (configModel.useIdentityVerification == true) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why dont we move this login into the userSwitcher? that way we want write tests on it?

}

fun fireJwtInvalidated(externalId: String) {
jwtInvalidatedNotifier.fireOnMain {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why should it be fired on main?

builder.register<LoginUserFromSubscriptionOperationExecutor>().provides<IOperationExecutor>()
builder.register<RefreshUserOperationExecutor>().provides<IOperationExecutor>()
builder.register<UserManager>().provides<IUserManager>()
builder.register<UserManager>().provides<IUserManager>().provides<UserManager>()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a typo?

* @param externalId The external ID of the user whose token is being updated.
* @param token The new JWT bearer token.
*/
fun updateUserJwt(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

like i mentioned elsewhere, we need suspend methods for these

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants