From 82da3f88cac60fe263f13153d9d66109ab7e9ea5 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Thu, 14 May 2026 19:57:50 +0800 Subject: [PATCH 1/2] Auth SW: ask page for token on miss before falling through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../utils/auth-service-worker-registration.ts | 47 +++ packages/host/public/auth-service-worker.js | 158 +++++++-- .../tests/unit/auth-service-worker-test.ts | 307 ++++++++++++++---- 3 files changed, 420 insertions(+), 92 deletions(-) diff --git a/packages/host/app/utils/auth-service-worker-registration.ts b/packages/host/app/utils/auth-service-worker-registration.ts index c35048c8a88..f2c3e5951a2 100644 --- a/packages/host/app/utils/auth-service-worker-registration.ts +++ b/packages/host/app/utils/auth-service-worker-registration.ts @@ -32,6 +32,23 @@ export async function registerAuthServiceWorker(): Promise { } }); + // Respond to on-miss token lookups from the SW. The SW asks here when it + // intercepts a GET to a known realm host but has no token in its in-memory + // map (SW activation race, post-upload window before per-realm sync lands, + // etc.). localStorage is the authoritative source of currently-valid + // tokens — the SW's map is a derived cache. + 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 }); + }); + try { await navigator.serviceWorker.register('/auth-service-worker.js', { scope: '/', @@ -121,3 +138,33 @@ function readTokensFromStorage(): Record | undefined { } return undefined; } + +// Find the longest realm-URL prefix in localStorage that matches the given +// request URL. Returns `undefined` for both fields when nothing matches — +// the SW will then preserve its existing pass-through behavior for that +// request. +function resolveTokenForRequestURL(requestURL: string | undefined): { + realmURL?: string; + token?: string; +} { + if (!requestURL) { + return {}; + } + let tokens = readTokensFromStorage(); + if (!tokens) { + return {}; + } + let bestRealmURL: string | undefined; + for (let realmURL of Object.keys(tokens)) { + if ( + requestURL.startsWith(realmURL) && + (!bestRealmURL || realmURL.length > bestRealmURL.length) + ) { + bestRealmURL = realmURL; + } + } + if (!bestRealmURL) { + return {}; + } + return { realmURL: bestRealmURL, token: tokens[bestRealmURL] }; +} diff --git a/packages/host/public/auth-service-worker.js b/packages/host/public/auth-service-worker.js index 3649ec10643..8f573d5fc25 100644 --- a/packages/host/public/auth-service-worker.js +++ b/packages/host/public/auth-service-worker.js @@ -4,10 +4,31 @@ // headers. This service worker intercepts those requests and adds the JWT // Bearer token so that authenticated realm images load correctly. // -// Tokens are synced from the main thread via postMessage. +// Tokens are synced from the main thread via postMessage. If a request hits +// a known realm host but no token has been synced yet (SW activation race, +// localStorage write happening just before the SW message round-trip lands, +// etc.), the SW asks the controlling page for a token via MessageChannel and +// retries once before falling through. // Map of realm URL prefix → JWT token const realmTokens = new Map(); +// Set of origins (e.g. "https://app.boxel.ai") that we have ever seen a +// realm token for. Used to scope the on-miss MessageChannel fallback so we +// don't message the page on every cross-origin font / analytics request. +const realmHosts = new Set(); +// In-flight token requests, keyed by request URL, single-flight so a burst +// of tags doesn't trigger a burst of postMessages. +const inflightTokenRequests = new Map(); + +const TOKEN_REQUEST_TIMEOUT_MS = 200; + +function recordRealmHost(realmURL) { + try { + realmHosts.add(new URL(realmURL).origin); + } catch { + // ignore malformed input + } +} self.addEventListener('install', () => { // Activate immediately, don't wait for existing clients to close @@ -29,6 +50,7 @@ self.addEventListener('message', (event) => { case 'set-realm-token': if (data.realmURL && data.token) { realmTokens.set(data.realmURL, data.token); + recordRealmHost(data.realmURL); } break; case 'remove-realm-token': @@ -38,6 +60,9 @@ self.addEventListener('message', (event) => { break; case 'clear-tokens': realmTokens.clear(); + // Keep realmHosts: clearing tokens (e.g. logout) doesn't change which + // hosts are "realm hosts," and keeping the set means the on-miss + // fallback still asks the page after re-login. break; case 'sync-tokens': // Bulk sync: data.tokens is a {realmURL: token} object @@ -46,6 +71,7 @@ self.addEventListener('message', (event) => { for (let [realmURL, token] of Object.entries(data.tokens)) { if (token) { realmTokens.set(realmURL, token); + recordRealmHost(realmURL); } } } @@ -53,24 +79,7 @@ self.addEventListener('message', (event) => { } }); -self.addEventListener('fetch', (event) => { - let request = event.request; - - // Only inject auth for GET and HEAD requests (resource loading). - // Other methods (POST, PUT, DELETE, etc.) are handled by the app's - // fetch middleware which already adds Authorization headers. - if (request.method !== 'GET' && request.method !== 'HEAD') { - return; - } - - // Don't inject if request already has an Authorization header - if (request.headers.get('Authorization')) { - return; - } - - let url = request.url; - - // Find the matching realm token with longest-prefix match +function lookupToken(url) { let matchedRealmURL = null; let matchedToken = null; for (let [realmURL, token] of realmTokens) { @@ -81,31 +90,120 @@ self.addEventListener('fetch', (event) => { } } } + return matchedToken; +} - if (!matchedToken) { - // Not a realm URL or no token available — pass through unchanged - return; +async function requestTokenFromClient(requestURL) { + // Single-flight per request URL + let existing = inflightTokenRequests.get(requestURL); + if (existing) { + return existing; } + let promise = (async () => { + let clientList = await self.clients.matchAll({ type: 'window' }); + if (clientList.length === 0) { + return undefined; + } + return new Promise((resolve) => { + let channel = new MessageChannel(); + let settled = false; + 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, + ]); + }); + })(); + inflightTokenRequests.set(requestURL, promise); + promise.finally(() => { + inflightTokenRequests.delete(requestURL); + }); + return promise; +} - // Create a new request with the Authorization header injected. - // +function buildAuthedRequest(request, token) { // Cross-origin and CSS background-image requests arrive with // mode: 'no-cors', which silently strips non-safelisted headers like // Authorization. We must upgrade to mode: 'cors' so the header is - // actually sent. The realm server already supports CORS with + // actually sent. The realm server supports CORS with // Access-Control-Allow-Origin: * and Authorization in allowed headers. - let headers = new Headers(request.headers); - headers.set('Authorization', `Bearer ${matchedToken}`); - + // // credentials must be explicitly set to 'same-origin' because cross-origin // requests default to 'include', and credentials: 'include' with // mode: 'cors' requires the server to send a specific origin in // Access-Control-Allow-Origin (not '*'), which the realm server doesn't do. - let authedRequest = new Request(request, { + let headers = new Headers(request.headers); + headers.set('Authorization', `Bearer ${token}`); + return new Request(request, { headers, mode: 'cors', credentials: 'same-origin', }); +} + +self.addEventListener('fetch', (event) => { + let request = event.request; + + // Only inject auth for GET and HEAD requests (resource loading). + // Other methods (POST, PUT, DELETE, etc.) are handled by the app's + // fetch middleware which already adds Authorization headers. + if (request.method !== 'GET' && request.method !== 'HEAD') { + return; + } - event.respondWith(fetch(authedRequest)); + // Don't inject if request already has an Authorization header + if (request.headers.get('Authorization')) { + return; + } + + let url = request.url; + let matchedToken = lookupToken(url); + + if (matchedToken) { + event.respondWith(fetch(buildAuthedRequest(request, matchedToken))); + return; + } + + // 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)) { + return; + } + + event.respondWith( + (async () => { + let token = await requestTokenFromClient(url); + if (token) { + return fetch(buildAuthedRequest(request, token)); + } + // No token available; preserve existing behavior (let it pass through + // and 401, rather than synthesizing a response). + return fetch(request); + })(), + ); }); diff --git a/packages/host/tests/unit/auth-service-worker-test.ts b/packages/host/tests/unit/auth-service-worker-test.ts index 07aa227f760..f1ddae55a03 100644 --- a/packages/host/tests/unit/auth-service-worker-test.ts +++ b/packages/host/tests/unit/auth-service-worker-test.ts @@ -3,11 +3,31 @@ import { module, test } from 'qunit'; // Test the auth service worker's fetch interception logic by simulating // the SW environment. The actual SW is at public/auth-service-worker.js. // -// We duplicate the core logic here (token matching, fetch interception) -// to test it in a standard QUnit context where service workers aren't available. - -function createServiceWorkerEnv() { +// We duplicate the core logic here (token matching, fetch interception, +// on-miss client fallback) to test it in a standard QUnit context where +// service workers aren't available. + +function createServiceWorkerEnv( + opts: { + // Simulates the response the controlling client would send when the SW + // requests a token via MessageChannel. Returns `undefined` to indicate + // the page has no token for that request URL. + clientTokenLookup?: ( + requestURL: string, + ) => Promise<{ realmURL: string; token: string } | undefined>; + } = {}, +) { const realmTokens = new Map(); + const realmHosts = new Set(); + const inflightTokenRequests = new Map>(); + + function recordRealmHost(realmURL: string) { + try { + realmHosts.add(new URL(realmURL).origin); + } catch { + /* ignore */ + } + } let processMessage = (data: any) => { if (!data || !data.type) return; @@ -15,6 +35,7 @@ function createServiceWorkerEnv() { case 'set-realm-token': if (data.realmURL && data.token) { realmTokens.set(data.realmURL, data.token); + recordRealmHost(data.realmURL); } break; case 'remove-realm-token': @@ -31,6 +52,7 @@ function createServiceWorkerEnv() { for (let [realmURL, token] of Object.entries(data.tokens)) { if (token) { realmTokens.set(realmURL, token as string); + recordRealmHost(realmURL); } } } @@ -38,39 +60,91 @@ function createServiceWorkerEnv() { } }; - let processFetch = (request: Request): Request | null => { + function lookupToken(url: string): string | undefined { + let bestRealmURL: string | undefined; + let bestToken: string | undefined; + for (let [realmURL, token] of realmTokens) { + if (url.startsWith(realmURL)) { + if (!bestRealmURL || realmURL.length > bestRealmURL.length) { + bestRealmURL = realmURL; + bestToken = token; + } + } + } + return bestToken; + } + + async function requestTokenFromClient( + requestURL: string, + ): Promise { + let existing = inflightTokenRequests.get(requestURL); + if (existing) return existing; + let promise = (async () => { + if (!opts.clientTokenLookup) return undefined; + let reply = await opts.clientTokenLookup(requestURL); + if (reply && reply.realmURL && reply.token) { + realmTokens.set(reply.realmURL, reply.token); + recordRealmHost(reply.realmURL); + return reply.token; + } + return undefined; + })(); + inflightTokenRequests.set(requestURL, promise); + promise.finally(() => inflightTokenRequests.delete(requestURL)); + return promise; + } + + function buildAuthedRequest(request: Request, token: string): Request { + let headers = new Headers(request.headers); + headers.set('Authorization', `Bearer ${token}`); + return new Request(request, { headers, mode: 'cors' }); + } + + // Returns: + // - Request: the SW would respondWith fetch of this authed Request + // - 'pass-through': the SW would not intercept (returns from fetch handler) + // - 'fallthrough-fetch': the SW called respondWith but with the original + // request (client had no token); will hit the network unauth'd + let processFetch = async ( + request: Request, + ): Promise => { if (request.method !== 'GET' && request.method !== 'HEAD') { - return null; // pass through + return 'pass-through'; } if (request.headers.get('Authorization')) { - return null; // pass through + return 'pass-through'; } let url = request.url; - let matchedRealmURL: string | null = null; - let matchedToken: string | null = null; - for (let [realmURL, token] of realmTokens) { - if (url.startsWith(realmURL)) { - if (!matchedRealmURL || realmURL.length > matchedRealmURL.length) { - matchedRealmURL = realmURL; - matchedToken = token; - } - } + let matchedToken = lookupToken(url); + if (matchedToken) { + return buildAuthedRequest(request, matchedToken); } - if (!matchedToken) { - return null; // pass through + let origin: string; + try { + origin = new URL(url).origin; + } catch { + return 'pass-through'; + } + if (!realmHosts.has(origin)) { + return 'pass-through'; } - // Mirror the actual SW: construct from original request, override headers - // and upgrade mode to 'cors' (cross-origin arrives as 'no-cors' - // which would strip the Authorization header). - let headers = new Headers(request.headers); - headers.set('Authorization', `Bearer ${matchedToken}`); - return new Request(request, { headers, mode: 'cors' }); + let token = await requestTokenFromClient(url); + if (token) { + return buildAuthedRequest(request, token); + } + return 'fallthrough-fetch'; }; - return { processMessage, processFetch, realmTokens }; + return { + processMessage, + processFetch, + realmTokens, + realmHosts, + inflightTokenRequests, + }; } module('Unit | auth-service-worker', function () { @@ -165,10 +239,26 @@ module('Unit | auth-service-worker', function () { sw.processMessage({ realmURL: 'http://example.com/', token: 'x' }); assert.strictEqual(sw.realmTokens.size, 0); }); + + test('realmHosts is populated on token sync', function (assert) { + let sw = createServiceWorkerEnv(); + sw.processMessage({ + type: 'set-realm-token', + realmURL: 'http://localhost:4201/user/realm/', + token: 't', + }); + assert.true(sw.realmHosts.has('http://localhost:4201')); + + sw.processMessage({ + type: 'sync-tokens', + tokens: { 'https://app.boxel.ai/user/realm/': 't2' }, + }); + assert.true(sw.realmHosts.has('https://app.boxel.ai')); + }); }); module('fetch interception', function () { - test('injects Authorization header for matching realm URL', function (assert) { + test('injects Authorization header for matching realm URL', async function (assert) { let sw = createServiceWorkerEnv(); sw.processMessage({ @@ -180,17 +270,22 @@ module('Unit | auth-service-worker', function () { let request = new Request( 'http://localhost:4201/user/realm/images/photo.png', ); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.ok(result, 'request was intercepted'); + assert.ok(result instanceof Request, 'request was intercepted'); assert.strictEqual( - result!.headers.get('Authorization'), + (result as Request).headers.get('Authorization'), 'Bearer my-jwt-token', ); }); - test('passes through requests to non-realm URLs', function (assert) { - let sw = createServiceWorkerEnv(); + test('passes through requests to non-realm hosts (no message round-trip)', async function (assert) { + let sw = createServiceWorkerEnv({ + clientTokenLookup: async () => { + assert.notOk(true, 'should not ask the client for unknown hosts'); + return undefined; + }, + }); sw.processMessage({ type: 'set-realm-token', @@ -199,12 +294,12 @@ module('Unit | auth-service-worker', function () { }); let request = new Request('https://cdn.example.com/image.png'); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.strictEqual(result, null, 'request was not intercepted'); + assert.strictEqual(result, 'pass-through'); }); - test('passes through POST requests even for realm URLs', function (assert) { + test('passes through POST requests even for realm URLs', async function (assert) { let sw = createServiceWorkerEnv(); sw.processMessage({ @@ -216,12 +311,12 @@ module('Unit | auth-service-worker', function () { let request = new Request('http://localhost:4201/user/realm/card.json', { method: 'POST', }); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.strictEqual(result, null, 'POST request was not intercepted'); + assert.strictEqual(result, 'pass-through'); }); - test('passes through requests that already have Authorization header', function (assert) { + test('passes through requests that already have Authorization header', async function (assert) { let sw = createServiceWorkerEnv(); sw.processMessage({ @@ -233,16 +328,12 @@ module('Unit | auth-service-worker', function () { let request = new Request('http://localhost:4201/user/realm/card.json', { headers: { Authorization: 'Bearer existing-token' }, }); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.strictEqual( - result, - null, - 'request with existing auth was not intercepted', - ); + assert.strictEqual(result, 'pass-through'); }); - test('uses longest-prefix match when multiple realms match', function (assert) { + test('uses longest-prefix match when multiple realms match', async function (assert) { let sw = createServiceWorkerEnv(); sw.processMessage({ @@ -259,17 +350,16 @@ module('Unit | auth-service-worker', function () { let request = new Request( 'http://localhost:4201/user/realm/images/photo.png', ); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.ok(result, 'request was intercepted'); + assert.ok(result instanceof Request); assert.strictEqual( - result!.headers.get('Authorization'), + (result as Request).headers.get('Authorization'), 'Bearer realm-specific-token', - 'used the more specific realm token', ); }); - test('intercepts HEAD requests', function (assert) { + test('intercepts HEAD requests', async function (assert) { let sw = createServiceWorkerEnv(); sw.processMessage({ @@ -282,16 +372,16 @@ module('Unit | auth-service-worker', function () { 'http://localhost:4201/user/realm/images/photo.png', { method: 'HEAD' }, ); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.ok(result, 'HEAD request was intercepted'); + assert.ok(result instanceof Request); assert.strictEqual( - result!.headers.get('Authorization'), + (result as Request).headers.get('Authorization'), 'Bearer my-jwt-token', ); }); - test('upgrades request mode to cors for intercepted requests', function (assert) { + test('upgrades request mode to cors for intercepted requests', async function (assert) { let sw = createServiceWorkerEnv(); sw.processMessage({ @@ -300,29 +390,122 @@ module('Unit | auth-service-worker', function () { token: 'my-jwt-token', }); - // Cross-origin elements arrive with mode: 'no-cors', which would - // silently strip the Authorization header. The SW must upgrade to 'cors'. let request = new Request( 'http://localhost:4201/user/realm/images/photo.png', { mode: 'no-cors' }, ); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.ok(result, 'request was intercepted'); - assert.strictEqual(result!.mode, 'cors', 'mode was upgraded to cors'); + assert.ok(result instanceof Request); + assert.strictEqual((result as Request).mode, 'cors'); assert.strictEqual( - result!.headers.get('Authorization'), + (result as Request).headers.get('Authorization'), 'Bearer my-jwt-token', ); }); - test('returns null when no tokens are set', function (assert) { + test('returns pass-through when no tokens or hosts are known', async function (assert) { let sw = createServiceWorkerEnv(); let request = new Request('http://localhost:4201/user/realm/image.png'); - let result = sw.processFetch(request); + let result = await sw.processFetch(request); - assert.strictEqual(result, null, 'no interception without tokens'); + assert.strictEqual(result, 'pass-through'); + }); + }); + + module('on-miss client fallback', function () { + test('asks the client for a token when the host is known but no token matches', async function (assert) { + let askCount = 0; + let sw = createServiceWorkerEnv({ + clientTokenLookup: async (requestURL) => { + askCount += 1; + assert.strictEqual( + requestURL, + 'http://localhost:4201/other-realm/image.png', + ); + return { + realmURL: 'http://localhost:4201/other-realm/', + token: 'late-arriving-token', + }; + }, + }); + + // Seed realmHosts via a prior token for a different realm on the same host. + sw.processMessage({ + type: 'set-realm-token', + realmURL: 'http://localhost:4201/user/realm/', + token: 'existing-token', + }); + + let request = new Request('http://localhost:4201/other-realm/image.png'); + let result = await sw.processFetch(request); + + assert.strictEqual(askCount, 1, 'client was asked exactly once'); + assert.ok(result instanceof Request, 'request was retried with auth'); + assert.strictEqual( + (result as Request).headers.get('Authorization'), + 'Bearer late-arriving-token', + ); + // Token is now cached in the SW for next time. + assert.strictEqual( + sw.realmTokens.get('http://localhost:4201/other-realm/'), + 'late-arriving-token', + ); + }); + + test('single-flights concurrent miss requests for the same URL', async function (assert) { + let askCount = 0; + let release: () => void; + let releaseSignal = new Promise((resolve) => { + release = resolve; + }); + let sw = createServiceWorkerEnv({ + clientTokenLookup: async () => { + askCount += 1; + await releaseSignal; + return { + realmURL: 'http://localhost:4201/r/', + token: 'tok', + }; + }, + }); + sw.processMessage({ + type: 'set-realm-token', + realmURL: 'http://localhost:4201/seed/', + token: 'seed', + }); + + // Fire two concurrent requests for the same URL before the first + // ask resolves. + let p1 = sw.processFetch( + new Request('http://localhost:4201/r/image.png'), + ); + let p2 = sw.processFetch( + new Request('http://localhost:4201/r/image.png'), + ); + + // The client should only have been asked once even though two requests + // are in flight. + release!(); + await Promise.all([p1, p2]); + assert.strictEqual(askCount, 1, 'client asked exactly once'); + }); + + test('falls through to unauthed fetch when client has no token', async function (assert) { + let sw = createServiceWorkerEnv({ + clientTokenLookup: async () => undefined, + }); + sw.processMessage({ + type: 'set-realm-token', + realmURL: 'http://localhost:4201/seed/', + token: 'seed', + }); + + let result = await sw.processFetch( + new Request('http://localhost:4201/unknown/image.png'), + ); + assert.strictEqual(result, 'fallthrough-fetch'); }); }); }); From fe889a49c0b39a45597d24e4c7e968204a72de33 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Thu, 14 May 2026 23:15:27 +0800 Subject: [PATCH 2/2] Auth SW: cold-start fallback + initiating client + timeout test (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/host/public/auth-service-worker.js | 44 ++++++++--- .../tests/unit/auth-service-worker-test.ts | 78 ++++++++++++++++++- 2 files changed, 107 insertions(+), 15 deletions(-) diff --git a/packages/host/public/auth-service-worker.js b/packages/host/public/auth-service-worker.js index 8f573d5fc25..03f4b293eb4 100644 --- a/packages/host/public/auth-service-worker.js +++ b/packages/host/public/auth-service-worker.js @@ -93,15 +93,35 @@ function lookupToken(url) { return matchedToken; } -async function requestTokenFromClient(requestURL) { +async function pickClientToAsk(initiatingClientId) { + // Prefer the client that initiated the fetch. 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; if we always ask the "first" window we can hang waiting + // for a client that cannot answer. + if (initiatingClientId) { + try { + let initiating = await self.clients.get(initiatingClientId); + if (initiating && initiating.type === 'window') { + return initiating; + } + } catch { + // ignore and fall through to broadcast + } + } + let clientList = await self.clients.matchAll({ type: 'window' }); + return clientList[0]; +} + +async function requestTokenFromClient(requestURL, initiatingClientId) { // Single-flight per request URL let existing = inflightTokenRequests.get(requestURL); if (existing) { return existing; } let promise = (async () => { - let clientList = await self.clients.matchAll({ type: 'window' }); - if (clientList.length === 0) { + let client = await pickClientToAsk(initiatingClientId); + if (!client) { return undefined; } return new Promise((resolve) => { @@ -125,9 +145,7 @@ async function requestTokenFromClient(requestURL) { 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 }, [ + client.postMessage({ type: 'request-realm-token', requestURL }, [ channel.port2, ]); }); @@ -182,22 +200,26 @@ self.addEventListener('fetch', (event) => { return; } - // 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. + // 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.has(requestOrigin)) { + if (realmHosts.size > 0 && !realmHosts.has(requestOrigin)) { return; } event.respondWith( (async () => { - let token = await requestTokenFromClient(url); + let token = await requestTokenFromClient(url, event.clientId); if (token) { return fetch(buildAuthedRequest(request, token)); } diff --git a/packages/host/tests/unit/auth-service-worker-test.ts b/packages/host/tests/unit/auth-service-worker-test.ts index f1ddae55a03..f76a7ac9320 100644 --- a/packages/host/tests/unit/auth-service-worker-test.ts +++ b/packages/host/tests/unit/auth-service-worker-test.ts @@ -15,6 +15,10 @@ function createServiceWorkerEnv( clientTokenLookup?: ( requestURL: string, ) => Promise<{ realmURL: string; token: string } | undefined>; + // Mirrors the SW's TOKEN_REQUEST_TIMEOUT_MS. When `clientTokenLookup` + // does not settle within this timeout, the scaffold resolves to + // `undefined` just like the real SW would. + tokenRequestTimeoutMs?: number; } = {}, ) { const realmTokens = new Map(); @@ -81,7 +85,18 @@ function createServiceWorkerEnv( if (existing) return existing; let promise = (async () => { if (!opts.clientTokenLookup) return undefined; - let reply = await opts.clientTokenLookup(requestURL); + let lookup = opts.clientTokenLookup(requestURL); + let reply: { realmURL: string; token: string } | undefined; + if (typeof opts.tokenRequestTimeoutMs === 'number') { + reply = await Promise.race([ + lookup, + new Promise((resolve) => + setTimeout(() => resolve(undefined), opts.tokenRequestTimeoutMs), + ), + ]); + } else { + reply = await lookup; + } if (reply && reply.realmURL && reply.token) { realmTokens.set(reply.realmURL, reply.token); recordRealmHost(reply.realmURL); @@ -127,7 +142,7 @@ function createServiceWorkerEnv( } catch { return 'pass-through'; } - if (!realmHosts.has(origin)) { + if (realmHosts.size > 0 && !realmHosts.has(origin)) { return 'pass-through'; } @@ -404,13 +419,17 @@ module('Unit | auth-service-worker', function () { ); }); - test('returns pass-through when no tokens or hosts are known', async function (assert) { + test('falls through (does not pass-through) at cold start with no client available', async function (assert) { + // realmHosts is empty so the SW does not know which origins are realm + // hosts and must try the on-miss client lookup. With no client and no + // token, the SW lands in the unauthed-refetch path rather than + // skipping interception entirely. let sw = createServiceWorkerEnv(); let request = new Request('http://localhost:4201/user/realm/image.png'); let result = await sw.processFetch(request); - assert.strictEqual(result, 'pass-through'); + assert.strictEqual(result, 'fallthrough-fetch'); }); }); @@ -507,5 +526,56 @@ module('Unit | auth-service-worker', function () { ); assert.strictEqual(result, 'fallthrough-fetch'); }); + + test('asks the client at cold start when realmHosts is empty', async function (assert) { + // SW just activated, page has not synced yet: realmHosts is empty. + // The page may still hold valid tokens in localStorage, so the SW + // must reach out instead of silently passing through. + let sw = createServiceWorkerEnv({ + clientTokenLookup: async (requestURL) => { + assert.strictEqual( + requestURL, + 'http://localhost:4201/realm/image.png', + ); + return { + realmURL: 'http://localhost:4201/realm/', + token: 'late-synced-token', + }; + }, + }); + + let result = await sw.processFetch( + new Request('http://localhost:4201/realm/image.png'), + ); + + assert.ok(result instanceof Request); + assert.strictEqual( + (result as Request).headers.get('Authorization'), + 'Bearer late-synced-token', + ); + assert.true( + sw.realmHosts.has('http://localhost:4201'), + 'realmHosts is populated after the cold-start lookup', + ); + }); + + test('times out and falls through when the client never replies', async function (assert) { + // Simulates an old controlled tab that has no request-realm-token + // listener installed. The SW must not hang waiting for it. + let sw = createServiceWorkerEnv({ + tokenRequestTimeoutMs: 10, + clientTokenLookup: () => new Promise(() => {}), + }); + sw.processMessage({ + type: 'set-realm-token', + realmURL: 'http://localhost:4201/seed/', + token: 'seed', + }); + + let result = await sw.processFetch( + new Request('http://localhost:4201/r/image.png'), + ); + assert.strictEqual(result, 'fallthrough-fetch'); + }); }); });