* Take client creating functionality out of MatrixClientPeg Because all sorts of things import MatrixClientPeg and this means they pull in all manner of things related to creating a client, when all they need is to get the current one (and specifically it fixes the import cycle that means I can't add my test). * Remove fake indexeddb as it seems like the tests didn't actually see the fake indexeddb before (somehow) but now do, and are failing because they have that but no postmessage. It feels like the right solution is for these tests to not need indexeddb. * Don't mock a refresh token The tests don't mock out enough for the token refreshing setup to work, it wa somehow always ending up as null previously and now wasn't, at which point it broke, so just make it actually unset. * Move formatter instatiation to lazy rather than eagerly at parse time, as this apparently shifted one test to hit this import cycle instead. * Tests for room name generator
1013 lines
43 KiB
TypeScript
1013 lines
43 KiB
TypeScript
/*
|
|
Copyright 2024 New Vector Ltd.
|
|
Copyright 2023 The Matrix.org Foundation C.I.C.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE files in the repository root for full details.
|
|
*/
|
|
|
|
import { Crypto } from "@peculiar/webcrypto";
|
|
import { logger } from "matrix-js-sdk/src/logger";
|
|
import * as MatrixJs from "matrix-js-sdk/src/matrix";
|
|
import { decodeBase64, encodeUnpaddedBase64 } from "matrix-js-sdk/src/matrix";
|
|
import * as encryptAESSecretStorageItemModule from "matrix-js-sdk/src/utils/encryptAESSecretStorageItem";
|
|
import { mocked, type MockedObject } from "jest-mock-vitest-adapter";
|
|
import fetchMock from "@fetch-mock/jest";
|
|
|
|
import StorageEvictedDialog from "../../src/components/views/dialogs/StorageEvictedDialog";
|
|
import * as Lifecycle from "../../src/Lifecycle";
|
|
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
|
import Modal from "../../src/Modal";
|
|
import * as StorageAccess from "../../src/utils/StorageAccess";
|
|
import { idbSave } from "../../src/utils/StorageAccess";
|
|
import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser, mockPlatformPeg } from "../test-utils";
|
|
import { OidcClientStore } from "../../src/stores/oidc/OidcClientStore";
|
|
import { makeDelegatedAuthConfig } from "../test-utils/oidc";
|
|
import { Action } from "../../src/dispatcher/actions";
|
|
import PlatformPeg from "../../src/PlatformPeg";
|
|
import { persistAccessTokenInStorage, persistRefreshTokenInStorage } from "../../src/utils/tokens/tokens";
|
|
import { encryptPickleKey } from "../../src/utils/tokens/pickling";
|
|
import * as StorageManager from "../../src/utils/StorageManager.ts";
|
|
import type BasePlatform from "../../src/BasePlatform.ts";
|
|
import * as createMatrixClientModule from "../../src/utils/createMatrixClient";
|
|
|
|
const { logout, restoreSessionFromStorage, setLoggedIn } = Lifecycle;
|
|
|
|
const webCrypto = new Crypto();
|
|
|
|
const windowCrypto = window.crypto;
|
|
|
|
describe("Lifecycle", () => {
|
|
const homeserverUrl = "https://domain";
|
|
const identityServerUrl = "https://is.org";
|
|
const userId = "@alice:domain";
|
|
const deviceId = "abc123";
|
|
const accessToken = "test-access-token";
|
|
|
|
let mockPlatform: MockedObject<BasePlatform>;
|
|
|
|
const realLocalStorage = global.localStorage;
|
|
|
|
let mockClient!: MockedObject<MatrixJs.MatrixClient>;
|
|
|
|
beforeEach(() => {
|
|
jest.restoreAllMocks();
|
|
mockPlatform = mockPlatformPeg();
|
|
mockClient = getMockClientWithEventEmitter({
|
|
...mockClientMethodsUser(),
|
|
stopClient: jest.fn(),
|
|
removeAllListeners: jest.fn(),
|
|
clearStores: jest.fn(),
|
|
getAccountData: jest.fn(),
|
|
getDeviceId: jest.fn().mockReturnValue(deviceId),
|
|
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(),
|
|
setGuest: jest.fn(),
|
|
setNotifTimelineSet: jest.fn(),
|
|
});
|
|
// stub this
|
|
jest.spyOn(MatrixClientPeg, "set").mockImplementation(() => {});
|
|
jest.spyOn(MatrixClientPeg, "start").mockResolvedValue(undefined);
|
|
|
|
// reset any mocking
|
|
// @ts-ignore mocking
|
|
delete global.localStorage;
|
|
global.localStorage = realLocalStorage;
|
|
|
|
// @ts-ignore mocking
|
|
delete window.crypto;
|
|
window.crypto = webCrypto;
|
|
|
|
jest.spyOn(encryptAESSecretStorageItemModule, "default").mockRestore();
|
|
});
|
|
|
|
afterAll(() => {
|
|
// @ts-ignore unmocking
|
|
delete window.crypto;
|
|
window.crypto = windowCrypto;
|
|
});
|
|
|
|
const initIdbMock = (mockStore: Record<string, Record<string, unknown>> = {}): void => {
|
|
jest.spyOn(StorageAccess, "idbLoad")
|
|
.mockClear()
|
|
.mockImplementation(
|
|
// @ts-ignore mock type
|
|
async (table: string, key: string) => mockStore[table]?.[key] ?? null,
|
|
);
|
|
jest.spyOn(StorageAccess, "idbSave")
|
|
.mockClear()
|
|
.mockImplementation(
|
|
// @ts-ignore mock type
|
|
async (tableKey: string, key: string, value: unknown) => {
|
|
const table = mockStore[tableKey] || {};
|
|
table[key as string] = value;
|
|
mockStore[tableKey] = table;
|
|
},
|
|
);
|
|
jest.spyOn(StorageAccess, "idbDelete")
|
|
.mockClear()
|
|
.mockImplementation(async (tableKey: string, key: string | string[]) => {
|
|
const table = mockStore[tableKey];
|
|
delete table?.[key as string];
|
|
});
|
|
jest.spyOn(StorageAccess, "idbClear")
|
|
.mockClear()
|
|
.mockImplementation(async (tableKey: string) => {
|
|
mockStore[tableKey] = {};
|
|
});
|
|
};
|
|
|
|
const localStorageSession: Record<string, string> = {
|
|
mx_hs_url: homeserverUrl,
|
|
mx_is_url: identityServerUrl,
|
|
mx_user_id: userId,
|
|
mx_device_id: deviceId,
|
|
mx_oidc_token_issuer: "test-issuer.dummy",
|
|
mx_oidc_client_id: "test-client-id",
|
|
};
|
|
const idbStorageSession = {
|
|
account: {
|
|
mx_access_token: accessToken,
|
|
},
|
|
};
|
|
const credentials = {
|
|
homeserverUrl,
|
|
identityServerUrl,
|
|
userId,
|
|
deviceId,
|
|
accessToken,
|
|
};
|
|
|
|
const refreshToken = "test-refresh-token";
|
|
|
|
const encryptedTokenShapedObject = {
|
|
ciphertext: expect.any(String),
|
|
iv: expect.any(String),
|
|
mac: expect.any(String),
|
|
};
|
|
|
|
describe("loadSession", () => {
|
|
beforeEach(() => {
|
|
// stub this out
|
|
jest.spyOn(Modal, "createDialog").mockReturnValue(
|
|
// @ts-ignore allow bad mock
|
|
{ finished: Promise.resolve([true]) },
|
|
);
|
|
});
|
|
|
|
it("should not show any error dialog when checkConsistency throws but abortSignal has triggered", async () => {
|
|
jest.spyOn(StorageManager, "checkConsistency").mockRejectedValue(new Error("test error"));
|
|
|
|
const abortController = new AbortController();
|
|
const prom = Lifecycle.loadSession({
|
|
enableGuest: true,
|
|
guestHsUrl: "https://guest.server",
|
|
urlParams: { guest: { guest_user_id: "a", guest_access_token: "b" } },
|
|
abortSignal: abortController.signal,
|
|
});
|
|
abortController.abort();
|
|
await expect(prom).resolves.toBeFalsy();
|
|
|
|
expect(Modal.createDialog).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("restoreSessionFromStorage()", () => {
|
|
beforeEach(() => {
|
|
initIdbMock();
|
|
|
|
jest.clearAllMocks();
|
|
jest.spyOn(logger, "log").mockClear();
|
|
|
|
jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
|
|
jest.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient);
|
|
|
|
// stub this out
|
|
jest.spyOn(Modal, "createDialog").mockReturnValue(
|
|
// @ts-ignore allow bad mock
|
|
{ finished: Promise.resolve([true]) },
|
|
);
|
|
});
|
|
|
|
it("should return false when localStorage is not available", async () => {
|
|
// @ts-ignore dirty mocking
|
|
delete global.localStorage;
|
|
// @ts-ignore dirty mocking
|
|
global.localStorage = undefined;
|
|
|
|
expect(await restoreSessionFromStorage()).toEqual(false);
|
|
});
|
|
|
|
it("should return false when no session data is found in local storage", async () => {
|
|
expect(await restoreSessionFromStorage()).toEqual(false);
|
|
expect(logger.log).toHaveBeenCalledWith("No previous session found.");
|
|
});
|
|
|
|
it("should abort login when we expect to find an access token but don't", async () => {
|
|
localStorage.setItem("mx_has_access_token", "true");
|
|
|
|
await expect(() => restoreSessionFromStorage()).rejects.toThrow();
|
|
expect(Modal.createDialog).toHaveBeenCalledWith(StorageEvictedDialog);
|
|
expect(mockClient.clearStores).toHaveBeenCalled();
|
|
});
|
|
|
|
describe("when session is found in storage", () => {
|
|
describe("guest account", () => {
|
|
beforeEach(() => {
|
|
localStorage.setItem("mx_is_guest", "true");
|
|
for (const key in localStorageSession) {
|
|
localStorage.setItem(key, localStorageSession[key]);
|
|
}
|
|
initIdbMock(idbStorageSession);
|
|
});
|
|
|
|
it("should ignore guest accounts when ignoreGuest is true", async () => {
|
|
expect(await restoreSessionFromStorage({ ignoreGuest: true })).toEqual(false);
|
|
expect(logger.log).toHaveBeenCalledWith(`Ignoring stored guest account: ${userId}`);
|
|
});
|
|
|
|
it("should restore guest accounts when ignoreGuest is false", async () => {
|
|
expect(await restoreSessionFromStorage({ ignoreGuest: false })).toEqual(true);
|
|
|
|
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId,
|
|
guest: true,
|
|
}),
|
|
undefined,
|
|
);
|
|
expect(localStorage.getItem("mx_is_guest")).toEqual("true");
|
|
});
|
|
});
|
|
|
|
describe("without a pickle key", () => {
|
|
beforeEach(() => {
|
|
for (const key in localStorageSession) {
|
|
localStorage.setItem(key, localStorageSession[key]);
|
|
}
|
|
initIdbMock(idbStorageSession);
|
|
});
|
|
|
|
it("should persist credentials", async () => {
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
expect(localStorage.getItem("mx_user_id")).toEqual(userId);
|
|
expect(localStorage.getItem("mx_has_access_token")).toEqual("true");
|
|
expect(localStorage.getItem("mx_is_guest")).toEqual("false");
|
|
expect(localStorage.getItem("mx_device_id")).toEqual(deviceId);
|
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
|
|
// dont put accessToken in localstorage when we have idb
|
|
expect(localStorage.getItem("mx_access_token")).not.toEqual(accessToken);
|
|
});
|
|
|
|
it("should persist access token when idb is not available", async () => {
|
|
jest.spyOn(StorageAccess, "idbSave").mockRejectedValue("oups");
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
|
|
// put accessToken in localstorage as fallback
|
|
expect(localStorage.getItem("mx_access_token")).toEqual(accessToken);
|
|
});
|
|
|
|
it("should create and start new matrix client with credentials", async () => {
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
|
|
{
|
|
userId,
|
|
accessToken,
|
|
homeserverUrl,
|
|
identityServerUrl,
|
|
deviceId,
|
|
freshLogin: false,
|
|
guest: false,
|
|
pickleKey: undefined,
|
|
},
|
|
undefined,
|
|
);
|
|
|
|
expect(MatrixClientPeg.start).toHaveBeenCalledWith({});
|
|
});
|
|
|
|
it("should remove fresh login flag from session storage", async () => {
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
expect(sessionStorage.getItem("mx_fresh_login")).toBeFalsy();
|
|
});
|
|
|
|
it("should start matrix client", async () => {
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
expect(MatrixClientPeg.start).toHaveBeenCalled();
|
|
});
|
|
|
|
describe("with a refresh token", () => {
|
|
beforeEach(() => {
|
|
localStorage.setItem("mx_refresh_token", refreshToken);
|
|
for (const key in localStorageSession) {
|
|
localStorage.setItem(key, localStorageSession[key]);
|
|
}
|
|
initIdbMock(idbStorageSession);
|
|
});
|
|
|
|
it("should persist credentials", async () => {
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
// refresh token from storage is re-persisted
|
|
expect(localStorage.getItem("mx_has_refresh_token")).toEqual("true");
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken);
|
|
});
|
|
|
|
it("should create new matrix client with credentials", async () => {
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
|
|
{
|
|
userId,
|
|
accessToken,
|
|
// refreshToken included in credentials
|
|
refreshToken,
|
|
homeserverUrl,
|
|
identityServerUrl,
|
|
deviceId,
|
|
freshLogin: false,
|
|
guest: false,
|
|
pickleKey: undefined,
|
|
},
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("with a normal pickle key", () => {
|
|
let pickleKey: string;
|
|
|
|
beforeEach(async () => {
|
|
for (const key in localStorageSession) {
|
|
localStorage.setItem(key, localStorageSession[key]);
|
|
}
|
|
initIdbMock({});
|
|
|
|
// Create a pickle key, and store it, encrypted, in IDB.
|
|
pickleKey = (await PlatformPeg.get()!.createPickleKey(credentials.userId, credentials.deviceId))!;
|
|
|
|
// Indicate that we should have a pickle key
|
|
localStorage.setItem("mx_has_pickle_key", "true");
|
|
|
|
await persistAccessTokenInStorage(credentials.accessToken, pickleKey);
|
|
});
|
|
|
|
it("should persist credentials", async () => {
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
expect(localStorage.getItem("mx_has_access_token")).toEqual("true");
|
|
|
|
// token encrypted and persisted
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
|
|
"account",
|
|
"mx_access_token",
|
|
encryptedTokenShapedObject,
|
|
);
|
|
});
|
|
|
|
it("should persist access token when idb is not available", async () => {
|
|
// dont fail for pickle key persist
|
|
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
|
|
async (table: string, key: string | string[]) => {
|
|
if (table === "account" && key === "mx_access_token") {
|
|
throw new Error("oups");
|
|
}
|
|
},
|
|
);
|
|
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
|
|
"account",
|
|
"mx_access_token",
|
|
encryptedTokenShapedObject,
|
|
);
|
|
// put accessToken in localstorage as fallback
|
|
expect(localStorage.getItem("mx_access_token")).toEqual(accessToken);
|
|
});
|
|
|
|
it("should create and start new matrix client with credentials", async () => {
|
|
// Check that the rust crypto key is as expected. We have to do this during the call, as
|
|
// the buffer is cleared afterwards.
|
|
mocked(MatrixClientPeg.start).mockImplementation(async (opts) => {
|
|
expect(opts?.rustCryptoStoreKey).toEqual(decodeBase64(pickleKey));
|
|
});
|
|
|
|
// Perform the restore
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
// Ensure that the expected calls were made
|
|
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
|
|
{
|
|
userId,
|
|
// decrypted accessToken
|
|
accessToken,
|
|
homeserverUrl,
|
|
identityServerUrl,
|
|
deviceId,
|
|
freshLogin: false,
|
|
guest: false,
|
|
pickleKey,
|
|
},
|
|
undefined,
|
|
);
|
|
|
|
expect(MatrixClientPeg.start).toHaveBeenCalledWith({ rustCryptoStoreKey: expect.any(Uint8Array) });
|
|
});
|
|
|
|
describe("with a refresh token", () => {
|
|
beforeEach(async () => {
|
|
await persistRefreshTokenInStorage(refreshToken, pickleKey);
|
|
});
|
|
|
|
it("should persist credentials", async () => {
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
// refresh token from storage is re-persisted
|
|
expect(localStorage.getItem("mx_has_refresh_token")).toEqual("true");
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
|
|
"account",
|
|
"mx_refresh_token",
|
|
encryptedTokenShapedObject,
|
|
);
|
|
});
|
|
|
|
it("should create new matrix client with credentials", async () => {
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
|
|
{
|
|
userId,
|
|
accessToken,
|
|
// refreshToken included in credentials
|
|
refreshToken,
|
|
homeserverUrl,
|
|
identityServerUrl,
|
|
deviceId,
|
|
freshLogin: false,
|
|
guest: false,
|
|
pickleKey: pickleKey,
|
|
},
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("with a non-standard pickle key", () => {
|
|
// Most pickle keys are 43 bytes of base64. Test what happens when it is something else.
|
|
let pickleKey: string;
|
|
|
|
beforeEach(async () => {
|
|
for (const key in localStorageSession) {
|
|
localStorage.setItem(key, localStorageSession[key]);
|
|
}
|
|
initIdbMock({});
|
|
|
|
// Generate the pickle key. I don't *think* it's possible for there to be a pickle key
|
|
// which is not some amount of base64.
|
|
const rawPickleKey = new Uint8Array(10);
|
|
crypto.getRandomValues(rawPickleKey);
|
|
pickleKey = encodeUnpaddedBase64(rawPickleKey);
|
|
|
|
// Store it, encrypted, in the db
|
|
await idbSave(
|
|
"pickleKey",
|
|
[userId, deviceId],
|
|
(await encryptPickleKey(rawPickleKey, userId, deviceId))!,
|
|
);
|
|
|
|
// Indicate that we should have a pickle key
|
|
localStorage.setItem("mx_has_pickle_key", "true");
|
|
|
|
await persistAccessTokenInStorage(credentials.accessToken, pickleKey);
|
|
});
|
|
|
|
it("should create and start new matrix client with credentials", async () => {
|
|
// Perform the restore
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
|
|
// Ensure that the expected calls were made
|
|
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
|
|
{
|
|
userId,
|
|
// decrypted accessToken
|
|
accessToken,
|
|
homeserverUrl,
|
|
identityServerUrl,
|
|
deviceId,
|
|
freshLogin: false,
|
|
guest: false,
|
|
pickleKey,
|
|
},
|
|
undefined,
|
|
);
|
|
|
|
expect(MatrixClientPeg.start).toHaveBeenCalledWith({ rustCryptoStorePassword: pickleKey });
|
|
});
|
|
});
|
|
|
|
it("should proceed if server is not accessible", async () => {
|
|
for (const key in localStorageSession) {
|
|
localStorage.setItem(key, localStorageSession[key]);
|
|
}
|
|
initIdbMock(idbStorageSession);
|
|
mockClient.isVersionSupported.mockRejectedValue(new Error("Oh, noes, the server is down!"));
|
|
|
|
expect(await restoreSessionFromStorage()).toEqual(true);
|
|
});
|
|
|
|
it("should throw if the token was persisted with a pickle key but there is no pickle key available now", async () => {
|
|
for (const key in localStorageSession) {
|
|
localStorage.setItem(key, localStorageSession[key]);
|
|
}
|
|
initIdbMock({});
|
|
|
|
// Create a pickle key, and store it, encrypted, in IDB.
|
|
const pickleKey = (await PlatformPeg.get()!.createPickleKey(credentials.userId, credentials.deviceId))!;
|
|
localStorage.setItem("mx_has_pickle_key", "true");
|
|
await persistAccessTokenInStorage(credentials.accessToken, pickleKey);
|
|
|
|
// Now destroy the pickle key
|
|
await PlatformPeg.get()!.destroyPickleKey(credentials.userId, credentials.deviceId);
|
|
|
|
await expect(restoreSessionFromStorage()).rejects.toThrow(
|
|
"Error decrypting secret access_token: no pickle key found.",
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("setLoggedIn()", () => {
|
|
beforeEach(() => {
|
|
initIdbMock();
|
|
|
|
jest.clearAllMocks();
|
|
jest.spyOn(logger, "log").mockClear();
|
|
|
|
jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
|
|
// remove any mock implementations
|
|
jest.spyOn(mockPlatform, "createPickleKey").mockRestore();
|
|
// but still spy and call through
|
|
jest.spyOn(mockPlatform, "createPickleKey");
|
|
});
|
|
|
|
const refreshToken = "test-refresh-token";
|
|
|
|
it("should remove fresh login flag from session storage", async () => {
|
|
await setLoggedIn(credentials);
|
|
|
|
expect(sessionStorage.getItem("mx_fresh_login")).toBeFalsy();
|
|
});
|
|
|
|
it("should start matrix client", async () => {
|
|
await setLoggedIn(credentials);
|
|
|
|
expect(MatrixClientPeg.start).toHaveBeenCalled();
|
|
});
|
|
|
|
describe("after a soft-logout", () => {
|
|
beforeEach(async () => {
|
|
await setLoggedIn(credentials);
|
|
localStorage.setItem("mx_soft_logout", "true");
|
|
});
|
|
|
|
it("should not clear the storage if device is the same", async () => {
|
|
await Lifecycle.hydrateSession(credentials);
|
|
|
|
expect(localStorage.getItem("mx_soft_logout")).toBeFalsy();
|
|
expect(mockClient.getUserId).toHaveReturnedWith(userId);
|
|
expect(mockClient.getDeviceId).toHaveReturnedWith(deviceId);
|
|
expect(mockClient.clearStores).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should clear the storage if device is not the same", async () => {
|
|
const fakeCredentials = {
|
|
homeserverUrl,
|
|
identityServerUrl,
|
|
userId: "@bob:domain",
|
|
deviceId,
|
|
accessToken,
|
|
};
|
|
await Lifecycle.hydrateSession(fakeCredentials);
|
|
|
|
expect(localStorage.getItem("mx_soft_logout")).toBeFalsy();
|
|
expect(mockClient.getUserId).toHaveReturnedWith(userId);
|
|
expect(mockClient.getDeviceId).toHaveReturnedWith(deviceId);
|
|
expect(mockClient.clearStores).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("without a pickle key", () => {
|
|
beforeEach(() => {
|
|
jest.spyOn(mockPlatform, "createPickleKey").mockResolvedValue(null);
|
|
jest.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient);
|
|
});
|
|
|
|
it("should persist credentials", async () => {
|
|
await setLoggedIn(credentials);
|
|
|
|
expect(localStorage.getItem("mx_user_id")).toEqual(userId);
|
|
expect(localStorage.getItem("mx_has_access_token")).toEqual("true");
|
|
expect(localStorage.getItem("mx_is_guest")).toEqual("false");
|
|
expect(localStorage.getItem("mx_device_id")).toEqual(deviceId);
|
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
|
|
// dont put accessToken in localstorage when we have idb
|
|
expect(localStorage.getItem("mx_access_token")).not.toEqual(accessToken);
|
|
});
|
|
|
|
it("should persist a refreshToken when present", async () => {
|
|
localStorage.setItem("mx_oidc_token_issuer", "test-issuer.dummy");
|
|
localStorage.setItem("mx_oidc_client_id", "test-client-id");
|
|
|
|
await setLoggedIn({
|
|
...credentials,
|
|
refreshToken,
|
|
});
|
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken);
|
|
// dont put accessToken in localstorage when we have idb
|
|
expect(localStorage.getItem("mx_access_token")).not.toEqual(accessToken);
|
|
});
|
|
|
|
it("should remove any access token from storage when there is none in credentials and idb save fails", async () => {
|
|
jest.spyOn(StorageAccess, "idbSave").mockRejectedValue("oups");
|
|
await setLoggedIn({
|
|
...credentials,
|
|
// @ts-ignore
|
|
accessToken: undefined,
|
|
});
|
|
|
|
expect(localStorage.getItem("mx_has_access_token")).toBeFalsy();
|
|
expect(localStorage.getItem("mx_access_token")).toBeFalsy();
|
|
});
|
|
|
|
it("should clear stores", async () => {
|
|
await setLoggedIn(credentials);
|
|
|
|
expect(StorageAccess.idbClear).toHaveBeenCalledWith("account");
|
|
expect(sessionStorage.length).toBe(0);
|
|
expect(mockClient.clearStores).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should create new matrix client with credentials", async () => {
|
|
expect(await setLoggedIn(credentials)).toEqual(mockClient);
|
|
|
|
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
|
|
{
|
|
userId,
|
|
accessToken,
|
|
homeserverUrl,
|
|
identityServerUrl,
|
|
deviceId,
|
|
freshLogin: true,
|
|
guest: false,
|
|
pickleKey: undefined,
|
|
},
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("with a pickle key", () => {
|
|
it("should not create a pickle key when credentials do not include deviceId", async () => {
|
|
await setLoggedIn({
|
|
...credentials,
|
|
deviceId: undefined,
|
|
});
|
|
|
|
// unpickled access token saved
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
|
|
expect(mockPlatform.createPickleKey).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates a pickle key with userId and deviceId", async () => {
|
|
await setLoggedIn(credentials);
|
|
|
|
expect(mockPlatform.createPickleKey).toHaveBeenCalledWith(userId, deviceId);
|
|
});
|
|
|
|
it("should persist credentials", async () => {
|
|
await setLoggedIn(credentials);
|
|
|
|
expect(localStorage.getItem("mx_user_id")).toEqual(userId);
|
|
expect(localStorage.getItem("mx_has_access_token")).toEqual("true");
|
|
expect(localStorage.getItem("mx_is_guest")).toEqual("false");
|
|
expect(localStorage.getItem("mx_device_id")).toEqual(deviceId);
|
|
|
|
expect(localStorage.getItem("mx_has_pickle_key")).toEqual("true");
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
|
|
"account",
|
|
"mx_access_token",
|
|
encryptedTokenShapedObject,
|
|
);
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("pickleKey", [userId, deviceId], expect.any(Object));
|
|
// dont put accessToken in localstorage when we have idb
|
|
expect(localStorage.getItem("mx_access_token")).not.toEqual(accessToken);
|
|
});
|
|
|
|
it("should persist token when encrypting the token fails", async () => {
|
|
jest.spyOn(encryptAESSecretStorageItemModule, "default").mockRejectedValue("MOCK REJECT ENCRYPTAES");
|
|
await setLoggedIn(credentials);
|
|
|
|
// persist the unencrypted token
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
|
|
});
|
|
|
|
it("should persist token in localStorage when idb fails to save token", async () => {
|
|
// dont fail for pickle key persist
|
|
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
|
|
async (table: string, key: string | string[]) => {
|
|
if (table === "account" && key === "mx_access_token") {
|
|
throw new Error("oups");
|
|
}
|
|
},
|
|
);
|
|
await setLoggedIn(credentials);
|
|
|
|
// put plain accessToken in localstorage when we dont have idb
|
|
expect(localStorage.getItem("mx_access_token")).toEqual(accessToken);
|
|
});
|
|
|
|
it("should remove any access token from storage when there is none in credentials and idb save fails", async () => {
|
|
// dont fail for pickle key persist
|
|
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
|
|
async (table: string, key: string | string[]) => {
|
|
if (table === "account" && key === "mx_access_token") {
|
|
throw new Error("oups");
|
|
}
|
|
},
|
|
);
|
|
await setLoggedIn({
|
|
...credentials,
|
|
// @ts-ignore
|
|
accessToken: undefined,
|
|
});
|
|
|
|
expect(localStorage.getItem("mx_has_access_token")).toBeFalsy();
|
|
expect(localStorage.getItem("mx_access_token")).toBeFalsy();
|
|
});
|
|
|
|
it("should create new matrix client with credentials", async () => {
|
|
jest.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient);
|
|
expect(await setLoggedIn(credentials)).toEqual(mockClient);
|
|
|
|
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
|
|
{
|
|
userId,
|
|
accessToken,
|
|
homeserverUrl,
|
|
identityServerUrl,
|
|
deviceId,
|
|
freshLogin: true,
|
|
guest: false,
|
|
pickleKey: expect.any(String),
|
|
},
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
// XXX: these tests are broken, Lifecycle.setLoggedIn does not work with OIDC and its token refreshers due to clearing storage
|
|
describe.skip("when authenticated via OIDC native flow", () => {
|
|
const clientId = "test-client-id";
|
|
const issuer = "https://auth.com/";
|
|
|
|
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
|
|
const idToken =
|
|
"eyJhbGciOiJSUzI1NiIsImtpZCI6Imh4ZEhXb0Y5bW4ifQ.eyJzdWIiOiIwMUhQUDJGU0JZREU5UDlFTU04REQ3V1pIUiIsImlzcyI6Imh0dHBzOi8vYXV0aC1vaWRjLmxhYi5lbGVtZW50LmRldi8iLCJpYXQiOjE3MTUwNzE5ODUsImF1dGhfdGltZSI6MTcwNzk5MDMxMiwiY19oYXNoIjoidGt5R1RhUjU5aTk3YXoyTU4yMGdidyIsImV4cCI6MTcxNTA3NTU4NSwibm9uY2UiOiJxaXhwM0hFMmVaIiwiYXVkIjoiMDFIWDk0Mlg3QTg3REgxRUs2UDRaNjI4WEciLCJhdF9oYXNoIjoiNFlFUjdPRlVKTmRTeEVHV2hJUDlnZyJ9.HxODneXvSTfWB5Vc4cf7b8GiN2gdwUuTiyVqZuupWske2HkZiJZUt5Lsxg9BW3gz28POkE0Ln17snlkmy02B_AD3DQxKOOxQCzIIARHdfFvZxgGWsMdFcVQZDW7rtXcqgj-SpVaUQ_8acsgxSrz_DF2o0O4tto0PT6wVUiw8KlBmgWTscWPeAWe-39T-8EiQ8Wi16h6oSPcz2NzOQ7eOM_S9fDkOorgcBkRGLl1nrahrPSdWJSGAeruk5mX4YxN714YThFDyEA2t9YmKpjaiSQ2tT-Xkd7tgsZqeirNs2ni9mIiFX3bRX6t2AhUNzA7MaX9ZyizKGa6go3BESO_oDg";
|
|
|
|
beforeEach(() => {
|
|
fetchMock.get(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`, delegatedAuthConfig);
|
|
fetchMock.get(`${delegatedAuthConfig.issuer}jwks`, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
keys: [],
|
|
});
|
|
|
|
// set values in local storage as they would be after a successful oidc authentication
|
|
localStorage.setItem("mx_oidc_client_id", clientId);
|
|
localStorage.setItem("mx_oidc_token_issuer", issuer);
|
|
localStorage.setItem("mx_oidc_id_token", idToken);
|
|
});
|
|
|
|
it("should not try to create a token refresher without a refresh token", async () => {
|
|
await setLoggedIn(credentials);
|
|
|
|
// didn't try to initialise token refresher
|
|
expect(fetchMock).toHaveFetchedTimes(
|
|
0,
|
|
`${delegatedAuthConfig.issuer}.well-known/openid-configuration`,
|
|
);
|
|
});
|
|
|
|
it("should not try to create a token refresher without a deviceId", async () => {
|
|
await expect(
|
|
setLoggedIn({
|
|
...credentials,
|
|
refreshToken,
|
|
deviceId: undefined,
|
|
}),
|
|
).rejects.toThrow("Expected deviceId in user credentials.");
|
|
|
|
// didn't try to initialise token refresher
|
|
expect(fetchMock).toHaveFetchedTimes(
|
|
0,
|
|
`${delegatedAuthConfig.issuer}.well-known/openid-configuration`,
|
|
);
|
|
});
|
|
|
|
it("should not try to create a token refresher without an issuer in session storage", async () => {
|
|
localStorage.removeItem("mx_oidc_token_issuer");
|
|
await expect(
|
|
setLoggedIn({
|
|
...credentials,
|
|
refreshToken,
|
|
}),
|
|
).rejects.toThrow("Cannot create an OIDC token refresher as no stored OIDC token issuer was found.");
|
|
|
|
// didn't try to initialise token refresher
|
|
expect(fetchMock).toHaveFetchedTimes(
|
|
0,
|
|
`${delegatedAuthConfig.issuer}.well-known/openid-configuration`,
|
|
);
|
|
});
|
|
|
|
it("should create a client with a tokenRefreshFunction", async () => {
|
|
expect(
|
|
await setLoggedIn({
|
|
...credentials,
|
|
refreshToken,
|
|
}),
|
|
).toEqual(mockClient);
|
|
|
|
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
accessToken,
|
|
refreshToken,
|
|
}),
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
|
|
it("should create a client when creating token refresher fails", async () => {
|
|
// create invalid value in local storage for a malformed oidc authentication
|
|
localStorage.removeItem("mx_oidc_client_id");
|
|
|
|
// succeeded
|
|
expect(
|
|
await setLoggedIn({
|
|
...credentials,
|
|
refreshToken,
|
|
}),
|
|
).toEqual(mockClient);
|
|
|
|
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
accessToken,
|
|
refreshToken,
|
|
}),
|
|
// no token refresh function
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("overwritelogin", () => {
|
|
beforeEach(async () => {
|
|
jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
|
|
});
|
|
|
|
it("should replace the current login with a new one", async () => {
|
|
const stopSpy = jest.spyOn(mockClient, "stopClient").mockReturnValue(undefined);
|
|
jest.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient);
|
|
const dis = window.mxDispatcher;
|
|
|
|
const firstLoginEvent: Promise<void> = new Promise((resolve) => {
|
|
dis.register(({ action }) => {
|
|
if (action === Action.OnLoggedIn) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
// set a logged in state
|
|
await setLoggedIn(credentials);
|
|
|
|
await firstLoginEvent;
|
|
|
|
expect(stopSpy).toHaveBeenCalledTimes(1);
|
|
// important the overwrite action should not call unset before replacing.
|
|
// So spy on it and make sure it's not called.
|
|
jest.spyOn(MatrixClientPeg, "unset").mockReturnValue(undefined);
|
|
|
|
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId,
|
|
}),
|
|
undefined,
|
|
);
|
|
|
|
const otherCredentials = {
|
|
...credentials,
|
|
userId: "@bob:server.org",
|
|
deviceId: "def456",
|
|
};
|
|
|
|
const secondLoginEvent: Promise<void> = new Promise((resolve) => {
|
|
dis.register(({ action }) => {
|
|
if (action === Action.OnLoggedIn) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Trigger the overwrite login action
|
|
dis.dispatch(
|
|
{
|
|
action: "overwrite_login",
|
|
credentials: otherCredentials,
|
|
},
|
|
true,
|
|
);
|
|
|
|
await secondLoginEvent;
|
|
// the client should have been stopped
|
|
expect(stopSpy).toHaveBeenCalledTimes(2);
|
|
|
|
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: otherCredentials.userId,
|
|
}),
|
|
undefined,
|
|
);
|
|
|
|
expect(MatrixClientPeg.unset).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|