Skip to content

feat(jwt): Handle logout and fetch IAMs (6/6)#2634

Merged
nan-li merged 12 commits intofeat/identity_verification_feature_flagged_major_releasefrom
feat/iv-logout-iam-06
May 7, 2026
Merged

feat(jwt): Handle logout and fetch IAMs (6/6)#2634
nan-li merged 12 commits intofeat/identity_verification_feature_flagged_major_releasefrom
feat/iv-logout-iam-06

Conversation

@nan-li
Copy link
Copy Markdown
Contributor

@nan-li nan-li commented May 1, 2026

Sixth and final PR re-implementing Identity Verification (IV) on the feat/identity_verification_feature_flagged_major_release integration branch.

Stack: #2623#2624#2625#2626#2633this

What this PR does

Wires IV-aware behavior for logout, read-your-write (RYW) consistency on login, and IAM fetch. Closes the loop on the customer-visible IV flows: a customer who configures jwt_required=true server-side now gets an SDK that signs requests, recovers from 401s when the developer supplies a fresh JWT, and keeps the IAM fetch consistent with the just-created user record.

All new code paths are gated by the two-layer pattern from #2625:

  • Outer gate newCodePathsRun at the dispatch site → keeps Phase 1 customers byte-for-byte on the legacy flow.
  • Inner gate ivBehaviorActive inside the *IvExtensions helpers → Phase 3 (new code path on, IV behavior off) exercises the structural plumbing without attaching JWTs or rewriting aliases.

Logout under IV-required

Under IV-required, the new device-scoped (anonymous) user cannot authenticate against the backend — there's no JWT for it. The legacy logout flow enqueues an anonymous LoginUserOperation plus a create-subscription op for the new push sub; both would 401 forever and permanently block the queue.

  • New SubscriptionModel.isDisabledInternally flag (internal-only). SubscriptionModelStoreListener short-circuits to (false, UNSUBSCRIBE) when set, suppressing backend ops for the marked subscription.
  • New LogoutHelperIvExtensions.switchUserIv: marks the current push sub as internally disabled and calls userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true). Returns true so LogoutHelper.switchUser skips the anonymous LoginUserOperation enqueue.
  • LogoutHelper constructor takes subscriptionModelStore and identityVerificationService; outer-gates dispatch on newCodePathsRun. Phase 1/3 still walk the legacy createAndSwitchToNewUser() + LoginUserOperation enqueue.

RYW plumbing for IAM consistency

Under IV the backend returns a ryw_token (with optional ryw_delay_ms) on /users create. IAM fetch must await this token via IConsistencyManager so the in-app message list reflects the just-written user record.

  • CreateUserResponse.rywData: RywData? — populated under IV, null otherwise.
  • JSONConverter.convertToCreateUserResponse parses ryw_token / ryw_delay.
  • LoginUserOperationExecutor.createUser sets the RYW data on ConsistencyManager for IamFetchRywTokenKey.USER, gated on newCodePathsRun. Phase 1 stays on existing behavior; Phase 3 exercises the parse + register path with no token attachment.

