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>
This commit is contained in:
Kerry 2023-10-16 10:35:25 +13:00 committed by GitHub
parent fe3ad78a02
commit 84ca519b3f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 258 additions and 44 deletions

View file

@ -19,15 +19,17 @@ import { logger } from "matrix-js-sdk/src/logger";
import * as MatrixJs from "matrix-js-sdk/src/matrix";
import { setCrypto } from "matrix-js-sdk/src/crypto/crypto";
import * as MatrixCryptoAes from "matrix-js-sdk/src/crypto/aes";
import { MockedObject } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import StorageEvictedDialog from "../src/components/views/dialogs/StorageEvictedDialog";
import { restoreFromLocalStorage, setLoggedIn } from "../src/Lifecycle";
import { logout, restoreFromLocalStorage, setLoggedIn } from "../src/Lifecycle";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
import Modal from "../src/Modal";
import * as StorageManager from "../src/utils/StorageManager";
import { getMockClientWithEventEmitter, mockPlatformPeg } from "./test-utils";
import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser, mockPlatformPeg } from "./test-utils";
import ToastStore from "../src/stores/ToastStore";
import { OidcClientStore } from "../src/stores/oidc/OidcClientStore";
import { makeDelegatedAuthConfig } from "./test-utils/oidc";
import { persistOidcAuthenticatedSettings } from "../src/utils/oidc/persistOidcSettings";
@ -40,24 +42,29 @@ describe("Lifecycle", () => {
const realLocalStorage = global.localStorage;
const mockClient = getMockClientWithEventEmitter({
stopClient: jest.fn(),
removeAllListeners: jest.fn(),
clearStores: jest.fn(),
getAccountData: jest.fn(),
getUserId: jest.fn(),
getDeviceId: jest.fn(),
isVersionSupported: jest.fn().mockResolvedValue(true),
getCrypto: jest.fn(),
getClientWellKnown: jest.fn(),
getThirdpartyProtocols: jest.fn(),
store: {
destroy: jest.fn(),
},
getVersions: jest.fn().mockResolvedValue({ versions: ["v1.1"] }),
});
let mockClient!: MockedObject<MatrixJs.MatrixClient>;
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
stopClient: jest.fn(),
removeAllListeners: jest.fn(),
clearStores: jest.fn(),
getAccountData: jest.fn(),
getDeviceId: jest.fn(),
isVersionSupported: jest.fn().mockResolvedValue(true),
getCrypto: jest.fn(),
getClientWellKnown: jest.fn(),
waitForClientWellKnown: jest.fn(),
getThirdpartyProtocols: jest.fn(),
store: {
destroy: jest.fn(),
},
getVersions: jest.fn().mockResolvedValue({ versions: ["v1.1"] }),
logout: jest.fn().mockResolvedValue(undefined),
getAccessToken: jest.fn(),
getRefreshToken: jest.fn(),
});
// stub this
jest.spyOn(MatrixClientPeg, "replaceUsingCreds").mockImplementation(() => {});
jest.spyOn(MatrixClientPeg, "start").mockResolvedValue(undefined);
@ -692,7 +699,7 @@ describe("Lifecycle", () => {
beforeEach(() => {
// mock oidc config for oidc client initialisation
mockClient.getClientWellKnown.mockReturnValue({
mockClient.waitForClientWellKnown.mockResolvedValue({
"m.authentication": {
issuer: issuer,
},
@ -776,4 +783,47 @@ describe("Lifecycle", () => {
});
});
});
describe("logout()", () => {
let oidcClientStore!: OidcClientStore;
const accessToken = "test-access-token";
const refreshToken = "test-refresh-token";
beforeEach(() => {
oidcClientStore = new OidcClientStore(mockClient);
// stub
jest.spyOn(oidcClientStore, "revokeTokens").mockResolvedValue(undefined);
mockClient.getAccessToken.mockReturnValue(accessToken);
mockClient.getRefreshToken.mockReturnValue(refreshToken);
});
it("should call logout on the client when oidcClientStore is falsy", async () => {
logout();
await flushPromises();
expect(mockClient.logout).toHaveBeenCalledWith(true);
});
it("should call logout on the client when oidcClientStore.isUserAuthenticatedWithOidc is falsy", async () => {
jest.spyOn(oidcClientStore, "isUserAuthenticatedWithOidc", "get").mockReturnValue(false);
logout(oidcClientStore);
await flushPromises();
expect(mockClient.logout).toHaveBeenCalledWith(true);
expect(oidcClientStore.revokeTokens).not.toHaveBeenCalled();
});
it("should revoke tokens when user is authenticated with oidc", async () => {
jest.spyOn(oidcClientStore, "isUserAuthenticatedWithOidc", "get").mockReturnValue(true);
logout(oidcClientStore);
await flushPromises();
expect(mockClient.logout).not.toHaveBeenCalled();
expect(oidcClientStore.revokeTokens).toHaveBeenCalledWith(accessToken, refreshToken);
});
});
});

View file

@ -14,7 +14,9 @@ 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";
@ -38,7 +40,7 @@ describe("OidcClientStore", () => {
};
const mockClient = getMockClientWithEventEmitter({
getClientWellKnown: jest.fn().mockReturnValue({}),
waitForClientWellKnown: jest.fn().mockResolvedValue({}),
});
beforeEach(() => {
@ -50,13 +52,15 @@ describe("OidcClientStore", () => {
account,
issuer: metadata.issuer,
});
mockClient.getClientWellKnown.mockReturnValue({
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()", () => {
@ -76,7 +80,7 @@ describe("OidcClientStore", () => {
describe("initialising oidcClient", () => {
it("should initialise oidc client from constructor", () => {
mockClient.getClientWellKnown.mockReturnValue(undefined);
mockClient.waitForClientWellKnown.mockResolvedValue(undefined as any);
const store = new OidcClientStore(mockClient);
// started initialising
@ -84,30 +88,33 @@ describe("OidcClientStore", () => {
expect(store.initialisingOidcClientPromise).toBeTruthy();
});
it("should log and return when no client well known is available", async () => {
mockClient.getClientWellKnown.mockReturnValue(undefined);
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);
expect(logger.error).toHaveBeenCalledWith("Cannot initialise OidcClientStore: client well known required.");
// no oidc client
// successfully created oidc client
// @ts-ignore private property
expect(await store.getOidcClient()).toEqual(undefined);
expect(await store.getOidcClient()).toBeTruthy();
});
it("should log and return when no clientId is found in storage", async () => {
jest.spyOn(sessionStorage.__proto__, "getItem").mockImplementation((key) =>
key === "mx_oidc_token_issuer" ? metadata.issuer : null,
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"),
);
// no oidc client
// @ts-ignore private property
expect(await store.getOidcClient()).toEqual(undefined);
});
it("should log and return when discovery and validation fails", async () => {
@ -180,4 +187,77 @@ describe("OidcClientStore", () => {
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");
});
});
});