Get the access token directly

pull/27326/head
Travis Ralston 2024-04-18 16:20:22 -06:00
parent 6cf7dca4f9
commit af1ba39f20
2 changed files with 148 additions and 40 deletions

View File

@ -1,3 +1,23 @@
/*
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 { encodeUnpaddedBase64 } from "matrix-js-sdk/src/base64";
const serverSupportMap: {
[serverUrl: string]: {
supportsMSC3916: boolean;
@ -5,20 +25,16 @@ const serverSupportMap: {
};
} = {};
const credentialStore: {
[serverUrl: string]: string;
} = {};
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());
});
// 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.
skipWaiting();
self.addEventListener("message", (event) => {
if (event.data?.type !== "credentials") return; // ignore
credentialStore[event.data.homeserverUrl] = event.data.accessToken;
console.log(
`[Service Worker] Updated access token for ${event.data.homeserverUrl} (accessToken? ${Boolean(event.data.accessToken)})`,
);
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 - getting types to work for this is difficult, so we anticipate that "addEventListener" doesn't
@ -42,34 +58,40 @@ self.addEventListener("fetch", (event: FetchEvent) => {
// later on we need to proxy the request through if it turns out the server doesn't support authentication.
event.respondWith(
(async (): Promise<Response> => {
// Figure out which homeserver we're communicating with
const csApi = url.substring(0, url.indexOf("/_matrix/media/v3"));
// Locate our access token, and populate the fetchConfig with the authentication header.
const accessToken = credentialStore[csApi];
let fetchConfig: { headers?: { [key: string]: string } } = {};
if (accessToken) {
fetchConfig = {
headers: {
Authorization: `Bearer ${accessToken}`,
},
};
}
try {
// Figure out which homeserver we're communicating with
const csApi = url.substring(0, url.indexOf("/_matrix/media/v3"));
// 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
};
}
// 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}`,
},
};
}
// 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.
url = url.replace(/\/media\/v3\/(.*)\//, "/client/unstable/org.matrix.msc3916/media/$1/");
} // else by default we make no changes
// 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.
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:
@ -79,3 +101,69 @@ self.addEventListener("fetch", (event: FetchEvent) => {
);
}
});
// 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 new Promise<{ userId: string; deviceId: string }>((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" });
});
// ... 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;
}
let pickleKey: string | undefined;
// Extract a useful pickle key out of our queries.
if (pickleKeyData) {
// We also need to generate the additional data needed for the key
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
for (let i = 0; i < userId.length; i++) {
additionalData[i] = userId.charCodeAt(i);
}
additionalData[userId.length] = 124; // "|"
for (let i = 0; i < deviceId.length; i++) {
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
}
// Convert pickle key to a base64 key we can use
const pickleKeyBuf = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: pickleKeyData.iv, additionalData },
pickleKeyData.cryptoKey,
pickleKeyData.encrypted,
);
if (pickleKeyBuf) {
pickleKey = encodeUnpaddedBase64(pickleKeyBuf);
}
}
// Finally, try decrypting the thing and return that. This may fail, but that's okay.
return tryDecryptToken(pickleKey, encryptedAccessToken, ACCESS_TOKEN_IV);
}

View File

@ -1,7 +1,7 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2016 OpenMarket Ltd
Copyright 2017-2020 New Vector Ltd
Copyright 2017-2020, 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.
@ -52,7 +52,27 @@ export default class WebPlatform extends VectorBasePlatform {
// Jest causes `register()` to return undefined, so swallow that case.
if (swPromise) {
swPromise
.then((r) => r.update())
.then(async (r) => {
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));
}
}