Skip to content

fix: [SDK-5137] Identity Verification config and token request follow-ups from the JWT review - #1743

Open
nan-li wants to merge 9 commits into
5.8-mainfrom
nan/sdk-5137-jwt-config
Open

nan-li wants to merge 9 commits into
5.8-mainfrom
nan/sdk-5137-jwt-config

Conversation

@nan-li

@nan-li nan-li commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Four follow-ups from the Identity Verification review, covering how the requirement is cached and how the app is asked for a token. Tracked on SDK-5137.

Details

Motivation

Review feedback on the Identity Verification stack was collected on SDK-5137 instead of amending the stacked branches. This PR takes the items around the cached requirement and the token request to the app. #1740 covers the request queue items.

Scope

Each fix is its own commit.

  • Readers of the Identity Verification requirement no longer wait on a disk write. The cached value was written while the lock was held, so every reader across the SDK waited on that write. It now happens after the lock is released.
  • Re-reading the cached requirement at startup now tells listeners about it. That re-read moved the requirement from unknown to the cached value silently, while the normal path runs a handler. Not a live bug today, since nothing listens at that point, but a trap for any later caller.
  • The list of token listeners is created once at startup instead of on first use. Two threads could each create one, and the app's listener could land in the copy that was thrown away. A token request that no listener hears now logs a warning naming the user, and the public listener methods document that the listener is held weakly.
  • Logging in again as the same user after a logout asks for a token again. The SDK asks once per user per session, and only a stored token cleared that. A user who was asked, never answered, logged out, and logged back in without a token was never asked again, so every request for that user stayed queued until the next launch. Each login that builds a new identity for the user now gets a fresh ask.

Follow-ups from review, each its own commit:

  • Three comments that still described the token request as once per session now say that logging in as that user asks again. Comments only.
  • The token request tests wait for the mock client to go idle before the next test starts, the same guard the lifecycle tests use. Test code only.
  • The token request is cleared only when a login brings no usable token; a stored token already clears it, so a login with a token no longer clears it twice.
  • The warning for a token request nobody hears fires only after a listener was registered. Before that it is a debug line, since registering a listener replays the request.
  • The token request tests read only the log lines they are about, under a lock. Test code only.

Not changed: the public API surface, request signing, in-app messaging.

Testing

Unit testing

One or more tests per fix except the first, next to the code they cover. For the second and fourth fixes, the new test was run against the previous commit and failed there, then passed with the fix. The first moves a disk write out of a lock, which no test can observe deterministically, so it has none. The third has no old behavior a test can observe, so its tests cover the new behavior only. The token request tests live in a new file, UserJwtAskTests, because the existing lifecycle test class is at SwiftLint's size limit. The follow-ups add three tests there: a login with an empty token asks again, an unheard request before any listener is registered logs no warning, and one after the registered listener was released does. The second fails with the warning forced on.

Manual testing

Not run on a device. The full unit test plan was run locally on an iPhone 17 Pro simulator.

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

🤖 Generated with Claude Code

`OSUserJwtConfig.hydrate` wrote the requirement to UserDefaults while
holding its lock, and that write flushes to disk. Every reader of
`requirement`, on the repo queue, on main for in-app messages, and on the
executor queues, waited on that flush. Update the value under the lock and
write after it is released, as the log and the handler already were.

Two hydrates racing with different values could now leave disk holding the
older value while memory holds the newer. `hydrate` has one guarded call
site per session, and the next session's params fetch heals a stale cache.
…m the cache

`OSUserJwtConfig.refreshIfUnknown` moved the requirement from unknown to
the cached value without firing the hydrated handler, while `hydrate` fires
it for the same transition. Not a live bug today: its only caller runs in
`OneSignalUserManagerImpl.start()` before any handler is registered, and a
late registrant is called immediately when the requirement is already
known. It was a trap for any future caller, and the User executor's doc
comment cited it as the reason for re-reading the requirement on every
send, which now stands on its own.

Two tests in OSUserJwtConfigTests: the handler fires with the adopted
value, and stays quiet while the cache is still empty. The first fails
without the fix.
…n ask nobody hears

`OneSignalUserManagerImpl.userJwtInvalidatedObserver` was a lazy optional
built with no lock. The ask path reaches it from the executor queues while
`addUserJwtInvalidatedListener` runs on the app's thread, so two first
touches could each build an observer and the app's listener could land in
the one that was discarded. Build it in `init`, and let the JWT repo's
notify closure capture it directly instead of reaching through
`sharedInstance`.

The observable holds listeners weakly, and the ask is the only way the SDK
gets a token, so an ask nobody hears now logs at WARN naming the external
ID. A cold-start ask that beats the app's registration is the normal path
and is replayed when the listener is added, which the message says. The
public listener methods, in the JWT extension, OneSignalFramework.h, and
OneSignalSwiftInterface.swift, now say the listener is held weakly.

Tests in the new UserJwtAskTests, since UserJwtLifecycleTests is at
SwiftLint's type body limit: an unheard ask warns with the external ID, and
a heard one is delivered without a warning. The old closure discarded the
result, so there is no seam to run these against.
…r a logout

`OSUserJwtRepo` asks the app once per external ID per session, and only a
stored token cleared that. `logout` leaves the entry in place, so a user
who was asked, never answered, logged out, and logged back in without a
token was never asked again: `park` got false from `askForToken` and logged
nothing, and every user-scoped call for that user stayed held for the rest
of the process. The same held for login(A), login(B), login(A).

Clear the ask wherever `login` builds a new Identity Model for the user,
in `createNewUser` and `identifyUser`, so each login is a fresh chance to
be asked. Clearing the outgoing user on logout instead would re-ask for A
right after a switch to B whenever A's held Create User is still queued,
which the app did not invite.

Tests: `clearAsk` in OSUserJwtRepoTests, and in UserJwtAskTests a login
with no token, a logout, and the same login again, asserting two asks.
The second fails without the fix.
…at still said once per session

`clearAsk` made three comments stale: the `askForToken` and `pendingTokenAsks` docs in OSUserJwtRepo, and the `park` doc in OSRequestAuth. Each now says a stored token or a login as that user clears the ask.
…kTests teardown

Same gate as UserJwtLifecycleTests: the mock answers late, and a Request still in flight at teardown would land in the next test and hydrate the shared models and JWT repo out from under it.
@nan-li
nan-li marked this pull request as ready for review September 18, 2026 01:24
…ble token

`storeJwt` clears the ask itself when it stores, so the explicit clear before it was redundant on that path and left a window where a flush between the two lines asked for the token the caller had just supplied. The clear now runs only when the login has no token or the repo refused it, so a login with an empty token still asks again.
…ide log

Log listeners hear every line in the process, on whichever thread logged it, so the exact count and the empty check could both trip on a warning from another test class, such as the executor's blocked-request retry. The capture now filters by level and text and appends under a lock.
…er was registered

An ask that fires before the app registers its listener is the cold-start order, and the replay on registration delivers it, so it now logs at debug instead of printing a warning at the default console level on every such launch. The warning stays for an ask after a listener was registered, which means the app let its listener go. The listeners and the flag live together in OSUserJwtInvalidatedListeners, which replaces the static and the bare observer.

Tests: an ask before any registration leaves no warning and one debug line; an ask after the registered listener was released warns. The first fails with the warning forced on.
@nan-li
nan-li changed the base branch from 5.7-main to 5.7-main-temp September 18, 2026 16:40
@nan-li
nan-li changed the base branch from 5.7-main-temp to 5.8-main September 18, 2026 16:43
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