Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 8395934

Browse files
authored
Support refresh tokens (#7802)
MSC: matrix-org/matrix-spec-proposals#2918 Fixes element-hq/element-web#18698 Fixes element-hq/element-web#20648 **Requires matrix-org/matrix-js-sdk#2178 **Note**: There's a lot of logging in this PR. That is intentional to ensure that if/when something goes wrong we can chase the exact code path. It does not log any tokens - just where the code is going. Overall, it should be fairly low volume spam (and can be relaxed at a later date). ---- This approach uses indexeddb (through a mutex library) to manage which tab actually triggers the refresh, preventing issues where multiple tabs try to update the token. If multiple tabs update the token then the server might consider the account hacked and hard logout all the tokens. If for some reason the timer code gets it wrong, or the user has been offline for too long and the token can't be refreshed, they should be sent to a soft logout screen by the server. This will retain the user's encryption state - they simply need to reauthenticate to get an active access token again. This additionally contains a change to fix soft logout not working, per the issue links above. Of interest may be the IPC approach which was ultimately declined in favour of this change instead: #7803
1 parent a958cd2 commit 8395934

File tree

9 files changed

+504
-25
lines changed

9 files changed

+504
-25
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"glob-to-regexp": "^0.4.1",
8484
"highlight.js": "^11.3.1",
8585
"html-entities": "^1.4.0",
86+
"idb-mutex": "^0.11.0",
8687
"is-ip": "^3.1.0",
8788
"jszip": "^3.7.0",
8889
"katex": "^0.12.0",

src/Lifecycle.ts

Lines changed: 219 additions & 23 deletions
Large diffs are not rendered by default.

src/Login.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { logger } from "matrix-js-sdk/src/logger";
2222

2323
import { IMatrixClientCreds } from "./MatrixClientPeg";
2424
import SecurityCustomisations from "./customisations/Security";
25+
import { TokenLifecycle } from "./TokenLifecycle";
2526

2627
interface ILoginOptions {
2728
defaultDeviceDisplayName?: string;
@@ -64,6 +65,11 @@ interface ILoginParams {
6465
token?: string;
6566
device_id?: string;
6667
initial_device_display_name?: string;
68+
69+
// If true, a refresh token will be requested. If the server supports it, it
70+
// will be returned. Does nothing out of the ordinary if not set, false, or
71+
// the server doesn't support the feature.
72+
refresh_token?: boolean;
6773
}
6874
/* eslint-enable camelcase */
6975

@@ -162,6 +168,7 @@ export default class Login {
162168
password,
163169
identifier,
164170
initial_device_display_name: this.defaultDeviceDisplayName,
171+
refresh_token: TokenLifecycle.instance.isFeasible,
165172
};
166173

167174
const tryFallbackHs = (originalError) => {
@@ -235,6 +242,9 @@ export async function sendLoginRequest(
235242
userId: data.user_id,
236243
deviceId: data.device_id,
237244
accessToken: data.access_token,
245+
// Use the browser's local time for expiration timestamp - see TokenLifecycle for more info
246+
accessTokenExpiryTs: data.expires_in_ms ? (data.expires_in_ms + Date.now()) : null,
247+
accessTokenRefreshToken: data.refresh_token,
238248
};
239249

240250
SecurityCustomisations.examineLoginResponse?.(data, creds);

src/MatrixClientPeg.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface IMatrixClientCreds {
4444
userId: string;
4545
deviceId?: string;
4646
accessToken: string;
47+
accessTokenExpiryTs?: number; // set if access token expires
48+
accessTokenRefreshToken?: string; // set if access token can be renewed
4749
guest?: boolean;
4850
pickleKey?: string;
4951
freshLogin?: boolean;
@@ -99,6 +101,14 @@ export interface IMatrixClientPeg {
99101
* @param {IMatrixClientCreds} creds The new credentials to use.
100102
*/
101103
replaceUsingCreds(creds: IMatrixClientCreds): void;
104+
105+
/**
106+
* Similar to replaceUsingCreds(), but without the replacement operation.
107+
* Credentials that can be updated in-place will be updated. All others
108+
* will be ignored.
109+
* @param {IMatrixClientCreds} creds The new credentials to use.
110+
*/
111+
updateUsingCreds(creds: IMatrixClientCreds): void;
102112
}
103113

104114
/**
@@ -164,6 +174,15 @@ class MatrixClientPegClass implements IMatrixClientPeg {
164174
this.createClient(creds);
165175
}
166176

177+
public updateUsingCreds(creds: IMatrixClientCreds): void {
178+
if (creds?.accessToken) {
179+
this.currentClientCreds = creds;
180+
this.matrixClient.setAccessToken(creds.accessToken);
181+
} else {
182+
// ignore, per signature
183+
}
184+
}
185+
167186
public async assign(): Promise<any> {
168187
for (const dbType of ['indexeddb', 'memory']) {
169188
try {
@@ -233,7 +252,15 @@ class MatrixClientPegClass implements IMatrixClientPeg {
233252
}
234253

235254
public getCredentials(): IMatrixClientCreds {
255+
let copiedCredentials = this.currentClientCreds;
256+
if (this.currentClientCreds?.userId !== this.matrixClient?.credentials?.userId) {
257+
// cached credentials belong to a different user - don't use them
258+
copiedCredentials = null;
259+
}
236260
return {
261+
// Copy the cached credentials before overriding what we can.
262+
...(copiedCredentials ?? {}),
263+
237264
homeserverUrl: this.matrixClient.baseUrl,
238265
identityServerUrl: this.matrixClient.idBaseUrl,
239266
userId: this.matrixClient.credentials.userId,

src/TokenLifecycle.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { logger } from "matrix-js-sdk/src/logger";
18+
import { MatrixClient } from "matrix-js-sdk/src";
19+
import { randomString } from "matrix-js-sdk/src/randomstring";
20+
import Mutex from "idb-mutex";
21+
import { Optional } from "matrix-events-sdk";
22+
23+
import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg";
24+
import { getRenewedStoredSessionVars, hydrateSessionInPlace } from "./Lifecycle";
25+
import { IDB_SUPPORTED } from "./utils/StorageManager";
26+
27+
export interface IRenewedMatrixClientCreds extends Pick<IMatrixClientCreds,
28+
"accessToken" | "accessTokenExpiryTs" | "accessTokenRefreshToken"> {}
29+
30+
const LOCALSTORAGE_UPDATED_BY_KEY = "mx_token_updated_by";
31+
32+
const CLIENT_ID = randomString(64);
33+
34+
export class TokenLifecycle {
35+
public static readonly instance = new TokenLifecycle();
36+
37+
private refreshAtTimerId: number;
38+
private mutex: Mutex;
39+
40+
protected constructor() {
41+
// we only really want one of these floating around, so private-ish
42+
// constructor. Protected allows for unit tests.
43+
44+
// Don't try to create a mutex if it'll explode
45+
if (IDB_SUPPORTED) {
46+
this.mutex = new Mutex("token_refresh", null, {
47+
expiry: 120000, // 2 minutes - enough time for the refresh request to time out
48+
});
49+
}
50+
51+
// Watch for other tabs causing token refreshes, so we can react to them too.
52+
window.addEventListener("storage", (ev: StorageEvent) => {
53+
if (ev.key === LOCALSTORAGE_UPDATED_BY_KEY) {
54+
const updateBy = localStorage.getItem(LOCALSTORAGE_UPDATED_BY_KEY);
55+
if (!updateBy || updateBy === CLIENT_ID) return; // ignore deletions & echos
56+
57+
logger.info("TokenLifecycle#storageWatch: Token update received");
58+
59+
// noinspection JSIgnoredPromiseFromCall
60+
this.forceHydration();
61+
}
62+
});
63+
}
64+
65+
/**
66+
* Can the client reasonably support token refreshes?
67+
*/
68+
public get isFeasible(): boolean {
69+
return IDB_SUPPORTED;
70+
}
71+
72+
// noinspection JSMethodCanBeStatic
73+
private get fiveMinutesAgo(): number {
74+
return Date.now() - 300000;
75+
}
76+
77+
// noinspection JSMethodCanBeStatic
78+
private get fiveMinutesFromNow(): number {
79+
return Date.now() + 300000;
80+
}
81+
82+
public flagNewCredentialsPersisted() {
83+
logger.info("TokenLifecycle#flagPersisted: Credentials marked as persisted - flagging for other tabs");
84+
if (localStorage.getItem(LOCALSTORAGE_UPDATED_BY_KEY) !== CLIENT_ID) {
85+
localStorage.setItem(LOCALSTORAGE_UPDATED_BY_KEY, CLIENT_ID);
86+
}
87+
}
88+
89+
/**
90+
* Attempts a token renewal, if renewal is needed/possible. If renewal is not possible
91+
* then this will return falsy. Otherwise, the new token's details (credentials) will
92+
* be returned or an error if something went wrong.
93+
* @param {IMatrixClientCreds} credentials The input credentials.
94+
* @param {MatrixClient} client A client set up with those credentials.
95+
* @returns {Promise<Optional<IRenewedMatrixClientCreds>>} Resolves to the new credentials,
96+
* or falsy if renewal not possible/needed. Throws on error.
97+
*/
98+
public async tryTokenExchangeIfNeeded(
99+
credentials: IMatrixClientCreds,
100+
client: MatrixClient,
101+
): Promise<Optional<IRenewedMatrixClientCreds>> {
102+
if (!credentials.accessTokenExpiryTs && credentials.accessTokenRefreshToken) {
103+
logger.warn(
104+
"TokenLifecycle#tryExchange: Got a refresh token, but no expiration time. The server is " +
105+
"not compliant with the specification and might result in unexpected logouts.",
106+
);
107+
}
108+
109+
if (!this.isFeasible) {
110+
logger.warn("TokenLifecycle#tryExchange: Client cannot do token refreshes reliably");
111+
return;
112+
}
113+
114+
if (credentials.accessTokenExpiryTs && credentials.accessTokenRefreshToken) {
115+
if (this.fiveMinutesAgo >= credentials.accessTokenExpiryTs) {
116+
logger.info("TokenLifecycle#tryExchange: Token has or will expire soon, refreshing");
117+
return await this.doTokenRefresh(credentials, client);
118+
}
119+
}
120+
}
121+
122+
// noinspection JSMethodCanBeStatic
123+
private async doTokenRefresh(
124+
credentials: IMatrixClientCreds,
125+
client: MatrixClient,
126+
): Promise<Optional<IRenewedMatrixClientCreds>> {
127+
try {
128+
logger.info("TokenLifecycle#doRefresh: Acquiring lock");
129+
await this.mutex.lock();
130+
logger.info("TokenLifecycle#doRefresh: Lock acquired");
131+
132+
logger.info("TokenLifecycle#doRefresh: Performing refresh");
133+
localStorage.removeItem(LOCALSTORAGE_UPDATED_BY_KEY);
134+
const newCreds = await client.refreshToken(credentials.accessTokenRefreshToken);
135+
return {
136+
// We use the browser's local time to do two things:
137+
// 1. Avoid having to write code that counts down and stores a "time left" variable
138+
// 2. Work around any time drift weirdness by assuming the user's local machine will
139+
// drift consistently with itself.
140+
// We additionally add our own safety buffer when renewing tokens to avoid cases where
141+
// the time drift is accelerating.
142+
accessTokenExpiryTs: Date.now() + newCreds.expires_in_ms,
143+
accessToken: newCreds.access_token,
144+
accessTokenRefreshToken: newCreds.refresh_token,
145+
};
146+
} catch (e) {
147+
logger.error("TokenLifecycle#doRefresh: Error refreshing token: ", e);
148+
if (e.errcode === "M_UNKNOWN_TOKEN") {
149+
// Emit the logout manually because the function inhibits it.
150+
client.emit("Session.logged_out", e);
151+
} else {
152+
throw e; // we can't do anything with it, so re-throw
153+
}
154+
} finally {
155+
logger.info("TokenLifecycle#doRefresh: Releasing lock");
156+
await this.mutex.unlock();
157+
}
158+
}
159+
160+
public startTimers(credentials: IMatrixClientCreds) {
161+
this.stopTimers();
162+
163+
if (!credentials.accessTokenExpiryTs && credentials.accessTokenRefreshToken) {
164+
logger.warn(
165+
"TokenLifecycle#start: Got a refresh token, but no expiration time. The server is " +
166+
"not compliant with the specification and might result in unexpected logouts.",
167+
);
168+
}
169+
170+
if (!this.isFeasible) {
171+
logger.warn("TokenLifecycle#start: Not starting refresh timers - browser unsupported");
172+
}
173+
174+
if (credentials.accessTokenExpiryTs && credentials.accessTokenRefreshToken) {
175+
// We schedule the refresh task for 5 minutes before the expiration timestamp as
176+
// a safety buffer. We assume/hope that servers won't be expiring tokens faster
177+
// than every 5 minutes, but we do need to consider cases where the expiration is
178+
// fairly quick (<10 minutes, for example).
179+
let relativeTime = credentials.accessTokenExpiryTs - this.fiveMinutesFromNow;
180+
if (relativeTime <= 0) {
181+
logger.warn(`TokenLifecycle#start: Refresh was set for ${relativeTime}ms - readjusting`);
182+
relativeTime = Math.floor(Math.random() * 5000) + 30000; // 30 seconds + 5s jitter
183+
}
184+
this.refreshAtTimerId = setTimeout(() => {
185+
// noinspection JSIgnoredPromiseFromCall
186+
this.forceTokenExchange();
187+
}, relativeTime);
188+
logger.info(`TokenLifecycle#start: Refresh timer set for ${relativeTime}ms from now`);
189+
} else {
190+
logger.info("TokenLifecycle#start: Not setting a refresh timer - token not renewable");
191+
}
192+
}
193+
194+
public stopTimers() {
195+
clearTimeout(this.refreshAtTimerId);
196+
logger.info("TokenLifecycle#stop: Stopped refresh timer (if it was running)");
197+
}
198+
199+
private async forceTokenExchange() {
200+
const credentials = MatrixClientPeg.getCredentials();
201+
await this.rehydrate(await this.doTokenRefresh(credentials, MatrixClientPeg.get()));
202+
this.flagNewCredentialsPersisted();
203+
}
204+
205+
private async forceHydration() {
206+
const {
207+
accessToken,
208+
accessTokenRefreshToken,
209+
accessTokenExpiryTs,
210+
} = await getRenewedStoredSessionVars();
211+
return this.rehydrate({ accessToken, accessTokenRefreshToken, accessTokenExpiryTs });
212+
}
213+
214+
private async rehydrate(newCreds: IRenewedMatrixClientCreds) {
215+
const credentials = MatrixClientPeg.getCredentials();
216+
try {
217+
if (!newCreds) {
218+
logger.error("TokenLifecycle#expireExchange: Expecting new credentials, got nothing. Rescheduling.");
219+
this.startTimers(credentials);
220+
} else {
221+
logger.info("TokenLifecycle#expireExchange: Updating client credentials using rehydration");
222+
await hydrateSessionInPlace({
223+
...credentials,
224+
...newCreds, // override from credentials
225+
});
226+
// hydrateSessionInPlace will ultimately call back to startTimers() for us, so no need to do it here.
227+
}
228+
} catch (e) {
229+
logger.error("TokenLifecycle#expireExchange: Error getting new credentials. Rescheduling.", e);
230+
this.startTimers(credentials);
231+
}
232+
}
233+
}

src/components/structures/auth/Registration.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import AuthBody from "../../views/auth/AuthBody";
3737
import AuthHeader from "../../views/auth/AuthHeader";
3838
import InteractiveAuth from "../InteractiveAuth";
3939
import Spinner from "../../views/elements/Spinner";
40+
import { TokenLifecycle } from "../../../TokenLifecycle";
4041

4142
interface IProps {
4243
serverConfig: ValidatedServerConfig;
@@ -415,6 +416,7 @@ export default class Registration extends React.Component<IProps, IState> {
415416
initial_device_display_name: this.props.defaultDeviceDisplayName,
416417
auth: undefined,
417418
inhibit_login: undefined,
419+
refresh_token: TokenLifecycle.instance.isFeasible,
418420
};
419421
if (auth) registerParams.auth = auth;
420422
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;

src/components/structures/auth/SoftLogout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import AccessibleButton from '../../views/elements/AccessibleButton';
3333
import Spinner from "../../views/elements/Spinner";
3434
import AuthHeader from "../../views/auth/AuthHeader";
3535
import AuthBody from "../../views/auth/AuthBody";
36+
import { TokenLifecycle } from "../../../TokenLifecycle";
3637

3738
const LOGIN_VIEW = {
3839
LOADING: 1,
@@ -154,6 +155,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
154155
},
155156
password: this.state.password,
156157
device_id: MatrixClientPeg.get().getDeviceId(),
158+
refresh_token: TokenLifecycle.instance.isFeasible,
157159
};
158160

159161
let credentials = null;
@@ -187,6 +189,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
187189
const loginParams = {
188190
token: this.props.realQueryParams['loginToken'],
189191
device_id: MatrixClientPeg.get().getDeviceId(),
192+
refresh_token: TokenLifecycle.instance.isFeasible,
190193
};
191194

192195
let credentials = null;

src/utils/StorageManager.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ const localStorage = window.localStorage;
2525

2626
// just *accessing* indexedDB throws an exception in firefox with
2727
// indexeddb disabled.
28-
let indexedDB;
28+
let indexedDB: IDBFactory;
2929
try {
3030
indexedDB = window.indexedDB;
3131
} catch (e) {}
3232

33+
export const IDB_SUPPORTED = !!indexedDB;
34+
3335
// The JS SDK will add a prefix of "matrix-js-sdk:" to the sync store name.
3436
const SYNC_STORE_NAME = "riot-web-sync";
3537
const CRYPTO_STORE_NAME = "matrix-js-sdk:crypto";
@@ -197,7 +199,7 @@ export function setCryptoInitialised(cryptoInited) {
197199
/* Simple wrapper functions around IndexedDB.
198200
*/
199201

200-
let idb = null;
202+
let idb: IDBDatabase = null;
201203

202204
async function idbInit(): Promise<void> {
203205
if (!indexedDB) {

0 commit comments

Comments
 (0)