element-web/test/stores/oidc/OidcClientStore-test.ts
Kerry 84ca519b3f
OIDC: revoke tokens on logout (#11718)
* test persistCredentials without a pickle key

* test setLoggedIn with pickle key

* lint

* type error

* extract token persisting code into function, persist refresh token

* store has_refresh_token too

* pass refreshToken from oidcAuthGrant into credentials

* rest restore session with pickle key

* retreive stored refresh token and add to credentials

* extract token decryption into function

* remove TODO

* very messy poc

* utils to persist clientId and issuer after oidc authentication

* add dep oidc-client-ts

* persist issuer and clientId after successful oidc auth

* add OidcClientStore

* comments and tidy

* expose getters for stored refresh and access tokens in Lifecycle

* revoke tokens with oidc provider

* test logout action in MatrixChat

* comments

* prettier

* test OidcClientStore.revokeTokens

* put pickle key destruction back

* comment pedantry

* working refresh without persistence

* extract token persistence functions to utils

* add sugar

* implement TokenRefresher class with persistence

* tidying

* persist idTokenClaims

* persist idTokenClaims

* tests

* remove unused cde

* create token refresher during doSetLoggedIn

* tidying

* also tidying

* OidcClientStore.initClient use stored issuer when client well known unavailable

* test Lifecycle.logout

* update Lifecycle test replaceUsingCreds calls

* fix test

* tidy

* test tokenrefresher creation in login flow

* test token refresher

* Update src/utils/oidc/TokenRefresher.ts

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

* use literal value for m.authentication

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

* improve comments

* fix test mock, comment

* typo

* add sdkContext to SoftLogout, pass oidcClientStore to logout

* fullstops

* comments

* fussy comment formatting

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-10-15 21:35:25 +00:00

263 lines
10 KiB
TypeScript

/*
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 { mocked } from "jest-mock";
import { OidcClient } from "oidc-client-ts";
import { M_AUTHENTICATION } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { discoverAndValidateAuthenticationConfig } from "matrix-js-sdk/src/oidc/discovery";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { OidcClientStore } from "../../../src/stores/oidc/OidcClientStore";
import { flushPromises, getMockClientWithEventEmitter } from "../../test-utils";
import { mockOpenIdConfiguration } from "../../test-utils/oidc";
jest.mock("matrix-js-sdk/src/oidc/discovery", () => ({
discoverAndValidateAuthenticationConfig: jest.fn(),
}));
describe("OidcClientStore", () => {
const clientId = "test-client-id";
const metadata = mockOpenIdConfiguration();
const account = metadata.issuer + "account";
const mockSessionStorage: Record<string, string> = {
mx_oidc_client_id: clientId,
mx_oidc_token_issuer: metadata.issuer,
};
const mockClient = getMockClientWithEventEmitter({
waitForClientWellKnown: jest.fn().mockResolvedValue({}),
});
beforeEach(() => {
jest.spyOn(sessionStorage.__proto__, "getItem")
.mockClear()
.mockImplementation((key) => mockSessionStorage[key as string] ?? null);
mocked(discoverAndValidateAuthenticationConfig).mockClear().mockResolvedValue({
metadata,
account,
issuer: metadata.issuer,
});
mockClient.waitForClientWellKnown.mockResolvedValue({
[M_AUTHENTICATION.stable!]: {
issuer: metadata.issuer,
account,
},
});
jest.spyOn(logger, "error").mockClear();
fetchMock.get(`${metadata.issuer}.well-known/openid-configuration`, metadata);
});
describe("isUserAuthenticatedWithOidc()", () => {
it("should return true when an issuer is in session storage", () => {
const store = new OidcClientStore(mockClient);
expect(store.isUserAuthenticatedWithOidc).toEqual(true);
});
it("should return false when no issuer is in session storage", () => {
jest.spyOn(sessionStorage.__proto__, "getItem").mockReturnValue(null);
const store = new OidcClientStore(mockClient);
expect(store.isUserAuthenticatedWithOidc).toEqual(false);
});
});
describe("initialising oidcClient", () => {
it("should initialise oidc client from constructor", () => {
mockClient.waitForClientWellKnown.mockResolvedValue(undefined as any);
const store = new OidcClientStore(mockClient);
// started initialising
// @ts-ignore private property
expect(store.initialisingOidcClientPromise).toBeTruthy();
});
it("should fallback to stored issuer when no client well known is available", async () => {
mockClient.waitForClientWellKnown.mockResolvedValue(undefined as any);
const store = new OidcClientStore(mockClient);
// successfully created oidc client
// @ts-ignore private property
expect(await store.getOidcClient()).toBeTruthy();
});
it("should log and return when no clientId is found in storage", async () => {
const sessionStorageWithoutClientId: Record<string, string | null> = {
...mockSessionStorage,
mx_oidc_client_id: null,
};
jest.spyOn(sessionStorage.__proto__, "getItem").mockImplementation(
(key) => sessionStorageWithoutClientId[key as string] ?? null,
);
const store = new OidcClientStore(mockClient);
// no oidc client
// @ts-ignore private property
expect(await store.getOidcClient()).toEqual(undefined);
expect(logger.error).toHaveBeenCalledWith(
"Failed to initialise OidcClientStore",
new Error("Oidc client id not found in storage"),
);
});
it("should log and return when discovery and validation fails", async () => {
mocked(discoverAndValidateAuthenticationConfig).mockRejectedValue(new Error(OidcError.OpSupport));
const store = new OidcClientStore(mockClient);
await flushPromises();
expect(logger.error).toHaveBeenCalledWith(
"Failed to initialise OidcClientStore",
new Error(OidcError.OpSupport),
);
// no oidc client
// @ts-ignore private property
expect(await store.getOidcClient()).toEqual(undefined);
});
it("should create oidc client correctly", async () => {
const store = new OidcClientStore(mockClient);
// @ts-ignore private property
const client = await store.getOidcClient();
expect(client?.settings.client_id).toEqual(clientId);
expect(client?.settings.authority).toEqual(metadata.issuer);
});
it("should set account management endpoint when configured", async () => {
const store = new OidcClientStore(mockClient);
// @ts-ignore private property
await store.getOidcClient();
expect(store.accountManagementEndpoint).toEqual(account);
});
it("should set account management endpoint to issuer when not configured", async () => {
mocked(discoverAndValidateAuthenticationConfig).mockClear().mockResolvedValue({
metadata,
account: undefined,
issuer: metadata.issuer,
});
const store = new OidcClientStore(mockClient);
// @ts-ignore private property
await store.getOidcClient();
expect(store.accountManagementEndpoint).toEqual(metadata.issuer);
});
it("should reuse initialised oidc client", async () => {
const store = new OidcClientStore(mockClient);
// @ts-ignore private property
store.getOidcClient();
// @ts-ignore private property
store.getOidcClient();
await flushPromises();
// finished initialising
// @ts-ignore private property
expect(await store.getOidcClient()).toBeTruthy();
// @ts-ignore private property
store.getOidcClient();
// only called once for multiple calls to getOidcClient
// before and after initialisation is complete
expect(discoverAndValidateAuthenticationConfig).toHaveBeenCalledTimes(1);
});
});
describe("revokeTokens()", () => {
const accessToken = "test-access-token";
const refreshToken = "test-refresh-token";
beforeEach(() => {
// spy and call through
jest.spyOn(OidcClient.prototype, "revokeToken").mockClear();
fetchMock.resetHistory();
fetchMock.post(
metadata.revocation_endpoint,
{
status: 200,
},
{ sendAsJson: true },
);
});
it("should throw when oidcClient could not be initialised", async () => {
// make oidcClient initialisation fail
mockClient.waitForClientWellKnown.mockResolvedValue(undefined as any);
const sessionStorageWithoutIssuer: Record<string, string | null> = {
...mockSessionStorage,
mx_oidc_token_issuer: null,
};
jest.spyOn(sessionStorage.__proto__, "getItem").mockImplementation(
(key) => sessionStorageWithoutIssuer[key as string] ?? null,
);
const store = new OidcClientStore(mockClient);
await expect(() => store.revokeTokens(accessToken, refreshToken)).rejects.toThrow("No OIDC client");
});
it("should revoke access and refresh tokens", async () => {
const store = new OidcClientStore(mockClient);
await store.revokeTokens(accessToken, refreshToken);
expect(fetchMock).toHaveFetchedTimes(2, metadata.revocation_endpoint);
expect(OidcClient.prototype.revokeToken).toHaveBeenCalledWith(accessToken, "access_token");
expect(OidcClient.prototype.revokeToken).toHaveBeenCalledWith(refreshToken, "refresh_token");
});
it("should still attempt to revoke refresh token when access token revocation fails", async () => {
// fail once, then succeed
fetchMock
.postOnce(
metadata.revocation_endpoint,
{
status: 404,
},
{ overwriteRoutes: true, sendAsJson: true },
)
.post(
metadata.revocation_endpoint,
{
status: 200,
},
{ sendAsJson: true },
);
const store = new OidcClientStore(mockClient);
await expect(() => store.revokeTokens(accessToken, refreshToken)).rejects.toThrow(
"Failed to revoke tokens",
);
expect(fetchMock).toHaveFetchedTimes(2, metadata.revocation_endpoint);
expect(OidcClient.prototype.revokeToken).toHaveBeenCalledWith(accessToken, "access_token");
});
});
});