Skip to content

feat(iv): attach JWT in HTTP client and operation executors (4/6)#2626

Open
nan-li wants to merge 3 commits intofeat/iv-queue-runtime-03from
feat/iv-http-executors-04
Open

feat(iv): attach JWT in HTTP client and operation executors (4/6)#2626
nan-li wants to merge 3 commits intofeat/iv-queue-runtime-03from
feat/iv-http-executors-04

Conversation

@nan-li
Copy link
Copy Markdown
Contributor

@nan-li nan-li commented Apr 24, 2026

Fourth of six PRs re-implementing Identity Verification (IV) on the feat/identity_verification_feature_flagged_major_release integration branch.

Stack: #2623#2624#2625this → (5/6) → (6/6)

What this PR does

Plumbs the JWT all the way from storage to the HTTP wire, and wraps all seven user-facing operation executors in the new-code-path extension pattern established in #2625.

HTTP layer (unconditional plumbing)

  • OptionalHeaders.jwt: String? field.
  • HttpClient sets Authorization: Bearer <jwt> when non-null.
  • logHTTPSent redacts Authorization defensively (the log call currently happens before the header is set, but protecting against future reordering).

Backend service signatures

  • jwt: String? = null default param added to 10 methods across 4 services:
    • IIdentityBackendService.setAlias / deleteAlias
    • IUserBackendService.createUser / updateUser / getUser
    • ISubscriptionBackendService.createSubscription / updateSubscription / deleteSubscription / transferSubscription
    • ICustomEventBackendService.sendCustomEvent
  • Each impl threads jwt into OptionalHeaders. Existing callers unchanged.
  • Excluded: getIdentityFromSubscription. That endpoint is not allowed when jwt_required=true; LoginUserFromSubscriptionOperationExecutor already returns FAIL_NORETRY under IV, so the call never reaches the backend service on the IV path. KDoc added to document this.

Executor gating — extension pattern

New file ExecutorsIvExtensions.kt exposes:

  • IvBackendParams(aliasLabel, aliasValue, jwt) with legacyFor(onesignalId) factory.
  • resolveIvBackendParams(op, onesignalId, jwtTokenStore) — alias + JWT for backend calls.
  • resolveIvJwt(op, jwtTokenStore) — JWT-only for calls that don't take alias label/value.
  • shouldFailLoginUserFromSubscription() — IV-active predicate.

Each helper short-circuits on IdentityVerificationGates.ivBehaviorActive == false. Base-class dispatch sites gate on IdentityVerificationGates.newCodePathsRun, so:

  • Phase 1 (newCodePathsRun=false): extensions never called; executors byte-for-byte identical to today.
  • Phase 3 (newCodePathsRun=true, ivBehaviorActive=false): extensions run and return legacy values; new code path exercised without IV behavior — validates structural changes.
  • IV active: alias switches to external_id, JWT attaches.

Seven executors wired:

Executor Resolution
LoginUserOperationExecutor resolveIvJwt (createUser uses identities map, no alias)
IdentityOperationExecutor resolveIvBackendParams (setAlias / deleteAlias)
SubscriptionOperationExecutor.createSubscription resolveIvBackendParams
SubscriptionOperationExecutor.updateSubscription / deleteSubscription resolveIvJwt
SubscriptionOperationExecutor.transferSubscription resolveIvBackendParams
UpdateUserOperationExecutor resolveIvBackendParams (externalId pulled from operations.first(); grouped ops share user)
RefreshUserOperationExecutor resolveIvBackendParams
LoginUserFromSubscriptionOperationExecutor shouldFailLoginUserFromSubscriptionFAIL_NORETRY
CustomEventOperationExecutor resolveIvJwt

