Skip to content

feat: [SDK-5465] broadcast a worker 401 to the page so the page invalidates the JWT - #1528

Merged
sherwinski merged 7 commits into
sherwin/sdk-5466from
sherwin/sdk-5465
Oct 26, 2026
Merged

sherwinski merged 7 commits into
sherwin/sdk-5466from
sherwin/sdk-5465

Conversation

@sherwinski

@sherwinski sherwinski commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Description

1 Line Summary

The service worker tells the page which JWT the server rejected, and the page removes that token and fires userJwtInvalidated.

Details

Stacked on #1527 (SDK-5466). Review only the last 7 commits.

After the first page view the session traffic (on_session upsert, session_time deactivate) runs in the worker only. On a 401 the worker only logged an error, because it has no token store. An expired or revoked token could get a 401 on every focus, blur, and unload, while the page never removed it and never fired userJwtInvalidated. The app got no signal to refresh the token until a page request also got a 401.

Worker (api/sw.ts): notifyPageIfUnauthorized replaces logIfUnauthorized. On a 401 for a signed request it still logs the error, and it posts WorkerMessengerCommand._JwtUnauthorized with { externalId, jwt } to one window client: the focused one, then a visible one, then the first one. One tab only, because tabs in separate processes do not see each other's localStorage delete in time, so a broadcast fired userJwtInvalidated once in every tab. Both the session upsert and the session duration update await it, so the post finishes inside the promise the event holds open and the browser does not stop the worker before postMessage. postToOnePage and broadcastToPages in workerMessenger/sw.ts are standalone functions so api/sw.ts can call them without a messenger instance; WorkerMessengerSW._broadcast now calls broadcastToPages, and other commands still go to all tabs. Both catch a clients.matchAll rejection, so a failed post on the duration path does not skip the outcome request. If the chosen tab throws on postMessage, for example while it closes, postToOnePage sends the report to the next tab in the order.

Page (ServiceWorkerManager._establishServiceWorkerChannel): the handler removes the token only when JwtTokenStore._getJwt(externalId) equals the rejected jwt, the same rule as the page on_session path and OperationRepo. A newer stored token is kept. _invalidateJwt is idempotent, so a repeated report in one tab fires userJwtInvalidated once.

Logs: the page receive log ([WM] Page received:) now redacts a jwt in the payload, as the worker receive log already does. The broadcast log prints the command and the client URL only.

Out of scope, as in the ticket: a retry of the session request with the new token. SDK-5466 covers the next on_session once a token arrives.

Bundle size

Measured with size-limit against the SDK-5466 head:

Bundle SDK-5466 This branch Delta
OneSignalSDK.page.es6.js 45.32 kB 45.48 kB +160 B
OneSignalSDK.sw.js 12.82 kB 13.00 kB +180 B

The page cost is the handler, the command string, and redactJwt, which the page bundle did not include before. The build: commit raises the limits to 45.50 kB (page) and 13.01 kB (worker).

Systems Affected

  • WebSDK
  • Backend
  • Dashboard

Validation

Tests

Info

  • vp test run — 72 files, 881 tests pass.
  • vp check — 0 errors, 0 warnings in the changed files.
  • vp run build:prod — passes with the raised limits.

New tests:

  • ServiceWorker.test.ts: a 401 on a signed upsert logs the error and posts _JwtUnauthorized with the external id and the token; the session request does not settle before the page gets the report; the report goes to one tab only (focused before visible before hidden, the first of several hidden tabs, and the next tab if the chosen one throws on postMessage); a matchAll rejection on the duration path does not skip the outcome; a 200 on a signed request and a 401 on an unsigned request post nothing; a 401 on the signed session duration update posts the same message. The existing "the jwt never reaches a log line" test still passes.
  • ServiceWorkerManager.test.ts: the page handler removes the rejected token and fires userJwtInvalidated once for 2 reports; a newer stored token is kept and nothing fires; the receive log shows jwt: '[redacted]' and no log line contains the token.

Checklist

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

Programming Checklist
Interfaces:

  • Don't use default export
  • New interfaces are in model files

Functions:

  • Don't use default export
  • All function signatures have return types
  • Helpers should not access any data but rather be given the data to operate on.

Typescript:

  • No Typescript warnings
  • Avoid silencing null/undefined warnings with the exclamation point

Other:

  • Iteration: refrain from using elem of array syntax. Prefer forEach or use map (the for...of in broadcastToPages is the existing loop moved out of the class)
  • Avoid using global OneSignal accessor for context if possible. Instead, we can pass it to function/constructor so that we don't call OneSignal.context

Screenshots

Info

Not needed: no UI change.

Checklist

  • I have included screenshots/recordings of the intended results or explained why they are not needed

Related Tickets


@abdulraqeeb33 abdulraqeeb33 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The compare-before-delete rule matches the page, and the JWT stays out of the logs. The broadcast itself is not part of the service worker event, so the invalidation can be dropped.

Comment thread src/shared/api/sw.ts Outdated

@abdulraqeeb33 abdulraqeeb33 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The await and the per-client catch close the earlier bug. The new test does not reach the throwing client, and a matchAll rejection on the duration path skips the outcome.

Comment thread src/sw/serviceWorker/ServiceWorker.test.ts Outdated
Comment thread src/shared/api/sw.ts Outdated
Comment thread src/shared/managers/ServiceWorkerManager.ts
@fadi-george

fadi-george commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Don't address, this just a note:
Onne new edge case, very minor: the report now has a single recipient. If that tab can't handle it, nothing fires for that 401. For example, a same-origin page that doesn't load the SDK, or a tab still starting up before the page has registered its handler for worker messages. It recovers on its own: the rejected token stays stored, so the next worker session request gets a 401 and reports again, likely to a different tab. That's a delay, not a lost event.

After the first page view the session traffic (on_session upsert,
session_time deactivate) runs in the worker only. On a 401 the worker
only logged an error, because it has no token store, so an expired
token could get a 401 on every focus, blur, and unload while the page
never removed it and never fired userJwtInvalidated.

The worker now posts WorkerMessengerCommand._JwtUnauthorized with the
externalId and the rejected jwt to all window clients. The page handler
in _establishServiceWorkerChannel removes the token only when it still
matches the stored one, the same rule as the page on_session path and
the operation queue, so a newer token is kept.

broadcastToPages is a standalone function so api/sw.ts can call it
without a messenger instance; WorkerMessengerSW._broadcast uses it. The
page receive log now redacts a jwt in the payload, as the worker side
does.
…b gets it

notifyPagesIfUnauthorized is awaited from both session requests, so the
broadcast finishes inside the promise the event holds open. broadcastToPages
catches each client.postMessage, so a tab in the middle of closing does not
stop the report to the other tabs.
sendSessionDuration awaits the broadcast in the same try as the outcome
request, so a rejection from matchAll skipped the outcome. The broadcast now
catches it and returns. The closing-client test queues a list for the focus
check first, so the throwing client reaches the broadcast.
Every tab that got the broadcast read, compared, and deleted the token on its
own. Tabs in separate processes do not see each other's localStorage delete in
time, so one 401 fired userJwtInvalidated once in every tab and the app asked
for N tokens.

postToOnePage sends the report to the focused tab, then a visible tab, then the
first tab. If the chosen tab throws, for example while it closes, the next tab
in the order gets the report. Other commands still go to all tabs.
@sherwinski
sherwinski merged commit 051d79b into feat/identity-verification Oct 26, 2026
2 checks passed
@sherwinski
sherwinski deleted the sherwin/sdk-5465 branch October 26, 2026 20:31
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.

3 participants