mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-06-08 15:21:53 +03:00
* Refactor how token refreshing works to be more resilient 1. ensure we do use the new token if it is not explicitly inhibited by the caller 2. eagerly refresh token if we know it is expired 3. allow refreshing a token multiple times if e.g. on bad connection or the environment has been slept and sufficient time has passed since the last refresh attempt 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> * Add exponential backoff Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Ensure no timing effects on `authedRequest` method call Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
/**
|
|
* @jest-environment jsdom
|
|
*/
|
|
|
|
/*
|
|
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-jest";
|
|
|
|
import { OidcTokenRefresher, TokenRefreshLogoutError } from "../../../src";
|
|
import { logger } from "../../../src/logger";
|
|
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);
|
|
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"),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
fetchMock.resetBehavior();
|
|
});
|
|
|
|
it("throws when oidc client cannot be initialised", async () => {
|
|
jest.spyOn(logger, "error");
|
|
fetchMock.get(
|
|
`${config.issuer}.well-known/openid-configuration`,
|
|
{
|
|
ok: false,
|
|
status: 404,
|
|
},
|
|
{ overwriteRoutes: true },
|
|
);
|
|
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
|
await expect(refresher.oidcClientReady).rejects.toThrow();
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
"Failed to initialise OIDC client.",
|
|
// error from OidcClient
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
|
|
it("initialises oidc client", async () => {
|
|
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
|
await refresher.oidcClientReady;
|
|
|
|
// @ts-ignore peek at private property to see we initialised the client correctly
|
|
expect(refresher.oidcClient.settings).toEqual(
|
|
expect.objectContaining({
|
|
client_id: clientId,
|
|
redirect_uri: redirectUri,
|
|
authority: authConfig.issuer,
|
|
scope,
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("doRefreshAccessToken()", () => {
|
|
it("should throw when oidcClient has not been initialised", async () => {
|
|
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
|
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow(
|
|
"Cannot get new token before OIDC client is initialised.",
|
|
);
|
|
});
|
|
|
|
it("should refresh the tokens", async () => {
|
|
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
|
await refresher.oidcClientReady;
|
|
|
|
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);
|
|
await refresher.oidcClientReady;
|
|
// spy on our stub
|
|
jest.spyOn(refresher, "persistTokens");
|
|
|
|
await refresher.doRefreshAccessToken("refresh-token");
|
|
|
|
expect(refresher.persistTokens).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
accessToken: "new-access-token",
|
|
refreshToken: "new-refresh-token",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("should only have one inflight refresh request at once", async () => {
|
|
fetchMock
|
|
.postOnce(
|
|
config.token_endpoint,
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
...makeTokenResponse("first-new-access-token", "first-new-refresh-token"),
|
|
},
|
|
{ overwriteRoutes: true },
|
|
)
|
|
.postOnce(
|
|
config.token_endpoint,
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
|
|
},
|
|
{ overwriteRoutes: false },
|
|
);
|
|
|
|
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
|
await refresher.oidcClientReady;
|
|
// reset call counts
|
|
fetchMock.resetHistory();
|
|
|
|
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.post(
|
|
config.token_endpoint,
|
|
{
|
|
status: 503,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
{ overwriteRoutes: true },
|
|
);
|
|
|
|
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
|
|
.postOnce(
|
|
config.token_endpoint,
|
|
{
|
|
status: 503,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
{ overwriteRoutes: true },
|
|
)
|
|
.postOnce(
|
|
config.token_endpoint,
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
|
|
},
|
|
{ overwriteRoutes: false },
|
|
);
|
|
|
|
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
|
await refresher.oidcClientReady;
|
|
// reset call counts
|
|
fetchMock.resetHistory();
|
|
|
|
// 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.post(
|
|
config.token_endpoint,
|
|
{
|
|
status: 400,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: {
|
|
error: "invalid_grant",
|
|
error_description: "The provided access grant is invalid, expired, or revoked.",
|
|
},
|
|
},
|
|
{ overwriteRoutes: true },
|
|
);
|
|
|
|
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
|
await refresher.oidcClientReady;
|
|
|
|
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow(TokenRefreshLogoutError);
|
|
});
|
|
});
|
|
});
|