DI auto-wires JwtTokenStore into executors through constructor reflection — no module changes needed (store was registered in #2623).

Tests

  • New ExecutorsIvExtensionsTests — 9 focused unit tests across Phase 1 / Phase 3 / IV-active for both resolveIvBackendParams and resolveIvJwt, plus shouldFailLoginUserFromSubscription.
  • New HttpClientTests cases — Authorization header set iff OptionalHeaders.jwt != null.
  • New IdentityOperationExecutorTests cases — IV-active and Phase 3 end-to-end verifies that the correct alias + JWT reach the backend mock.
  • Updated existing test mocks in 4 backend test files + 6 executor test files to match new three-arg HTTP calls / new constructor params.
  • New ExecutorMocks.getJwtTokenStore() helper (real empty JwtTokenStore backed by MockPreferencesService).

Test plan

  • ./gradlew :OneSignal:core:testDebugUnitTest — 814 pass / 2 fail (both pre-existing SDKInitTests failures, unrelated; reproduced on baseline).
  • :OneSignal:in-app-messages, :OneSignal:notifications, :OneSignal:location all compile clean.
  • Manual smoke: verify Authorization: Bearer <jwt> is present on network requests when a customer has jwt_required=true (will be testable once PR 5 wires the public login(externalId, jwt) API).

Out of scope (deferred to PR 5)

  • IAM / InAppBackendService JWT integration
  • RYW plumbing on CreateUserResponse / JSONConverter
  • IAM 401/403 retry via IJwtUpdateListener
  • JwtTokenStore.pruneToExternalIds caller (logout path)

🤖 Generated with Claude Code

@nan-li
Copy link
Copy Markdown
Contributor Author

nan-li commented Apr 24, 2026

@claude review

@nan-li nan-li force-pushed the feat/iv-queue-runtime-03 branch 5 times, most recently from 2227767 to 09c9246 Compare April 30, 2026 18:53
nan-li and others added 2 commits April 30, 2026 21:55
HTTP layer:
- OptionalHeaders.jwt; HttpClient sets Authorization: Bearer header when
  non-null. Defensive redaction of Authorization in logHTTPSent.

Backend services (unconditional jwt: String? = null plumbing):
- IIdentityBackendService.setAlias / deleteAlias
- IUserBackendService.createUser / updateUser / getUser
- ISubscriptionBackendService.createSubscription / updateSubscription
  / deleteSubscription / transferSubscription
- ICustomEventBackendService.sendCustomEvent
- getIdentityFromSubscription deliberately excluded: endpoint is not
  allowed when jwt_required=true; LoginUserFromSubscription executor
  already returns FAIL_NORETRY under IV.

Executor gating via extension pattern (matches PR 2625 precedent):
- New file ExecutorsIvExtensions.kt exposes resolveIvBackendParams,
  resolveIvJwt, shouldFailLoginUserFromSubscription. Outer dispatch
  at each base executor checks IdentityVerificationGates.newCodePathsRun;
  inner checks ivBehaviorActive to keep Phase 3 users (new code path
  on, IV behavior off) on legacy alias/jwt values byte-for-byte.
- 7 executors dispatch around alias + JWT resolution:
  LoginUser, Identity, Subscription (Create/Update/Delete/Transfer),
  UpdateUser, RefreshUser, LoginUserFromSubscription, CustomEvent.
- LoginUserFromSubscription short-circuits FAIL_NORETRY when IV active.

DI wires automatically via constructor reflection; no module changes.

Tests:
- ExecutorsIvExtensionsTests: 9 focused tests covering all three phases.
- IdentityOperationExecutor: IV-active and Phase 3 integration tests.
- HttpClient: Authorization header presence/absence.
- Backend service test mocks updated for the new three-arg http calls.
- ExecutorMocks gained getJwtTokenStore() helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…UTHORIZED

Three bugs surfaced by bot review on PR 2626:

1. HttpClient: set all optional headers (cacheKey, rywToken, retryCount,
   sessionDuration, jwt) BEFORE the body write. On real Android,
   `getOutputStream()` triggers `connect()`, after which
   `setRequestProperty` throws or is silently dropped. Pre-PR this was
   latent because OptionalHeaders was only passed on GET. This PR wires
   it through POST/PATCH/DELETE, activating the bug for every
   authenticated call. Matches the fix from old IV branch commit
   1dab8e0.

   MockHttpURLConnection now simulates the real contract: `connect`,
   `getOutputStream`, and `getResponseCode` flip `headersCommitted=true`,
   and `setRequestProperty` throws after that. A new HttpClientTests
   case (POST + JWT) now regression-guards the ordering fix.

2. LoginUserOperationExecutor: under `ivBehaviorActive`, skip the
   optimistic inline SetAliasOperation and go straight to createUser.
   The inline op identifies the target by `onesignal_id =
   existingOnesignalId`, but IV's alias resolution rewrites the call
   to `external_id = newExternalId` — targeting a user that doesn't
   exist yet. 404 -> FAIL_NORETRY -> fallthrough to createUser
   orphans the anonymous user; or a 200 idempotent-PATCH against a
   different pre-existing user corrupts local identity. createUser's
   upsert semantics handle the merge correctly via the identities map.

3. SubscriptionOperationExecutor: add UNAUTHORIZED -> FAIL_UNAUTHORIZED
   to update/delete/transfer catch blocks. `createSubscription` handles
   it correctly; the other three fall through to `else -> FAIL_NORETRY`
   which swallows 401s without invalidating the stored JWT or firing
   `jwtInvalidatedHandler`. Pre-existing asymmetry that this PR
   activates by wiring JWT into these endpoints.

Tests:
- HttpClientTests: POST + JWT doesn't throw (ordering enforced by mock).
- LoginUserOperationExecutorTests: IV active + existingOnesignalId +
  externalId -> createUser; IdentityOperationExecutor not invoked.
- SubscriptionOperationExecutorTests: update/delete/transfer each
  return FAIL_UNAUTHORIZED on 401 under IV.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nan-li nan-li force-pushed the feat/iv-http-executors-04 branch from d2f321a to b7e2264 Compare May 1, 2026 07:03
Comment on lines 44 to 55
try {
when (operation) {
is TrackCustomEventOperation -> {
val jwt =
if (identityVerificationService.newCodePathsRun) {
resolveIvJwt(operation, jwtTokenStore, identityVerificationService.ivBehaviorActive)
} else {
null
}
customEventBackendService.sendCustomEvent(
operation.appId,
operation.onesignalId,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 🔴 CustomEventOperationExecutor.execute (catch block at lines 65–73) maps UNAUTHORIZED (401/403) to else -> FAIL_NORETRY instead of FAIL_UNAUTHORIZED. This PR is what first attaches OptionalHeaders(jwt = jwt) on POST apps/$appId/custom_events, so a realistic 401 from an expired JWT becomes user-triggerable for the first time — and because OperationRepo only routes FAIL_UNAUTHORIZED to handleFailUnauthorized, the stale JWT in JwtTokenStore is never invalidated, jwtInvalidatedHandler never fires, and every subsequent trackEvent under the same user silently drops. Fix is mechanical, mirroring the same fix already applied to SubscriptionOperationExecutor.update/delete/transferSubscription in this PR: add a NetworkUtils.ResponseStatusType.UNAUTHORIZED -> ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED, retryAfterSeconds = ex.retryAfterSeconds) branch.

Extended reasoning...

What is broken

CustomEventOperationExecutor.execute (lines 65–73) catches BackendException and uses:

return when (responseType) {
    NetworkUtils.ResponseStatusType.RETRYABLE ->
        ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = ex.retryAfterSeconds)
    else ->
        ExecutionResponse(ExecutionResult.FAIL_NORETRY)
}

There is no UNAUTHORIZED branch. NetworkUtils.getResponseStatusType maps both 401 and 403 to UNAUTHORIZED, so a 401/403 from an expired JWT falls through to else and becomes FAIL_NORETRY. Every other executor wired by this PR — IdentityOperationExecutor, UpdateUserOperationExecutor, RefreshUserOperationExecutor, LoginUserOperationExecutor, LoginUserFromSubscriptionOperationExecutor, and all four SubscriptionOperationExecutor paths (after this PR's own fix to updateSubscription / deleteSubscription / transferSubscription) — maps UNAUTHORIZED -> FAIL_UNAUTHORIZED. CustomEventOperationExecutor is the lone outlier.

Why it matters under IV

OperationRepo.executeOperations only routes FAIL_UNAUTHORIZED to handleFailUnauthorized, which (1) calls jwtTokenStore.invalidateJwt(externalId), (2) re-queues the failed op, and (3) fires IJwtUpdateListener.onJwtInvalidated so the app can supply a fresh token. FAIL_NORETRY instead just drops the op and wakes waiters with falseJwtTokenStore keeps the expired JWT, jwtInvalidatedHandler never fires, and the developer has no signal that re-auth is needed.

Why this PR makes it live

The catch block predates the PR, but this PR is what first attaches a JWT to the custom-events endpoint. CustomEventOperationExecutor.kt lines 47–52 now call resolveIvJwt(operation, jwtTokenStore, ivBehaviorActive) and pass jwt into customEventBackendService.sendCustomEvent(..., jwt) at line 61, and CustomEventBackendService.kt now sends OptionalHeaders(jwt = jwt) on the POST. Before this PR, custom events had no Authorization header, so a realistic 401 was not user-triggerable. This is the same reasoning that justified the updateSubscription/deleteSubscription/transferSubscription fix already in this PR; CustomEventOperationExecutor was overlooked in that pass.

Step-by-step proof

  1. App enables IV (jwt_required=true); login finishes; JwtTokenStore holds jwt-A for ext-1.
  2. jwt-A expires server-side.
  3. App calls OneSignal.User.trackEvent("event-name", ...), which enqueues a TrackCustomEventOperation for ext-1.
  4. CustomEventOperationExecutor.execute runs. newCodePathsRun and ivBehaviorActive are both true, so resolveIvJwt(operation, jwtTokenStore, true) returns jwt-A.
  5. customEventBackendService.sendCustomEvent(..., jwt = jwt-A) calls httpClient.post(".../custom_events", body, OptionalHeaders(jwt = jwt-A)). Backend rejects with 401.
  6. BackendException(statusCode=401) is caught; NetworkUtils.getResponseStatusType(401) returns UNAUTHORIZED.
  7. The when falls through to else -> ExecutionResponse(FAIL_NORETRY).
  8. OperationRepo removes the op and wakes waiters. JwtTokenStore still contains expired jwt-A. jwtInvalidatedHandler never fires.
  9. Every subsequent trackEvent under ext-1 repeats steps 4–8 and silently drops, until login is retried by some other path.

Fix

Mechanical, mirror the fix this PR already applied to SubscriptionOperationExecutor:

return when (responseType) {
    NetworkUtils.ResponseStatusType.RETRYABLE ->
        ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = ex.retryAfterSeconds)
    NetworkUtils.ResponseStatusType.UNAUTHORIZED ->
        ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED, retryAfterSeconds = ex.retryAfterSeconds)
    else ->
        ExecutionResponse(ExecutionResult.FAIL_NORETRY)
}

Same severity tier as the subscription fix already in this PR.

runCatching { listener.onJwtInvalidated(externalId) }
.onFailure { ex ->
Logging.warn("JwtTokenStore: subscriber threw on onJwtInvalidated for externalId=$externalId", ex)
Logging.warn("JwtTokenStore: subscriber threw on onJwtInvalidated for externalId=$externalId: ${ex.message}")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The catch-block log call in JwtTokenStore.invalidateJwt was changed from Logging.warn("...", ex) to inline ${ex.message} into the message string, which loses the stack trace. Logging.warn's second Throwable parameter is what propagates through to android.util.Log.w(TAG, message, throwable) to dump the full stack via Log.getStackTraceString; with only ex.message you get the message text but not the cause class or frames. Trivial to fix: pass ex as the second arg again.

Extended reasoning...

What changed

This PR (commit b7e2264) modified JwtTokenStore.invalidateJwt at OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt:79:

// Before (aec7562):
Logging.warn("JwtTokenStore: subscriber threw on onJwtInvalidated for externalId=$externalId", ex)
// After (this PR):
Logging.warn("JwtTokenStore: subscriber threw on onJwtInvalidated for externalId=$externalId: ${ex.message}")

Why this loses information

Logging.warn's signature is fun warn(message: String, throwable: Throwable? = null). The throwable arg is forwarded through log(level, message, throwable) to android.util.Log.w(TAG, message, throwable) (Logging.kt:147), and Android's Log.w(tag, msg, tr) overload appends Log.getStackTraceString(tr) to the output — that's how the SDK normally surfaces stack traces. The visual-logger branch (Logging.kt:160-164) similarly uses a StringWriter dump of the throwable. With only ex.message interpolated into the string, callers see the message text but get no stack frames and no cause-chain class info.

Why the runCatching/onFailure wrapper exists in the first place

This call site is exclusively defensive: it catches exceptions thrown by app-supplied IJwtUpdateListener implementations (the developer's jwtInvalidatedHandler) so one buggy listener can't break the others or propagate up into the operation queue. When this branch fires, the listener that threw is by definition external to the SDK — the stack trace is the only useful diagnostic the SDK has, since the message alone often won't identify which listener failed or why (e.g. a NullPointerException with no message at all is common).

Step-by-step proof

  1. App registers a custom IJwtUpdateListener (typical IV usage — they need one to refresh tokens on onJwtInvalidated).
  2. SDK calls JwtTokenStore.invalidateJwt("ext-1") (e.g. from OperationRepo.handleFailUnauthorized on a 401).
  3. The listener's onJwtInvalidated throws — say, an NullPointerException from a !! on an unset field. ex.message is null.
  4. onFailure runs the new log call: Logging.warn("JwtTokenStore: subscriber threw on onJwtInvalidated for externalId=ext-1: null").
  5. Logcat shows the message; no stack trace, no class name, no line number. The developer sees "something threw, somewhere in my listener". Under the prior code they would have gotten the full NullPointerException stack.

Severity

Nit — purely diagnostic, no behavioral impact. The runCatching wrapper still does its job (other subscribers still fire, the queue isn't broken). But this is exactly the case where stack traces materially help triage, and the prior PR aec7562 had it right. Trivial to restore:

Logging.warn("JwtTokenStore: subscriber threw on onJwtInvalidated for externalId=$externalId", ex)

…onService

PR 3 dropped the IdentityVerificationGates singleton in favor of an
injectable IdentityVerificationService. Migrate all 7 operation executors
and the ExecutorsIvExtensions helpers to read gate state from the injected
service. Tests use a new ExecutorMocks.getIdentityVerificationService()
helper.

Also: switch JwtTokenStore.invalidateJwt's subscriber-throw log from
Logging.warn(msg, ex) to Logging.warn(msg + ex.message). The throwable
form calls android.util.Log.getStackTraceString which isn't mocked
outside Robolectric, and the broader executor test suite leaves a stale
log listener registered between tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nan-li nan-li force-pushed the feat/iv-http-executors-04 branch from b7e2264 to ba86934 Compare May 1, 2026 07:35
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.

1 participant