961 lines
		
	
	
		
			40 KiB
		
	
	
	
		
			TypeScript
		
	
	
			
		
		
	
	
			961 lines
		
	
	
		
			40 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
 | |
| 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, MockedObject } from "jest-mock";
 | |
| import fetchMock from "fetch-mock-jest";
 | |
| 
 | |
| import StorageEvictedDialog from "../../src/components/views/dialogs/StorageEvictedDialog";
 | |
| import { logout, restoreSessionFromStorage, setLoggedIn } 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 { persistOidcAuthenticatedSettings } from "../../src/utils/oidc/persistOidcSettings";
 | |
| 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";
 | |
| 
 | |
| const webCrypto = new Crypto();
 | |
| 
 | |
| const windowCrypto = window.crypto;
 | |
| 
 | |
| describe("Lifecycle", () => {
 | |
|     const mockPlatform = mockPlatformPeg();
 | |
| 
 | |
|     const realLocalStorage = global.localStorage;
 | |
| 
 | |
|     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);
 | |
| 
 | |
|         // 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 initLocalStorageMock = (mockStore: Record<string, unknown> = {}): void => {
 | |
|         jest.spyOn(localStorage.__proto__, "getItem")
 | |
|             .mockClear()
 | |
|             .mockImplementation((key: unknown) => mockStore[key as string] ?? null);
 | |
|         jest.spyOn(localStorage.__proto__, "removeItem")
 | |
|             .mockClear()
 | |
|             .mockImplementation((key: unknown) => {
 | |
|                 const { [key as string]: toRemove, ...newStore } = mockStore;
 | |
|                 mockStore = newStore;
 | |
|                 return toRemove;
 | |
|             });
 | |
|         jest.spyOn(localStorage.__proto__, "setItem")
 | |
|             .mockClear()
 | |
|             .mockImplementation((key: unknown, value: unknown) => {
 | |
|                 mockStore[key as string] = value;
 | |
|             });
 | |
|     };
 | |
| 
 | |
|     const initSessionStorageMock = (mockStore: Record<string, unknown> = {}): void => {
 | |
|         jest.spyOn(sessionStorage.__proto__, "getItem")
 | |
|             .mockClear()
 | |
|             .mockImplementation((key: unknown) => mockStore[key as string] ?? null);
 | |
|         jest.spyOn(sessionStorage.__proto__, "removeItem")
 | |
|             .mockClear()
 | |
|             .mockImplementation((key: unknown) => {
 | |
|                 const { [key as string]: toRemove, ...newStore } = mockStore;
 | |
|                 mockStore = newStore;
 | |
|                 return toRemove;
 | |
|             });
 | |
|         jest.spyOn(sessionStorage.__proto__, "setItem")
 | |
|             .mockClear()
 | |
|             .mockImplementation((key: unknown, value: unknown) => {
 | |
|                 mockStore[key as string] = value;
 | |
|             });
 | |
|         jest.spyOn(sessionStorage.__proto__, "clear").mockClear();
 | |
|     };
 | |
| 
 | |
|     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];
 | |
|             });
 | |
|     };
 | |
| 
 | |
|     const homeserverUrl = "https://server.org";
 | |
|     const identityServerUrl = "https://is.org";
 | |
|     const userId = "@alice:server.org";
 | |
|     const deviceId = "abc123";
 | |
|     const accessToken = "test-access-token";
 | |
|     const localStorageSession = {
 | |
|         mx_hs_url: homeserverUrl,
 | |
|         mx_is_url: identityServerUrl,
 | |
|         mx_user_id: userId,
 | |
|         mx_device_id: deviceId,
 | |
|     };
 | |
