feat: [JWT-8] demo app Identity Verification UI - #1708
Conversation
There was a problem hiding this comment.
Multimodal adversarial review (interrogate)
Skill: Cursor interrogate (pstack). Reviewers: claude-fable-5-thinking-xhigh, gpt-5.6-sol-xhigh, cursor-grok-4.5-high-fast, claude-opus-5-thinking-high. Lead judgment applied below — feed findings into the stacked PRs (#1705–#1707+), not as a merge-to-main checklist.
Intent
External-ID–scoped JWTs so user-mutating and related SDK traffic can be Identity-Verification gated, with ownership tracking so switches / races don’t apply the wrong user’s token or unblock the wrong waiter. Umbrella stack: gate + JWT repo/public API + delta ownership + IV request pipeline + IAM under IV + RYW fixes + demo UI.
Reviewers
- Reviewer A: claude-fable-5-thinking-xhigh, 10 findings
- Reviewer B: gpt-5.6-sol-xhigh, 7 findings
- Reviewer C: cursor-grok-4.5-high-fast, 9 findings
- Reviewer D: claude-opus-5-thinking-high, 10 findings
Act On
- Bearer JWT logged at verbose (and headers logger now prints it) — A/B/C/D.
updateUserJwtstill logs the full token (// TODO: omit… before shipping). Separately,OSRequestAuth.setBearerpopulatesAuthorizationintoadditionalHeaders, andOneSignalClientalready logs that dictionary verbatim — pre-IV that path was inert; it is now a credential sink whenever verbose is on. Redact both before any IV GA. - Parked Create User can complete after a user switch and report the wrong current user — A/D.
_executePendingRequestssteps overawaitsTokenso another user’s work can run first; when the parked create later succeeds,parseFetchUserResponse→identityModel.hydrate→OSUserStateSnapshot.fireUserStateChangedis not gated bycurrentUser(matching:), while later hydration in the same function is. Add a regression test forlogin(A)(no token) →login(B)→updateUserJwt(A). - App-id change clears IV disk keys / retries without resetting in-memory gate — C (also flagged on #1707).
handleAppIdChangeremovesOSUD_USE_IDENTITY_VERIFICATION/ feature flags but leavesOSUserJwtConfig/OSFeatureManagermemory;refreshIfUnknown/refreshIfEmptythen no-op. StalescheduleDownloadIOSParamsRetryWithAppId:can still hydrate the previous app’sjwt_requiredinto the shared singleton. - JWT persisted into shared UserDefaults archives — A/B/C/D. Encoding
jwtBearerTokenonOSIdentityModel(and thus model/request caches) expands exposure vs the prior in-memory-only + “make this token secure” TODO (removed). Persist via Keychain (or drop cross-launch persistence and re-ask), and stop embedding the secret in every archived request identity copy. - IAM deferred-fetch slot can be wiped by
onUserWillChangewith a nil subscription id — D. SingledeferredFetchSubscriptionId; login while Create User is parked (no push sub id yet) overwrites a prior 401 park withnil, andretryDeferredFetchthen no-ops for the rest of the session.
Consider
- Feature-flag layer has no production writer — A/B/C/D.
setEnabledFeatureKeysis test-only;newCodePathsRuncollapses toivBehaviorActive. Land remote-params delivery with the flag, or delete the dead branch until then. - Deltas stamped from live
identityModel.externalIdduringclearUserData— D. Fetch clears aliases before hydrate; tags/aliases in that window stampniland are purged under IV. - Cached
.off+ async params hydrate — B. Unsigned mutations can 401 and be dropped before the session learns IV turned on. currentUser(matching:)then mutate/logout is not atomic — B. Check-then-act across executors can still clear/logout the wrong user under a tight switch.- JWT-invalidated observer lazy init race — D. Lost listener +
askedForTokencan strand asks for the session. - RYW subscription bar uses sticky prior tokens / shared condition clear-on-timeout — B/A/D. Later subscription writes can look “ready” too early; timeout can lower the bar for a concurrent waiter.
- IAM 401 never invalidates JWT — A. Documented ambiguity vs mid-session expiry with no user traffic; consider bounded invalidate after repeated signed 401s.
- Logout unsubscribe vs immediate re-login create ordering — A. Concurrent executors can leave the device unsubscribed server-side.
- Create/Fetch
ownerExternalIdstill live-read — C. Dual ownership convention vs stamped requests; weak underclearData.
Noted
- Pre-ownership / pre-
addsNewRecordscache decode → purge or skip cool-down on upgrade (release note / decode default). OSRequestGetInAppMessagesencode-failure returns path-less request.OneSignalUserManagerImpl.swiftcrossed ~1k lines despite+Jwtextraction.- Listener replay after remove / possible duplicate ask — B.
Dismissed
- Rewrites that only prefer a different structure without a broken path (general “code judo” without concrete failure).
- Re-litigating three-state
jwt_required/ hold-until-known design (intent of the stack). - Treating umbrella-vs-stacked-PR process as a code defect.
Agreement Map
Strong consensus on credential handling (verbose token log + UserDefaults persistence) and on dead OSFeatureManager producer. Two independent models (A/D) reconstructed the same parked-create → wrong OSUserStateObserver path after step-over. C resurfaced the #1707 app-id / stale-retry gate bugs still present in the tip. B uniquely stressed rollout-transition silent drops and RYW generation mismatch; D uniquely stressed IAM defer-nil cancel and observer lazy-init. Net: IV ownership/gating story is real, but shipping blockers are credential leakage/persistence, wrong-user observer after park-step-over, and app-id gate memory/retry consistency.
Inline comments below map to Act On / high-signal Consider items.
Sent by Cursor Automation: Untitled
| return | ||
| } | ||
| // TODO: omit the token from this log before shipping — keep for testing. | ||
| OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.updateUserJwt called for externalId: \(externalId) with token: \(token)") |
There was a problem hiding this comment.
Act On — credential in logs (consensus A/B/C/D)
This still logs the full bearer token at verbose, with an explicit pre-ship TODO. Separately, OSRequestAuth.setBearer puts Authorization: Bearer … into additionalHeaders, and OneSignalClient logs that dictionary verbatim — so every signed request becomes a second leak once verbose is on.
Omit the token here (externalId / presence only) and redact Authorization in the client logger before IV GA.
| lock.withLock { | ||
| super.encode(with: coder) | ||
| coder.encode(aliases, forKey: "aliases") | ||
| coder.encode(jwtBearerTokenLocked, forKey: OS_JWT_BEARER_TOKEN) |
There was a problem hiding this comment.
Act On — JWT archived to shared UserDefaults (consensus A/B/C/D)
Encoding jwtBearerTokenLocked (and set(property:) → model-store save) persists the bearer into App Group UserDefaults / request archives. Pre-stack this was in-memory with a “make this token secure” TODO; the TODO was removed while exposure grew.
Prefer Keychain (external-id keyed) or session-only memory + re-ask; do not keep N copies of the secret in model/request plists.
There was a problem hiding this comment.
Not addressing. Same answer as on #1709, product decision. The token is valid for at most 1 hour, and a user who kills and reopens the app inside that window should keep working without another ask, keep its token across a relaunch.
|
|
||
| internalAddAliases(remoteAliases) | ||
| fireUserStateChanged(newOnesignalId: newOnesignalId, newExternalId: newExternalId) | ||
| OSUserStateSnapshot.fireUserStateChanged(newOnesignalId: newOnesignalId, newExternalId: newExternalId) |
There was a problem hiding this comment.
Act On — companion to parked-create race
fireUserStateChanged runs unconditionally from hydration. Callers that hydrate non-current models (see OSUserExecutor step-over of token-parked creates) will notify the app of the wrong current user.
Move this fire to call sites that know the model is current, or gate on currentUser(matching:).
There was a problem hiding this comment.
same as above, filed together, one fix will address both
| [sharedUserDefaults removeValueForKey:OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY]; | ||
|
|
||
| [sharedUserDefaults removeValueForKey:OSUD_USE_IDENTITY_VERIFICATION]; | ||
| [sharedUserDefaults removeValueForKey:OSUD_SDK_FEATURE_FLAGS]; |
There was a problem hiding this comment.
Act On — disk clear without in-memory reset (C; also #1707)
App-id change removes OSUD_USE_IDENTITY_VERIFICATION / OSUD_SDK_FEATURE_FLAGS but does not reset OSUserJwtConfig.shared / OSFeatureManager.shared. refreshIfUnknown / refreshIfEmpty no-op when memory is already set, so the previous app’s gate can keep driving IV behavior until (or instead of) the new params hydrate.
Reset requirement to .unknown and clear feature keys here (same moment as the disk clear).
There was a problem hiding this comment.
Not addressing, tracked in SDK-5142. Same answer as on #1707. App ID changes are not supported since v5. A change between launches is covered because the config is rebuilt from the cleared disk key on the next process. A mid-process change is what SDK-5142 scopes.
| if (_downloadedParameters || _didCallDownloadParameters) | ||
| return; | ||
|
|
||
| [self downloadIOSParamsWithAppId:appId]; |
There was a problem hiding this comment.
Act On — stale appId on params retry (C; also #1707)
This block captures the failing appId. handleAppIdChange resets download flags but does not cancel the delayed retry, so it can still call downloadIOSParamsWithAppId: for the old app and hydrate jwt_required into the shared config used by the new app.
Compare against OneSignalIdentifiers.currentAppId (or a generation stamp) before retrying.
There was a problem hiding this comment.
Same answer as above and on #1707. App ID changes are not supported, tracked in SDK-5142.
| @synchronized (self) { | ||
| self.userGeneration += 1; | ||
| } | ||
| [self deferFetchWithSubscriptionId:OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId]; |
There was a problem hiding this comment.
Act On — deferred IAM fetch erased by nil (D)
One slot: deferFetchWithSubscriptionId: overwrites unconditionally. Under IV, login while Create User is parked often has no push subscription id yet, so this writes nil and drops a fetch parked by handleUnauthorizedFetch: / “no onesignal id”. retryDeferredFetch then no-ops for the rest of the session.
Ignore nil here, or separate “fetch owed” from “subscription id to use”.
There was a problem hiding this comment.
Not addressing. A parked fetch always has a subscription id, because the fetch returns before parking when there is none, and onUserWillChange reads the same push model. The two only differ if a 404 cleared the id first, and the next Create User brings a new id that starts its own fetch through the push subscription observer.
| name: OS_UPDATE_PROPERTIES_DELTA, | ||
| identityModelId: userInstance.identityModel.modelId, | ||
| identityModelId: identityModel.modelId, | ||
| externalId: identityModel.externalId, |
There was a problem hiding this comment.
Consider — live externalId stamp during clear/hydrate window (D)
Deltas take identityModel.externalId from the live model. clearUserData blanks aliases before fetch hydrate; work in that window stamps nil and OSOperationRepo drops it under IV (silent loss for an identified user).
Preserve external_id across clearData, or hydrate aliases atomically so an identified model never reads anonymous.
2d7dfa9 to
3f0ca5e
Compare
6280471 to
4df957f
Compare
4df957f to
3db98a5
Compare
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Umbrella-only notes. The stacked PRs have the product comments; these two are fixture / demo hygiene on the tip.
Not a merge vehicle, I know. Flagging here because this is where CI is red and where the demo JWT lands on disk.
| - shouldFetchOnUserChangeWithSubscriptionID is cleared | ||
| - deferredFetchSubscriptionId is cleared | ||
| */ | ||
| func testRetriesFetchWhenUserStateChangesWithValidOneSignalID() throws { |
There was a problem hiding this comment.
This test is red on the umbrella for a fixture mismatch, not because the retry path is wrong.
It seeds RYW for testOneSignalId, then setDefaultIdentifyUserResponses hydrates anonUserOSID. After 1712 the retry goes through authorizationForFetchOrDefer and the RYW wait. waitUntil is 5s, RYW timeout is 30s, so the test times out waiting for a token that will never arrive for that id.
IamFetchIdentityVerificationTests already seeds RYW for the post-login id. I’d do the same here, or pass testOneSignalId through the identify mock so the two ids match.
There was a problem hiding this comment.
Thanks for digging in. I don't think the ids are mismatched. executeIdentifyUserRequest ignores the mock response and hydrates from the anon model's own onesignal_id, which is testOneSignalId, the one the test seeds, so the fetch already waits on the id that has a token. It also passed on a re-run of the same commit with no code change (run 33195383982, attempt 2), and the earlier red runs on this branch failed on unrelated tests, so this is a flaky test rather than a fixture problem.
|
|
||
| func setSessionJwtToken(_ value: String?) { | ||
| if let value = value, !value.isEmpty { | ||
| defaults.set(value, forKey: Key.sessionJwtToken) |
There was a problem hiding this comment.
Dev-only, but this still writes a bearer to UserDefaults.standard (backups, anyone dumping defaults). It also teaches an unsafe pattern.
I’d keep it in memory for the process, or Keychain. The update path already prints Updated JWT for: without the token, which is the right instinct.
There was a problem hiding this comment.
Not addressing. Same reasoning as on #1709. The token is valid for at most 1 hour, and the SDK itself keeps it across a relaunch on purpose, so the demo keeping it for its REST fetch matches that.
Exercises the Identity Verification surface end to end: log in with a token, watch a token be rejected and supply a replacement through the invalidated listener, and see how the SDK behaves while the requirement is still unknown. Co-authored-by: Cursor <cursoragent@cursor.com>
3db98a5 to
20d6bd3
Compare
abdulraqeeb33
left a comment
There was a problem hiding this comment.
JWT-contract gaps in the demo vs the APIs on 5.7-main. Head 20d6bd3.
-
initialize()stilllogin(storedExternalId)with no token. Identity-model persistence plus same-user no-op means the SDK does keep a valid JWT across relaunch, so the PR body is wrong. Cache miss orOS_JWT_TOKEN_INVALIDthen parks and asks, and the demo already has the token in prefs. Passprefs.getSessionJwtToken()intologin(externalId:token:), or skip the extra login. -
onUserJwtInvalidatedonly prints. ClearsessionJwtToken(REST keeps sending the rejected bearer), surface the ask, and prefill UPDATE USER JWT withevent.externalId. -
UPDATE USER JWT takes any id and writes prefs before the SDK accepts it. Lock it to the current external id.
-
Same-user login with an empty JWT field clears the demo bearer while the SDK keeps its token. Same-user login with a JWT wipes hydrate and never refetches, because user-state does not fire.
-
The IV toggle does not refetch, and
/usersfailures collapse tonilwith no status. Relabel the toggle so it is not read as a client-sidejwt_requiredswitch.
…ing in at launch The demo called login(storedExternalId) on every cold start. The SDK already restores the user from its own cache, so that call was a no-op, and passing the stored token there would have hidden the path a customer's app hits on relaunch, where the SDK either signs with the token it archived or asks for one through OSUserJwtInvalidatedListener. Drop the cold-start login. When the SDK asks for a token, clear the demo's stale copy so the REST fetch stops sending a rejected bearer, show a banner with the external id and a PROVIDE JWT button, toast the ask, and prefill the UPDATE USER JWT dialog with that id.
…t user UPDATE USER JWT accepts any external id, and the SDK ignores a token for an id it has no identity model for. The demo saved the token as its REST bearer before the SDK decided, so a mismatched id left the demo signing the current user's fetch with someone else's token. Only write prefs when the id is the current user.
A same-user login with an empty JWT field cleared the demo's stored token while the SDK kept its own, so the REST fetch lost its bearer for no reason. Only overwrite the stored token when one was supplied or the user changed. A same-user login also wiped the hydrated display and waited for a user-state event that never fires for the same user. Refetch directly in that case.
…/users result The toggle read as a client-side jwt_required switch. It only changes how the demo's own /users fetch is addressed, so name it that way and refetch when it flips instead of waiting for the next login. A failed fetch collapsed to nil and left the last hydrated state on screen with no indication, so a 401 under Identity Verification looked like nothing happened. The fetch now returns a Result, and the User section shows OK or the failure reason.
|
Thanks, these are good ease of use points for the demo app
|
The JWT UI work pushed OneSignalViewModel's class body to 360 lines, past SwiftLint's 350-line type_body_length error threshold, which failed the Swift Lint job. Moved outcomes, in-app messages, triggers, custom events, notifications and Live Activities into an extension in the same file. The rule only counts the primary declaration, so the body drops to 264 lines and leaves room for the next demo change. Pure code movement, no logic change.
abdulraqeeb33
left a comment
There was a problem hiding this comment.
The five from the last pass are closed at 70d5450.
One leftover: updateUserJwt does not refetch the demo /users call, so after PROVIDE JWT for the current user the REST row can stay on the previous 401 until a user-state event or a toggle flip. Not blocking.
updateUserJwt stored a fresh bearer but never refetched, so the REST fetch row kept showing the 401 that prompted the ask until a user-state event or a toggle flip happened to fire. Refetch when the token is for the current user, the same condition the service uses to decide whether to store the bearer, matching what a same-user login already does.
This is a nice to have, so I will add it. Addressed in 5edbe37. updateUserJwt now refetches when the token is for the current user |




Summary
Final slice of the Identity Verification (JWT) stack: the demo app UI for logging in with a JWT, replacing a user's JWT, and pointing the demo's REST user fetch at
external_idwith a bearer token.This PR was previously the umbrella review PR for the whole stack against
main. Everything below it has since merged into5.7-main, so the diff is now only the demo app, 9 files and +382/-36, all underexamples/demo/. No SDK source changes.Already merged into
5.7-main: #1706 read-your-write consistency, #1707 feature flags, JWT config and the IV gate, #1709 identity model JWT and public API, #1710 delta ownership, #1711 request pipeline, #1712 in-app messages.What this adds
LoginUserDialogreplaces the generic add-item dialog for login. External id is required, JWT is optional. With a token it callsOneSignal.login(externalId:token:), without one the single-argumentlogin. A same-user login keeps the stored bearer unless a new one is supplied, and refetches directly since no user-state event fires for the same user.OneSignal.updateUserJwt(externalId:token:), so token replacement can be exercised without a fresh login. The external id field is prefilled with the id the SDK asked for, or the current user. The demo only saves the token as its own REST bearer when the id is the current user; the SDK call goes through either way.OSUserJwtInvalidatedListenerfires, the User section shows a banner with the external id and a PROVIDE JWT button, a toast announces it, and the demo drops its stored copy of the rejected token.onesignal_idunsigned. On, it hydrates byexternal_idwith anAuthorization: Bearerheader. Flipping it refetches. It only changes the demo's own REST fetch, not anything in the SDK.UserFetchService.fetchUsertakes an alias label and value, percent-encodes the alias so external ids containing reserved characters do not misroute the GET, sendsAcceptalongside the optional bearer, and returns aResultwith the HTTP status on failure.PreferencesServicestores the toggle and the demo session JWT.logout()clears the token.addUserJwtInvalidatedListenerandremoveUserJwtInvalidatedListener.Cold-start behavior
The demo no longer calls
loginat launch. The SDK restores the user from its own cache and keeps the JWT it archived. If that token is missing, was rejected before the app was killed, or has expired, the new session's first signed request parks and the SDK firesOSUserJwtInvalidatedListener. The demo shows that ask as the banner above and clears its own stored copy, so the path a customer's app hits on relaunch is visible instead of being papered over by re-supplying the token at launch.Test plan
onesignal_idfetch still hydratesexternal_idwith a bearer and the status row reads OK/,#, space), confirm the GET does not misroute