Auth SW: ask page for token on miss before falling through#4828
Auth SW: ask page for token on miss before falling through#4828richardhjtan wants to merge 2 commits into
Conversation
When the auth service worker intercepts a GET to a known realm host but has no token in its in-memory map, send a MessageChannel request to the controlling page asking for one and retry with auth if a token comes back. Falls through to the existing unauthed-fetch behavior when no token is available. Fixes the broken-image-icon symptom on first paint for: SW activation races (per-realm sync was dropped because navigator.serviceWorker.controller was null), and any other window where the SW's token map is stale relative to localStorage. The page reads from localStorage via the existing SessionLocalStorageKey, so this is purely a freshness fix — it does not change which realms a user has access to. The host-side listener replies via the MessagePort; single-flight per request URL keeps a burst of <img> tags from triggering a burst of postMessages. Origin-gated to known realm hosts so unrelated cross-origin asset requests don't pay the round-trip cost. CS-11144 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Preview deploymentsHost Test Results 1 files ±0 1 suites ±0 1h 39m 49s ⏱️ - 1m 10s Results for commit 2b9f986. ± Comparison against earlier commit df97848. Realm Server Test Results 1 files ±0 1 suites ±0 11m 18s ⏱️ +22s Results for commit 2b9f986. ± Comparison against earlier commit df97848. |
There was a problem hiding this comment.
Pull request overview
Adds a service-worker fallback path so intercepted realm asset requests can recover when the SW token cache misses by asking the controlled page for a token from localStorage before falling back to an unauthenticated fetch.
Changes:
- Tracks known realm origins and in-flight token requests in the auth service worker.
- Adds a page-side
request-realm-tokenmessage handler that resolves the best matching token from localStorage. - Extends unit coverage for on-miss token lookup, caching, single-flight behavior, and fall-through.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
packages/host/public/auth-service-worker.js |
Adds known-host tracking, token lookup via MessageChannel, and retry/fall-through logic. |
packages/host/app/utils/auth-service-worker-registration.ts |
Adds the page-side service worker message handler and localStorage token resolution. |
packages/host/tests/unit/auth-service-worker-test.ts |
Updates the simulated SW test harness and adds coverage for the fallback path. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // No token in the map. Only attempt the on-miss client fallback when the | ||
| // request is to a host we've ever held a realm token for — that keeps the | ||
| // round-trip cost off every unrelated cross-origin asset request. | ||
| let requestOrigin; | ||
| try { | ||
| requestOrigin = new URL(url).origin; | ||
| } catch { | ||
| return; | ||
| } | ||
| if (!realmHosts.has(requestOrigin)) { |
| // Ask the first window client. If multiple are open, any one of them | ||
| // can answer from the shared localStorage / session state. | ||
| clientList[0].postMessage({ type: 'request-realm-token', requestURL }, [ | ||
| channel.port2, |
| let timer = setTimeout(() => { | ||
| if (settled) return; | ||
| settled = true; | ||
| resolve(undefined); | ||
| }, TOKEN_REQUEST_TIMEOUT_MS); | ||
| channel.port1.onmessage = (event) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| clearTimeout(timer); | ||
| let reply = event.data; | ||
| if (reply && reply.realmURL && reply.token) { | ||
| realmTokens.set(reply.realmURL, reply.token); | ||
| recordRealmHost(reply.realmURL); | ||
| resolve(reply.token); | ||
| } else { | ||
| resolve(undefined); | ||
| } | ||
| }; | ||
| // Ask the first window client. If multiple are open, any one of them | ||
| // can answer from the shared localStorage / session state. | ||
| clientList[0].postMessage({ type: 'request-realm-token', requestURL }, [ | ||
| channel.port2, | ||
| ]); |
…lot review) Three follow-ups from the PR review: 1. Cold start. The on-miss client fallback used to require realmHosts to already contain the request's origin. At cold start (SW just activated, page hasn't synced yet) realmHosts is empty even though localStorage has tokens — which is the exact stale-cache case the fallback is meant to recover. Now: when realmHosts is empty, allow the fallback through; once populated, gate as before to keep the round-trip off unrelated cross-origin asset requests. 2. Ask the initiating client first. With skipWaiting() + clients.claim() multiple tabs can be controlled by this SW where some still run an older bundle without the request-realm-token listener. Always asking "first window" could hang on such a tab. Prefer event.clientId, fall back to first window only if the initiating client isn't a window (or no clientId was provided). 3. Timeout-path test. The test harness now mirrors the SW's race- against-timer behavior so a stuck clientTokenLookup results in fallthrough-fetch rather than hanging the suite. CS-11144 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
packages/host/public/auth-service-worker.js:143
- A delayed MessageChannel reply is accepted and cached unconditionally. If a
clear-tokens,remove-realm-token, or newersync-tokensmessage arrives while this lookup is in flight, this handler can re-add a token after logout or overwrite a freshly synced token with a stale one; the fallback should be invalidated or version-checked against later token mutations before caching/using the reply.
if (reply && reply.realmURL && reply.token) {
realmTokens.set(reply.realmURL, reply.token);
recordRealmHost(reply.realmURL);
resolve(reply.token);
| // No token in the map. Attempt the on-miss client fallback when either | ||
| // (a) the SW has not yet learned any realm hosts (cold-start: SW just | ||
| // activated and the page hasn't synced yet — exactly when we want the | ||
| // fallback to recover from a stale empty cache), or (b) the request | ||
| // origin matches a host we have ever held a token for. Skip the | ||
| // fallback for clearly-unrelated cross-origin assets once realmHosts | ||
| // is populated. | ||
| let requestOrigin; | ||
| try { | ||
| requestOrigin = new URL(url).origin; | ||
| } catch { | ||
| return; | ||
| } | ||
| if (realmHosts.size > 0 && !realmHosts.has(requestOrigin)) { |
| async function requestTokenFromClient(requestURL, initiatingClientId) { | ||
| // Single-flight per request URL | ||
| let existing = inflightTokenRequests.get(requestURL); | ||
| if (existing) { | ||
| return existing; |
| navigator.serviceWorker.addEventListener('message', (event) => { | ||
| if (!event.data || event.data.type !== 'request-realm-token') { | ||
| return; | ||
| } | ||
| let port = event.ports?.[0]; | ||
| if (!port) { | ||
| return; | ||
| } | ||
| let { realmURL, token } = resolveTokenForRequestURL(event.data.requestURL); | ||
| port.postMessage({ realmURL, token }); |
Summary
When the auth service worker intercepts an image / asset GET to a known realm host but has no token in its in-memory map, ask the controlling page for one via
MessageChanneland retry withAuthorizationif a token comes back. Falls through to the existing unauthed fetch when no token is available, so current behavior is preserved for non-realm hosts and for users who genuinely have no session for the realm.Part of a broader investigation into broken-image symptoms on staging and production where users see broken
<img>icons and a manual refresh fixes them. This PR addresses one of the failure modes — image requests that 401 because the SW's token map is stale relative to localStorage. Full investigation report shared separately.Linear: CS-11144
What this fixes
The SW caches realm tokens in memory and is updated by the host page via
postMessage. If the cache is missing a token at the moment an<img>GET is intercepted, the SW used to forward the request unauthenticated and the realm-server returned 401. After this PR, the SW asks the page for the token, reads from the authoritativeSessionLocalStorageKeyin localStorage, and retries with auth.This is a freshness / resilience fix on the existing auth path — it does not change which realms a user has access to.
Implementation notes
MessageChannelround-trip.Files
packages/host/public/auth-service-worker.js— fetch handler now does on-miss MessageChannel lookup.packages/host/app/utils/auth-service-worker-registration.ts— listens for the SW's token request and replies from localStorage.packages/host/tests/unit/auth-service-worker-test.ts— extended to cover the new path: positive case, single-flight, fall-through.Test plan
pnpm lint/pnpm lint:typesgreen.Out of scope
Other failure modes from the same investigation (URL-construction bugs on the indexer side, prerender base-URL handling, content-negotiation edge cases) are tracked separately and will land on their own branches.
🤖 Generated with Claude Code