feat(iv): attach JWT in HTTP client and operation executors (4/6)#2626
feat(iv): attach JWT in HTTP client and operation executors (4/6)#2626nan-li wants to merge 3 commits intofeat/iv-queue-runtime-03from
Conversation
|
@claude review |
2227767 to
09c9246
Compare
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>
d2f321a to
b7e2264
Compare
| try { | ||
| when (operation) { | ||
| is TrackCustomEventOperation -> { | ||
| val jwt = | ||
| if (identityVerificationService.newCodePathsRun) { | ||
| resolveIvJwt(operation, jwtTokenStore, identityVerificationService.ivBehaviorActive) | ||
| } else { | ||
| null | ||
| } | ||
| customEventBackendService.sendCustomEvent( | ||
| operation.appId, | ||
| operation.onesignalId, |
There was a problem hiding this comment.
🔴 🔴 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 false — JwtTokenStore 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
- App enables IV (
jwt_required=true); login finishes;JwtTokenStoreholdsjwt-Aforext-1. jwt-Aexpires server-side.- App calls
OneSignal.User.trackEvent("event-name", ...), which enqueues aTrackCustomEventOperationforext-1. CustomEventOperationExecutor.executeruns.newCodePathsRunandivBehaviorActiveare both true, soresolveIvJwt(operation, jwtTokenStore, true)returnsjwt-A.customEventBackendService.sendCustomEvent(..., jwt = jwt-A)callshttpClient.post(".../custom_events", body, OptionalHeaders(jwt = jwt-A)). Backend rejects with 401.BackendException(statusCode=401)is caught;NetworkUtils.getResponseStatusType(401)returnsUNAUTHORIZED.- The
whenfalls through toelse -> ExecutionResponse(FAIL_NORETRY). OperationReporemoves the op and wakes waiters.JwtTokenStorestill contains expiredjwt-A.jwtInvalidatedHandlernever fires.- Every subsequent
trackEventunderext-1repeats 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}") |
There was a problem hiding this comment.
🟡 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
- App registers a custom
IJwtUpdateListener(typical IV usage — they need one to refresh tokens ononJwtInvalidated). - SDK calls
JwtTokenStore.invalidateJwt("ext-1")(e.g. fromOperationRepo.handleFailUnauthorizedon a 401). - The listener's
onJwtInvalidatedthrows — say, anNullPointerExceptionfrom a!!on an unset field.ex.messageisnull. onFailureruns the new log call:Logging.warn("JwtTokenStore: subscriber threw on onJwtInvalidated for externalId=ext-1: null").- 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
NullPointerExceptionstack.
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>
b7e2264 to
ba86934
Compare
Fourth of six PRs re-implementing Identity Verification (IV) on the
feat/identity_verification_feature_flagged_major_releaseintegration branch.Stack: #2623 → #2624 → #2625 → this → (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.HttpClientsetsAuthorization: Bearer <jwt>when non-null.logHTTPSentredactsAuthorizationdefensively (the log call currently happens before the header is set, but protecting against future reordering).Backend service signatures
jwt: String? = nulldefault param added to 10 methods across 4 services:IIdentityBackendService.setAlias/deleteAliasIUserBackendService.createUser/updateUser/getUserISubscriptionBackendService.createSubscription/updateSubscription/deleteSubscription/transferSubscriptionICustomEventBackendService.sendCustomEventjwtintoOptionalHeaders. Existing callers unchanged.getIdentityFromSubscription. That endpoint is not allowed whenjwt_required=true;LoginUserFromSubscriptionOperationExecutoralready returnsFAIL_NORETRYunder 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.ktexposes:IvBackendParams(aliasLabel, aliasValue, jwt)withlegacyFor(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 onIdentityVerificationGates.newCodePathsRun, so:newCodePathsRun=false): extensions never called; executors byte-for-byte identical to today.newCodePathsRun=true,ivBehaviorActive=false): extensions run and return legacy values; new code path exercised without IV behavior — validates structural changes.external_id, JWT attaches.Seven executors wired:
LoginUserOperationExecutorresolveIvJwt(createUser uses identities map, no alias)IdentityOperationExecutorresolveIvBackendParams(setAlias / deleteAlias)SubscriptionOperationExecutor.createSubscriptionresolveIvBackendParamsSubscriptionOperationExecutor.updateSubscription/deleteSubscriptionresolveIvJwtSubscriptionOperationExecutor.transferSubscriptionresolveIvBackendParamsUpdateUserOperationExecutorresolveIvBackendParams(externalId pulled fromoperations.first(); grouped ops share user)RefreshUserOperationExecutorresolveIvBackendParamsLoginUserFromSubscriptionOperationExecutorshouldFailLoginUserFromSubscription→FAIL_NORETRYCustomEventOperationExecutorresolveIvJwtDI auto-wires
JwtTokenStoreinto executors through constructor reflection — no module changes needed (store was registered in #2623).Tests
ExecutorsIvExtensionsTests— 9 focused unit tests across Phase 1 / Phase 3 / IV-active for bothresolveIvBackendParamsandresolveIvJwt, plusshouldFailLoginUserFromSubscription.HttpClientTestscases — Authorization header set iffOptionalHeaders.jwt != null.IdentityOperationExecutorTestscases — IV-active and Phase 3 end-to-end verifies that the correct alias + JWT reach the backend mock.ExecutorMocks.getJwtTokenStore()helper (real emptyJwtTokenStorebacked byMockPreferencesService).Test plan
./gradlew :OneSignal:core:testDebugUnitTest— 814 pass / 2 fail (both pre-existingSDKInitTestsfailures, unrelated; reproduced on baseline).:OneSignal:in-app-messages,:OneSignal:notifications,:OneSignal:locationall compile clean.Authorization: Bearer <jwt>is present on network requests when a customer hasjwt_required=true(will be testable once PR 5 wires the publiclogin(externalId, jwt)API).Out of scope (deferred to PR 5)
InAppBackendServiceJWT integrationCreateUserResponse/JSONConverterIJwtUpdateListenerJwtTokenStore.pruneToExternalIdscaller (logout path)🤖 Generated with Claude Code