/* 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; 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 = {}): 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 = {}): 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> = {}): 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(Buffer) }); }); 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: null, }, 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 = 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 = 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(); }); }); });