-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Typescriptify & use service worker for MSC3916 authentication #27326
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
Changes from 11 commits
69817ca
296c82c
8542ce2
b333b29
6cf7dca
af1ba39
9494257
7d63b90
8067197
ea7e8fb
d0dcf89
3fa2a42
80dd415
0395ee4
2ad00c0
0951fe7
c20d5f1
3947d90
7d9e7d6
d4efdf2
37e3dfd
0d5e2a9
ec159a3
310284b
b80adc5
cdbfd80
a57c111
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| /* | ||
| Copyright 2024 New Vector Ltd | ||
|
|
||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||
| you may not use this file except in compliance with the License. | ||
| You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, software | ||
| distributed under the License is distributed on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| See the License for the specific language governing permissions and | ||
| limitations under the License. | ||
| */ | ||
|
|
||
| import { idbLoad } from "matrix-react-sdk/src/utils/StorageAccess"; | ||
| import { ACCESS_TOKEN_IV, tryDecryptToken } from "matrix-react-sdk/src/utils/tokens/tokens"; | ||
| import { buildAndEncodePickleKey } from "matrix-react-sdk/src/utils/tokens/pickling"; | ||
|
|
||
| const serverSupportMap: { | ||
| [serverUrl: string]: { | ||
| supportsMSC3916: boolean; | ||
| cacheExpires: number; | ||
| }; | ||
| } = {}; | ||
|
|
||
| self.addEventListener("install", (event) => { | ||
| // We skipWaiting() to update the service worker more frequently, particularly in development environments. | ||
| // @ts-expect-error - service worker types are not available. See 'fetch' event handler. | ||
| event.waitUntil(skipWaiting()); | ||
| }); | ||
|
|
||
| self.addEventListener("activate", (event) => { | ||
| // We force all clients to be under our control, immediately. This could be old tabs. | ||
| // @ts-expect-error - service worker types are not available. See 'fetch' event handler. | ||
| event.waitUntil(clients.claim()); | ||
| }); | ||
|
|
||
| // @ts-expect-error - the service worker types conflict with the DOM types available through TypeScript. Many hours | ||
| // have been spent trying to convince the type system that there's no actual conflict, but it has yet to work. Instead | ||
| // of trying to make it do the thing, we force-cast to something close enough where we can (and ignore errors otherwise). | ||
| self.addEventListener("fetch", (event: FetchEvent) => { | ||
| // This is the authenticated media (MSC3916) check, proxying what was unauthenticated to the authenticated variants. | ||
|
|
||
| if (event.request.method !== "GET") { | ||
| return; // not important to us | ||
| } | ||
|
|
||
| // Note: ideally we'd keep the request headers and etc, but in practice we can't even see those details. | ||
|
turt2live marked this conversation as resolved.
Outdated
|
||
| // See https://stackoverflow.com/a/59152482 | ||
| let url = event.request.url; | ||
|
|
||
| // 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")) { | ||
|
turt2live marked this conversation as resolved.
Outdated
|
||
| // 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. rewriting the URL in the depths of the serviceworker feels very, very magical. Shouldn't this happen in the js-sdk?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Where in the js-sdk? Our URL generation for image elements and etc is synchronous, and making it async would require massive refactoring. This prevents us from doing a Instead, the idea is we let the service worker rewrite the url and if that fails or is unsupported, the natural fallback behaviour exists instead.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking of a method on MatrixClient or something for turning mxc URIs into http URLs. Cache the versions check early on, I guess.
Seems like we can't rely on this anyway, because we're going to need authentication. Or rather: if it fails often enough that we need that fallback, then the whole approach seems doomed.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The functions which convert MXC URIs to HTTP URLs are not async-capable, unfortunately, and there's nowhere in the app where we can realistically cache While the MSC is unstable I think we can (and should) maintain the fallback, though as we get closer to freezing the unauthenticated endpoints mentioned in MSC3916, we can change the js-sdk to use the authenticated endpoints by default (or always?). The service worker would detect that route and append an access token rather than rewriting it at that point.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well: we can't use the authenticated endpoint until we get hold of an access token, and getting hold of an access token is an async operation. That seems to me a good time to decide whether we ought to use that access token in media requests. I guess that's a bunch more work, though :(. The fact we might be able to get rid of this code in future does little to quell my concerns. Experience strongly suggests that this code will still be here in 8 years time. I think my concerns here are twofold:
At the end of the day, if we can't do better then we can't do better. We can at least do our best to document our way out of it though.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. tldr - js-sdk PR for documentation is here: matrix-org/matrix-js-sdk#4185
The issue is that the call stack is not capable of having any async code in it. The It is indeed a bunch more work to fix this, and matrix-org/matrix-react-sdk@5cdd706 only covers some of it (as an alternative I wrote to avoid using service workers). Even if we replace the URL rewrite with a js-sdk function call, we're still collecting blobs and filling up our memory buffers. The hardest things to fix are the hidden uses of non-image elements, like in the case of the composer where avatars are applied using CSS. Service workers cover all of these nuanced cases, though ideally I think the js-sdk would indeed be producing authentication-first URLs (which the service worker can detect and append an
This is my experience too, fwiw. I'm hopeful our rollout plan (internal only) will help actually make this code temporary and work to eradicate the unauthenticated option, though the service worker itself will need to be maintained. We can at least remove/reduce some of the magic as the MSC progresses towards total stability.
I'm not sure I'd classify it as "useless" given servers aren't likely to turn on an unstable feature just yet, but it is something worth documenting. I've done so here: matrix-org/matrix-js-sdk#4185 I suspect it's possible for the js-sdk to support these sorts of operations when it's running in a NodeJS environment, though in a browser environment it's almost certainly a service worker problem given the consumer's (react-sdk) usage.
Mentioned above, it is pretty magical. With the browser devtools making it clear what's happening and the rollout strategy though, I think we'll be okay. At the very least we can switch to using stable endpoints in the js-sdk pretty quickly by default (once the spec process allows us to), and convert the service worker to 'just' adding the Authorization header in place. This would break compatibility with older servers, but at least on T&S we consider that to be a feature (once the MSC is stable and/or released - doing so right now would require servers to advertise unstable functionality, which effectively bypasses the MSC process and leads to defacto spec).
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Thanks for starting to document this. It certainly helps quell some of my concerns here.
I don't really follow this. Why can't we make the So is the problem that there are some places that we construct media links where there is no access to a ... which is not to say it's not a bunch of work, but I'm still keen to understand if we're doing this for pragmatic reasons or because it's the only way it can possibly work.
Well, I'll believe it when I see it...
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. moving comment into thread
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The service worker avoids the lack of This is out of pragmatism imo, though I'm still open to suggestions/hints on where to put this code otherwise. |
||
| } 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); | ||
| })(), | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| // 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> { | ||
| // Access tokens are encrypted at rest, so while we can grab the "access token", we'll need to do work to get the | ||
| // real thing. | ||
| const encryptedAccessToken = await idbLoad("account", "mx_access_token"); | ||
|
|
||
| // We need to extract a user ID and device ID from localstorage, which means calling WebPlatform for the | ||
| // read operation. Service workers can't access localstorage. | ||
| const { userId, deviceId } = await askClientForUserIdParams(client); | ||
|
|
||
| // ... and this is why we need the user ID and device ID: they're index keys for the pickle key table. | ||
| const pickleKeyData = await idbLoad("pickleKey", [userId, deviceId]); | ||
| if (pickleKeyData && (!pickleKeyData.encrypted || !pickleKeyData.iv || !pickleKeyData.cryptoKey)) { | ||
| console.error("SW: Invalid pickle key loaded - ignoring"); | ||
| return undefined; | ||
| } | ||
|
|
||
| // Finally, try decrypting the thing and return that. This may fail, but that's okay. | ||
| try { | ||
| const pickleKey = await buildAndEncodePickleKey(pickleKeyData, userId, deviceId); | ||
| return tryDecryptToken(pickleKey, encryptedAccessToken, ACCESS_TOKEN_IV); | ||
| } catch (e) { | ||
| console.error("SW: Error decrypting access token.", e); | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| // 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 inside the function. | ||
| async function askClientForUserIdParams(client: unknown): Promise<{ userId: string; deviceId: string }> { | ||
|
turt2live marked this conversation as resolved.
|
||
| return new Promise((resolve, reject) => { | ||
| // Avoid stalling the tab in case something goes wrong. | ||
| const timeoutId = setTimeout(() => reject(new Error("timeout in postMessage")), 1000); | ||
|
|
||
| // We don't need particularly good randomness here - we just use this to generate a request ID, so we know | ||
| // which postMessage reply is for our active request. | ||
| const responseKey = Math.random().toString(36); | ||
|
|
||
| // Add the listener first, just in case the tab is *really* fast. | ||
| const listener = (event: MessageEvent): void => { | ||
| if (event.data?.responseKey !== responseKey) return; // not for us | ||
| clearTimeout(timeoutId); // do this as soon as possible, avoiding a race between resolve and reject. | ||
| resolve(event.data); // "unblock" the remainder of the thread, if that were such a thing in JavaScript. | ||
| self.removeEventListener("message", listener); // cleanup, since we're not going to do anything else. | ||
| }; | ||
| self.addEventListener("message", listener); | ||
|
|
||
| // Ask the tab for the information we need. This is handled by WebPlatform. | ||
| (client as Window).postMessage({ responseKey, type: "userinfo" }); | ||
| }); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.