|     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("restoreSessionFromStorage()", () => {
 | |
|         beforeEach(() => {
 | |
|             initLocalStorageMock();
 | |
|             initSessionStorageMock();
 | |
|             initIdbMock();
 | |
| 
 | |
|             jest.clearAllMocks();
 | |
|             jest.spyOn(logger, "log").mockClear();
 | |
| 
 | |
|             jest.spyOn(MatrixJs, "createClient").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 () => {
 | |
|             initLocalStorageMock({ 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(() => {
 | |
|                     initLocalStorageMock({ ...localStorageSession, mx_is_guest: "true" });
 | |
|                     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(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
 | |
|                         expect.objectContaining({
 | |
|                             userId,
 | |
|                             guest: true,
 | |
|                         }),
 | |
|                         undefined,
 | |
|                     );
 | |
|                     expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "true");
 | |
|                 });
 | |
|             });
 | |
| 
 | |
|             describe("without a pickle key", () => {
 | |
|                 beforeEach(() => {
 | |
|                     initLocalStorageMock(localStorageSession);
 | |
|                     initIdbMock(idbStorageSession);
 | |
|                 });
 | |
| 
 | |
|                 it("should persist credentials", async () => {
 | |
|                     expect(await restoreSessionFromStorage()).toEqual(true);
 | |
| 
 | |
|                     expect(localStorage.setItem).toHaveBeenCalledWith("mx_user_id", userId);
 | |
|                     expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
 | |
|                     expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
 | |
|                     expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);
 | |
| 
 | |
|                     expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
 | |
|                     // dont put accessToken in localstorage when we have idb
 | |
|                     expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", 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.setItem).toHaveBeenCalledWith("mx_access_token", accessToken);
 | |
|                 });
 | |
| 
 | |
|                 it("should create and start new matrix client with credentials", async () => {
 | |
|                     expect(await restoreSessionFromStorage()).toEqual(true);
 | |
| 
 | |
|                     expect(MatrixClientPeg.replaceUsingCreds).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.removeItem).toHaveBeenCalledWith("mx_fresh_login");
 | |
|                 });
 | |
| 
 | |
|                 it("should start matrix client", async () => {
 | |
|                     expect(await restoreSessionFromStorage()).toEqual(true);
 | |
| 
 | |
|                     expect(MatrixClientPeg.start).toHaveBeenCalled();
 | |
|                 });
 | |
| 
 | |
|                 describe("with a refresh token", () => {
 | |
|                     beforeEach(() => {
 | |
|                         initLocalStorageMock({
 | |
|                             ...localStorageSession,
 | |
|                             mx_refresh_token: refreshToken,
 | |
|                         });
 | |
|                         initIdbMock(idbStorageSession);
 | |
|                     });
 | |
| 
 | |
|                     it("should persist credentials", async () => {
 | |
|                         expect(await restoreSessionFromStorage()).toEqual(true);
 | |
| 
 | |
|                         // refresh token from storage is re-persisted
 | |
|                         expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_refresh_token", "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(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
 | |
|                             {
 | |
|                                 userId,
 | |
|                                 accessToken,
 | |
|                                 // refreshToken included in credentials
 | |
|                                 refreshToken,
 | |
|                                 homeserverUrl,
 | |
|                                 identityServerUrl,
 | |
|                                 deviceId,
 | |
|                                 freshLogin: false,
 | |
|                                 guest: false,
 | |
|                                 pickleKey: undefined,
 | |
|                             },
 | |
|                             undefined,
 | |
|                         );
 | |
|                     });
 | |
|                 });
 | |
|             });
 | |
| 
 | |
|             describe("with a normal pickle key", () => {
 | |
|                 let pickleKey: string;
 | |
| 
 | |
|                 beforeEach(async () => {
 | |
|                     initLocalStorageMock(localStorageSession);
 | |
|                     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.setItem).toHaveBeenCalledWith("mx_has_access_token", "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.setItem).toHaveBeenCalledWith("mx_access_token", 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(MatrixClientPeg.replaceUsingCreds).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.setItem).toHaveBeenCalledWith("mx_has_refresh_token", "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(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
 | |
|                             {
 | |
|                                 userId,
 | |
|                                 accessToken,
 | |
|                                 // refreshToken included in credentials
 | |
|                                 refreshToken,
 | |
|                                 homeserverUrl,
 | |
|                                 identityServerUrl,
 | |
|                                 deviceId,
 | |
|                                 freshLogin: false,
 | |
|                                 guest: false,
 | |
|                                 pickleKey: pickleKey,
 | |
|                             },
 | |
|                             undefined,
 | |
|                         );
 | |
|                     });
 | |
|                 });
 | |
|             });
 | |
