Skip to content

Commit 72b997d

Browse files
authored
Fix idempotency issue around token refresh (#4730)
* Fix idempotency issue around token refresh Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Improve test Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> --------- Signed-off-by: Michael Telatynski <[email protected]>
1 parent a3bbc49 commit 72b997d

File tree

10 files changed

+145
-17
lines changed

10 files changed

+145
-17
lines changed

babel.config.cjs

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ module.exports = {
2626
],
2727
],
2828
plugins: [
29+
["@babel/plugin-proposal-decorators", { version: "2023-11" }],
2930
"@babel/plugin-transform-numeric-separator",
3031
"@babel/plugin-transform-class-properties",
3132
"@babel/plugin-transform-object-rest-spread",

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"@babel/core": "^7.12.10",
7373
"@babel/eslint-parser": "^7.12.10",
7474
"@babel/eslint-plugin": "^7.12.10",
75+
"@babel/plugin-proposal-decorators": "^7.25.9",
7576
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
7677
"@babel/plugin-transform-class-properties": "^7.12.1",
7778
"@babel/plugin-transform-numeric-separator": "^7.12.7",

spec/unit/http-api/fetch.spec.ts

+69-12
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ describe("FetchHttpApi", () => {
125125
).resolves.toBe(text);
126126
});
127127