IAM IV integration

  • New IInAppBackendService.listInAppMessagesIv(appId, aliasLabel, aliasValue, jwt?) alongside the legacy listInAppMessages(appId, subscriptionId) — alias-based URL (/apps/{appId}/users/by/{aliasLabel}/{aliasValue}/iams), Authorization: Bearer <jwt> when JWT non-null, throws BackendException on 401/403 so the manager can react.
  • InAppMessagesManager injects JwtTokenStore + IdentityVerificationService and subscribes to JwtTokenStore as IJwtUpdateListener so a fresh JWT (via updateUserJwt from feat(jwt): public API — IUserJwtInvalidatedListener, updateUserJwt, login JWT (5/6) #2633) replays the deferred fetch.
  • fetchMessages outer-gates on newCodePathsRun: dispatches to fetchIvOrSaveRetry under the new path, falls through to the legacy subscriptionId fetch otherwise.
  • fetchIvOrSaveRetry inner-gates on ivBehaviorActive: under IV uses external_id + JWT (caches pendingJwtRetry{ExternalId,RywData} on 401 for replay); Phase 3 uses onesignal_id + null JWT (exercises the new endpoint structurally, no IV behavior).
  • 401 from the IV fetch resets the rate-limiter so the listener-driven retry isn't throttled.
  • Pending retry state is cleared on user-switch via the existing identityModelChangeHandler.onModelReplaced.

Cross-module visibility

The IAM module is a separate Gradle module, so a few core types had their internal modifier dropped to be Kotlin-visible cross-module: JwtTokenStore, IdentityVerificationService, IJwtUpdateListener, IFeatureManager, FeatureFlag, FeatureActivationMode. They remain package-internal by convention (.internal. paths).

Misc

Includes a small init-readiness fix on top of #2633: addUserJwtInvalidatedListener / removeUserJwtInvalidatedListener now match the updateUserJwt pattern (waitForInit when background threading is enabled, throw IllegalStateException otherwise) so listeners aren't silently bound to a not-yet-wired UserManager.

Testing

Unit testing

  • Updated test fixtures for the new constructor params: LogoutHelperTests, LoginUserOperationExecutorTests, InAppMessagesManagerTests. Existing legacy-path assertions still pass; new helpers accept mocks for subscriptionModelStore, identityVerificationService, jwtTokenStore, and _consistencyManager with newCodePathsRun = false / ivBehaviorActive = false so legacy behavior is the default in tests.

Manual testing

  • Logout while jwt_required=true: verify no anonymous LoginUserOperation is enqueued and the queue does not stall on 401s.
  • IAM fetch under IV: verify the SDK awaits ryw_token from /users before issuing the alias-based fetch, attaches Authorization: Bearer <jwt>, and retries once updateUserJwt is called after a 401.
  • Phase 1 / Phase 3 regressions: verify legacy IAM fetch and legacy logout flows are byte-for-byte unchanged when newCodePathsRun=false.

Affected code

  • Public API: addUserJwtInvalidatedListener / removeUserJwtInvalidatedListener init guards (no signature change).
  • In-App Messaging: new alias-based fetch endpoint + JWT retry path.
  • REST API requests: new GET /apps/{appId}/users/by/{aliasLabel}/{aliasValue}/iams (IV path only).
  • Logout flow: IV-specific user-switch that suppresses backend ops.

🤖 Generated with Claude Code

Comment on lines +392 to +410
Triple(IdentityConstants.ONESIGNAL_ID, onesignalId, null)
}