| 
 | |
|             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 () => {
 | |
|                     initLocalStorageMock(localStorageSession);
 | |
|                     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(MatrixClientPeg.replaceUsingCreds).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 () => {
 | |
|                 initLocalStorageMock(localStorageSession);
 | |
|                 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 () => {
 | |
|                 initLocalStorageMock(localStorageSession);
 | |
|                 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(() => {
 | |
|             initLocalStorageMock();
 | |
|             initSessionStorageMock();
 | |
|             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.removeItem).toHaveBeenCalledWith("mx_fresh_login");
 | |
|         });
 | |
| 
 | |
|         it("should start matrix client", async () => {
 | |
|             await setLoggedIn(credentials);
 | |
| 
 | |
|             expect(MatrixClientPeg.start).toHaveBeenCalled();
 | |
|         });
 | |
| 
 | |
|         describe("without a pickle key", () => {
 | |
|             beforeEach(() => {
 | |
|                 jest.spyOn(mockPlatform, "createPickleKey").mockResolvedValue(null);
 | |
|             });
 | |
| 
 | |
|             it("should persist credentials", async () => {
 | |
|                 await setLoggedIn(credentials);
 | |
| 
 | |
|                 expect(localStorage.setItem).toHaveBeenCalledWith("mx_user_id", userId);
 | |
|                 expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
 | |
|                 expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
 | |
|                 expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);
 | |
| 
 | |
|                 expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
 | |
|                 // dont put accessToken in localstorage when we have idb
 | |
|                 expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
 | |
|             });
 | |
| 
 | |
|             it("should persist a refreshToken when present", async () => {
 | |
|                 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.setItem).not.toHaveBeenCalledWith("mx_access_token", 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.removeItem).toHaveBeenCalledWith("mx_has_access_token");
 | |
|                 expect(localStorage.removeItem).toHaveBeenCalledWith("mx_access_token");
 | |
|             });
 | |
| 
 | |
|             it("should clear stores", async () => {
 | |
|                 await setLoggedIn(credentials);
 | |
| 
 | |
|                 expect(StorageAccess.idbDelete).toHaveBeenCalledWith("account", "mx_access_token");
 | |
|                 expect(sessionStorage.clear).toHaveBeenCalled();
 | |
|                 expect(mockClient.clearStores).toHaveBeenCalled();
 | |
|             });
 | |
| 
 | |
|             it("should create new matrix client with credentials", async () => {
 | |
|                 expect(await setLoggedIn(credentials)).toEqual(mockClient);
 | |
| 
 | |
|                 expect(MatrixClientPeg.replaceUsingCreds).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.setItem).toHaveBeenCalledWith("mx_user_id", userId);
 | |
|                 expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
 | |
|                 expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
 | |
|                 expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);
 | |
| 
 | |
|                 expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_pickle_key", "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.setItem).not.toHaveBeenCalledWith("mx_access_token", 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.setItem).toHaveBeenCalledWith("mx_access_token", 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.removeItem).toHaveBeenCalledWith("mx_has_access_token");
 | |
|                 expect(localStorage.removeItem).toHaveBeenCalledWith("mx_access_token");
 | |
|             });
 | |
| 
 | |
|             it("should create new matrix client with credentials", async () => {
 | |
|                 expect(await setLoggedIn(credentials)).toEqual(mockClient);
 | |
| 
 | |
|                 expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
 | |
|                     {
 | |
|                         userId,
 | |
|                         accessToken,
 | |
|                         homeserverUrl,
 | |
|                         identityServerUrl,
 | |
|                         deviceId,
 | |
|                         freshLogin: true,
 | |
|                         guest: false,
 | |
|                         pickleKey: expect.any(String),
 | |
|                     },
 | |
|                     undefined,
 | |
|                 );
 | |
|             });
 | |
