Files
matrix-js-sdk/spec/unit/oidc/tokenRefresher.spec.ts
Michael Telatynski 899cdb0e1d Switch from Jest to Vitest (#5131)
* Skip unwritten tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Tidy jest fake timers

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove unnecessary sessionStorage mock

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve types

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve async assertions

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve error assertions

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve object assertions

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove assertion testing unclear mock

This test failed when ran individually, same as after the clearAllMocks call

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Avoid awaiting non-thenables

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Pass nop function when stubbing out console, vitest won't accept it any other way

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove unnecessary mock which causes tests to fail after updating fetch-mock & fix typo

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix mistaken assertions not testing all values in array

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix hidden non-running tests in room.spec.ts

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update fetch-mock-jest to @fetch-mock/jest

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Delint

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Make knip happier

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Make knip happier 2.0

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Delint

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Switch from Jest to Vitest

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Delint

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix CI

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove unnecessary fake timers

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update vite

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Revert irrelevant changes

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix coverage spec paths

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix slow test reporter

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix bad merge conflict resolution

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix babel config

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-01-15 11:15:37 +00:00

282 lines
10 KiB
TypeScript

/**
* @vitest-environment happy-dom
*/
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import fetchMock from "@fetch-mock/vitest";
import { OidcTokenRefresher, TokenRefreshLogoutError } from "../../../src";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
describe("OidcTokenRefresher", () => {
// OidcTokenRefresher props
// see class declaration for info
const authConfig = {
issuer: "https://issuer.org/",
};
const clientId = "test-client-id";
const redirectUri = "https://test.org";
const deviceId = "abc123";
const idTokenClaims = {
exp: Date.now() / 1000 + 100000,
aud: clientId,
iss: authConfig.issuer,
sub: "123",
iat: 123,
};
// used to mock a valid token response, as consumed by OidcClient library
const scope = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
// auth config used in mocked calls to OP .well-known
const config = makeDelegatedAuthConfig(authConfig.issuer);
const makeTokenResponse = (accessToken: string, refreshToken?: string) => ({
access_token: accessToken,
refresh_token: refreshToken,
token_type: "Bearer",
expires_in: 300,
scope: scope,
});
beforeEach(() => {
fetchMock.get(`${config.issuer}.well-known/openid-configuration`, config, { name: "openid-configuration" });
fetchMock.get(`${config.issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
fetchMock.post(
config.token_endpoint,
{
status: 200,
headers: {
"Content-Type": "application/json",
},
...makeTokenResponse("new-access-token", "new-refresh-token"),
},
{ name: "token-endpoint" },
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("doRefreshAccessToken()", () => {
it("should throw when oidcClient has not been initialised", async () => {
fetchMock.modifyRoute("openid-configuration", {
response: {
ok: false,
status: 404,
},
});
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow("Failed to initialise OIDC client.");
});
it("should retry initialisation", async () => {
fetchMock.modifyRoute("openid-configuration", {
response: {
ok: false,
status: 404,
},
});
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow("Failed to initialise OIDC client.");
// put the successful mock back
fetchMock.modifyRoute("openid-configuration", {
response: config,
});
const result = await refresher.doRefreshAccessToken("token");
expect(result).toEqual(
expect.objectContaining({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
}),
);
});
it("should refresh the tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
const result = await refresher.doRefreshAccessToken("refresh-token");
expect(fetchMock).toHaveFetched(config.token_endpoint, {
method: "POST",
});
expect(result).toEqual(
expect.objectContaining({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
}),
);
});
it("should persist the new tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
// spy on our stub
vi.spyOn(refresher as any, "persistTokens");
await refresher.doRefreshAccessToken("refresh-token");
expect((refresher as any).persistTokens).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
}),
);
});
it("should only have one inflight refresh request at once", async () => {
fetchMock.removeRoute("token-endpoint");
fetchMock
.postOnce(config.token_endpoint, {
status: 200,
headers: {
"Content-Type": "application/json",
},
...makeTokenResponse("first-new-access-token", "first-new-refresh-token"),
})
.postOnce(config.token_endpoint, {
status: 200,
headers: {
"Content-Type": "application/json",
},
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
});
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// reset call counts
fetchMock.clearHistory();
const refreshToken = "refresh-token";
const first = refresher.doRefreshAccessToken(refreshToken);
const second = refresher.doRefreshAccessToken(refreshToken);
const result1 = await second;
const result2 = await first;
// only one call to token endpoint
expect(fetchMock).toHaveFetchedTimes(1, config.token_endpoint);
expect(result1).toEqual(
expect.objectContaining({
accessToken: "first-new-access-token",
refreshToken: "first-new-refresh-token",
}),
);
// same response
expect(result1).toEqual(result2);
// call again after first request resolves
const third = await refresher.doRefreshAccessToken("first-new-refresh-token");
// called token endpoint, got new tokens
expect(third).toEqual(
expect.objectContaining({
accessToken: "second-new-access-token",
refreshToken: "second-new-refresh-token",
}),
);
});
it("should log and rethrow when token refresh fails", async () => {
fetchMock.modifyRoute("token-endpoint", {
response: {
status: 503,
headers: {
"Content-Type": "application/json",
},
},
});
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
});
it("should make fresh request after a failed request", async () => {
// make sure inflight request is cleared after a failure
fetchMock.removeRoute("token-endpoint");
fetchMock
.postOnce(config.token_endpoint, {
status: 503,
headers: {
"Content-Type": "application/json",
},
})
.postOnce(config.token_endpoint, {
status: 200,
headers: {
"Content-Type": "application/json",
},
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
});
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// reset call counts
fetchMock.clearHistory();
// first call fails
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
// call again after first request resolves
const result = await refresher.doRefreshAccessToken("first-new-refresh-token");
// called token endpoint, got new tokens
expect(result).toEqual(
expect.objectContaining({
accessToken: "second-new-access-token",
refreshToken: "second-new-refresh-token",
}),
);
});
it("should throw TokenRefreshLogoutError when expired", async () => {
fetchMock.modifyRoute("token-endpoint", {
response: {
status: 400,
headers: {
"Content-Type": "application/json",
},
body: {
error: "invalid_grant",
error_description: "The provided access grant is invalid, expired, or revoked.",
},
},
});
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow(TokenRefreshLogoutError);
});
});
});