Skip to content

Commit 71bffb6

Browse files
authored
Handle unexpected token refresh failures gracefully (#4731)
* 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]> * Handle unexpected token refresh failures gracefully e.g. connection errors, proxy errors differently from token invalidated errors Signed-off-by: Michael Telatynski <[email protected]> --------- Signed-off-by: Michael Telatynski <[email protected]>
1 parent 72b997d commit 71bffb6

File tree

3 files changed

+71
-16
lines changed

3 files changed

+71
-16
lines changed

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

+23-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { FetchHttpApi } from "../../../src/http-api/fetch";
2020
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
2121
import {
2222
ClientPrefix,
23+
ConnectionError,
2324
HttpApiEvent,
2425
type HttpApiEventHandlerMap,
2526
IdentityPrefix,
@@ -288,7 +289,7 @@ describe("FetchHttpApi", () => {
288289

289290
describe("with a tokenRefreshFunction", () => {
290291
it("should emit logout and throw when token refresh fails", async () => {
291-
const error = new Error("uh oh");
292+
const error = new MatrixError();
292293
const tokenRefreshFunction = jest.fn().mockRejectedValue(error);
293294
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
294295
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
@@ -308,6 +309,27 @@ describe("FetchHttpApi", () => {
308309
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
309310
});
310311

312+
it("should not emit logout but still throw when token refresh fails due to transitive fault", async () => {
313+
const error = new ConnectionError("transitive fault");
314+
const tokenRefreshFunction = jest.fn().mockRejectedValue(error);
315+
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
316+
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
317+
jest.spyOn(emitter, "emit");
318+
const api = new FetchHttpApi(emitter, {
319+
baseUrl,
320+
prefix,
321+
fetchFn,
322+
tokenRefreshFunction,
323+
accessToken,
324+
refreshToken,
325+
});
326+
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
327+
unknownTokenErr,
328+
);
329+
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
330+
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
331+
});
332+
311333
it("should refresh token and retry request", async () => {
312334
const newAccessToken = "new-access-token";
313335
const newRefreshToken = "new-refresh-token";

src/http-api/errors.ts

+15
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,18 @@ export class ConnectionError extends Error {
197197
return "ConnectionError";
198198
}
199199
}
200+
201+
/**
202+
* Construct a TokenRefreshError. This indicates that a request failed due to the token being expired,
203+
* and attempting to refresh said token also failed but in a way which was not indicative of token invalidation.
204+
* Assumed to be a temporary failure.
205+
*/
206+
export class TokenRefreshError extends Error {
207+
public constructor(cause?: Error) {
208+
super(cause?.message ?? "");
209+
}
210+
211+
public get name(): string {
212+
return "TokenRefreshError";
213+
}
214+
}

src/http-api/fetch.ts

+33-15
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ limitations under the License.
1818
* This is an internal module. See {@link MatrixHttpApi} for the public class.
1919
*/
2020

21+
import { ErrorResponse as OidcAuthError } from "oidc-client-ts";
22+
2123
import { checkObjectHasKeys, encodeParams } from "../utils.ts";
2224
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
2325
import { Method } from "./method.ts";
24-
import { ConnectionError, type MatrixError } from "./errors.ts";
26+
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
2527
import {
2628
HttpApiEvent,
2729
type HttpApiEventHandlerMap,
@@ -43,6 +45,12 @@ export type ResponseType<T, O extends IHttpOpts> = O extends undefined
4345
? T
4446
: TypedResponse<T>;
4547

48+
const enum TokenRefreshOutcome {
49+
Success = "success",
50+
Failure = "failure",
51+
Logout = "logout",
52+
}
53+
4654
export class FetchHttpApi<O extends IHttpOpts> {
4755
private abortController = new AbortController();
4856

@@ -174,29 +182,36 @@ export class FetchHttpApi<O extends IHttpOpts> {
174182
const response = await this.request<T>(method, path, queryParams, body, opts);
175183
return response;
176184
} catch (error) {
177-
const err = error as MatrixError;
185+
if (!(error instanceof MatrixError)) {
186+
throw error;
187+
}
178188

179-
if (err.errcode === "M_UNKNOWN_TOKEN" && !opts.doNotAttemptTokenRefresh) {
189+
if (error.errcode === "M_UNKNOWN_TOKEN" && !opts.doNotAttemptTokenRefresh) {
180190
const tokenRefreshPromise = this.tryRefreshToken();
181191
this.tokenRefreshPromise = Promise.allSettled([tokenRefreshPromise]);
182-
const shouldRetry = await tokenRefreshPromise;
183-
// if we got a new token retry the request
184-
if (shouldRetry) {
192+
const outcome = await tokenRefreshPromise;
193+
194+
if (outcome === TokenRefreshOutcome.Success) {
195+
// if we got a new token retry the request
185196
return this.authedRequest(method, path, queryParams, body, {
186197
...paramOpts,
187198
doNotAttemptTokenRefresh: true,
188199
});
189200
}
201+
if (outcome === TokenRefreshOutcome.Failure) {
202+
throw new TokenRefreshError(error);
203+
}
204+
// Fall through to SessionLoggedOut handler below
190205
}
191206

192207
// otherwise continue with error handling
193-
if (err.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
194-
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err);
195-
} else if (err.errcode == "M_CONSENT_NOT_GIVEN") {
196-
this.eventEmitter.emit(HttpApiEvent.NoConsent, err.message, err.data.consent_uri);
208+
if (error.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
209+
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, error);
210+
} else if (error.errcode == "M_CONSENT_NOT_GIVEN") {
211+
this.eventEmitter.emit(HttpApiEvent.NoConsent, error.message, error.data.consent_uri);
197212
}
198213

199-
throw err;
214+
throw error;
200215
}
201216
}
202217

@@ -206,20 +221,23 @@ export class FetchHttpApi<O extends IHttpOpts> {
206221
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
207222
*/
208223
@singleAsyncExecution
209-
private async tryRefreshToken(): Promise<boolean> {
224+
private async tryRefreshToken(): Promise<TokenRefreshOutcome> {
210225
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
211-
return false;
226+
return TokenRefreshOutcome.Logout;
212227
}
213228

214229
try {
215230
const { accessToken, refreshToken } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
216231
this.opts.accessToken = accessToken;
217232
this.opts.refreshToken = refreshToken;
218233
// successfully got new tokens
219-
return true;
234+
return TokenRefreshOutcome.Success;
220235
} catch (error) {
221236
this.opts.logger?.warn("Failed to refresh token", error);
222-
return false;
237+
if (error instanceof OidcAuthError || error instanceof MatrixError) {
238+
return TokenRefreshOutcome.Logout;
239+
}
240+
return TokenRefreshOutcome.Failure;
223241
}
224242
}
225243

0 commit comments

Comments
 (0)