|         });
 | |
| 
 | |
|         describe("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";
 | |
| 
 | |
|             beforeAll(() => {
 | |
|                 fetchMock.get(
 | |
|                     `${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
 | |
|                     delegatedAuthConfig.metadata,
 | |
|                 );
 | |
|                 fetchMock.get(`${delegatedAuthConfig.metadata.issuer}jwks`, {
 | |
|                     status: 200,
 | |
|                     headers: {
 | |
|                         "Content-Type": "application/json",
 | |
|                     },
 | |
|                     keys: [],
 | |
|                 });
 | |
|             });
 | |
| 
 | |
|             beforeEach(() => {
 | |
|                 initSessionStorageMock();
 | |
|                 // set values in session storage as they would be after a successful oidc authentication
 | |
|                 persistOidcAuthenticatedSettings(clientId, issuer, 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).not.toHaveFetched(
 | |
|                     `${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
 | |
|                 );
 | |
|             });
 | |
| 
 | |
|             it("should not try to create a token refresher without a deviceId", async () => {
 | |
|                 await setLoggedIn({
 | |
|                     ...credentials,
 | |
|                     refreshToken,
 | |
|                     deviceId: undefined,
 | |
|                 });
 | |
| 
 | |
|                 // didn't try to initialise token refresher
 | |
|                 expect(fetchMock).not.toHaveFetched(
 | |
|                     `${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
 | |
|                 );
 | |
|             });
 | |
| 
 | |
|             it("should not try to create a token refresher without an issuer in session storage", async () => {
 | |
|                 persistOidcAuthenticatedSettings(
 | |
|                     clientId,
 | |
|                     // @ts-ignore set undefined issuer
 | |
|                     undefined,
 | |
|                     idToken,
 | |
|                 );
 | |
|                 await setLoggedIn({
 | |
|                     ...credentials,
 | |
|                     refreshToken,
 | |
|                 });
 | |
| 
 | |
|                 // didn't try to initialise token refresher
 | |
|                 expect(fetchMock).not.toHaveFetched(
 | |
|                     `${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
 | |
|                 );
 | |
|             });
 | |
| 
 | |
|             it("should create a client with a tokenRefreshFunction", async () => {
 | |
|                 expect(
 | |
|                     await setLoggedIn({
 | |
|                         ...credentials,
 | |
|                         refreshToken,
 | |
|                     }),
 | |
|                 ).toEqual(mockClient);
 | |
| 
 | |
|                 expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
 | |
|                     expect.objectContaining({
 | |
|                         accessToken,
 | |
|                         refreshToken,
 | |
|                     }),
 | |
|                     expect.any(Function),
 | |
|                 );
 | |
|             });
 | |
| 
 | |
|             it("should create a client when creating token refresher fails", async () => {
 | |
|                 // set invalid value in session storage for a malformed oidc authentication
 | |
|                 persistOidcAuthenticatedSettings(null as any, issuer, idToken);
 | |
| 
 | |
|                 // succeeded
 | |
|                 expect(
 | |
|                     await setLoggedIn({
 | |
|                         ...credentials,
 | |
|                         refreshToken,
 | |
|                     }),
 | |
|                 ).toEqual(mockClient);
 | |
| 
 | |
|                 expect(MatrixClientPeg.replaceUsingCreds).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);
 | |
|             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(MatrixClientPeg.replaceUsingCreds).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(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
 | |
|                 expect.objectContaining({
 | |
|                     userId: otherCredentials.userId,
 | |
|                 }),
 | |
|                 undefined,
 | |
|             );
 | |
| 
 | |
|             expect(MatrixClientPeg.unset).not.toHaveBeenCalled();
 | |
|         });
 | |
|     });
 | |
| });
 |