899cdb0e1d
* 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>
282 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|