parent
81f52283cf
commit
aba61fa390
|
@ -83,7 +83,6 @@
|
|||
"glob-to-regexp": "^0.4.1",
|
||||
"highlight.js": "^11.3.1",
|
||||
"html-entities": "^1.4.0",
|
||||
"idb-mutex": "^0.11.0",
|
||||
"is-ip": "^3.1.0",
|
||||
"jszip": "^3.7.0",
|
||||
"katex": "^0.12.0",
|
||||
|
|
242
src/Lifecycle.ts
242
src/Lifecycle.ts
|
@ -58,7 +58,6 @@ import LazyLoadingDisabledDialog from "./components/views/dialogs/LazyLoadingDis
|
|||
import SessionRestoreErrorDialog from "./components/views/dialogs/SessionRestoreErrorDialog";
|
||||
import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog";
|
||||
import { setSentryUser } from "./sentry";
|
||||
import { IRenewedMatrixClientCreds, TokenLifecycle } from "./TokenLifecycle";
|
||||
|
||||
const HOMESERVER_URL_KEY = "mx_hs_url";
|
||||
const ID_SERVER_URL_KEY = "mx_is_url";
|
||||
|
@ -204,7 +203,6 @@ export function attemptTokenLogin(
|
|||
"m.login.token", {
|
||||
token: queryParams.loginToken as string,
|
||||
initial_device_display_name: defaultDeviceDisplayName,
|
||||
refresh_token: TokenLifecycle.instance.isFeasible,
|
||||
},
|
||||
).then(function(creds) {
|
||||
logger.log("Logged in with token");
|
||||
|
@ -311,8 +309,6 @@ export interface IStoredSession {
|
|||
userId: string;
|
||||
deviceId: string;
|
||||
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> {
|
||||
const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY);
|
||||
const isUrl = localStorage.getItem(ID_SERVER_URL_KEY);
|
||||
let accessToken: string;
|
||||
let accessToken;
|
||||
try {
|
||||
accessToken = await StorageManager.idbLoad("account", "mx_access_token");
|
||||
} 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
|
||||
// token, then we should say we have an access token
|
||||
const hasAccessToken =
|
||||
|
@ -393,17 +352,7 @@ export async function getStoredSessionVars(): Promise<IStoredSession> {
|
|||
isGuest = localStorage.getItem("matrix-is-guest") === "true";
|
||||
}
|
||||
|
||||
return {
|
||||
hsUrl,
|
||||
isUrl,
|
||||
hasAccessToken,
|
||||
accessToken,
|
||||
accessTokenExpiryTs,
|
||||
accessTokenRefreshToken,
|
||||
userId,
|
||||
deviceId,
|
||||
isGuest,
|
||||
};
|
||||
return { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest };
|
||||
}
|
||||
|
||||
// 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
|
||||
// localstorage
|
||||
//
|
||||
|
@ -494,16 +408,7 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
|
|||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
hsUrl,
|
||||
isUrl,
|
||||
hasAccessToken,
|
||||
accessToken,
|
||||
userId,
|
||||
deviceId,
|
||||
isGuest,
|
||||
accessTokenExpiryTs,
|
||||
} = await getStoredSessionVars();
|
||||
const { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest } = await getStoredSessionVars();
|
||||
|
||||
if (hasAccessToken && !accessToken) {
|
||||
abortLogin();
|
||||
|
@ -515,11 +420,18 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
|
|||
return false;
|
||||
}
|
||||
|
||||
let decryptedAccessToken = accessToken;
|
||||
const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId);
|
||||
const {
|
||||
accessToken: decryptedAccessToken,
|
||||
accessTokenRefreshToken: decryptedRefreshToken,
|
||||
} = await getRenewedStoredSessionVars();
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
logger.log("No pickle key available");
|
||||
}
|
||||
|
||||
const freshLogin = sessionStorage.getItem("mx_fresh_login") === "true";
|
||||
sessionStorage.removeItem("mx_fresh_login");
|
||||
|
@ -534,8 +446,6 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
|
|||
guest: isGuest,
|
||||
pickleKey: pickleKey,
|
||||
freshLogin: freshLogin,
|
||||
accessTokenExpiryTs: accessTokenExpiryTs,
|
||||
accessTokenRefreshToken: decryptedRefreshToken as string,
|
||||
}, false);
|
||||
return true;
|
||||
} 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,
|
||||
* 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
|
||||
*/
|
||||
export async function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixClient> {
|
||||
export function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixClient> {
|
||||
const oldUserId = MatrixClientPeg.get().getUserId();
|
||||
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");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* to localstorage, starts the new client.
|
||||
|
@ -675,10 +554,8 @@ async function doSetLoggedIn(
|
|||
" deviceId: " + credentials.deviceId +
|
||||
" guest: " + credentials.guest +
|
||||
" hs: " + credentials.homeserverUrl +
|
||||
" softLogout: " + softLogout +
|
||||
" freshLogin: " + credentials.freshLogin +
|
||||
" tokenExpires: " + (!!credentials.accessTokenExpiryTs) +
|
||||
" tokenRenewable: " + (!!credentials.accessTokenRefreshToken),
|
||||
" softLogout: " + softLogout,
|
||||
" freshLogin: " + credentials.freshLogin,
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
|
||||
if (PosthogAnalytics.instance.isEnabled()) {
|
||||
|
@ -751,12 +605,8 @@ async function doSetLoggedIn(
|
|||
if (localStorage) {
|
||||
try {
|
||||
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");
|
||||
|
||||
if (didTokenRefresh) {
|
||||
TokenLifecycle.instance.flagNewCredentialsPersisted();
|
||||
}
|
||||
} catch (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!");
|
||||
}
|
||||
|
||||
// Start the token lifecycle as late as possible in case something above goes wrong
|
||||
TokenLifecycle.instance.startTimers(credentials);
|
||||
|
||||
dis.dispatch({ action: 'on_logged_in' });
|
||||
|
||||
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_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
|
||||
// where IndexedDB is blown away
|
||||
if (credentials.accessToken) {
|
||||
localStorage.setItem("mx_has_access_token", "true");
|
||||
} else {
|
||||
localStorage.removeItem("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
|
||||
}
|
||||
localStorage.deleteItem("mx_has_access_token");
|
||||
}
|
||||
|
||||
if (credentials.pickleKey) {
|
||||
let encryptedAccessToken: IEncryptedPayload;
|
||||
let encryptedRefreshToken: IEncryptedPayload;
|
||||
let encryptedAccessToken;
|
||||
try {
|
||||
// try to encrypt the access token using the pickle key
|
||||
const encrKey = await pickleKeyToAesKey(credentials.pickleKey);
|
||||
encryptedAccessToken = await encryptAES(credentials.accessToken, encrKey, "access_token");
|
||||
if (credentials.accessTokenRefreshToken) {
|
||||
encryptedRefreshToken = await encryptAES(
|
||||
credentials.accessTokenRefreshToken, encrKey, "refresh_token",
|
||||
);
|
||||
}
|
||||
encrKey.fill(0);
|
||||
} catch (e) {
|
||||
logger.warn("Could not encrypt access token", e);
|
||||
|
@ -843,20 +666,11 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise<void
|
|||
"account", "mx_access_token",
|
||||
encryptedAccessToken || credentials.accessToken,
|
||||
);
|
||||
if (encryptedRefreshToken) {
|
||||
await StorageManager.idbSave(
|
||||
"account", "mx_refresh_token",
|
||||
encryptedRefreshToken || credentials.accessTokenRefreshToken,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// if we couldn't save to indexedDB, fall back to localStorage. We
|
||||
// store the access token unencrypted since localStorage only saves
|
||||
// strings.
|
||||
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));
|
||||
} else {
|
||||
|
@ -867,15 +681,6 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise<void
|
|||
} catch (e) {
|
||||
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")) {
|
||||
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.
|
||||
*/
|
||||
export function stopMatrixClient(unsetClient = true): void {
|
||||
TokenLifecycle.instance.stopTimers();
|
||||
Notifier.stop();
|
||||
CallHandler.instance.stop();
|
||||
UserActivity.sharedInstance().stop();
|
||||
|
|
10
src/Login.ts
10
src/Login.ts
|
@ -22,7 +22,6 @@ import { logger } from "matrix-js-sdk/src/logger";
|
|||
|
||||
import { IMatrixClientCreds } from "./MatrixClientPeg";
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
import { TokenLifecycle } from "./TokenLifecycle";
|
||||
|
||||
interface ILoginOptions {
|
||||
defaultDeviceDisplayName?: string;
|
||||
|
@ -65,11 +64,6 @@ interface ILoginParams {
|
|||
token?: string;
|
||||
device_id?: 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 */
|
||||
|
||||
|
@ -168,7 +162,6 @@ export default class Login {
|
|||
password,
|
||||
identifier,
|
||||
initial_device_display_name: this.defaultDeviceDisplayName,
|
||||
refresh_token: TokenLifecycle.instance.isFeasible,
|
||||
};
|
||||
|
||||
const tryFallbackHs = (originalError) => {
|
||||
|
@ -242,9 +235,6 @@ export async function sendLoginRequest(
|
|||
userId: data.user_id,
|
||||
deviceId: data.device_id,
|
||||
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);
|
||||
|
|
|
@ -44,8 +44,6 @@ export interface IMatrixClientCreds {
|
|||
userId: string;
|
||||
deviceId?: string;
|
||||
accessToken: string;
|
||||
accessTokenExpiryTs?: number; // set if access token expires
|
||||
accessTokenRefreshToken?: string; // set if access token can be renewed
|
||||
guest?: boolean;
|
||||
pickleKey?: string;
|
||||
freshLogin?: boolean;
|
||||
|
@ -101,14 +99,6 @@ export interface IMatrixClientPeg {
|
|||
* @param {IMatrixClientCreds} creds The new credentials to use.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
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> {
|
||||
for (const dbType of ['indexeddb', 'memory']) {
|
||||
try {
|
||||
|
@ -252,15 +233,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
|
|||
}
|
||||
|
||||
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 {
|
||||
// Copy the cached credentials before overriding what we can.
|
||||
...(copiedCredentials ?? {}),
|
||||
|
||||
homeserverUrl: this.matrixClient.baseUrl,
|
||||
identityServerUrl: this.matrixClient.idBaseUrl,
|
||||
userId: this.matrixClient.credentials.userId,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -37,7 +37,6 @@ import AuthBody from "../../views/auth/AuthBody";
|
|||
import AuthHeader from "../../views/auth/AuthHeader";
|
||||
import InteractiveAuth from "../InteractiveAuth";
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
import { TokenLifecycle } from "../../../TokenLifecycle";
|
||||
|
||||
interface IProps {
|
||||
serverConfig: ValidatedServerConfig;
|
||||
|
@ -416,7 +415,6 @@ export default class Registration extends React.Component<IProps, IState> {
|
|||
initial_device_display_name: this.props.defaultDeviceDisplayName,
|
||||
auth: undefined,
|
||||
inhibit_login: undefined,
|
||||
refresh_token: TokenLifecycle.instance.isFeasible,
|
||||
};
|
||||
if (auth) registerParams.auth = auth;
|
||||
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;
|
||||
|
|
|
@ -33,7 +33,6 @@ import AccessibleButton from '../../views/elements/AccessibleButton';
|
|||
import Spinner from "../../views/elements/Spinner";
|
||||
import AuthHeader from "../../views/auth/AuthHeader";
|
||||
import AuthBody from "../../views/auth/AuthBody";
|
||||
import { TokenLifecycle } from "../../../TokenLifecycle";
|
||||
|
||||
const LOGIN_VIEW = {
|
||||
LOADING: 1,
|
||||
|
@ -155,7 +154,6 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
|||
},
|
||||
password: this.state.password,
|
||||
device_id: MatrixClientPeg.get().getDeviceId(),
|
||||
refresh_token: TokenLifecycle.instance.isFeasible,
|
||||
};
|
||||
|
||||
let credentials = null;
|
||||
|
@ -189,7 +187,6 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
|||
const loginParams = {
|
||||
token: this.props.realQueryParams['loginToken'],
|
||||
device_id: MatrixClientPeg.get().getDeviceId(),
|
||||
refresh_token: TokenLifecycle.instance.isFeasible,
|
||||
};
|
||||
|
||||
let credentials = null;
|
||||
|
|
|
@ -25,13 +25,11 @@ const localStorage = window.localStorage;
|
|||
|
||||
// just *accessing* indexedDB throws an exception in firefox with
|
||||
// indexeddb disabled.
|
||||
let indexedDB: IDBFactory;
|
||||
let indexedDB;
|
||||
try {
|
||||
indexedDB = window.indexedDB;
|
||||
} catch (e) {}
|
||||
|
||||
export const IDB_SUPPORTED = !!indexedDB;
|
||||
|
||||
// The JS SDK will add a prefix of "matrix-js-sdk:" to the sync store name.
|
||||
const SYNC_STORE_NAME = "riot-web-sync";
|
||||
const CRYPTO_STORE_NAME = "matrix-js-sdk:crypto";
|
||||
|
@ -199,7 +197,7 @@ export function setCryptoInitialised(cryptoInited) {
|
|||
/* Simple wrapper functions around IndexedDB.
|
||||
*/
|
||||
|
||||
let idb: IDBDatabase = null;
|
||||
let idb = null;
|
||||
|
||||
async function idbInit(): Promise<void> {
|
||||
if (!indexedDB) {
|
||||
|
|
|
@ -4750,11 +4750,6 @@ iconv-lite@^0.6.2:
|
|||
dependencies:
|
||||
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:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
||||
|
|
Loading…
Reference in New Issue