return try {
_backend.listInAppMessagesIv(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt)
} catch (ex: BackendException) {
// 401/403 from the IV-aware fetch — save retry state so [onJwtUpdated]
// can re-fetch when the developer supplies a fresh JWT.
if (ivBehaviorActive && externalId != null) {
pendingJwtRetryExternalId = externalId
pendingJwtRetryRywData = rywData
Logging.info("InAppMessagesManager: IAM fetch returned ${ex.statusCode}, awaiting JWT refresh for $externalId")
// Reset the rate-limiter so the retry isn't throttled.
lastTimeFetchedIAMs = null
} else {
Logging.warn("InAppMessagesManager: IAM fetch returned ${ex.statusCode}: ${ex.response}")
}
null
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Race window between the IAM-fetch 401 catch and a concurrent updateUserJwt can drop the pending JWT-retry state in InAppMessagesManager. The catch block at lines 401-402 sets pendingJwtRetryExternalId/RywData after the failed HTTP response arrives, so an onJwtUpdated firing during the in-flight window will read pending == null and return early — leaving pending state set with no future onJwtUpdated to consume it until an unrelated event re-triggers fetchMessagesWhenConditionIsMet. Practical impact is bounded (IAMs recover on next session / push-sub change / ConfigModel update); fix is to set pending before listInAppMessagesIv and clear on success, or guard the read-then-act and the catch's write under a single Mutex.

Extended reasoning...

Race condition

InAppMessagesManager.fetchIvOrSaveRetry (lines ~371-410) writes the pending-retry state after the backend call throws:

return try {
    _backend.listInAppMessagesIv(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt)
} catch (ex: BackendException) {
    if (ivBehaviorActive && externalId != null) {
        pendingJwtRetryExternalId = externalId   // ← written AFTER 401 response arrived
        pendingJwtRetryRywData = rywData
        ...
    }
    null
}

Meanwhile, onJwtUpdated (lines 419-425) reads-then-acts on the same fields:

override fun onJwtUpdated(externalId: String) {
    val pending = pendingJwtRetryExternalId
    val pendingRyw = pendingJwtRetryRywData
    if (pending == null || pending != externalId || pendingRyw == null) return
    ...
}

@Volatile makes individual reads/writes atomic but does not linearize the compound (catch writes pending) ↔ (onJwtUpdated reads pending) sequence.

How it manifests (step-by-step proof)

  1. IAM fetch on coroutine A issues listInAppMessagesIv with a stale JWT — request in flight, pendingJwtRetryExternalId == null.
  2. Concurrently, an unrelated OperationRepo op on coroutine B fails 401 → OperationRepo.handleFailUnauthorized invokes JwtTokenStore.invalidateJwt(externalId) → developers IUserJwtInvalidatedListener fires → developer responds with OneSignal.updateUserJwt(externalId, freshToken)JwtTokenStore.putJwt → synchronously fires IInAppMessagesManager.onJwtUpdated (per EventProducer.fire, dispatched on the callers thread).
  3. onJwtUpdated(externalId) runs now: reads pendingJwtRetryExternalId == null, returns at the early-exit. No retry scheduled.
  4. Coroutine As 401 finally surfaces → catch block writes pendingJwtRetryExternalId = externalId and pendingJwtRetryRywData = rywData.
  5. Pending state is set, but the developer has already supplied the fresh JWT — no further onJwtUpdated will fire for this externalId. The retry is orphaned.

The window is the entire HTTP round-trip of the in-flight IAM fetch (plus delay(rywDelay) ≥ 500 ms before it), not just the few µs between throw and field assignment that the refutation describes — the developers updateUserJwt can land at any point while the IAM request is in flight, since the request was issued before the JWT was rotated.

Why existing code does not prevent it

  • fetchIAMMutex only guards the rate-limiter read/write at lines ~322-330; it is released before the network call.
  • @Volatile provides per-field atomicity, not compound atomicity for the read-then-act vs. write sequence.
  • The identityModelStore change handler that clears the pending fields only fires on user-switch, which is unrelated.

Impact

Bounded and recoverable: the deferred IAM fetch is silently delayed until the next fetchMessagesWhenConditionIsMet trigger (onSessionStarted, push subscription id change, ConfigModel.appId update, or onesignalId hydration). End-user visibility is "no IAMs displayed in this session" — they show on next cold start or session.

The refutation argues "not actionable for this PR." Three independent verifiers reviewed and all confirmed the race is real; they downgraded to nit because the impact is bounded and the trigger is uncommon, not because the bug is invalid. Filing as nit to match.

Suggested fix

Set the pending state before invoking listInAppMessagesIv and clear it on successful return. That makes onJwtUpdated see the pending fields any time during the in-flight window, regardless of when the response surfaces:

if (ivBehaviorActive && externalId != null) {
    pendingJwtRetryExternalId = externalId
    pendingJwtRetryRywData = rywData
}
return try {
    val result = _backend.listInAppMessagesIv(...)
    // success: clear pending
    pendingJwtRetryExternalId = null
    pendingJwtRetryRywData = null
    result
} catch (ex: BackendException) {
    // pending already set; just log
    ...
}

Alternatively, wrap onJwtUpdateds read+act and the catchs write under a single Mutex / synchronized block so they linearize.

@nan-li nan-li force-pushed the feat/iv-public-api-05 branch from 21e9999 to dcf7f9b Compare May 4, 2026 04:56
@nan-li nan-li changed the title feat(iv): Handle logout and fetch IAMs feat(iv): Handle logout and fetch IAMs (6/6) May 4, 2026
@nan-li nan-li force-pushed the feat/iv-logout-iam-06 branch from 24a94bc to 91b47fd Compare May 4, 2026 04:58
@nan-li nan-li force-pushed the feat/iv-public-api-05 branch from dcf7f9b to 0fbd10c Compare May 4, 2026 17:12
@nan-li nan-li force-pushed the feat/iv-logout-iam-06 branch from 91b47fd to 6666ba1 Compare May 4, 2026 17:13
@nan-li nan-li force-pushed the feat/iv-public-api-05 branch from 0fbd10c to 660458d Compare May 4, 2026 17:51
@nan-li nan-li force-pushed the feat/iv-logout-iam-06 branch 3 times, most recently from 3365407 to 3762b49 Compare May 4, 2026 19:24
@nan-li nan-li force-pushed the feat/iv-public-api-05 branch from 7916683 to a038970 Compare May 4, 2026 19:24
operationRepo = operationRepo,
configModel = configModel,
subscriptionModelStore = subscriptionModelStore,
identityVerificationService = services.getService<IdentityVerificationService>(),
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.

please make this lazy

@nan-li nan-li force-pushed the feat/iv-logout-iam-06 branch from 3762b49 to dc93995 Compare May 5, 2026 15:57
@nan-li nan-li force-pushed the feat/iv-public-api-05 branch from a038970 to c233c25 Compare May 5, 2026 15:57
@nan-li nan-li force-pushed the feat/iv-logout-iam-06 branch from 88eb9e2 to 1351fc1 Compare May 5, 2026 18:48
@nan-li nan-li force-pushed the feat/iv-public-api-05 branch from c233c25 to eb44594 Compare May 6, 2026 20:25
@nan-li nan-li force-pushed the feat/iv-logout-iam-06 branch 2 times, most recently from d1397ee to 24a24a0 Compare May 6, 2026 23:29
@nan-li nan-li force-pushed the feat/iv-public-api-05 branch from c3a683f to 8c4c64a Compare May 7, 2026 01:21
@nan-li nan-li force-pushed the feat/iv-logout-iam-06 branch 3 times, most recently from 4bc0832 to 471e644 Compare May 7, 2026 02:19
@nan-li nan-li force-pushed the feat/iv-public-api-05 branch from 8c4c64a to a463522 Compare May 7, 2026 20:50
Base automatically changed from feat/iv-public-api-05 to feat/identity_verification_feature_flagged_major_release May 7, 2026 20:50
nan-li and others added 6 commits May 7, 2026 13:51
Logout under IV-required (Step A):
- Add SubscriptionModel.isDisabledInternally — internal-only flag that
  tells SubscriptionModelStoreListener to suppress backend ops for the
  marked subscription.
- LogoutHelperIvExtensions.switchUserIv: when ivBehaviorActive, mark
  the current push sub as internally disabled and switchUser with
  suppressBackendOperation=true. The new device-scoped (anonymous) user
  can't authenticate without a JWT, so we must NOT enqueue an anonymous
  LoginUserOperation that would block the queue.
- LogoutHelper.switchUser: outer gate (newCodePathsRun) dispatches to
  the extension; legacy logout flow runs untouched for Phase 1/3.

RYW plumbing (Step B):
- CreateUserResponse.rywData (nullable) — backend sends ryw_token under
  IV so InAppMessagesManager can await user-record propagation before
  the IAM fetch.
- JSONConverter.convertToCreateUserResponse parses ryw_token / ryw_delay.
- LoginUserOperationExecutor.createUser sets RYW data in
  ConsistencyManager (gated on newCodePathsRun) so the IamFetchRywToken
  condition resolves and unblocks the IAM fetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds IInAppBackendService.listInAppMessagesIv (alias-based URL, JWT bearer
support, throws BackendException on 401/403) alongside the existing legacy
listInAppMessages so Phase 1 users keep the legacy endpoint untouched.

InAppMessagesManager:
- Inject JwtTokenStore + IdentityVerificationService.
- Subscribe to JwtTokenStore as IJwtUpdateListener so the IAM fetch can
  retry once the developer supplies a fresh JWT (via updateUserJwt) after
  a 401.
- fetchMessages outer-gates on newCodePathsRun: dispatches to
  fetchIvOrSaveRetry under IV, falls through to legacy otherwise.
- fetchIvOrSaveRetry inner-gates on ivBehaviorActive: external_id+JWT
  under IV; onesignal_id+null JWT for Phase 3 (exercises new endpoint
  structurally, no IV behavior).
- @volatile pendingJwtRetry{ExternalId,RywData}; cleared on user-switch
  via existing identityModelChangeHandler.onModelReplaced.
- 401 from IV fetch resets the rate-limiter so the retry isn't throttled.

Cross-module visibility: drop `internal` modifier from JwtTokenStore,
IdentityVerificationService, IJwtUpdateListener, IFeatureManager,
FeatureFlag, FeatureActivationMode (still package-internal by convention
via `.internal.` paths; Kotlin-visible cross-module).

Test fixtures updated: InAppMessagesManagerTests, LogoutHelperTests,
LoginUserOperationExecutorTests get the new ctor params.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Setting isDisabledInternally = true on the OLD push sub model BEFORE
createAndSwitchToNewUser fires a NORMAL-tagged change that propagates
through SubscriptionModelStoreListener.getUpdateOperation. The listener
reads the still-current OLD identity (alice / OS-A) and enqueues an
UpdateSubscriptionOperation(externalId = "alice", enabled = false,
status = UNSUBSCRIBE) — which then dispatches with alice's still-valid
JWT and unsubscribes the just-departed user server-side.

Reorder so the user-switch happens first (suppressBackendOperation = true
so the new model's add doesn't fire), then set the flag on the NEW push
sub with NO_PROPOGATE so the listener filters it. The new push sub
reuses the old id, so configModel.pushSubscriptionId after the switch
points at the new model.

Also fixes the secondary issue from the same review: previously the new
SubscriptionModel was created without isDisabledInternally, so the flag
never landed on the post-logout anonymous user. With the new ordering,
the flag is set directly on the new model.

Add tests for the ordering and the Phase 3 short-circuit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When jwt_required hydrates true and removeOperationsWithoutExternalId
drops the anon ops, surviving LoginUserOperations may still carry
existingOnesignalId pointing at the just-dropped anon user (the
merge-anon-into-identified link set by LoginHelper). Under IV that
link is permanently unresolvable — anon user creation needs a JWT-less
call the backend rejects — so canStartExecute=false sticks forever and
deadlocks the queue (no other op can dispatch since they all wait on
the login to resolve the local onesignal_id).

Clear existingOnesignalId on every surviving LoginUserOperation so the
executor takes the createUser (upsert) path. Matches the same fix in
reference branches #2599 and #2613.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Revert the order swap from dc93995 — that commit reordered switchUserIv
to switch users first and then set isDisabledInternally on the new push
sub with NO_PROPOGATE, on the bot review's claim that firing an
UpdateSubscriptionOperation against the OLD user was a bug. It is not a
bug — it is the intentional behavior in reference branches #2599 and
#2613: setting the flag on the CURRENT push sub with the default NORMAL
tag fires an UpdateSubscriptionOperation that tells the backend "this
device is unsubscribing as the user logs out", dispatched with the OLD
user's still-valid JWT before the switch. Without this, logout under IV
silently leaves the just-departed user's push sub subscribed
server-side.

The new (anonymous) user's model not carrying isDisabledInternally is
fine: anon ops are filtered by hasValidJwtIfRequired (externalId=null
under IV-active), so they accumulate but never dispatch.

Update the test to verify the corrected order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LoginHelper.switchUser was setting existingOnesignalId = currentOneSignalId
when the prior user was anonymous (currentExternalId == null). Under
IV-required that anon user was never created server-side — no JWT — so
its onesignalId stays a local id forever. Carrying it as
existingOnesignalId on the new LoginUserOperation makes
canStartExecute=false stick, deadlocking the queue across logout→login
cycles.

Skip the merge-link entirely when useIdentityVerification == REQUIRED so
the executor takes the createUser (upsert) path. Matches reference
branches #2599 and #2613.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li and others added 6 commits May 7, 2026 13:51
Add shouldSuppressAnonymousOp from reference branch #2599: any non-
LoginUserOperation enqueued without an externalId is dropped at the
enqueue boundary when useIdentityVerification == REQUIRED, since it
can't authenticate and would otherwise sit in the queue forever blocked
by hasValidJwtIfRequired. LoginUserOperation is exempt — it's enqueued
intentionally during logout and purged later by
removeOperationsWithoutExternalId if needed.

Outer-gated on _identityVerificationService.newCodePathsRun so Phase 1
customers stay byte-for-byte on the legacy enqueue path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
and use Identity verification toggle to make requests
- 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.
Previously the loginUser block left the loading overlay on under the
expectation that onUserStateChange → fetchUserDataFromApi() would
dismiss it. Under IV, login can fail (bad JWT, anon-purge, etc.) and
onUserStateChange may never fire, leaving the overlay up indefinitely.
Match #2599 commit 0f1ad82 — dismiss the loader as soon as the SDK
call returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nit: fix import order in LoginUserOperationExecutor
@nan-li nan-li force-pushed the feat/iv-logout-iam-06 branch from d2d19ae to 0062df7 Compare May 7, 2026 20:52
@nan-li nan-li changed the title feat(iv): Handle logout and fetch IAMs (6/6) feat(jwt): Handle logout and fetch IAMs (6/6) May 7, 2026
@nan-li nan-li merged commit f23efc7 into feat/identity_verification_feature_flagged_major_release May 7, 2026
2 of 3 checks passed
@nan-li nan-li deleted the feat/iv-logout-iam-06 branch May 7, 2026 20:53
@nan-li nan-li mentioned this pull request May 7, 2026
11 tasks
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