feat(iv): foundation: gates singleton, JWT token store, feature flag entry (1/6)#2623
Conversation
8d2cd44 to
44bc2f4
Compare
|
@claude review |
There was a problem hiding this comment.
Prior race-condition concern was addressed (payload dropped from IJwtUpdateListener); only a nit remains, but deferring since this lands persistent JWT storage and the IV gate primitives that subsequent PRs will build on.
Extended reasoning...
Overview
This is a foundation PR for the Identity Verification (IV) initiative across the OneSignal Android SDK. It adds: FeatureFlag.IDENTITY_VERIFICATION (IMMEDIATE activation), an IdentityVerificationGates singleton tracking newCodePathsRun/ivBehaviorActive, a SharedPreferences-backed JwtTokenStore (externalId → JWT) with an IJwtUpdateListener event interface, a JwtRequirement enum that replaces ConfigModel.useIdentityVerification: Boolean so callers can distinguish pre-HYDRATE from explicit false, and DI wiring in CoreModule. There is no consumer wiring or public API change in this PR — those are deferred to follow-on PRs.
Security risks
The PR introduces persistent storage of JWT tokens in unencrypted SharedPreferences (PREFS_OS_JWT_TOKENS). On most modern Android devices file-based encryption protects this at rest, but tokens are still readable by any process running as the app's UID (e.g. via root or a backup). This is consistent with how the SDK persists other identifiers, but is worth flagging for human review since this is the first JWT-bearing material the SDK persists. No injection / auth-bypass / TOCTOU concerns in the storage logic itself; ensureLoaded recovers gracefully from corrupt JSON and tests cover that path.
Level of scrutiny
Higher than a typical config tweak. The PR is foundation code for an auth-adjacent feature (JWT signing of SDK requests), introduces a new persistence surface, and changes the type of an existing ConfigModel property that is read across the codebase. Even though it lands no consumer wiring, the contracts here (gate semantics, event interface, persistence format) constrain the design of the four follow-on PRs in the series. That makes it worth a human pass before merge.
Other factors
Test coverage is thorough: the gate-state matrix (including the documented ERROR STATE row where jwt_required overrides a false feature flag), JWT store put/get/invalidate/prune, listener broadcast, and FeatureManager propagation on init and HYDRATE are all covered. The author addressed my prior race-condition concern by taking option 1 from the suggestion (commit ae498ac dropped the JWT payload from IJwtUpdateListener.onJwtUpdated so subscribers must re-query getJwt, and added a doc comment to that effect on the interface) and additionally refactored Boolean? to a three-state JwtRequirement enum (commit b02645d) for clearer pre-HYDRATE handling. The only new finding from this round is a nit about preserving the Throwable stack trace in JwtTokenStore.ensureLoaded — non-blocking. Deferring to nan-li for the human pass they requested.
b02645d to
474f1b3
Compare
There was a problem hiding this comment.
Previous structural concern about event payload ordering was addressed by dropping the payload from IJwtUpdateListener; remaining items are nits.
Extended reasoning...
Overview
Foundation PR (1 of 5) for Identity Verification: adds IdentityVerificationGates singleton, JwtTokenStore, JwtRequirement enum, IJwtUpdateListener, a new IDENTITY_VERIFICATION FeatureFlag entry, and threads ConfigModel through FeatureManager.applySideEffects. ConfigModel.useIdentityVerification changes from Boolean to the new enum but backs the same nullable-boolean storage (no cache migration). Test coverage is thorough (gate matrix including the ERROR STATE row, JwtTokenStore put/get/invalidate/prune/persistence/listener behaviors, FeatureManager IV wiring including HYDRATE propagation and the mid-session IMMEDIATE flip).
Security risks
Low for this PR in isolation — nothing reads the gates or the JWT store yet, so there is no runtime behavior change. The JWT store persists tokens in SharedPreferences under a new key; this matches how other sensitive config is stored in the SDK and the actual auth/HTTP integration is deferred to PR 3. No injection or auth-bypass surface introduced here.
Level of scrutiny
Moderate. It is foundation code for a security feature, but it is intentionally inert — the real scrutiny load lands in PRs 2–5 when consumers wire in. The one design decision worth calling out (payload-less event with a re-query contract) was already surfaced in my prior review and the author adopted it, which is visible in the current IJwtUpdateListener signature and its documentation comment.
Other factors
- My earlier concern about event-ordering in
JwtTokenStorewas addressed by dropping the JWT payload fromonJwtUpdatedand explicitly documenting the re-query contract. That was the substantive issue. - Outstanding items are nits: (a)
ensureLoadedlogse.messagerather than forwarding the throwable toLogging.warn; (b) the two@Volatilewrites inIdentityVerificationGates.updateare sequential so a reader can transiently observe(newCodePathsRun=false, ivBehaviorActive=true)during a (true,true)→(false,false) transition, violating the file-level invariant. Neither affects runtime today since there are no consumers, and both are trivially fixable before PR 2 lands. - Test coverage is strong and the PR scope is tight.
There was a problem hiding this comment.
Prior feedback was addressed in follow-up commits — payload dropped from IJwtUpdateListener and newCodePathsRun derived on read to structurally enforce the gate invariant.
Extended reasoning...
Overview
This is PR 1/6 of an Identity Verification rollout. It lands foundation primitives — IdentityVerificationGates singleton, JwtTokenStore (SharedPreferences-backed externalId→JWT map), JwtRequirement enum, IJwtUpdateListener wake-up interface, and a new SDK_IDENTITY_VERIFICATION feature flag — wired into DI and FeatureManager.applySideEffects but otherwise inert (no consumer reads the gates or token store yet).
Security risks
The PR introduces JWT-related infrastructure (storage and gates), which is security-adjacent, but in this PR it has no consumers and no public API surface. JwtTokenStore persists tokens to SharedPreferences with an unconditional externalId → JWT map; this is the documented design (multi-user resolution at op-execute time) and the actual use is gated downstream. No auth, crypto, signing, or transport changes land here. Pre-init the volatile gate fields default to false (safe fallback). No injection or data-exposure risks in the diff.
Level of scrutiny
Foundation/inert: low immediate risk because nothing reads the new code. However, the gates and listener contracts established here are load-bearing for PRs 2–5, so my prior reviews flagged ordering and atomicity concerns at the contract level. The author has now (a) dropped the payload from IJwtUpdateListener so subscribers must re-query getJwt(externalId) (commit 790e997), and (b) made newCodePathsRun a derived read of the two stored inputs so the documented invariant ivBehaviorActive ⇒ newCodePathsRun cannot be transiently violated (commit 76cdf20). Both fixes match the cleanest options I suggested.
Other factors
Test coverage is thorough (9 IdentityVerificationGatesTests, 17 JwtTokenStoreTests, plus IV-specific FeatureManagerTests covering the ERROR STATE row and IMMEDIATE mid-session flips). The remaining open thread (logging stack trace nit on ensureLoaded) is genuinely a nit and was marked resolved. All three of my prior inline comments are resolved. The bug hunting system found no new issues on this revision. PR is well-scoped, well-tested, and lands as planned.
2f615fc to
3c9b55d
Compare
1119ed2 to
3c0226b
Compare
3c9b55d to
dce3184
Compare
a91dd39 to
9a3c903
Compare
…entry First of set of PRs against the IV integration branch. Lands only the foundation primitives; no consumer wiring or public API yet. - Add `FeatureFlag.IDENTITY_VERIFICATION` (IMMEDIATE) so a Turbine kill-switch takes effect without a cold start. - Add `IdentityVerificationGates` singleton (mirrors `ThreadingMode`) holding `newCodePathsRun` (featureFlag || jwt_required) and `ivBehaviorActive` (jwt_required alone). `FeatureManager.applySideEffects` pushes both values in on every refresh, reading from the passed `model` parameter to stay in sync with the HYDRATE snapshot. - Add `JwtTokenStore` (SharedPreferences-backed externalId -> JWT) with an `EventProducer<IJwtUpdateListener>` for PR 5's IAM retry to subscribe to. - Change `ConfigModel.useIdentityVerification` from `Boolean` to `Boolean?`; `null` means pre-HYDRATE so PR 2's `getNextOps` deferral can distinguish it from an explicit `false`. - Add `PREFS_OS_JWT_TOKENS` key. - Register `JwtTokenStore` in DI. `IdentityVerificationService` is deferred to PR 2 where its HYDRATE handler has real work. Tests cover the gate matrix (including the ERROR STATE row where jwt_required=true overrides a false feature flag), JWT store put/get/ invalidate/prune + listener broadcast, and FeatureManager propagation to the gates on init and on HYDRATE.
…dering JwtTokenStore fires update events after releasing the synchronized block (matching the codebase's ModelStore pattern). With two concurrent writers on the same externalId, event delivery order isn't guaranteed to match mutation order — so a subscriber reading the event payload could see a stale token. Changed [IJwtUpdateListener] to a pure wake-up (`onJwtUpdated(externalId)`); subscribers must call [JwtTokenStore.getJwt] to read the current value. This matches the codebase's "events as wake-ups" convention (e.g. ModelStore handlers re-read after notification) and removes the attractive nuisance before PR 5's IAM retry is written against it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ConfigModel.useIdentityVerification` was `Boolean?` where `null` silently meant "pre-HYDRATE, not yet known" — easy to miss and easy to confuse with the SDK-side `FeatureFlag.IDENTITY_VERIFICATION`. Replace with a tri-state `JwtRequirement` enum (UNKNOWN / NOT_REQUIRED / REQUIRED) that names the customer-config side explicitly and ties to the backend param (`jwt_required`). - New `JwtRequirement` with a `fromBoolean(Boolean?)` helper so `ConfigModelStoreListener` can map the backend's nullable boolean. - `ConfigModel.useIdentityVerification` is now `internal var … : JwtRequirement` (internal because the type is internal-only). Backing storage is unchanged — still a nullable boolean under the hood, so no cache migration needed. - `IdentityVerificationGates.update` takes `jwtRequirement: JwtRequirement`; `required` is derived locally as `== REQUIRED`. - Tests updated to use enum values.
`IdentityVerificationGates.update` previously wrote the two `@Volatile` fields sequentially. On a `(true, true) → (false, false)` downgrade, a reader could observe `(newCodePathsRun=false, ivBehaviorActive=true)` between the writes, violating the documented invariant that `ivBehaviorActive=true` implies `newCodePathsRun=true`. Drop `newCodePathsRun` as a stored `@Volatile` field and compute it on read from the two stored inputs (`_featureFlagOn` and `ivBehaviorActive`). The invariant holds algebraically at every observation — `X || Y >= Y` — so consumers can read either field (or both, in any order) without observing an inconsistent state. Writer still performs two volatile stores but their ordering no longer matters for the documented invariant. Per-field access stays identical for consumers; `newCodePathsRun` is now a computed property (two volatile reads + a boolean OR) but the perf delta is negligible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ify gate logging - FeatureFlag.IDENTITY_VERIFICATION -> SDK_IDENTITY_VERIFICATION; remote key "identity_verification" -> "sdk_identity_verification". Mirrors the SDK_BACKGROUND_THREADING naming convention and clarifies that this is the SDK-side rollout switch (vs. customer-side jwt_required). - IdentityVerificationGates.update: drop the changed-vs-unchanged branching and the previousNewCode/previousIvActive temporaries that existed only to pick info vs debug log level. Single debug line now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JwtRequirement is tri-state (UNKNOWN / NOT_REQUIRED / REQUIRED) but the gate previously collapsed it to a Boolean ivBehaviorActive defaulting to false, conflating "we don't know yet" with "customer opted out." All current consumers are downstream of OperationRepo's pre-HYDRATE gate (`isInitializedWithRemote`) so the asymmetry is latent today, but it's a footgun for any future consumer that reads the gate without that upstream guard. Make jwtRequirement the stored field (defaults UNKNOWN); derive ivBehaviorActive (`== REQUIRED`) and newCodePathsRun on read. UNKNOWN reads as false for both Booleans, preserving observable behavior at all existing call sites. Future consumers that need the tri-state distinction can read jwtRequirement directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9a3c903 to
5835891
Compare
Description
One Line Summary
First of five PRs against the Identity Verification integration branch — lands foundation primitives (
IdentityVerificationGatessingleton,JwtTokenStore,FeatureFlag.IDENTITY_VERIFICATIONentry) with no consumer wiring or public API yet.Details
Motivation
IV is gated behind two nested conditions:
FeatureFlag.IDENTITY_VERIFICATIONcontrolled via Turbine.jwt_requiredremote param from the backend.Post-release rollout strategy: ship with feature flag OFF; customers with
jwt_required=trueget IV via the customer-config override; progressively flip the feature flag ON for remaining customers to validate structural changes independent of IV activation.Scope
All additions are inert at runtime — nothing consumes the gates or the JWT store yet. Subsequent PRs wire them in:
OperationRepoIV gating, pre-HYDRATE deferral, anon-op purge race fix.login(externalId, jwt),updateUserJwt, listeners) + replay.What lands in this PR:
New files:
IdentityVerificationGates.kt— singletonobjectmirroringThreadingMode. VolatilenewCodePathsRun(=featureFlag || jwtRequired == REQUIRED) andivBehaviorActive(=jwtRequired == REQUIRED). The two gates differ on purpose: customer config must be honored even when the feature flag is off, otherwise a kill-switch flip would break customers who've already enabled IV server-side.JwtRequirement.kt— tri-state enum (UNKNOWN/NOT_REQUIRED/REQUIRED) forConfigModel.useIdentityVerification, replacing the originalBoolean?. Explicit about pre-HYDRATE; visually distinct fromFeatureFlag.IDENTITY_VERIFICATION.JwtTokenStore.kt—SharedPreferences-backedexternalId → JWTmap. Multi-user so ops queued under a previous user can still resolve their JWT at execution time. Storage is unconditional; usage is gated onIdentityVerificationGates.ivBehaviorActive.IJwtUpdateListener.kt— pure wake-up notification (onJwtUpdated(externalId)with no payload). Subscribers must re-readgetJwt(externalId); matches theModelStoreconvention and avoids stale-ordering hazards when multiple threads write.Modified files:
FeatureFlag.kt— addedIDENTITY_VERIFICATION("identity_verification", IMMEDIATE). IMMEDIATE because a Turbine kill-switch should take effect within a foreground-poll cycle rather than wait for a cold start, and because thejwt_requiredside of the gate is already live-updating via HYDRATE — keeping both inputs symmetric.FeatureManager.kt—applySideEffectsnow has anIDENTITY_VERIFICATIONbranch pushing both intoIdentityVerificationGates.ConfigModel.kt—useIdentityVerificationtype changed toJwtRequirement(internal property because the enum is internal). Backing storage is unchanged (still a nullable boolean), so no cache migration needed.IPreferencesService.kt— addedPREFS_OS_JWT_TOKENSkey.CoreModule.kt— registeredJwtTokenStorein DI.Gate-access pattern: single-tier. Consumers read
IdentityVerificationGates.newCodePathsRun/.ivBehaviorActivedirectly. Pre-init the volatile fields default tofalse(safe fallback). Post-init they're populated byFeatureManager.init → refreshEnabledFeatures → applySideEffects, and refreshed on every HYDRATE becauseIDENTITY_VERIFICATIONis IMMEDIATE.IdentityVerificationServiceis NOT in this PR — moves to PR 2 where its HYDRATE handler has real work (anon-op purge, queue wake). The IMMEDIATE activation mode meansFeatureManager.applySideEffectsrefreshes the gates on every HYDRATE without needing a separate listener in PR 1.Testing
Unit testing
IdentityVerificationGatesTests— 9 tests covering the full gate matrix including the ERROR STATE row (feature flag off,jwt_required=REQUIRED→ customer config wins).JwtTokenStoreTests— 17 tests: put/get/invalidate/prune, persistence round-trip, listener notifications (fire on change, no-op on no-change, fire on invalidation/prune), malformed JSON recovery.FeatureManagerTests— added IV coverage: flag-off baseline, flag-on newCodePathsRun, ERROR STATE row, full IV, HYDRATE propagation, IMMEDIATE mid-session flip.FeatureFlagTests— key and activation-mode assertions forIDENTITY_VERIFICATION.stubConfigModelhelper updated to stubuseIdentityVerificationwithJwtRequirement.UNKNOWNby default.Manual testing
Not applicable — no runtime behavior change in this PR. All new code is unreferenced by consumers.
Affected code checklist
Checklist
Overview
Testing
SDKInitTestsfailures on the integration branch are unrelated)Final pass