diff --git a/packages/host/app/instance-initializers/register-auth-service-worker.ts b/packages/host/app/instance-initializers/register-auth-service-worker.ts index 552982a0cac..dc52c35161e 100644 --- a/packages/host/app/instance-initializers/register-auth-service-worker.ts +++ b/packages/host/app/instance-initializers/register-auth-service-worker.ts @@ -1,12 +1,32 @@ import type ApplicationInstance from '@ember/application/instance'; -import { registerAuthServiceWorker } from '../utils/auth-service-worker-registration'; +import { + isServiceWorkerSupported, + registerAuthServiceWorker, +} from '../utils/auth-service-worker-registration'; + +import type MatrixService from '../services/matrix-service'; +import type RealmService from '../services/realm'; // Register the auth service worker eagerly at app boot, before any lazy // services are instantiated. This ensures realm tokens are synced to the // SW before card rendering triggers image requests to authenticated realms. -export function initialize(_appInstance: ApplicationInstance): void { - registerAuthServiceWorker(); +export function initialize(appInstance: ApplicationInstance): void { + // Gate before lookup so we don't force eager instantiation of matrix / + // realm services in tests or non-SW environments. + if (!isServiceWorkerSupported()) { + return; + } + let matrixService = appInstance.lookup('service:matrix-service') as + | MatrixService + | undefined; + let realmService = appInstance.lookup('service:realm') as + | RealmService + | undefined; + if (!matrixService || !realmService) { + return; + } + registerAuthServiceWorker({ matrixService, realmService }); } export default { diff --git a/packages/host/app/utils/auth-service-worker-registration.ts b/packages/host/app/utils/auth-service-worker-registration.ts index c35048c8a88..44cbe450504 100644 --- a/packages/host/app/utils/auth-service-worker-registration.ts +++ b/packages/host/app/utils/auth-service-worker-registration.ts @@ -10,7 +10,18 @@ import window from 'ember-window-mock'; import { SessionLocalStorageKey } from './local-storage-keys'; -function isServiceWorkerSupported(): boolean { +// Structural so tests can stub without the full service surface. +export interface AuthServiceWorkerDeps { + realmService: { + realmOf(input: URL): string | undefined; + reauthenticate(realmURL: string): Promise; + }; + matrixService: { + readonly isLoggedIn: boolean; + }; +} + +export function isServiceWorkerSupported(): boolean { return ( !isTesting() && typeof navigator !== 'undefined' && @@ -18,7 +29,9 @@ function isServiceWorkerSupported(): boolean { ); } -export async function registerAuthServiceWorker(): Promise { +export async function registerAuthServiceWorker( + deps: AuthServiceWorkerDeps, +): Promise { if (!isServiceWorkerSupported()) { return; } @@ -32,6 +45,11 @@ export async function registerAuthServiceWorker(): Promise { } }); + navigator.serviceWorker.addEventListener( + 'message', + createTokenRequestHandler(deps), + ); + try { await navigator.serviceWorker.register('/auth-service-worker.js', { scope: '/', @@ -50,6 +68,58 @@ export async function registerAuthServiceWorker(): Promise { } } +export function createTokenRequestHandler(deps: AuthServiceWorkerDeps) { + return async (event: MessageEvent) => { + if (!event.data || event.data.type !== 'request-realm-token') { + return; + } + let port = event.ports?.[0]; + if (!port) { + return; + } + let requestURL: string | undefined = event.data.requestURL; + + let { realmURL, token } = resolveTokenForRequestURL(requestURL); + if (realmURL && token) { + port.postMessage({ realmURL, token }); + return; + } + + let owningRealm: string | undefined; + if (requestURL) { + try { + owningRealm = deps.realmService.realmOf(new URL(requestURL)); + } catch { + owningRealm = undefined; + } + } + if (!owningRealm || !deps.matrixService.isLoggedIn) { + port.postMessage({}); + return; + } + + // Tell the SW to use its refresh budget; reauthenticate is single-flighted + // per realm and syncs the new token to the SW as a side effect. + try { + port.postMessage({ type: 'pending' }); + } catch { + return; + } + try { + let refreshed = await deps.realmService.reauthenticate(owningRealm); + port.postMessage( + refreshed ? { realmURL: owningRealm, token: refreshed } : {}, + ); + } catch { + try { + port.postMessage({}); + } catch { + // port closed (page navigated) + } + } + }; +} + export function syncTokenToServiceWorker( realmURL: string, token: string | undefined, @@ -121,3 +191,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..f1a6f63e7e9 100644 --- a/packages/host/public/auth-service-worker.js +++ b/packages/host/public/auth-service-worker.js @@ -4,10 +4,33 @@ // 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(); + +// Common-case budget. Refresh path posts `{type:'pending'}` to extend. +const TOKEN_REQUEST_TIMEOUT_MS = 200; +const TOKEN_REQUEST_REFRESH_TIMEOUT_MS = 3000; + +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 +52,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 +62,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 +73,7 @@ self.addEventListener('message', (event) => { for (let [realmURL, token] of Object.entries(data.tokens)) { if (token) { realmTokens.set(realmURL, token); + recordRealmHost(realmURL); } } } @@ -53,24 +81,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 +92,151 @@ self.addEventListener('fetch', (event) => { } } } + return matchedToken; +} - if (!matchedToken) { - // Not a realm URL or no token available — pass through unchanged - return; +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]; +} - // Create a new request with the Authorization header injected. - // +async function requestTokenFromClient(requestURL, initiatingClientId) { + // Single-flight per request URL + let existing = inflightTokenRequests.get(requestURL); + if (existing) { + return existing; + } + let promise = (async () => { + let client = await pickClientToAsk(initiatingClientId); + if (!client) { + 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; + let reply = event.data; + if (reply && reply.type === 'pending') { + clearTimeout(timer); + timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve(undefined); + }, TOKEN_REQUEST_REFRESH_TIMEOUT_MS); + return; + } + settled = true; + clearTimeout(timer); + if (reply && reply.realmURL && reply.token) { + realmTokens.set(reply.realmURL, reply.token); + recordRealmHost(reply.realmURL); + resolve(reply.token); + } else { + resolve(undefined); + } + }; + client.postMessage({ type: 'request-realm-token', requestURL }, [ + channel.port2, + ]); + }); + })(); + inflightTokenRequests.set(requestURL, promise); + promise.finally(() => { + inflightTokenRequests.delete(requestURL); + }); + return promise; +} + +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; + } + + // Don't inject if request already has an Authorization header + if (request.headers.get('Authorization')) { + return; + } - event.respondWith(fetch(authedRequest)); + let url = request.url; + let matchedToken = lookupToken(url); + + if (matchedToken) { + event.respondWith(fetch(buildAuthedRequest(request, matchedToken))); + return; + } + + // 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)) { + return; + } + + event.respondWith( + (async () => { + let token = await requestTokenFromClient(url, event.clientId); + 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..8473ba05a08 100644 --- a/packages/host/tests/unit/auth-service-worker-test.ts +++ b/packages/host/tests/unit/auth-service-worker-test.ts @@ -1,13 +1,53 @@ +import window from 'ember-window-mock'; +import { setupWindowMock } from 'ember-window-mock/test-support'; import { module, test } from 'qunit'; +import { createTokenRequestHandler } from '@cardstack/host/utils/auth-service-worker-registration'; +import { SessionLocalStorageKey } from '@cardstack/host/utils/local-storage-keys'; + // 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>; + // Two-phase variant: the test gets a `post` callback that can be + // invoked multiple times to simulate the real page-side handler + // sending `{type:'pending'}` first, then the actual reply later. + // Mirrors auth-service-worker.js's MessageChannel handling. + clientRespond?: ( + requestURL: string, + post: (msg: any) => void, + ) => Promise | void; + // Mirrors the SW's TOKEN_REQUEST_TIMEOUT_MS. When the client doesn't + // settle within this timeout, the scaffold resolves to `undefined` + // just like the real SW would. + tokenRequestTimeoutMs?: number; + // Mirrors TOKEN_REQUEST_REFRESH_TIMEOUT_MS — the extended budget + // applied after the client posts `{type:'pending'}`. + tokenRequestRefreshTimeoutMs?: number; + } = {}, +) { 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 +55,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 +72,7 @@ function createServiceWorkerEnv() { for (let [realmURL, token] of Object.entries(data.tokens)) { if (token) { realmTokens.set(realmURL, token as string); + recordRealmHost(realmURL); } } } @@ -38,39 +80,130 @@ 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 = new Promise((resolve) => { + if (!opts.clientTokenLookup && !opts.clientRespond) { + resolve(undefined); + return; + } + let settled = false; + let timer: ReturnType | undefined; + if (typeof opts.tokenRequestTimeoutMs === 'number') { + timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve(undefined); + }, opts.tokenRequestTimeoutMs); + } + let post = (msg: any) => { + if (settled) return; + // Two-phase extension: page asked for more time. + if (msg && msg.type === 'pending') { + if (timer) clearTimeout(timer); + if (typeof opts.tokenRequestRefreshTimeoutMs === 'number') { + timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve(undefined); + }, opts.tokenRequestRefreshTimeoutMs); + } else { + timer = undefined; + } + return; + } + settled = true; + if (timer) clearTimeout(timer); + if (msg && msg.realmURL && msg.token) { + realmTokens.set(msg.realmURL, msg.token); + recordRealmHost(msg.realmURL); + resolve(msg.token); + } else { + resolve(undefined); + } + }; + if (opts.clientRespond) { + void opts.clientRespond(requestURL, post); + } else if (opts.clientTokenLookup) { + opts.clientTokenLookup(requestURL).then( + (reply) => post(reply), + () => post(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.size > 0 && !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 +298,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 +329,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 +353,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 +370,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 +387,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 +409,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 +431,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 +449,399 @@ 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('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 = sw.processFetch(request); + let result = await sw.processFetch(request); + + assert.strictEqual(result, 'fallthrough-fetch'); + }); + }); + + 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'); + }); + + 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.strictEqual(result, null, 'no interception without tokens'); + 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'); + }); + + test('two-phase: pending extends timeout so a slow refresh still resolves', async function (assert) { + // Initial budget is short (would normally fall through), but the + // page first posts {type:'pending'} to claim the longer budget, + // then posts the real reply after a delay that exceeds the short + // budget but fits within the refresh budget. + let sw = createServiceWorkerEnv({ + tokenRequestTimeoutMs: 10, + tokenRequestRefreshTimeoutMs: 200, + clientRespond: async (_url, post) => { + post({ type: 'pending' }); + await new Promise((r) => setTimeout(r, 50)); + post({ + realmURL: 'http://localhost:4201/r/', + token: 'refreshed-token', + }); + }, + }); + + let result = await sw.processFetch( + new Request('http://localhost:4201/r/image.png'), + ); + + assert.ok(result instanceof Request, 'request was intercepted with auth'); + assert.strictEqual( + (result as Request).headers.get('Authorization'), + 'Bearer refreshed-token', + ); + }); + + test('two-phase: pending without follow-up still times out within refresh budget', async function (assert) { + // Page signals pending but never replies. The SW must still fall + // through after the extended budget elapses. + let sw = createServiceWorkerEnv({ + tokenRequestTimeoutMs: 5, + tokenRequestRefreshTimeoutMs: 20, + clientRespond: async (_url, post) => { + post({ type: 'pending' }); + // intentionally never post the real reply + }, + }); + 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'); }); }); }); + +// Direct tests of the page-side `request-realm-token` handler factory. +// These exercise the real exported function (not the SW-side scaffold +// above), including the on-miss reauthenticate path that lets the page +// refresh a stale/missing JWT in response to the SW. +module( + 'Unit | auth-service-worker | createTokenRequestHandler', + function (hooks) { + setupWindowMock(hooks); + + interface PostedMessage { + realmURL?: string; + token?: string; + type?: 'pending'; + } + + function makeEvent( + requestURL: string | undefined, + posted: PostedMessage[], + ) { + return { + data: { type: 'request-realm-token', requestURL }, + ports: [ + { + postMessage: (msg: PostedMessage) => posted.push(msg), + }, + ], + } as unknown as MessageEvent; + } + + function makeDeps(opts: { + isLoggedIn?: boolean; + realmOf?: (url: URL) => string | undefined; + reauthenticate?: (realmURL: string) => Promise; + }) { + let reauthCalls: string[] = []; + let deps = { + matrixService: { isLoggedIn: opts.isLoggedIn ?? true }, + realmService: { + realmOf: opts.realmOf ?? (() => undefined), + reauthenticate: async (realmURL: string) => { + reauthCalls.push(realmURL); + return opts.reauthenticate + ? opts.reauthenticate(realmURL) + : undefined; + }, + }, + }; + return { deps, reauthCalls }; + } + + test('localStorage hit posts token without calling reauthenticate', async function (assert) { + window.localStorage.setItem( + SessionLocalStorageKey, + JSON.stringify({ 'http://realm/': 'tok-from-storage' }), + ); + let posted: PostedMessage[] = []; + let { deps, reauthCalls } = makeDeps({ + realmOf: () => 'http://realm/', + reauthenticate: async () => 'should-not-be-called', + }); + + await createTokenRequestHandler(deps)( + makeEvent('http://realm/img.png', posted), + ); + + assert.deepEqual(posted, [ + { realmURL: 'http://realm/', token: 'tok-from-storage' }, + ]); + assert.deepEqual(reauthCalls, [], 'reauthenticate not invoked'); + }); + + test('localStorage miss + logged-in + known realm triggers reauthenticate and two-phase posts pending then fresh token', async function (assert) { + let posted: PostedMessage[] = []; + let { deps, reauthCalls } = makeDeps({ + isLoggedIn: true, + realmOf: () => 'http://realm/', + reauthenticate: async () => 'fresh-token', + }); + + await createTokenRequestHandler(deps)( + makeEvent('http://realm/img.png', posted), + ); + + assert.deepEqual(reauthCalls, ['http://realm/']); + assert.deepEqual(posted, [ + { type: 'pending' }, + { realmURL: 'http://realm/', token: 'fresh-token' }, + ]); + }); + + test('logged-out user posts empty reply without calling reauthenticate', async function (assert) { + let posted: PostedMessage[] = []; + let { deps, reauthCalls } = makeDeps({ + isLoggedIn: false, + realmOf: () => 'http://realm/', + reauthenticate: async () => 'should-not-be-called', + }); + + await createTokenRequestHandler(deps)( + makeEvent('http://realm/img.png', posted), + ); + + assert.deepEqual(posted, [{}]); + assert.deepEqual(reauthCalls, [], 'reauthenticate not invoked'); + }); + + test('unknown realm posts empty reply without calling reauthenticate', async function (assert) { + let posted: PostedMessage[] = []; + let { deps, reauthCalls } = makeDeps({ + isLoggedIn: true, + realmOf: () => undefined, + reauthenticate: async () => 'should-not-be-called', + }); + + await createTokenRequestHandler(deps)( + makeEvent('https://cdn.example.com/img.png', posted), + ); + + assert.deepEqual(posted, [{}]); + assert.deepEqual(reauthCalls, [], 'reauthenticate not invoked'); + }); + + test('reauthenticate returning undefined posts pending then empty reply', async function (assert) { + let posted: PostedMessage[] = []; + let { deps } = makeDeps({ + isLoggedIn: true, + realmOf: () => 'http://realm/', + reauthenticate: async () => undefined, + }); + + await createTokenRequestHandler(deps)( + makeEvent('http://realm/img.png', posted), + ); + + assert.deepEqual(posted, [{ type: 'pending' }, {}]); + }); + + test('reauthenticate throwing posts pending then empty reply', async function (assert) { + let posted: PostedMessage[] = []; + let { deps } = makeDeps({ + isLoggedIn: true, + realmOf: () => 'http://realm/', + reauthenticate: async () => { + throw new Error('matrix down'); + }, + }); + + await createTokenRequestHandler(deps)( + makeEvent('http://realm/img.png', posted), + ); + + assert.deepEqual(posted, [{ type: 'pending' }, {}]); + }); + + test('ignores events that are not request-realm-token', async function (assert) { + let posted: PostedMessage[] = []; + let { deps, reauthCalls } = makeDeps({}); + let handler = createTokenRequestHandler(deps); + + await handler({ + data: { type: 'some-other-message' }, + ports: [{ postMessage: (m: PostedMessage) => posted.push(m) }], + } as unknown as MessageEvent); + + assert.deepEqual(posted, [], 'no reply posted'); + assert.deepEqual(reauthCalls, []); + }); + }, +); diff --git a/packages/realm-server/tests/prerendering-test.ts b/packages/realm-server/tests/prerendering-test.ts index b5f0a7c035e..02cc3cbb267 100644 --- a/packages/realm-server/tests/prerendering-test.ts +++ b/packages/realm-server/tests/prerendering-test.ts @@ -2173,8 +2173,8 @@ module(basename(__filename), function () { 'search doc includes name', ); assert.ok( - result.response.deps.includes(`${baseRealm.url}file-api`), - 'deps include base file-api module', + result.response.deps.includes(`${baseRealm.url}card-api`), + 'deps include base card-api module (where FileDef is defined)', ); assert.notOk( result.response.deps.includes(fileURL),