Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Typescriptify & use service worker for MSC3916 authentication #27326

Merged
merged 27 commits into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
69817ca
Typescriptify & use service worker for MSC3916 authentication
turt2live Apr 11, 2024
296c82c
appease the linter
turt2live Apr 11, 2024
8542ce2
appease jest
turt2live Apr 11, 2024
b333b29
appease linter
turt2live Apr 11, 2024
6cf7dca
Merge branch 'develop' into travis/msc3916
turt2live Apr 18, 2024
af1ba39
Get the access token directly
turt2live Apr 18, 2024
9494257
Add a bit of jitter
turt2live Apr 18, 2024
7d63b90
Merge remote-tracking branch 'origin/develop' into travis/msc3916
turt2live Apr 22, 2024
8067197
Improve legibility, use factored-out functions for pickling
turt2live Apr 22, 2024
ea7e8fb
Add docs
turt2live Apr 22, 2024
d0dcf89
Appease the linter
turt2live Apr 22, 2024
3fa2a42
Merge remote-tracking branch 'origin/develop' into travis/msc3916
turt2live Apr 23, 2024
80dd415
Document risks of postMessage
turt2live Apr 23, 2024
0395ee4
Split service worker post message handling out to function
turt2live Apr 23, 2024
2ad00c0
Move registration to async function
turt2live Apr 23, 2024
0951fe7
Use more early returns
turt2live Apr 23, 2024
c20d5f1
Thanks(?), WebStorm
turt2live Apr 23, 2024
3947d90
Handle case of no access token for /versions
turt2live Apr 23, 2024
7d9e7d6
Appease linter
turt2live Apr 23, 2024
d4efdf2
Merge branch 'develop' into travis/msc3916
turt2live May 1, 2024
37e3dfd
Apply suggestions from code review
turt2live May 1, 2024
0d5e2a9
Remove spurious try/catch
turt2live May 1, 2024
ec159a3
Factor out fetch config stuff
turt2live May 1, 2024
310284b
Merge branch 'develop' into travis/msc3916
turt2live May 13, 2024
b80adc5
Apply suggestions from code review
turt2live May 14, 2024
cdbfd80
Merge branch 'develop' into travis/msc3916
turt2live May 14, 2024
a57c111
Finish applying code review suggestions
turt2live May 14, 2024
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
126 changes: 76 additions & 50 deletions src/serviceworker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,59 +54,76 @@ self.addEventListener("fetch", (event: FetchEvent) => {
// We only intercept v3 download and thumbnail requests as presumably everything else is deliberate.
// For example, `/_matrix/media/unstable` or `/_matrix/media/v3/preview_url` are something well within
// the control of the application, and appear to be choices made at a higher level than us.
if (url.includes("/_matrix/media/v3/download") || url.includes("/_matrix/media/v3/thumbnail")) {
// We need to call respondWith synchronously, otherwise we may never execute properly. This means
// later on we need to proxy the request through if it turns out the server doesn't support authentication.
event.respondWith(
(async (): Promise<Response> => {
let fetchConfig: { headers?: { [key: string]: string } } = {};
try {
// Figure out which homeserver we're communicating with
const csApi = url.substring(0, url.indexOf("/_matrix/media/v3"));

// Add jitter to reduce request spam, particularly to `/versions` on initial page load
await new Promise<void>((resolve) => setTimeout(() => resolve(), Math.random() * 10));

// Locate our access token, and populate the fetchConfig with the authentication header.
// @ts-expect-error - service worker types are not available. See 'fetch' event handler.
const client = await self.clients.get(event.clientId);
const accessToken = await getAccessToken(client);
if (accessToken) {
fetchConfig = {
headers: {
Authorization: `Bearer ${accessToken}`,
},
};
}

// Update or populate the server support map using a (usually) authenticated `/versions` call.
if (!serverSupportMap[csApi] || serverSupportMap[csApi].cacheExpires <= new Date().getTime()) {
const versions = await (await fetch(`${csApi}/_matrix/client/versions`, fetchConfig)).json();
serverSupportMap[csApi] = {
supportsMSC3916: Boolean(versions?.unstable_features?.["org.matrix.msc3916"]),
cacheExpires: new Date().getTime() + 2 * 60 * 60 * 1000, // 2 hours from now
};
}

// If we have server support (and a means of authentication), rewrite the URL to use MSC3916 endpoints.
if (serverSupportMap[csApi].supportsMSC3916 && accessToken) {
// Currently unstable only.
// TODO: Support stable endpoints when available.
url = url.replace(/\/media\/v3\/(.*)\//, "/client/unstable/org.matrix.msc3916/media/$1/");
} // else by default we make no changes
} catch (err) {
console.error("SW: Error in request rewrite.", err);
}

// Add authentication and send the request. We add authentication even if MSC3916 endpoints aren't
// being used to ensure patches like this work:
// https://github.com/matrix-org/synapse/commit/2390b66bf0ec3ff5ffb0c7333f3c9b239eeb92bb
return fetch(url, fetchConfig);
})(),
);
if (!url.includes("/_matrix/media/v3/download") && !url.includes("/_matrix/media/v3/thumbnail")) {
return; // not a URL we care about
}

// We need to call respondWith synchronously, otherwise we may never execute properly. This means
// later on we need to proxy the request through if it turns out the server doesn't support authentication.
event.respondWith(
(async (): Promise<Response> => {
let accessToken: string | undefined;
try {
// Figure out which homeserver we're communicating with
const csApi = url.substring(0, url.indexOf("/_matrix/media/v3"));

// Add jitter to reduce request spam, particularly to `/versions` on initial page load
await new Promise<void>((resolve) => setTimeout(() => resolve(), Math.random() * 10));

// Locate our access token, and populate the fetchConfig with the authentication header.
// @ts-expect-error - service worker types are not available. See 'fetch' event handler.
const client = await self.clients.get(event.clientId);
accessToken = await getAccessToken(client);

// Update or populate the server support map using a (usually) authenticated `/versions` call.
await tryUpdateServerSupportMap(csApi, accessToken);

// If we have server support (and a means of authentication), rewrite the URL to use MSC3916 endpoints.
if (serverSupportMap[csApi].supportsMSC3916 && accessToken) {
// Currently unstable only.
// TODO: Support stable endpoints when available.
url = url.replace(/\/media\/v3\/(.*)\//, "/client/unstable/org.matrix.msc3916/media/$1/");
} // else by default we make no changes
} catch (err) {
console.error("SW: Error in request rewrite.", err);
}

// Add authentication and send the request. We add authentication even if MSC3916 endpoints aren't
// being used to ensure patches like this work:
// https://github.com/matrix-org/synapse/commit/2390b66bf0ec3ff5ffb0c7333f3c9b239eeb92bb
const config = !accessToken
? undefined
: {
headers: {
Authorization: `Bearer ${accessToken}`,
},
};
turt2live marked this conversation as resolved.
Show resolved Hide resolved
return fetch(url, config);
})(),
);
});

async function tryUpdateServerSupportMap(clientApiUrl: string, accessToken?: string): Promise<void> {
// only update if we don't know about it, or if the data is stale
if (serverSupportMap[clientApiUrl]?.cacheExpires > new Date().getTime()) {
return; // up to date
}

const config = !accessToken
? undefined
: {
headers: {
Authorization: `Bearer ${accessToken}`,
},
};
const versions = await (await fetch(`${clientApiUrl}/_matrix/client/versions`, config)).json();

serverSupportMap[clientApiUrl] = {
supportsMSC3916: Boolean(versions?.unstable_features?.["org.matrix.msc3916"]),
cacheExpires: new Date().getTime() + 2 * 60 * 60 * 1000, // 2 hours from now
};
}

// Ideally we'd use the `Client` interface for `client`, but since it's not available (see 'fetch' listener), we use
// unknown for now and force-cast it to something close enough later.
async function getAccessToken(client: unknown): Promise<string | undefined> {
Expand Down Expand Up @@ -139,6 +156,15 @@ async function getAccessToken(client: unknown): Promise<string | undefined> {
// unknown for now and force-cast it to something close enough inside the function.
async function askClientForUserIdParams(client: unknown): Promise<{ userId: string; deviceId: string }> {
turt2live marked this conversation as resolved.
Show resolved Hide resolved
return new Promise((resolve, reject) => {
// Dev note: this uses postMessage, which is a highly insecure channel. postMessage is typically visible to other
// tabs, windows, browser extensions, etc, making it far from ideal for sharing sensitive information. This is
// why our service worker calculates/decrypts the access token manually: we don't want the user's access token
// to be available to (potentially) malicious listeners. We do require some information for that decryption to
// work though, and request that in the least sensitive way possible.
//
// We could also potentially use some version of TLS to encrypt postMessage, though that feels way more involved
// than just reading IndexedDB ourselves.

// Avoid stalling the tab in case something goes wrong.
const timeoutId = setTimeout(() => reject(new Error("timeout in postMessage")), 1000);

Expand Down
71 changes: 40 additions & 31 deletions src/vector/platform/WebPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,38 +44,47 @@ export default class WebPlatform extends VectorBasePlatform {

public constructor() {
super();
// Register service worker if available on this platform
if ("serviceWorker" in navigator) {
// sw.js is exported by webpack, sourced from `/src/serviceworker/index.ts`
const swPromise = navigator.serviceWorker.register("sw.js");

// Jest causes `register()` to return undefined, so swallow that case.
if (swPromise) {
swPromise
.then(async (r) => {
// always ask the browser to update. The browser might not actually do it, but at least we asked.
await r.update();
return r;
})
.then((r) => {
navigator.serviceWorker.addEventListener("message", (e) => {
try {
if (e.data?.["type"] === "userinfo" && e.data?.["responseKey"]) {
const userId = localStorage.getItem("mx_user_id");
const deviceId = localStorage.getItem("mx_device_id");
r.active!.postMessage({
responseKey: e.data["responseKey"],
userId,
deviceId,
});
}
} catch (e) {
console.error("Error responding to service worker: ", e);
}
});
})
.catch((e) => console.error("Error registering/updating service worker:", e));

// noinspection JSIgnoredPromiseFromCall - can run async
this.tryRegisterServiceWorker();
turt2live marked this conversation as resolved.
Show resolved Hide resolved
}

private async tryRegisterServiceWorker(): Promise<void> {
if (!("serviceWorker" in navigator)) {
return; // not available on this platform - don't try to register the service worker
}

// sw.js is exported by webpack, sourced from `/src/serviceworker/index.ts`
const swPromise = navigator.serviceWorker.register("sw.js");
if (!swPromise) {
// Registration didn't return a promise for some reason - assume failed and ignore.
// This typically happens in Jest.
return;
}

try {
const registration = await swPromise;
turt2live marked this conversation as resolved.
Show resolved Hide resolved
await registration.update();
navigator.serviceWorker.addEventListener("message", this.onServiceWorkerPostMessage.bind(this));
} catch (e) {
console.error("Error registering/updating service worker:", e);
// otherwise ignore the error and remain unregistered
}
}

private onServiceWorkerPostMessage(event: MessageEvent): void {
try {
if (event.data?.["type"] === "userinfo" && event.data?.["responseKey"]) {
const userId = localStorage.getItem("mx_user_id");
const deviceId = localStorage.getItem("mx_device_id");
event.source!.postMessage({
responseKey: event.data["responseKey"],
userId,
deviceId,
});
}
} catch (e) {
console.error("Error responding to service worker: ", e);
}
}

Expand Down
Loading