1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-06 12:02:40 +03:00

OIDC: refresh tokens (#3764)

* very messy poc

* iterate

* more types and use tokenRefreshFunction

* working refresh without persistence

* tidy

* add claims to completeauhtorizationcodegrant response

* export tokenrefresher from matrix

* add idtokenclaims

* add claims to completeauhtorizationcodegrant response

* only one token refresh attempt at a time

* tests

* comments

* add tokenRefresher class

* export generateScope

* export oidc from matrix

* test refreshtoken

* mark experimental

* add getRefreshToken to client

* Apply suggestions from code review

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* remove some vars in test

* make TokenRefresher un-abstract, comments and improvements

* remove invalid jsdoc

* Update src/oidc/tokenRefresher.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Code review improvements

* fix verification integ tests

* remove unused type from props

* fix incomplete mock fn in fetch.spec

* document TokenRefreshFunction

* comments

* tidying

* update for injected logger

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
Kerry
2023-10-12 11:00:02 +13:00
committed by GitHub
parent 1de6de05a1
commit 0f4fa5ad51
5 changed files with 228 additions and 15 deletions

View File

@@ -18,7 +18,15 @@ import { Mocked } from "jest-mock";
import { FetchHttpApi } from "../../../src/http-api/fetch";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import { ClientPrefix, HttpApiEvent, HttpApiEventHandlerMap, IdentityPrefix, IHttpOpts, Method } from "../../../src";
import {
ClientPrefix,
HttpApiEvent,
HttpApiEventHandlerMap,
IdentityPrefix,
IHttpOpts,
MatrixError,
Method,
} from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import { defer, QueryDict } from "../../../src/utils";
import { Logger } from "../../../src/logger";
@@ -231,13 +239,145 @@ describe("FetchHttpApi", () => {
});
describe("authedRequest", () => {
it("should not include token if unset", () => {
const fetchFn = jest.fn();
it("should not include token if unset", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn });
api.authedRequest(Method.Post, "/account/password");
await api.authedRequest(Method.Post, "/account/password");
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBeUndefined();
});
describe("with refresh token", () => {
const accessToken = "test-access-token";
const refreshToken = "test-refresh-token";
describe("when an unknown token error is encountered", () => {
const unknownTokenErrBody = {
errcode: "M_UNKNOWN_TOKEN",
error: "Token is not active",
soft_logout: false,
};
const unknownTokenErr = new MatrixError(unknownTokenErrBody, 401);
const unknownTokenResponse = {
ok: false,
status: 401,
headers: {
get(name: string): string | null {
return name === "Content-Type" ? "application/json" : null;
},
},
text: jest.fn().mockResolvedValue(JSON.stringify(unknownTokenErrBody)),
};
const okayResponse = {
ok: true,
status: 200,
};
describe("without a tokenRefreshFunction", () => {
it("should emit logout and throw", async () => {
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
jest.spyOn(emitter, "emit");
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, accessToken, refreshToken });
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
);
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
});
});
describe("with a tokenRefreshFunction", () => {
it("should emit logout and throw when token refresh fails", async () => {
const error = new Error("uh oh");
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).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
});
it("should refresh token and retry request", async () => {
const newAccessToken = "new-access-token";
const newRefreshToken = "new-refresh-token";
const tokenRefreshFunction = jest.fn().mockResolvedValue({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
const fetchFn = jest
.fn()
.mockResolvedValueOnce(unknownTokenResponse)
.mockResolvedValueOnce(okayResponse);
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
jest.spyOn(emitter, "emit");
const api = new FetchHttpApi(emitter, {
baseUrl,
prefix,
fetchFn,
tokenRefreshFunction,
accessToken,
refreshToken,
});
const result = await api.authedRequest(Method.Post, "/account/password");
expect(result).toEqual(okayResponse);
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
expect(fetchFn).toHaveBeenCalledTimes(2);
// uses new access token
expect(fetchFn.mock.calls[1][1].headers.Authorization).toEqual("Bearer new-access-token");
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
});
it("should only try to refresh the token once", async () => {
const newAccessToken = "new-access-token";
const newRefreshToken = "new-refresh-token";
const tokenRefreshFunction = jest.fn().mockResolvedValue({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
// fetch doesn't like our new or old tokens
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,
);
// tried to refresh the token once
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
expect(fetchFn).toHaveBeenCalledTimes(2);
// uses new access token on retry
expect(fetchFn.mock.calls[1][1].headers.Authorization).toEqual("Bearer new-access-token");
// logged out after refreshed access token is rejected
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
});
});
});
});
});
describe("getUrl()", () => {