You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-08-06 12:02:40 +03:00
Handle unexpected token refresh failures gracefully (#4731)
* Fix idempotency issue around token refresh Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve test Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Handle unexpected token refresh failures gracefully e.g. connection errors, proxy errors differently from token invalidated errors Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
committed by
GitHub
parent
72b997d1f3
commit
71bffb6c1b
@@ -20,6 +20,7 @@ import { FetchHttpApi } from "../../../src/http-api/fetch";
|
|||||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||||
import {
|
import {
|
||||||
ClientPrefix,
|
ClientPrefix,
|
||||||
|
ConnectionError,
|
||||||
HttpApiEvent,
|
HttpApiEvent,
|
||||||
type HttpApiEventHandlerMap,
|
type HttpApiEventHandlerMap,
|
||||||
IdentityPrefix,
|
IdentityPrefix,
|
||||||
@@ -288,7 +289,7 @@ describe("FetchHttpApi", () => {
|
|||||||
|
|
||||||
describe("with a tokenRefreshFunction", () => {
|
describe("with a tokenRefreshFunction", () => {
|
||||||
it("should emit logout and throw when token refresh fails", async () => {
|
it("should emit logout and throw when token refresh fails", async () => {
|
||||||
const error = new Error("uh oh");
|
const error = new MatrixError();
|
||||||
const tokenRefreshFunction = jest.fn().mockRejectedValue(error);
|
const tokenRefreshFunction = jest.fn().mockRejectedValue(error);
|
||||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||||
@@ -308,6 +309,27 @@ describe("FetchHttpApi", () => {
|
|||||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not emit logout but still throw when token refresh fails due to transitive fault", async () => {
|
||||||
|
const error = new ConnectionError("transitive fault");
|
||||||
|
const tokenRefreshFunction = jest.fn().mockRejectedValue(error);
|
||||||
|
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||||
|
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||||
|
jest.spyOn(emitter, "emit");
|
||||||
|
const api = new FetchHttpApi(emitter, {
|
||||||
|
baseUrl,
|
||||||
|
prefix,
|
||||||
|
fetchFn,
|
||||||
|
tokenRefreshFunction,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||||
|
unknownTokenErr,
|
||||||
|
);
|
||||||
|
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||||
|
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||||
|
});
|
||||||
|
|
||||||
it("should refresh token and retry request", async () => {
|
it("should refresh token and retry request", async () => {
|
||||||
const newAccessToken = "new-access-token";
|
const newAccessToken = "new-access-token";
|
||||||
const newRefreshToken = "new-refresh-token";
|
const newRefreshToken = "new-refresh-token";
|
||||||
|
@@ -197,3 +197,18 @@ export class ConnectionError extends Error {
|
|||||||
return "ConnectionError";
|
return "ConnectionError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a TokenRefreshError. This indicates that a request failed due to the token being expired,
|
||||||
|
* and attempting to refresh said token also failed but in a way which was not indicative of token invalidation.
|
||||||
|
* Assumed to be a temporary failure.
|
||||||
|
*/
|
||||||
|
export class TokenRefreshError extends Error {
|
||||||
|
public constructor(cause?: Error) {
|
||||||
|
super(cause?.message ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public get name(): string {
|
||||||
|
return "TokenRefreshError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -18,10 +18,12 @@ limitations under the License.
|
|||||||
* This is an internal module. See {@link MatrixHttpApi} for the public class.
|
* This is an internal module. See {@link MatrixHttpApi} for the public class.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { ErrorResponse as OidcAuthError } from "oidc-client-ts";
|
||||||
|
|
||||||
import { checkObjectHasKeys, encodeParams } from "../utils.ts";
|
import { checkObjectHasKeys, encodeParams } from "../utils.ts";
|
||||||
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||||
import { Method } from "./method.ts";
|
import { Method } from "./method.ts";
|
||||||
import { ConnectionError, type MatrixError } from "./errors.ts";
|
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
|
||||||
import {
|
import {
|
||||||
HttpApiEvent,
|
HttpApiEvent,
|
||||||
type HttpApiEventHandlerMap,
|
type HttpApiEventHandlerMap,
|
||||||
@@ -43,6 +45,12 @@ export type ResponseType<T, O extends IHttpOpts> = O extends undefined
|
|||||||
? T
|
? T
|
||||||
: TypedResponse<T>;
|
: TypedResponse<T>;
|
||||||
|
|
||||||
|
const enum TokenRefreshOutcome {
|
||||||
|
Success = "success",
|
||||||
|
Failure = "failure",
|
||||||
|
Logout = "logout",
|
||||||
|
}
|
||||||
|
|
||||||
export class FetchHttpApi<O extends IHttpOpts> {
|
export class FetchHttpApi<O extends IHttpOpts> {
|
||||||
private abortController = new AbortController();
|
private abortController = new AbortController();
|
||||||
|
|
||||||
@@ -174,29 +182,36 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
|||||||
const response = await this.request<T>(method, path, queryParams, body, opts);
|
const response = await this.request<T>(method, path, queryParams, body, opts);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as MatrixError;
|
if (!(error instanceof MatrixError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
if (err.errcode === "M_UNKNOWN_TOKEN" && !opts.doNotAttemptTokenRefresh) {
|
if (error.errcode === "M_UNKNOWN_TOKEN" && !opts.doNotAttemptTokenRefresh) {
|
||||||
const tokenRefreshPromise = this.tryRefreshToken();
|
const tokenRefreshPromise = this.tryRefreshToken();
|
||||||
this.tokenRefreshPromise = Promise.allSettled([tokenRefreshPromise]);
|
this.tokenRefreshPromise = Promise.allSettled([tokenRefreshPromise]);
|
||||||
const shouldRetry = await tokenRefreshPromise;
|
const outcome = await tokenRefreshPromise;
|
||||||
// if we got a new token retry the request
|
|
||||||
if (shouldRetry) {
|
if (outcome === TokenRefreshOutcome.Success) {
|
||||||
|
// if we got a new token retry the request
|
||||||
return this.authedRequest(method, path, queryParams, body, {
|
return this.authedRequest(method, path, queryParams, body, {
|
||||||
...paramOpts,
|
...paramOpts,
|
||||||
doNotAttemptTokenRefresh: true,
|
doNotAttemptTokenRefresh: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (outcome === TokenRefreshOutcome.Failure) {
|
||||||
|
throw new TokenRefreshError(error);
|
||||||
|
}
|
||||||
|
// Fall through to SessionLoggedOut handler below
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise continue with error handling
|
// otherwise continue with error handling
|
||||||
if (err.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
|
if (error.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
|
||||||
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err);
|
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, error);
|
||||||
} else if (err.errcode == "M_CONSENT_NOT_GIVEN") {
|
} else if (error.errcode == "M_CONSENT_NOT_GIVEN") {
|
||||||
this.eventEmitter.emit(HttpApiEvent.NoConsent, err.message, err.data.consent_uri);
|
this.eventEmitter.emit(HttpApiEvent.NoConsent, error.message, error.data.consent_uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw err;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,9 +221,9 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
|||||||
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
|
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
|
||||||
*/
|
*/
|
||||||
@singleAsyncExecution
|
@singleAsyncExecution
|
||||||
private async tryRefreshToken(): Promise<boolean> {
|
private async tryRefreshToken(): Promise<TokenRefreshOutcome> {
|
||||||
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
|
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
|
||||||
return false;
|
return TokenRefreshOutcome.Logout;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -216,10 +231,13 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
|||||||
this.opts.accessToken = accessToken;
|
this.opts.accessToken = accessToken;
|
||||||
this.opts.refreshToken = refreshToken;
|
this.opts.refreshToken = refreshToken;
|
||||||
// successfully got new tokens
|
// successfully got new tokens
|
||||||
return true;
|
return TokenRefreshOutcome.Success;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.opts.logger?.warn("Failed to refresh token", error);
|
this.opts.logger?.warn("Failed to refresh token", error);
|
||||||
return false;
|
if (error instanceof OidcAuthError || error instanceof MatrixError) {
|
||||||
|
return TokenRefreshOutcome.Logout;
|
||||||
|
}
|
||||||
|
return TokenRefreshOutcome.Failure;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user