Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/app/components/message/content/VideoContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const VideoContent = as<'div', VideoContentProps>(
// support; a stale SW build would otherwise serve the bare URL to
// the homeserver and the element would fail with a 4xx.
if (!preferBlobRef.current && (await probeSWMediaAuthSupport())) return mediaUrl;
return createObjectURL(downloadMedia(mediaUrl));
return createObjectURL(downloadMedia(mediaUrl, { forceDirectAuth: true }));
}
if (isTauri()) {
await setMediaEncryption(mediaUrl, encInfo, mimeType);
Expand Down
73 changes: 73 additions & 0 deletions src/app/utils/mediaTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,48 @@ describe('fetchMediaBlob', () => {
expect(mediaCache.putInMediaCache).toHaveBeenCalledTimes(1);
});

it('does not share an inflight SW request with forced direct auth', async () => {
swMediaAuth.getCachedSWMediaAuthSupport.mockReturnValue(true);
const { fetchMediaBlob } = await import('./mediaTransport');
const url = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/media-id';
const headersSeen: Array<string | null> = [];
let resolveSWRequest!: (response: Response) => void;
const pendingSWRequest = new Promise<Response>((resolve) => {
resolveSWRequest = resolve;
});

localStorage.setItem(
'matrixSessions',
JSON.stringify([
{
baseUrl: 'https://matrix.example.org',
userId: '@alice:example.org',
deviceId: 'DEVICE',
accessToken: 'token-1',
},
])
);
localStorage.setItem('matrixActiveSession', '@alice:example.org');

vi.mocked(fetch).mockImplementation(async (_input, init) => {
const authorization = new Headers(init?.headers).get('authorization');
headersSeen.push(authorization);
if (authorization === null) return pendingSWRequest;
return new Response('direct', { status: 200 });
});

const ordinaryRequest = fetchMediaBlob(url);
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));

const directRequest = fetchMediaBlob(url, { forceDirectAuth: true });
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
await expect(directRequest).resolves.toHaveProperty('size', 6);
expect(headersSeen).toEqual([null, 'Bearer token-1']);

resolveSWRequest(new Response('ordinary', { status: 200 }));
await expect(ordinaryRequest).resolves.toHaveProperty('size', 8);
});

