diff --git a/src/sw-media-auth-recovery.test.ts b/src/sw-media-auth-recovery.test.ts index 02b7067e1..7d5720958 100644 --- a/src/sw-media-auth-recovery.test.ts +++ b/src/sw-media-auth-recovery.test.ts @@ -13,19 +13,25 @@ describe('service worker media auth recovery', () => { let swTestHooks: SwTestHooks; let clients: Map; let addEventListener: ReturnType; + let persistedSessions: string | undefined; beforeEach(async () => { vi.resetModules(); clients = new Map(); addEventListener = vi.fn(); + persistedSessions = undefined; vi.stubGlobal('self', { __WB_MANIFEST: [], addEventListener, caches: { open: vi.fn(async () => ({ delete: vi.fn(async () => true), - match: vi.fn(async () => undefined), - put: vi.fn(async () => undefined), + match: vi.fn(async () => + persistedSessions ? new Response(persistedSessions) : undefined + ), + put: vi.fn(async (_key: string, response: Response) => { + persistedSessions = await response.text(); + }), })), }, clients: { @@ -55,6 +61,73 @@ describe('service worker media auth recovery', () => { expect(respondWith).not.toHaveBeenCalled(); }); + it('does not borrow another account session for an unscoped media request', async () => { + const fetchHandler = addEventListener.mock.calls.find(([type]) => type === 'fetch')?.[1] as + | ((event: FetchEvent) => void) + | undefined; + const respondWith = vi.fn<(response: Promise) => void>(); + const request = new Request( + 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/media-id' + ); + + await swTestHooks.setSession( + 'alice-window', + 'alice-token', + 'https://matrix.example.org', + '@alice:example.org' + ); + fetchHandler?.({ request, respondWith, clientId: 'widget-client' } as unknown as FetchEvent); + + await expect(respondWith.mock.calls[0]?.[0]).resolves.toMatchObject({ status: 503 }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('removes a stale persisted account when a client switches users', async () => { + await swTestHooks.setSession( + 'client-a', + 'alice-token', + 'https://matrix.example.org', + '@alice:example.org' + ); + await swTestHooks.setSession( + 'client-a', + 'bob-token', + 'https://matrix.example.org', + '@bob:example.org' + ); + + expect(Object.keys(JSON.parse(persistedSessions ?? '{}'))).toEqual(['@bob:example.org']); + }); + + it('keeps a persisted account until its final client logs out', async () => { + await swTestHooks.setSession( + 'alice-a', + 'alice-token', + 'https://matrix.example.org', + '@alice:example.org' + ); + await swTestHooks.setSession( + 'alice-b', + 'alice-token', + 'https://matrix.example.org', + '@alice:example.org' + ); + await swTestHooks.setSession( + 'alice-a', + 'bob-token', + 'https://matrix.example.org', + '@bob:example.org' + ); + + expect(Object.keys(JSON.parse(persistedSessions ?? '{}')).toSorted()).toEqual([ + '@alice:example.org', + '@bob:example.org', + ]); + + await swTestHooks.setSession('alice-b', undefined, undefined); + expect(Object.keys(JSON.parse(persistedSessions ?? '{}'))).toEqual(['@bob:example.org']); + }); + it('shares recovery and preserves each Range header on retry', async () => { const client = { id: 'client-a', diff --git a/src/sw.ts b/src/sw.ts index 6a0bbfd4e..c11abfc17 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -27,7 +27,7 @@ const { handlePushNotificationPushData } = createPushNotifications(self, () => ( const SW_SETTINGS_CACHE = 'sable-sw-settings-v1'; const SW_SETTINGS_URL = '/sw-settings-meta'; -/** Cache key used to persist the active session so push-event fetches work after SW restart. */ +/** Cache key used to persist sessions so push-event fetches work after SW restart. */ const SW_SESSION_CACHE = 'sable-sw-session-v1'; const SW_SESSION_URL = '/sw-session-meta'; @@ -78,35 +78,54 @@ async function loadPersistedSettings() { } } -async function persistSession(session: SessionInfo): Promise { +async function loadPersistedSessions(): Promise> { try { const cache = await self.caches.open(SW_SESSION_CACHE); - await cache.put( - SW_SESSION_URL, - new Response(JSON.stringify(session), { - headers: { 'Content-Type': 'application/json' }, - }) - ); + const response = await cache.match(SW_SESSION_URL); + if (!response) return {}; + const value = await response.json(); + const sessions: Record = {}; + + if (typeof value !== 'object' || value === null) return sessions; + const legacySession = readPersistedSession(value); + if (legacySession?.userId) return { [legacySession.userId]: legacySession }; + for (const [userId, candidate] of Object.entries(value)) { + const session = readPersistedSession(candidate); + if (session?.userId === userId) sessions[userId] = session; + } + return sessions; } catch { - // Ignore — caches may be unavailable in some environments. + return {}; } } -async function clearPersistedSession(): Promise { - try { - const cache = await self.caches.open(SW_SESSION_CACHE); - await cache.delete(SW_SESSION_URL); - } catch { - // Ignore. - } +let persistedSessionWrites = Promise.resolve(); + +function updatePersistedSession(userId: string, session?: SessionInfo): Promise { + const update = async () => { + try { + const cache = await self.caches.open(SW_SESSION_CACHE); + const sessions = await loadPersistedSessions(); + if (session) sessions[userId] = session; + else delete sessions[userId]; + await cache.put( + SW_SESSION_URL, + new Response(JSON.stringify(sessions), { + headers: { 'Content-Type': 'application/json' }, + }) + ); + } catch { + // Ignore, caches may be unavailable in some environments. + } + }; + + persistedSessionWrites = persistedSessionWrites.then(update, update); + return persistedSessionWrites; } -async function loadPersistedSession(): Promise { +async function loadPersistedSession(userId: string): Promise { try { - const cache = await self.caches.open(SW_SESSION_CACHE); - const response = await cache.match(SW_SESSION_URL); - if (!response) return undefined; - return readPersistedSession(await response.json()); + return (await loadPersistedSessions())[userId]; } catch { return undefined; } @@ -124,14 +143,6 @@ type SessionInfo = { */ const sessions = new Map(); -/** - * Session pre-loaded from cache on SW activation. Acts as an immediate - * fallback so media fetches don't 401 during the window between SW restart - * and the first live setSession message from the page. - * Cleared as soon as any real setSession call comes in. - */ -let preloadedSession: SessionInfo | undefined; - type PendingSessionRequest = { promise: Promise; resolve: (value: SessionInfo | undefined) => void; @@ -156,7 +167,8 @@ function setSession( baseUrl: unknown, userId?: unknown ): Promise { - let persistence: Promise; + const previous = sessions.get(clientId); + const persistence: Promise[] = []; if (typeof accessToken === 'string' && typeof baseUrl === 'string') { const info: SessionInfo = { accessToken, @@ -164,17 +176,20 @@ function setSession( userId: typeof userId === 'string' ? userId : undefined, }; sessions.set(clientId, info); - // A real session has arrived — discard the preloaded fallback. - preloadedSession = undefined; console.debug('[SW] setSession: stored', clientId, baseUrl); - // Persist so push-event fetches work after iOS restarts the SW. - persistence = persistSession(info); + if (info.userId) persistence.push(updatePersistedSession(info.userId, info)); } else { // Logout or invalid session sessions.delete(clientId); - preloadedSession = undefined; console.debug('[SW] setSession: removed', clientId); - persistence = clearPersistedSession(); + } + + if ( + previous?.userId && + previous.userId !== sessions.get(clientId)?.userId && + ![...sessions.values()].some((session) => session.userId === previous.userId) + ) { + persistence.push(updatePersistedSession(previous.userId)); } const pending = pendingSessionRequests.get(clientId); @@ -183,7 +198,7 @@ function setSession( pendingSessionRequests.delete(clientId); } - return persistence; + return Promise.all(persistence).then(() => undefined); } function requestSession(client: Client): Promise { @@ -367,12 +382,9 @@ function mxcToNotificationUrl(mxcUrl: string, baseUrl: string): string | undefin return `${baseUrl}/_matrix/media/v3/thumbnail/${encodeURIComponent(server)}/${encodeURIComponent(mediaId)}?width=96&height=96&method=crop`; } -/** - * Return the first any-session we have stored (used for push fetches where we - * don't have a client ID, e.g. when the app is backgrounded but still loaded). - */ -function getAnyStoredSession(): SessionInfo | undefined { - return sessions.values().next().value; +async function getSessionForPush(userId: string): Promise { + const liveSession = [...sessions.values()].find((session) => session.userId === userId); + return liveSession ?? loadPersistedSession(userId); } /** @@ -435,12 +447,10 @@ async function requestDecryptionFromClient( async function handleMinimalPushPayload( roomId: string, eventId: string, + userId: string | undefined, windowClients: readonly Client[] ): Promise { - // On iOS the SW is killed and restarted for every push, clearing the in-memory sessions - // Map. Fall back to the Cache Storage copy that was written when the user last opened - // the app (same pattern as settings persistence). - const session = getAnyStoredSession() ?? (await loadPersistedSession()); + const session = userId ? await getSessionForPush(userId) : undefined; if (!session) { // No session anywhere — app was never opened since install, or the user logged out. @@ -556,10 +566,6 @@ self.addEventListener('activate', (event: ExtendableEvent) => { (async () => { await self.clients.claim(); await cleanupDeadClients(); - // Pre-load the persisted session into memory so that media fetches arriving - // before the first setSession message from the page are immediately - // authenticated rather than falling through to a 3-second timeout. - preloadedSession = await loadPersistedSession(); // Proactively request sessions from all window clients so the sessions Map // is pre-populated after a SW restart, rather than waiting for the first // media fetch to trigger requestSessionWithTimeout. @@ -915,34 +921,9 @@ self.addEventListener('fetch', (event: FetchEvent) => { return; } - // Since widgets like element call have their own client ids, - // we need this logic. We just go through the sessions list and get a session - // with the right base url. Media requests to a homeserver simply are fine with any account - // on the homeserver authenticating it, so this is fine. But it can be technically wrong. - // If you have two tabs for different users on the same homeserver, it might authenticate - // as the wrong one. - // Thus any logic in the future which cares about which user is authenticating the request - // might break this. Also, again, it is technically wrong. - // Also checks preloadedSession — populated from cache at SW activate — for the window - // between SW restart and the first live setSession arriving from the page. - const byBaseUrl = - [...sessions.values()].find((s) => validMediaRequest(url, s.baseUrl)) ?? - (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl) - ? preloadedSession - : undefined); - if (byBaseUrl) { - event.respondWith(respondWithMediaAuthRecovery(event.request, byBaseUrl, redirect, clientId)); - return; - } - - // No clientId: the fetch came from a context not associated with a specific - // window (e.g. a prerender). Fall back to the persisted session directly. if (!clientId) { event.respondWith( - loadPersistedSession().then((persisted) => { - if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return respondWithMediaAuthRecovery(event.request, persisted, redirect); - } + Promise.resolve().then(() => { if (authenticatedMediaPath(url)) return unavailableAuthenticatedMediaResponse(); return fetch(event.request); }) @@ -956,12 +937,6 @@ self.addEventListener('fetch', (event: FetchEvent) => { if (s && validMediaRequest(url, s.baseUrl)) { return respondWithMediaAuthRecovery(event.request, s, redirect, clientId); } - // Fallback: try the persisted session (helps when SW restarts on iOS and - // the client window hasn't responded to requestSession yet). - const persisted = await loadPersistedSession(); - if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return respondWithMediaAuthRecovery(event.request, persisted, redirect, clientId); - } if (authenticatedMediaPath(url)) return unavailableAuthenticatedMediaResponse(); return fetch(event.request); }) @@ -970,7 +945,9 @@ self.addEventListener('fetch', (event: FetchEvent) => { // Detect a minimal (event_id_only) payload: has room_id + event_id but no // event type field — meaning the homeserver stripped the event content. -function isMinimalPushPayload(data: unknown): data is { room_id: string; event_id: string } { +function isMinimalPushPayload( + data: unknown +): data is { room_id: string; event_id: string; user_id?: string } { if (!data || typeof data !== 'object') return false; const d = data as Record; return typeof d.room_id === 'string' && typeof d.event_id === 'string' && !d.type; @@ -982,9 +959,8 @@ const onPushNotification = async (event: PushEvent) => { // The SW may have been restarted by the OS (iOS is aggressive about this), // so in-memory settings would be at their defaults. Reload from cache and // match active clients in parallel — they are independent operations. - const [, , clients] = await Promise.all([ + const [, clients] = await Promise.all([ loadPersistedSettings(), - loadPersistedSession(), self.clients.matchAll({ type: 'window', includeUncontrolled: true }), ]); @@ -1041,7 +1017,7 @@ const onPushNotification = async (event: PushEvent) => { // to relay decryption to an open app tab. if (isMinimalPushPayload(pushData)) { console.debug('[SW push] minimal payload detected — fetching event', pushData.event_id); - await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients); + await handleMinimalPushPayload(pushData.room_id, pushData.event_id, pushData.user_id, clients); return; }