Revert "Support refresh tokens (#7802)"

This reverts commit 839593412c.
This commit is contained in:
Travis Ralston 2022-02-16 12:32:38 -07:00 committed by GitHub
parent 81f52283cf
commit aba61fa390
9 changed files with 25 additions and 504 deletions

View file

@ -83,7 +83,6 @@
"glob-to-regexp": "^0.4.1", "glob-to-regexp": "^0.4.1",
"highlight.js": "^11.3.1", "highlight.js": "^11.3.1",
"html-entities": "^1.4.0", "html-entities": "^1.4.0",
"idb-mutex": "^0.11.0",
"is-ip": "^3.1.0", "is-ip": "^3.1.0",
"jszip": "^3.7.0", "jszip": "^3.7.0",
"katex": "^0.12.0", "katex": "^0.12.0",

View file

@ -58,7 +58,6 @@ import LazyLoadingDisabledDialog from "./components/views/dialogs/LazyLoadingDis
import SessionRestoreErrorDialog from "./components/views/dialogs/SessionRestoreErrorDialog"; import SessionRestoreErrorDialog from "./components/views/dialogs/SessionRestoreErrorDialog";
import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog"; import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog";
import { setSentryUser } from "./sentry"; import { setSentryUser } from "./sentry";
import { IRenewedMatrixClientCreds, TokenLifecycle } from "./TokenLifecycle";
const HOMESERVER_URL_KEY = "mx_hs_url"; const HOMESERVER_URL_KEY = "mx_hs_url";
const ID_SERVER_URL_KEY = "mx_is_url"; const ID_SERVER_URL_KEY = "mx_is_url";
@ -204,7 +203,6 @@ export function attemptTokenLogin(
"m.login.token", { "m.login.token", {
token: queryParams.loginToken as string, token: queryParams.loginToken as string,
initial_device_display_name: defaultDeviceDisplayName, initial_device_display_name: defaultDeviceDisplayName,
refresh_token: TokenLifecycle.instance.isFeasible,
}, },
).then(function(creds) { ).then(function(creds) {
logger.log("Logged in with token"); logger.log("Logged in with token");
@ -311,8 +309,6 @@ export interface IStoredSession {
userId: string; userId: string;
deviceId: string; deviceId: string;
isGuest: boolean; isGuest: boolean;
accessTokenExpiryTs?: number; // set if the token expires
accessTokenRefreshToken?: string | IEncryptedPayload; // set if the token can be renewed
} }
/** /**
@ -323,7 +319,7 @@ export interface IStoredSession {
export async function getStoredSessionVars(): Promise<IStoredSession> { export async function getStoredSessionVars(): Promise<IStoredSession> {
const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY); const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY);
const isUrl = localStorage.getItem(ID_SERVER_URL_KEY); const isUrl = localStorage.getItem(ID_SERVER_URL_KEY);
let accessToken: string; let accessToken;
try { try {
accessToken = await StorageManager.idbLoad("account", "mx_access_token"); accessToken = await StorageManager.idbLoad("account", "mx_access_token");
} catch (e) { } catch (e) {
@ -341,43 +337,6 @@ export async function getStoredSessionVars(): Promise<IStoredSession> {
} }
} }
} }
let accessTokenExpiryTs: number;
let accessTokenRefreshToken: string;
if (accessToken) {
const expiration = localStorage.getItem("mx_access_token_expires_ts");
if (expiration) accessTokenExpiryTs = Number(expiration);
if (localStorage.getItem("mx_has_refresh_token")) {
try {
accessTokenRefreshToken = await StorageManager.idbLoad(
"account", "mx_refresh_token",
);
} catch (e) {
logger.warn(
"StorageManager.idbLoad failed for account:mx_refresh_token " +
"(presuming no refresh token)",
e,
);
}
if (!accessTokenRefreshToken) {
accessTokenRefreshToken = localStorage.getItem("mx_refresh_token");
if (accessTokenRefreshToken) {
try {
// try to migrate refresh token to IndexedDB if we can
await StorageManager.idbSave(
"account", "mx_refresh_token", accessTokenRefreshToken,
);
localStorage.removeItem("mx_refresh_token");
} catch (e) {
logger.error("migration of refresh token to IndexedDB failed", e);
}
}
}
}
}
// if we pre-date storing "mx_has_access_token", but we retrieved an access // if we pre-date storing "mx_has_access_token", but we retrieved an access
// token, then we should say we have an access token // token, then we should say we have an access token
const hasAccessToken = const hasAccessToken =
@ -393,17 +352,7 @@ export async function getStoredSessionVars(): Promise<IStoredSession> {
isGuest = localStorage.getItem("matrix-is-guest") === "true"; isGuest = localStorage.getItem("matrix-is-guest") === "true";
} }
return { return { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest };
hsUrl,
isUrl,
hasAccessToken,
accessToken,
accessTokenExpiryTs,
accessTokenRefreshToken,
userId,
deviceId,
isGuest,
};
} }
// The pickle key is a string of unspecified length and format. For AES, we // The pickle key is a string of unspecified length and format. For AES, we
@ -442,41 +391,6 @@ async function abortLogin() {
} }
} }
export async function getRenewedStoredSessionVars(): Promise<IRenewedMatrixClientCreds> {
const {
userId,
deviceId,
accessToken,
accessTokenExpiryTs,
accessTokenRefreshToken,
} = await getStoredSessionVars();
let decryptedAccessToken = accessToken;
let decryptedRefreshToken = accessTokenRefreshToken;
const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId);
if (pickleKey) {
logger.log("Got pickle key");
if (typeof accessToken !== "string") {
const encrKey = await pickleKeyToAesKey(pickleKey);
decryptedAccessToken = await decryptAES(accessToken, encrKey, "access_token");
encrKey.fill(0);
}
if (accessTokenRefreshToken && typeof accessTokenRefreshToken !== "string") {
const encrKey = await pickleKeyToAesKey(pickleKey);
decryptedRefreshToken = await decryptAES(accessTokenRefreshToken, encrKey, "refresh_token");
encrKey.fill(0);
}
} else {
logger.log("No pickle key available");
}
return {
accessToken: decryptedAccessToken as string,
accessTokenExpiryTs: accessTokenExpiryTs,
accessTokenRefreshToken: decryptedRefreshToken as string,
};
}
// returns a promise which resolves to true if a session is found in // returns a promise which resolves to true if a session is found in
// localstorage // localstorage
// //
@ -494,16 +408,7 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
return false; return false;
} }
const { const { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest } = await getStoredSessionVars();
hsUrl,
isUrl,
hasAccessToken,
accessToken,
userId,
deviceId,
isGuest,
accessTokenExpiryTs,
} = await getStoredSessionVars();
if (hasAccessToken && !accessToken) { if (hasAccessToken && !accessToken) {
abortLogin(); abortLogin();
@ -515,11 +420,18 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
return false; return false;
} }
let decryptedAccessToken = accessToken;
const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId); const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId);
const { if (pickleKey) {
accessToken: decryptedAccessToken, logger.log("Got pickle key");
accessTokenRefreshToken: decryptedRefreshToken, if (typeof accessToken !== "string") {
} = await getRenewedStoredSessionVars(); const encrKey = await pickleKeyToAesKey(pickleKey);
decryptedAccessToken = await decryptAES(accessToken, encrKey, "access_token");
encrKey.fill(0);
}
} else {
logger.log("No pickle key available");
}
const freshLogin = sessionStorage.getItem("mx_fresh_login") === "true"; const freshLogin = sessionStorage.getItem("mx_fresh_login") === "true";
sessionStorage.removeItem("mx_fresh_login"); sessionStorage.removeItem("mx_fresh_login");
@ -534,8 +446,6 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
guest: isGuest, guest: isGuest,
pickleKey: pickleKey, pickleKey: pickleKey,
freshLogin: freshLogin, freshLogin: freshLogin,
accessTokenExpiryTs: accessTokenExpiryTs,
accessTokenRefreshToken: decryptedRefreshToken as string,
}, false); }, false);
return true; return true;
} else { } else {
@ -601,10 +511,12 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<Matr
* *
* If the credentials belong to a different user from the session already stored, * If the credentials belong to a different user from the session already stored,
* the old session will be cleared automatically. * the old session will be cleared automatically.
* @param {IMatrixClientCreds} credentials The credentials to use *
* @param {MatrixClientCreds} credentials The credentials to use
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started * @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/ */
export async function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixClient> { export function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixClient> {
const oldUserId = MatrixClientPeg.get().getUserId(); const oldUserId = MatrixClientPeg.get().getUserId();
const oldDeviceId = MatrixClientPeg.get().getDeviceId(); const oldDeviceId = MatrixClientPeg.get().getDeviceId();
@ -617,42 +529,9 @@ export async function hydrateSession(credentials: IMatrixClientCreds): Promise<M
logger.warn("Clearing all data: Old session belongs to a different user/session"); logger.warn("Clearing all data: Old session belongs to a different user/session");
} }
if (!credentials.pickleKey) {
logger.info("Lifecycle#hydrateSession: Pickle key not provided - trying to get one");
credentials.pickleKey = await PlatformPeg.get().getPickleKey(credentials.userId, credentials.deviceId);
}
return doSetLoggedIn(credentials, overwrite); return doSetLoggedIn(credentials, overwrite);
} }
/**
* Similar to hydrateSession(), this will update the credentials used by the current
* session in-place. Services will not be restarted, and storage will not be deleted.
* @param {IMatrixClientCreds} credentials The credentials to use
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
export async function hydrateSessionInPlace(credentials: IMatrixClientCreds): Promise<MatrixClient> {
const oldUserId = MatrixClientPeg.get().getUserId();
const oldDeviceId = MatrixClientPeg.get().getDeviceId();
if (credentials.userId !== oldUserId || credentials.deviceId !== oldDeviceId) {
throw new Error("Attempted to hydrate in-place with a different session");
}
const cli = MatrixClientPeg.get();
if (!cli) {
throw new Error("Attempted to hydrate a non-existent MatrixClient");
}
logger.info("Lifecycle#hydrateInPlace: Persisting credentials and updating access token");
await persistCredentials(credentials);
MatrixClientPeg.updateUsingCreds(credentials);
// reset the token timers
TokenLifecycle.instance.startTimers(credentials);
return cli;
}
/** /**
* fires on_logging_in, optionally clears localstorage, persists new credentials * fires on_logging_in, optionally clears localstorage, persists new credentials
* to localstorage, starts the new client. * to localstorage, starts the new client.
@ -675,10 +554,8 @@ async function doSetLoggedIn(
" deviceId: " + credentials.deviceId + " deviceId: " + credentials.deviceId +
" guest: " + credentials.guest + " guest: " + credentials.guest +
" hs: " + credentials.homeserverUrl + " hs: " + credentials.homeserverUrl +
" softLogout: " + softLogout + " softLogout: " + softLogout,
" freshLogin: " + credentials.freshLogin + " freshLogin: " + credentials.freshLogin,
" tokenExpires: " + (!!credentials.accessTokenExpiryTs) +
" tokenRenewable: " + (!!credentials.accessTokenRefreshToken),
); );
// This is dispatched to indicate that the user is still in the process of logging in // This is dispatched to indicate that the user is still in the process of logging in
@ -706,29 +583,6 @@ async function doSetLoggedIn(
MatrixClientPeg.replaceUsingCreds(credentials); MatrixClientPeg.replaceUsingCreds(credentials);
// Check the token's renewal early so we don't have to undo some of the work down below.
logger.info("Lifecycle#doSetLoggedIn: Trying token refresh in case it is needed");
let didTokenRefresh = false;
try {
const result = await TokenLifecycle.instance.tryTokenExchangeIfNeeded(credentials, MatrixClientPeg.get());
if (result) {
logger.info("Lifecycle#doSetLoggedIn: Token refresh successful, using credentials");
credentials.accessToken = result.accessToken;
credentials.accessTokenExpiryTs = result.accessTokenExpiryTs;
credentials.accessTokenRefreshToken = result.accessTokenRefreshToken;
// don't forget to replace the client with the new credentials
MatrixClientPeg.replaceUsingCreds(credentials);
didTokenRefresh = true;
} else {
logger.info("Lifecycle#doSetLoggedIn: Token refresh indicated as not needed");
}
} catch (e) {
logger.error("Lifecycle#doSetLoggedIn: Failed to exchange token", e);
await abortLogin();
}
setSentryUser(credentials.userId); setSentryUser(credentials.userId);
if (PosthogAnalytics.instance.isEnabled()) { if (PosthogAnalytics.instance.isEnabled()) {
@ -751,12 +605,8 @@ async function doSetLoggedIn(
if (localStorage) { if (localStorage) {
try { try {
await persistCredentials(credentials); await persistCredentials(credentials);
// make sure we don't think that it's a fresh login anymore // make sure we don't think that it's a fresh login any more
sessionStorage.removeItem("mx_fresh_login"); sessionStorage.removeItem("mx_fresh_login");
if (didTokenRefresh) {
TokenLifecycle.instance.flagNewCredentialsPersisted();
}
} catch (e) { } catch (e) {
logger.warn("Error using local storage: can't persist session!", e); logger.warn("Error using local storage: can't persist session!", e);
} }
@ -764,9 +614,6 @@ async function doSetLoggedIn(
logger.warn("No local storage available: can't persist session!"); logger.warn("No local storage available: can't persist session!");
} }
// Start the token lifecycle as late as possible in case something above goes wrong
TokenLifecycle.instance.startTimers(credentials);
dis.dispatch({ action: 'on_logged_in' }); dis.dispatch({ action: 'on_logged_in' });
await startMatrixClient(/*startSyncing=*/!softLogout); await startMatrixClient(/*startSyncing=*/!softLogout);
@ -793,44 +640,20 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise<void
localStorage.setItem("mx_user_id", credentials.userId); localStorage.setItem("mx_user_id", credentials.userId);
localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest));
if (credentials.accessTokenExpiryTs) {
localStorage.setItem("mx_access_token_expires_ts", credentials.accessTokenExpiryTs.toString());
}
// store whether we expect to find an access token, to detect the case // store whether we expect to find an access token, to detect the case
// where IndexedDB is blown away // where IndexedDB is blown away
if (credentials.accessToken) { if (credentials.accessToken) {
localStorage.setItem("mx_has_access_token", "true"); localStorage.setItem("mx_has_access_token", "true");
} else { } else {
localStorage.removeItem("mx_has_access_token"); localStorage.deleteItem("mx_has_access_token");
}
// store a similar flag for the refresh token
if (credentials.accessTokenRefreshToken) {
localStorage.setItem("mx_has_refresh_token", "true");
} else {
localStorage.removeItem("mx_has_refresh_token");
localStorage.removeItem("mx_refresh_token");
try {
await StorageManager.idbDelete("account", "mx_refresh_token");
} catch (e) {
// ignore - no action needed
}
} }
if (credentials.pickleKey) { if (credentials.pickleKey) {
let encryptedAccessToken: IEncryptedPayload; let encryptedAccessToken;
let encryptedRefreshToken: IEncryptedPayload;
try { try {
// try to encrypt the access token using the pickle key // try to encrypt the access token using the pickle key
const encrKey = await pickleKeyToAesKey(credentials.pickleKey); const encrKey = await pickleKeyToAesKey(credentials.pickleKey);
encryptedAccessToken = await encryptAES(credentials.accessToken, encrKey, "access_token"); encryptedAccessToken = await encryptAES(credentials.accessToken, encrKey, "access_token");
if (credentials.accessTokenRefreshToken) {
encryptedRefreshToken = await encryptAES(
credentials.accessTokenRefreshToken, encrKey, "refresh_token",
);
}
encrKey.fill(0); encrKey.fill(0);
} catch (e) { } catch (e) {
logger.warn("Could not encrypt access token", e); logger.warn("Could not encrypt access token", e);
@ -843,20 +666,11 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise<void
"account", "mx_access_token", "account", "mx_access_token",
encryptedAccessToken || credentials.accessToken, encryptedAccessToken || credentials.accessToken,
); );
if (encryptedRefreshToken) {
await StorageManager.idbSave(
"account", "mx_refresh_token",
encryptedRefreshToken || credentials.accessTokenRefreshToken,
);
}
} catch (e) { } catch (e) {
// if we couldn't save to indexedDB, fall back to localStorage. We // if we couldn't save to indexedDB, fall back to localStorage. We
// store the access token unencrypted since localStorage only saves // store the access token unencrypted since localStorage only saves
// strings. // strings.
localStorage.setItem("mx_access_token", credentials.accessToken); localStorage.setItem("mx_access_token", credentials.accessToken);
if (credentials.accessTokenRefreshToken) {
localStorage.setItem("mx_refresh_token", credentials.accessTokenRefreshToken);
}
} }
localStorage.setItem("mx_has_pickle_key", String(true)); localStorage.setItem("mx_has_pickle_key", String(true));
} else { } else {
@ -867,15 +681,6 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise<void
} catch (e) { } catch (e) {
localStorage.setItem("mx_access_token", credentials.accessToken); localStorage.setItem("mx_access_token", credentials.accessToken);
} }
if (credentials.accessTokenRefreshToken) {
try {
await StorageManager.idbSave(
"account", "mx_refresh_token", credentials.accessTokenRefreshToken,
);
} catch (e) {
localStorage.setItem("mx_refresh_token", credentials.accessTokenRefreshToken);
}
}
if (localStorage.getItem("mx_has_pickle_key")) { if (localStorage.getItem("mx_has_pickle_key")) {
logger.error("Expected a pickle key, but none provided. Encryption may not work."); logger.error("Expected a pickle key, but none provided. Encryption may not work.");
} }
@ -1086,7 +891,6 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void
* on MatrixClientPeg after stopping. * on MatrixClientPeg after stopping.
*/ */
export function stopMatrixClient(unsetClient = true): void { export function stopMatrixClient(unsetClient = true): void {
TokenLifecycle.instance.stopTimers();
Notifier.stop(); Notifier.stop();
CallHandler.instance.stop(); CallHandler.instance.stop();
UserActivity.sharedInstance().stop(); UserActivity.sharedInstance().stop();

View file

@ -22,7 +22,6 @@ import { logger } from "matrix-js-sdk/src/logger";
import { IMatrixClientCreds } from "./MatrixClientPeg"; import { IMatrixClientCreds } from "./MatrixClientPeg";
import SecurityCustomisations from "./customisations/Security"; import SecurityCustomisations from "./customisations/Security";
import { TokenLifecycle } from "./TokenLifecycle";
interface ILoginOptions { interface ILoginOptions {
defaultDeviceDisplayName?: string; defaultDeviceDisplayName?: string;
@ -65,11 +64,6 @@ interface ILoginParams {
token?: string; token?: string;
device_id?: string; device_id?: string;
initial_device_display_name?: string; initial_device_display_name?: string;
// If true, a refresh token will be requested. If the server supports it, it
// will be returned. Does nothing out of the ordinary if not set, false, or
// the server doesn't support the feature.
refresh_token?: boolean;
} }
/* eslint-enable camelcase */ /* eslint-enable camelcase */
@ -168,7 +162,6 @@ export default class Login {
password, password,
identifier, identifier,
initial_device_display_name: this.defaultDeviceDisplayName, initial_device_display_name: this.defaultDeviceDisplayName,
refresh_token: TokenLifecycle.instance.isFeasible,
}; };
const tryFallbackHs = (originalError) => { const tryFallbackHs = (originalError) => {
@ -242,9 +235,6 @@ export async function sendLoginRequest(
userId: data.user_id, userId: data.user_id,
deviceId: data.device_id, deviceId: data.device_id,
accessToken: data.access_token, accessToken: data.access_token,
// Use the browser's local time for expiration timestamp - see TokenLifecycle for more info
accessTokenExpiryTs: data.expires_in_ms ? (data.expires_in_ms + Date.now()) : null,
accessTokenRefreshToken: data.refresh_token,
}; };
SecurityCustomisations.examineLoginResponse?.(data, creds); SecurityCustomisations.examineLoginResponse?.(data, creds);

View file

@ -44,8 +44,6 @@ export interface IMatrixClientCreds {
userId: string; userId: string;
deviceId?: string; deviceId?: string;
accessToken: string; accessToken: string;
accessTokenExpiryTs?: number; // set if access token expires
accessTokenRefreshToken?: string; // set if access token can be renewed
guest?: boolean; guest?: boolean;
pickleKey?: string; pickleKey?: string;
freshLogin?: boolean; freshLogin?: boolean;
@ -101,14 +99,6 @@ export interface IMatrixClientPeg {
* @param {IMatrixClientCreds} creds The new credentials to use. * @param {IMatrixClientCreds} creds The new credentials to use.
*/ */
replaceUsingCreds(creds: IMatrixClientCreds): void; replaceUsingCreds(creds: IMatrixClientCreds): void;
/**
* Similar to replaceUsingCreds(), but without the replacement operation.
* Credentials that can be updated in-place will be updated. All others
* will be ignored.
* @param {IMatrixClientCreds} creds The new credentials to use.
*/
updateUsingCreds(creds: IMatrixClientCreds): void;
} }
/** /**
@ -174,15 +164,6 @@ class MatrixClientPegClass implements IMatrixClientPeg {
this.createClient(creds); this.createClient(creds);
} }
public updateUsingCreds(creds: IMatrixClientCreds): void {
if (creds?.accessToken) {
this.currentClientCreds = creds;
this.matrixClient.setAccessToken(creds.accessToken);
} else {
// ignore, per signature
}
}
public async assign(): Promise<any> { public async assign(): Promise<any> {
for (const dbType of ['indexeddb', 'memory']) { for (const dbType of ['indexeddb', 'memory']) {
try { try {
@ -252,15 +233,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
} }
public getCredentials(): IMatrixClientCreds { public getCredentials(): IMatrixClientCreds {
let copiedCredentials = this.currentClientCreds;
if (this.currentClientCreds?.userId !== this.matrixClient?.credentials?.userId) {
// cached credentials belong to a different user - don't use them
copiedCredentials = null;
}
return { return {
// Copy the cached credentials before overriding what we can.
...(copiedCredentials ?? {}),
homeserverUrl: this.matrixClient.baseUrl, homeserverUrl: this.matrixClient.baseUrl,
identityServerUrl: this.matrixClient.idBaseUrl, identityServerUrl: this.matrixClient.idBaseUrl,
userId: this.matrixClient.credentials.userId, userId: this.matrixClient.credentials.userId,

View file

@ -1,233 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { logger } from "matrix-js-sdk/src/logger";
import { MatrixClient } from "matrix-js-sdk/src";
import { randomString } from "matrix-js-sdk/src/randomstring";
import Mutex from "idb-mutex";
import { Optional } from "matrix-events-sdk";
import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg";
import { getRenewedStoredSessionVars, hydrateSessionInPlace } from "./Lifecycle";
import { IDB_SUPPORTED } from "./utils/StorageManager";
export interface IRenewedMatrixClientCreds extends Pick<IMatrixClientCreds,
"accessToken" | "accessTokenExpiryTs" | "accessTokenRefreshToken"> {}
const LOCALSTORAGE_UPDATED_BY_KEY = "mx_token_updated_by";
const CLIENT_ID = randomString(64);
export class TokenLifecycle {
public static readonly instance = new TokenLifecycle();
private refreshAtTimerId: number;
private mutex: Mutex;
protected constructor() {
// we only really want one of these floating around, so private-ish
// constructor. Protected allows for unit tests.
// Don't try to create a mutex if it'll explode
if (IDB_SUPPORTED) {
this.mutex = new Mutex("token_refresh", null, {
expiry: 120000, // 2 minutes - enough time for the refresh request to time out
});
}
// Watch for other tabs causing token refreshes, so we can react to them too.
window.addEventListener("storage", (ev: StorageEvent) => {
if (ev.key === LOCALSTORAGE_UPDATED_BY_KEY) {
const updateBy = localStorage.getItem(LOCALSTORAGE_UPDATED_BY_KEY);
if (!updateBy || updateBy === CLIENT_ID) return; // ignore deletions & echos
logger.info("TokenLifecycle#storageWatch: Token update received");
// noinspection JSIgnoredPromiseFromCall
this.forceHydration();
}
});
}
/**
* Can the client reasonably support token refreshes?
*/
public get isFeasible(): boolean {
return IDB_SUPPORTED;
}
// noinspection JSMethodCanBeStatic
private get fiveMinutesAgo(): number {
return Date.now() - 300000;
}
// noinspection JSMethodCanBeStatic
private get fiveMinutesFromNow(): number {
return Date.now() + 300000;
}
public flagNewCredentialsPersisted() {
logger.info("TokenLifecycle#flagPersisted: Credentials marked as persisted - flagging for other tabs");
if (localStorage.getItem(LOCALSTORAGE_UPDATED_BY_KEY) !== CLIENT_ID) {
localStorage.setItem(LOCALSTORAGE_UPDATED_BY_KEY, CLIENT_ID);
}
}
/**
* Attempts a token renewal, if renewal is needed/possible. If renewal is not possible
* then this will return falsy. Otherwise, the new token's details (credentials) will
* be returned or an error if something went wrong.
* @param {IMatrixClientCreds} credentials The input credentials.
* @param {MatrixClient} client A client set up with those credentials.
* @returns {Promise<Optional<IRenewedMatrixClientCreds>>} Resolves to the new credentials,
* or falsy if renewal not possible/needed. Throws on error.
*/
public async tryTokenExchangeIfNeeded(
credentials: IMatrixClientCreds,
client: MatrixClient,
): Promise<Optional<IRenewedMatrixClientCreds>> {
if (!credentials.accessTokenExpiryTs && credentials.accessTokenRefreshToken) {
logger.warn(
"TokenLifecycle#tryExchange: Got a refresh token, but no expiration time. The server is " +
"not compliant with the specification and might result in unexpected logouts.",
);
}
if (!this.isFeasible) {
logger.warn("TokenLifecycle#tryExchange: Client cannot do token refreshes reliably");
return;
}
if (credentials.accessTokenExpiryTs && credentials.accessTokenRefreshToken) {
if (this.fiveMinutesAgo >= credentials.accessTokenExpiryTs) {
logger.info("TokenLifecycle#tryExchange: Token has or will expire soon, refreshing");
return await this.doTokenRefresh(credentials, client);
}
}
}
// noinspection JSMethodCanBeStatic
private async doTokenRefresh(
credentials: IMatrixClientCreds,
client: MatrixClient,
): Promise<Optional<IRenewedMatrixClientCreds>> {
try {
logger.info("TokenLifecycle#doRefresh: Acquiring lock");
await this.mutex.lock();
logger.info("TokenLifecycle#doRefresh: Lock acquired");
logger.info("TokenLifecycle#doRefresh: Performing refresh");
localStorage.removeItem(LOCALSTORAGE_UPDATED_BY_KEY);
const newCreds = await client.refreshToken(credentials.accessTokenRefreshToken);
return {
// We use the browser's local time to do two things:
// 1. Avoid having to write code that counts down and stores a "time left" variable
// 2. Work around any time drift weirdness by assuming the user's local machine will
// drift consistently with itself.
// We additionally add our own safety buffer when renewing tokens to avoid cases where
// the time drift is accelerating.
accessTokenExpiryTs: Date.now() + newCreds.expires_in_ms,
accessToken: newCreds.access_token,
accessTokenRefreshToken: newCreds.refresh_token,
};
} catch (e) {
logger.error("TokenLifecycle#doRefresh: Error refreshing token: ", e);
if (e.errcode === "M_UNKNOWN_TOKEN") {
// Emit the logout manually because the function inhibits it.
client.emit("Session.logged_out", e);
} else {
throw e; // we can't do anything with it, so re-throw
}
} finally {
logger.info("TokenLifecycle#doRefresh: Releasing lock");
await this.mutex.unlock();
}
}
public startTimers(credentials: IMatrixClientCreds) {
this.stopTimers();
if (!credentials.accessTokenExpiryTs && credentials.accessTokenRefreshToken) {
logger.warn(
"TokenLifecycle#start: Got a refresh token, but no expiration time. The server is " +
"not compliant with the specification and might result in unexpected logouts.",
);
}
if (!this.isFeasible) {
logger.warn("TokenLifecycle#start: Not starting refresh timers - browser unsupported");
}
if (credentials.accessTokenExpiryTs && credentials.accessTokenRefreshToken) {
// We schedule the refresh task for 5 minutes before the expiration timestamp as
// a safety buffer. We assume/hope that servers won't be expiring tokens faster
// than every 5 minutes, but we do need to consider cases where the expiration is
// fairly quick (<10 minutes, for example).
let relativeTime = credentials.accessTokenExpiryTs - this.fiveMinutesFromNow;
if (relativeTime <= 0) {
logger.warn(`TokenLifecycle#start: Refresh was set for ${relativeTime}ms - readjusting`);
relativeTime = Math.floor(Math.random() * 5000) + 30000; // 30 seconds + 5s jitter
}
this.refreshAtTimerId = setTimeout(() => {
// noinspection JSIgnoredPromiseFromCall
this.forceTokenExchange();
}, relativeTime);
logger.info(`TokenLifecycle#start: Refresh timer set for ${relativeTime}ms from now`);
} else {
logger.info("TokenLifecycle#start: Not setting a refresh timer - token not renewable");
}
}
public stopTimers() {
clearTimeout(this.refreshAtTimerId);
logger.info("TokenLifecycle#stop: Stopped refresh timer (if it was running)");
}
private async forceTokenExchange() {
const credentials = MatrixClientPeg.getCredentials();
await this.rehydrate(await this.doTokenRefresh(credentials, MatrixClientPeg.get()));
this.flagNewCredentialsPersisted();
}
private async forceHydration() {
const {
accessToken,
accessTokenRefreshToken,
accessTokenExpiryTs,
} = await getRenewedStoredSessionVars();
return this.rehydrate({ accessToken, accessTokenRefreshToken, accessTokenExpiryTs });
}
private async rehydrate(newCreds: IRenewedMatrixClientCreds) {
const credentials = MatrixClientPeg.getCredentials();
try {
if (!newCreds) {
logger.error("TokenLifecycle#expireExchange: Expecting new credentials, got nothing. Rescheduling.");
this.startTimers(credentials);
} else {
logger.info("TokenLifecycle#expireExchange: Updating client credentials using rehydration");
await hydrateSessionInPlace({
...credentials,
...newCreds, // override from credentials
});
// hydrateSessionInPlace will ultimately call back to startTimers() for us, so no need to do it here.
}
} catch (e) {
logger.error("TokenLifecycle#expireExchange: Error getting new credentials. Rescheduling.", e);
this.startTimers(credentials);
}
}
}

View file

@ -37,7 +37,6 @@ import AuthBody from "../../views/auth/AuthBody";
import AuthHeader from "../../views/auth/AuthHeader"; import AuthHeader from "../../views/auth/AuthHeader";
import InteractiveAuth from "../InteractiveAuth"; import InteractiveAuth from "../InteractiveAuth";
import Spinner from "../../views/elements/Spinner"; import Spinner from "../../views/elements/Spinner";
import { TokenLifecycle } from "../../../TokenLifecycle";
interface IProps { interface IProps {
serverConfig: ValidatedServerConfig; serverConfig: ValidatedServerConfig;
@ -416,7 +415,6 @@ export default class Registration extends React.Component<IProps, IState> {
initial_device_display_name: this.props.defaultDeviceDisplayName, initial_device_display_name: this.props.defaultDeviceDisplayName,
auth: undefined, auth: undefined,
inhibit_login: undefined, inhibit_login: undefined,
refresh_token: TokenLifecycle.instance.isFeasible,
}; };
if (auth) registerParams.auth = auth; if (auth) registerParams.auth = auth;
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin; if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;

View file

@ -33,7 +33,6 @@ import AccessibleButton from '../../views/elements/AccessibleButton';
import Spinner from "../../views/elements/Spinner"; import Spinner from "../../views/elements/Spinner";
import AuthHeader from "../../views/auth/AuthHeader"; import AuthHeader from "../../views/auth/AuthHeader";
import AuthBody from "../../views/auth/AuthBody"; import AuthBody from "../../views/auth/AuthBody";
import { TokenLifecycle } from "../../../TokenLifecycle";
const LOGIN_VIEW = { const LOGIN_VIEW = {
LOADING: 1, LOADING: 1,
@ -155,7 +154,6 @@ export default class SoftLogout extends React.Component<IProps, IState> {
}, },
password: this.state.password, password: this.state.password,
device_id: MatrixClientPeg.get().getDeviceId(), device_id: MatrixClientPeg.get().getDeviceId(),
refresh_token: TokenLifecycle.instance.isFeasible,
}; };
let credentials = null; let credentials = null;
@ -189,7 +187,6 @@ export default class SoftLogout extends React.Component<IProps, IState> {
const loginParams = { const loginParams = {
token: this.props.realQueryParams['loginToken'], token: this.props.realQueryParams['loginToken'],
device_id: MatrixClientPeg.get().getDeviceId(), device_id: MatrixClientPeg.get().getDeviceId(),
refresh_token: TokenLifecycle.instance.isFeasible,
}; };
let credentials = null; let credentials = null;

View file

@ -25,13 +25,11 @@ const localStorage = window.localStorage;
// just *accessing* indexedDB throws an exception in firefox with // just *accessing* indexedDB throws an exception in firefox with
// indexeddb disabled. // indexeddb disabled.
let indexedDB: IDBFactory; let indexedDB;
try { try {
indexedDB = window.indexedDB; indexedDB = window.indexedDB;
} catch (e) {} } catch (e) {}
export const IDB_SUPPORTED = !!indexedDB;
// The JS SDK will add a prefix of "matrix-js-sdk:" to the sync store name. // The JS SDK will add a prefix of "matrix-js-sdk:" to the sync store name.
const SYNC_STORE_NAME = "riot-web-sync"; const SYNC_STORE_NAME = "riot-web-sync";
const CRYPTO_STORE_NAME = "matrix-js-sdk:crypto"; const CRYPTO_STORE_NAME = "matrix-js-sdk:crypto";
@ -199,7 +197,7 @@ export function setCryptoInitialised(cryptoInited) {
/* Simple wrapper functions around IndexedDB. /* Simple wrapper functions around IndexedDB.
*/ */
let idb: IDBDatabase = null; let idb = null;
async function idbInit(): Promise<void> { async function idbInit(): Promise<void> {
if (!indexedDB) { if (!indexedDB) {

View file

@ -4750,11 +4750,6 @@ iconv-lite@^0.6.2:
dependencies: dependencies:
safer-buffer ">= 2.1.2 < 3.0.0" safer-buffer ">= 2.1.2 < 3.0.0"
idb-mutex@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/idb-mutex/-/idb-mutex-0.11.0.tgz#1573321f74ab83c12c3d200c7cf22ee7c6800d2d"
integrity sha512-jirzMahSlkvNpq9MXzr5uBKjxQrA9gdPYhOJkQXhDW7MvP6RuJpSbog50HYOugkmZWfJ0WmHVhhX0/lG39qOZQ==
ieee754@^1.1.12, ieee754@^1.1.13: ieee754@^1.1.12, ieee754@^1.1.13:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"