128-
it("should send token via query params if useAuthorizationHeader=false", () => {
128+
it("should send token via query params if useAuthorizationHeader=false", async () => {
129129
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
130130
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
131131
baseUrl,
@@ -134,19 +134,19 @@ describe("FetchHttpApi", () => {
134134
accessToken: "token",
135135
useAuthorizationHeader: false,
136136
});
137-
api.authedRequest(Method.Get, "/path");
137+
await api.authedRequest(Method.Get, "/path");
138138
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("token");
139139
});
140140

141-
it("should send token via headers by default", () => {
141+
it("should send token via headers by default", async () => {
142142
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
143143
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
144144
baseUrl,
145145
prefix,
146146
fetchFn,
147147
accessToken: "token",
148148
});
149-
api.authedRequest(Method.Get, "/path");
149+
await api.authedRequest(Method.Get, "/path");
150150
expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer token");
151151
});
152152

@@ -163,7 +163,7 @@ describe("FetchHttpApi", () => {
163163
expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBeFalsy();
164164
});
165165

166-
it("should ensure no token is leaked out via query params if sending via headers", () => {
166+
it("should ensure no token is leaked out via query params if sending via headers", async () => {
167167
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
168168
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
169169
baseUrl,
@@ -172,12 +172,12 @@ describe("FetchHttpApi", () => {
172172
accessToken: "token",
173173
useAuthorizationHeader: true,
174174
});
175-
api.authedRequest(Method.Get, "/path", { access_token: "123" });
175+
await api.authedRequest(Method.Get, "/path", { access_token: "123" });
176176
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBeFalsy();
177177
expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer token");
178178
});
179179

180-
it("should not override manually specified access token via query params", () => {
180+
it("should not override manually specified access token via query params", async () => {
181181
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
182182
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
183183
baseUrl,
@@ -186,11 +186,11 @@ describe("FetchHttpApi", () => {
186186
accessToken: "token",
187187
useAuthorizationHeader: false,
188188
});
189-
api.authedRequest(Method.Get, "/path", { access_token: "RealToken" });
189+
await api.authedRequest(Method.Get, "/path", { access_token: "RealToken" });
190190
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("RealToken");
191191
});
192192

193-
it("should not override manually specified access token via header", () => {
193+
it("should not override manually specified access token via header", async () => {
194194
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
195195
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
196196
baseUrl,
@@ -199,16 +199,16 @@ describe("FetchHttpApi", () => {
199199
accessToken: "token",
200200
useAuthorizationHeader: true,
201201
});
202-
api.authedRequest(Method.Get, "/path", undefined, undefined, {
202+
await api.authedRequest(Method.Get, "/path", undefined, undefined, {
203203
headers: { Authorization: "Bearer RealToken" },
204204
});
205205
expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer RealToken");
206206
});
207207

208-
it("should not override Accept header", () => {
208+
it("should not override Accept header", async () => {
209209
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
210210
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
211-
api.authedRequest(Method.Get, "/path", undefined, undefined, {
211+
await api.authedRequest(Method.Get, "/path", undefined, undefined, {
212212
headers: { Accept: "text/html" },
213213
});
214214
expect(fetchFn.mock.calls[0][1].headers["Accept"]).toBe("text/html");
@@ -468,4 +468,61 @@ describe("FetchHttpApi", () => {
468468
]
469469
`);
470470
});
471+
472+
it("should not make multiple concurrent refresh token requests", async () => {
473+
const tokenInactiveError = new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Token is not active" }, 401);
474+
475+
const deferredTokenRefresh = defer<{ accessToken: string; refreshToken: string }>();
476+
const fetchFn = jest.fn().mockResolvedValue({
477+
ok: false,
478+
status: tokenInactiveError.httpStatus,
479+
async text() {
480+
return JSON.stringify(tokenInactiveError.data);
481+
},
482+
async json() {
483+
return tokenInactiveError.data;
484+
},
485+
headers: {
486+
get: jest.fn().mockReturnValue("application/json"),
487+
},
488+
});
489+
const tokenRefreshFunction = jest.fn().mockReturnValue(deferredTokenRefresh.promise);
490+
491+
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
492+
baseUrl,
493+
prefix,
494+
fetchFn,
495+
doNotAttemptTokenRefresh: false,
496+
tokenRefreshFunction,
497+
accessToken: "ACCESS_TOKEN",
498+
refreshToken: "REFRESH_TOKEN",
499+
});
500+
501+
const prom1 = api.authedRequest(Method.Get, "/path1");
502+
const prom2 = api.authedRequest(Method.Get, "/path2");
503+
504+
await jest.advanceTimersByTimeAsync(10); // wait for requests to fire
505+
expect(fetchFn).toHaveBeenCalledTimes(2);
506+
fetchFn.mockResolvedValue({
507+
ok: true,
508+
status: 200,
509+
async text() {
510+
return "{}";
511+
},
512+
async json() {
513+
return {};
514+
},
515+
headers: {
516+
get: jest.fn().mockReturnValue("application/json"),
517+
},
518+
});
519+
deferredTokenRefresh.resolve({ accessToken: "NEW_ACCESS_TOKEN", refreshToken: "NEW_REFRESH_TOKEN" });
520+
521+
await prom1;
522+
await prom2;
523+
expect(fetchFn).toHaveBeenCalledTimes(4); // 2 original calls + 2 retries
524+
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
525+
expect(api.opts.accessToken).toBe("NEW_ACCESS_TOKEN");
526+
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
527+
});
471528
});

spec/unit/http-api/index.spec.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,12 @@ describe("MatrixHttpApi", () => {
5959
xhr.onreadystatechange?.(new Event("test"));
6060
});
6161

62-
it("should fall back to `fetch` where xhr is unavailable", () => {
62+
it("should fall back to `fetch` where xhr is unavailable", async () => {
6363
globalThis.XMLHttpRequest = undefined!;
6464
const fetchFn = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({}) });
6565
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
6666
upload = api.uploadContent({} as File);
67+
await upload;
6768
expect(fetchFn).toHaveBeenCalled();
6869
});
6970

spec/unit/matrix-client.spec.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2753,12 +2753,13 @@ describe("MatrixClient", function () {
27532753
// WHEN we call `setAccountData` ...
27542754
const setProm = client.setAccountData(eventType, content);
27552755

2756+
await jest.advanceTimersByTimeAsync(10);
27562757
// THEN, the REST call should have happened, and had the correct content
27572758
const lastCall = fetchMock.lastCall("put-account-data");
27582759
expect(lastCall).toBeDefined();
27592760
expect(lastCall?.[1]?.body).toEqual(JSON.stringify(content));
27602761

2761-
// Even after waiting a bit, the method should not yet have returned
2762+
// Even after waiting a bit more, the method should not yet have returned
27622763
await jest.advanceTimersByTimeAsync(10);
27632764
let finished = false;
27642765
setProm.finally(() => (finished = true));

src/http-api/fetch.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
} from "./interface.ts";
3232
import { anySignal, parseErrorResponse, timeoutSignal } from "./utils.ts";
3333
import { type QueryDict } from "../utils.ts";
34+
import { singleAsyncExecution } from "../utils/decorators.ts";
3435

3536
interface TypedResponse<T> extends Response {
3637
json(): Promise<T>;
@@ -106,6 +107,12 @@ export class FetchHttpApi<O extends IHttpOpts> {
106107
return this.requestOtherUrl(method, fullUri, body, opts);
107108
}
108109

110+
/**
111+
* Promise used to block authenticated requests during a token refresh to avoid repeated expected errors.
112+
* @private
113+
*/
114+
private tokenRefreshPromise?: Promise<unknown>;
115+
109116
/**
110117
* Perform an authorised request to the homeserver.
111118
* @param method - The HTTP method e.g. "GET".
@@ -162,13 +169,17 @@ export class FetchHttpApi<O extends IHttpOpts> {
162169
}
163170

164171
try {
172+
// Await any ongoing token refresh
173+
await this.tokenRefreshPromise;
165174
const response = await this.request<T>(method, path, queryParams, body, opts);
166175
return response;
167176
} catch (error) {
168177
const err = error as MatrixError;
169178

170179
if (err.errcode === "M_UNKNOWN_TOKEN" && !opts.doNotAttemptTokenRefresh) {
171-
const shouldRetry = await this.tryRefreshToken();
180+
const tokenRefreshPromise = this.tryRefreshToken();
181+
this.tokenRefreshPromise = Promise.allSettled([tokenRefreshPromise]);
182+
const shouldRetry = await tokenRefreshPromise;
172183
// if we got a new token retry the request
173184
if (shouldRetry) {
174185
return this.authedRequest(method, path, queryParams, body, {
@@ -177,6 +188,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
177188
});
178189
}
179190
}
191+
180192
// otherwise continue with error handling
181193
if (err.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
182194
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err);
@@ -193,6 +205,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
193205
* On success, sets new access and refresh tokens in opts.
194206
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
195207
*/
208+
@singleAsyncExecution
196209
private async tryRefreshToken(): Promise<boolean> {
197210
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
198211
return false;

src/utils/decorators.ts

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
Copyright 2025 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+
/**
18+
* Method decorator to ensure that only one instance of the method is running at a time,
19+
* and any concurrent calls will return the same promise as the original call.
20+
* After execution is complete a new call will be able to run the method again.
21+
*/
22+
export function singleAsyncExecution<This, Args extends unknown[], Return>(
23+
target: (this: This, ...args: Args) => Promise<Return>,
24+
): (this: This, ...args: Args) => Promise<Return> {
25+
let promise: Promise<Return> | undefined;
26+
27+
async function replacementMethod(this: This, ...args: Args): Promise<Return> {
28+
if (promise) return promise;
29+
try {
30+
promise = target.call(this, ...args);
31+
await promise;
32+
return promise;
33+
} finally {
34+
promise = undefined;
35+
}
36+
}
37+
38+
return replacementMethod;
39+
}

tsconfig-build.json

-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
"declarationMap": true,
66
"sourceMap": true,
77
"noEmit": false,
8-
"emitDecoratorMetadata": true,
98
"outDir": "./lib",
109
"rootDir": "src"
1110
},

tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"compilerOptions": {
33
"target": "es2022",
4-
"experimentalDecorators": true,
4+
"experimentalDecorators": false,
55
"esModuleInterop": true,
66
"module": "commonjs",
77
"moduleResolution": "node",

yarn.lock

+16
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,15 @@
394394
"@babel/helper-plugin-utils" "^7.25.9"
395395
"@babel/traverse" "^7.25.9"
396396

397+
"@babel/plugin-proposal-decorators@^7.25.9":
398+
version "7.25.9"
399+
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.25.9.tgz#8680707f943d1a3da2cd66b948179920f097e254"
400+
integrity sha512-smkNLL/O1ezy9Nhy4CNosc4Va+1wo5w4gzSZeLe6y6dM4mmHfYOCPolXQPHQxonZCF+ZyebxN9vqOolkYrSn5g==
401+
dependencies:
402+
"@babel/helper-create-class-features-plugin" "^7.25.9"
403+
"@babel/helper-plugin-utils" "^7.25.9"
404+
"@babel/plugin-syntax-decorators" "^7.25.9"
405+
397406
"@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2":
398407
version "7.21.0-placeholder-for-preset-env.2"
399408
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz#7844f9289546efa9febac2de4cfe358a050bd703"
@@ -420,6 +429,13 @@
420429
dependencies:
421430
"@babel/helper-plugin-utils" "^7.12.13"
422431

432+
"@babel/plugin-syntax-decorators@^7.25.9":
433+
version "7.25.9"
434+
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.25.9.tgz#986b4ca8b7b5df3f67cee889cedeffc2e2bf14b3"
435+
integrity sha512-ryzI0McXUPJnRCvMo4lumIKZUzhYUO/ScI+Mz4YVaTLt04DHNSjEUjKVvbzQjZFLuod/cYEc07mJWhzl6v4DPg==
436+
dependencies:
437+
"@babel/helper-plugin-utils" "^7.25.9"
438+
423439
"@babel/plugin-syntax-dynamic-import@^7.8.3":
424440
version "7.8.3"
425441
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3"

0 commit comments

Comments
 (0)