it('re-resolves auth once after a 401 in direct-fetch mode', async () => {
const { fetchMediaBlob } = await import('./mediaTransport');
const url = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/media-id';
Expand Down Expand Up @@ -397,6 +439,37 @@ describe('fetchMediaBlob', () => {
expect(headersSeen).toEqual(['Bearer widget-token']);
});

it('bypasses the service worker path when direct auth is forced', async () => {
swMediaAuth.getCachedSWMediaAuthSupport.mockReturnValue(true);
const { fetchMediaBlob } = await import('./mediaTransport');
const url = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/media-id';
const headersSeen: Array<string | null> = [];

localStorage.setItem(
'matrixSessions',
JSON.stringify([
{
baseUrl: 'https://matrix.example.org',
userId: '@alice:example.org',
deviceId: 'DEVICE',
accessToken: 'token-1',
},
])
);
localStorage.setItem('matrixActiveSession', '@alice:example.org');

vi.mocked(fetch).mockImplementation(async (_input, init) => {
const headers = new Headers(init?.headers);
headersSeen.push(headers.get('authorization'));
return new Response('ok', { status: 200 });
});

const blob = await fetchMediaBlob(url, { forceDirectAuth: true });

expect(await blob.text()).toBe('ok');
expect(headersSeen).toEqual(['Bearer token-1']);
});

it('uses direct auth fetches when service workers are supported but not controlling', async () => {
swMediaAuth.getCachedSWMediaAuthSupport.mockReturnValue(false);
const { fetchMediaBlob } = await import('./mediaTransport');
Expand Down
17 changes: 13 additions & 4 deletions src/app/utils/mediaTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type MediaFetchCacheMode = 'default' | 'reload' | 'bypass';

export type MediaTransportOptions = {
cache?: MediaFetchCacheMode;
forceDirectAuth?: boolean;
accessToken?: string | null;
getAccessToken?: () => string | null | undefined;
sessionScope?: string;
Expand Down Expand Up @@ -179,8 +180,13 @@ function getFetchCacheMode(cacheMode: MediaFetchCacheMode): RequestCache {
return 'default';
}

function getRequestKey(url: string, cacheMode: MediaFetchCacheMode): string {
return `${cacheMode}:${getStableMediaCacheKeyFragment(url)}`;
function getRequestKey(
url: string,
cacheMode: MediaFetchCacheMode,
forceDirectAuth: boolean
): string {
const transportMode = forceDirectAuth ? 'direct-auth' : 'default';
return `${transportMode}:${cacheMode}:${getStableMediaCacheKeyFragment(url)}`;
}

type MatrixMediaInfo = {
Expand Down Expand Up @@ -290,7 +296,9 @@ async function fetchMediaBlobInternal(url: string, options?: MediaTransportOptio
// Only let the service worker attach the token once it has proven media-auth
// support; a stale SW build would forward the request bare and get a 4xx.
const useServiceWorker =
getCachedSWMediaAuthSupport() === true && !hasExplicitMediaAuthOverride(options);
getCachedSWMediaAuthSupport() === true &&
!hasExplicitMediaAuthOverride(options) &&
!options?.forceDirectAuth;
const fetchAndCache = async (response: Response): Promise<Blob> => {
if (!response.ok) {
throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`);
Expand Down Expand Up @@ -351,7 +359,8 @@ export async function fetchMediaBlob(url: string, options?: MediaTransportOption
const cacheMode = options?.cache ?? 'default';
const requestKey = getRequestKey(
getScopedMediaCacheKey(url, resolveSessionScope(options)),
cacheMode
cacheMode,
options?.forceDirectAuth === true
);

const inflight = inflightRequests.get(requestKey);
Expand Down
83 changes: 76 additions & 7 deletions src/app/utils/swMediaAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ const platform = vi.hoisted(() => ({

vi.mock('$utils/platform', () => platform);

function stubServiceWorker(controller: unknown): void {
function stubServiceWorker(controller: unknown) {
const serviceWorker = {
controller,
addEventListener: vi.fn<(...args: unknown[]) => void>(),
removeEventListener: vi.fn<(...args: unknown[]) => void>(),
};
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
controller,
addEventListener: vi.fn<(...args: unknown[]) => void>(),
removeEventListener: vi.fn<(...args: unknown[]) => void>(),
},
value: serviceWorker,
});
return serviceWorker;
}

describe('swMediaAuth', () => {
Expand All @@ -24,6 +26,7 @@ describe('swMediaAuth', () => {
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

Expand Down Expand Up @@ -68,9 +71,75 @@ describe('swMediaAuth', () => {
const mod = await import('./swMediaAuth');

await expect(mod.probeSWMediaAuthSupport()).resolves.toBe(false);
expect(mod.getCachedSWMediaAuthSupport()).toBe(false);
expect(mod.getCachedSWMediaAuthSupport()).toBeUndefined();
}, 10_000);

it('retries a timed-out probe for the same controller', async () => {
platform.hasServiceWorker.mockReturnValue(true);
let probeCount = 0;
const postMessage = vi.fn<(...args: unknown[]) => void>((...args: unknown[]) => {
probeCount += 1;
if (probeCount === 2) {
const [port] = args[1] as MessagePort[];
port?.postMessage({ type: 'swMediaAuth', supported: true, version: 1 });
}
});
stubServiceWorker({ postMessage });
const mod = await import('./swMediaAuth');

const firstProbe = mod.probeSWMediaAuthSupport();
expect(postMessage).toHaveBeenCalledOnce();
await expect(firstProbe).resolves.toBe(false);
expect(mod.getCachedSWMediaAuthSupport()).toBeUndefined();

await expect(mod.probeSWMediaAuthSupport()).resolves.toBe(true);
expect(postMessage).toHaveBeenCalledTimes(2);
}, 10_000);

it('notifies unsupported while a replacement controller probe is unresolved', async () => {
platform.hasServiceWorker.mockReturnValue(true);
const firstController = {
postMessage: vi.fn<(...args: unknown[]) => void>((...args: unknown[]) => {
const [port] = args[1] as MessagePort[];
port?.postMessage({ type: 'swMediaAuth', supported: true, version: 1 });
}),
};
const serviceWorker = stubServiceWorker(firstController);
const mod = await import('./swMediaAuth');
const listener = vi.fn<(supported: boolean) => void>();
mod.subscribeSWMediaAuthSupport(listener);

await expect(mod.probeSWMediaAuthSupport()).resolves.toBe(true);
listener.mockClear();
vi.useFakeTimers();

serviceWorker.controller = { postMessage: vi.fn<() => void>() };
const controllerChange = serviceWorker.addEventListener.mock.calls.find(
([type]) => type === 'controllerchange'
)?.[1] as (() => void) | undefined;
controllerChange?.();

expect(controllerChange).toBeTypeOf('function');
expect(listener).toHaveBeenCalledWith(false);
expect(mod.getCachedSWMediaAuthSupport()).toBeUndefined();

await vi.advanceTimersByTimeAsync(1500);
expect(mod.getCachedSWMediaAuthSupport()).toBeUndefined();
});

it('resolves false when posting the probe throws', async () => {
platform.hasServiceWorker.mockReturnValue(true);
stubServiceWorker({
postMessage: vi.fn<() => void>(() => {
throw new Error('postMessage failed');
}),
});
const mod = await import('./swMediaAuth');

await expect(mod.probeSWMediaAuthSupport()).resolves.toBe(false);
expect(mod.getCachedSWMediaAuthSupport()).toBe(false);
});

it('resolves false when posting to a stale controller throws', async () => {
platform.hasServiceWorker.mockReturnValue(true);
const postMessage = vi.fn<() => void>(() => {
Expand Down
50 changes: 27 additions & 23 deletions src/app/utils/swMediaAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export function probeSWMediaAuthSupport(): Promise<boolean> {

probedController = controller;
inflightProbe = new Promise<boolean>((resolve) => {
const channel = new MessageChannel();
let channel: MessageChannel | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let settled = false;
const onMessage = (ev: MessageEvent) => {
const data = ev.data as { type?: unknown; supported?: unknown; version?: unknown };
Expand All @@ -62,55 +63,58 @@ export function probeSWMediaAuthSupport(): Promise<boolean> {
data.version >= SW_MEDIA_AUTH_PROTOCOL_VERSION
);
};
const timeoutId = setTimeout(() => finish(false), PROBE_TIMEOUT_MS);

const finish = (supported: boolean) => {
const finish = (supported: boolean, shouldCache = true) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
channel.port1.removeEventListener('message', onMessage);
channel.port1.close();
if (timeoutId !== undefined) clearTimeout(timeoutId);
channel?.port1.removeEventListener('message', onMessage);
channel?.port1.close();
// Controller may have changed mid-probe; activation resets the cache and
// starts a fresh probe, so don't clobber that state with a stale answer.
if (navigator.serviceWorker.controller === controller) {
cachedSupport = supported;
notify(supported);
if (shouldCache) {
cachedSupport = supported;
notify(supported);
}
}
resolve(supported);
};

channel.port1.addEventListener('message', onMessage);
channel.port1.start();

try {
channel = new MessageChannel();
timeoutId = setTimeout(() => finish(false, false), PROBE_TIMEOUT_MS);
channel.port1.addEventListener('message', onMessage);
channel.port1.start();

// oxlint-disable-next-line unicorn/require-post-message-target-origin
controller.postMessage({ type: 'swMediaAuthProbe' }, [channel.port2]);
} catch {
// The controller can become redundant between reading it and posting the
// probe. Treat that race like any other unsupported controller so callers
// can use their authenticated blob fallback.
channel.port2.close();
channel?.port2.close();
finish(false);
}
}).finally(() => {
if (probedController === controller) {
inflightProbe = undefined;
}
});
})
.catch(() => false)
.finally(() => {
if (probedController === controller) {
inflightProbe = undefined;
}
});

return inflightProbe;
}

if (typeof window !== 'undefined' && typeof navigator !== 'undefined' && hasServiceWorker()) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
cachedSupport = undefined;
const { controller } = navigator.serviceWorker;
cachedSupport = controller ? undefined : false;
inflightProbe = undefined;
probedController = undefined;
if (navigator.serviceWorker.controller) {
notify(false);
if (controller) {
void probeSWMediaAuthSupport();
} else {
cachedSupport = false;
notify(false);
}
});
}
20 changes: 19 additions & 1 deletion src/sw-media-auth-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ type SwTestHooks = typeof swTestHooksHelper;
describe('service worker media auth recovery', () => {
let swTestHooks: SwTestHooks;
let clients: Map<string, Client>;
let addEventListener: ReturnType<typeof vi.fn>;

beforeEach(async () => {
vi.resetModules();
clients = new Map();
addEventListener = vi.fn();
vi.stubGlobal('self', {
__WB_MANIFEST: [],
addEventListener: vi.fn(),
addEventListener,
caches: {
open: vi.fn(async () => ({
delete: vi.fn(async () => true),
Expand All @@ -37,6 +39,22 @@ describe('service worker media auth recovery', () => {
swTestHooks = (await import('./sw')).swTestHooks;
});

it('does not intercept media requests that already carry authorization', () => {
const fetchHandler = addEventListener.mock.calls.find(([type]) => type === 'fetch')?.[1] as
| ((event: FetchEvent) => void)
| undefined;
const respondWith = vi.fn();
const request = new Request(
'https://matrix.example.org/_matrix/client/v1/media/download/example.org/media-id',
{ headers: { Authorization: 'Bearer direct-token' } }
);

fetchHandler?.({ request, respondWith, clientId: 'client-a' } as unknown as FetchEvent);

expect(fetchHandler).toBeTypeOf('function');
expect(respondWith).not.toHaveBeenCalled();
});

it('shares recovery and preserves each Range header on retry', async () => {
const client = {
id: 'client-a',
Expand Down
4 changes: 4 additions & 0 deletions src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,10 @@ self.addEventListener('fetch', (event: FetchEvent) => {

if (!mediaPath(url)) return;

// Direct-auth fallback requests already carry the page's token. Let the
// browser send them unchanged instead of routing them back through SW auth.
if (event.request.headers.has('Authorization')) return;

const { clientId } = event;

// For browser sub-resource loads (images, video, audio, etc.), 'follow' is
Expand Down
Loading