Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-sw-media-auth-401.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix service worker authenticated media requests returning 401 errors after SW restart or when session data is missing/stale.
60 changes: 52 additions & 8 deletions src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@
*/
const sessions = new Map<string, SessionInfo>();

/**
* 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;

const clientToResolve = new Map<string, (value: SessionInfo | undefined) => void>();
const clientToSessionPromise = new Map<string, Promise<SessionInfo | undefined>>();

Expand All @@ -142,12 +150,15 @@
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);

Check warning on line 155 in src/sw.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
// Persist so push-event fetches work after iOS restarts the SW.
persistSession(info).catch(() => undefined);
} else {
// Logout or invalid session
sessions.delete(clientId);
preloadedSession = undefined;
console.debug('[SW] setSession: removed', clientId);
clearPersistedSession().catch(() => undefined);
}
Expand Down Expand Up @@ -460,6 +471,15 @@
(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.
const windowClients = await self.clients.matchAll({ type: 'window' });
windowClients.forEach((client) => client.postMessage({ type: 'requestSession' }));
})()
);
});
Expand Down Expand Up @@ -579,7 +599,6 @@
if (method !== 'GET' || !mediaPath(url)) return;

const { clientId } = event;
if (!clientId) return;

// For browser sub-resource loads (images, video, audio, etc.), 'follow' is
// the correct mode: the auth header is sent to the Matrix server which owns
Expand All @@ -588,11 +607,9 @@
// the browser cannot render as an <img>/<video>/etc.
const redirect: RequestRedirect = 'follow';

const session = sessions.get(clientId);
if (session) {
if (validMediaRequest(url, session.baseUrl)) {
event.respondWith(fetch(url, { ...fetchConfig(session.accessToken), redirect }));
}
const session = clientId ? sessions.get(clientId) : undefined;
if (session && validMediaRequest(url, session.baseUrl)) {
event.respondWith(fetch(url, { ...fetchConfig(session.accessToken), redirect }));
return;
}

Expand All @@ -604,17 +621,44 @@
// 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.
const byBaseUrl = [...sessions.values()].find((s) => validMediaRequest(url, s.baseUrl));
// 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(fetch(url, { ...fetchConfig(byBaseUrl.accessToken), redirect }));
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 fetch(url, { ...fetchConfig(persisted.accessToken), redirect });
}
return fetch(event.request);
})
);
return;
}

event.respondWith(
requestSessionWithTimeout(clientId).then((s) => {
requestSessionWithTimeout(clientId).then(async (s) => {
// Primary: session received from the live client window.
if (s && validMediaRequest(url, s.baseUrl)) {
return fetch(url, { ...fetchConfig(s.accessToken), redirect });
}
// 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 fetch(url, { ...fetchConfig(persisted.accessToken), redirect });
}
console.warn(
'[SW fetch] No valid session for media request',
{ url, clientId, hasSession: !!s },
Expand Down
Loading