0a01320fca
* Element-R: pass pickleKey in as raw key for indexeddb encryption Currently, we pass the `pickleKey` to the rust library for use as a passphrase for encrypting its crypto store. The Rust libary then passes that passphrase through 200000 rounds of PBKDF2 to generate an encryption key, which is (deliberately) slow. However, the pickleKey is actually 32 bytes of random data (base64-encoded). By passing the raw key into the rust library, we can therefore save the PBKDF operation. Backwards-compatibility with existing sessions is maintained, because if the rust library discovers that the store was previously encrypted with a key based on a PBKDF, it will re-base64 and PBKDF the key we provide, thus reconstructing the right key. * Update src/Lifecycle.ts Co-authored-by: Florian Duros <florianduros@element.io> * Lifecycle-test: clean up test setup Rely less on the unit under test for setting up the test preconditions -- not least because we don't really want to fire up matrix clients and the like during test setup. * Factor out "encryptPickleKey" method For a start it makes it easier to grok what's going on, but also I went to use this in a test * Improve tests for `Lifecycle.restoreFromLocalStorage` --------- Co-authored-by: Florian Duros <florianduros@element.io>
950 lines
39 KiB
TypeScript
950 lines
39 KiB
TypeScript
/*
|
|
Copyright 2023 The Matrix.org Foundation C.I.C.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
import { 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 { setCrypto } from "matrix-js-sdk/src/crypto/crypto";
|
|
import * as MatrixCryptoAes from "matrix-js-sdk/src/crypto/aes";
|
|
import { mocked, MockedObject } from "jest-mock";
|
|
import fetchMock from "fetch-mock-jest";
|
|
|
|
import StorageEvictedDialog from "../src/components/views/dialogs/StorageEvictedDialog";
|
|
import { logout, restoreFromLocalStorage, 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;
|
|
|
|
setCrypto(webCrypto);
|
|
// @ts-ignore mocking
|
|
delete window.crypto;
|
|
window.crypto = webCrypto;
|
|
|
|
jest.spyOn(MatrixCryptoAes, "encryptAES").mockRestore();
|
|
});
|
|
|
|
afterAll(() => {
|
|
setCrypto(windowCrypto);
|
|
|
|
// @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().mockResolvedValue(undefined);
|
|
};
|
|
|
|
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("restoreFromLocalStorage()", () => {
|
|
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 restoreFromLocalStorage()).toEqual(false);
|
|
});
|
|
|
|
it("should return false when no session data is found in local storage", async () => {
|
|
expect(await restoreFromLocalStorage()).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(() => restoreFromLocalStorage()).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 restoreFromLocalStorage({ 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 restoreFromLocalStorage({ 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 restoreFromLocalStorage()).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 restoreFromLocalStorage()).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 restoreFromLocalStorage()).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 restoreFromLocalStorage()).toEqual(true);
|
|
|
|
expect(sessionStorage.removeItem).toHaveBeenCalledWith("mx_fresh_login");
|
|
});
|
|
|
|
it("should start matrix client", async () => {
|
|
expect(await restoreFromLocalStorage()).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 restoreFromLocalStorage()).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 restoreFromLocalStorage()).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 restoreFromLocalStorage()).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 restoreFromLocalStorage()).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 restoreFromLocalStorage()).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 restoreFromLocalStorage()).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 restoreFromLocalStorage()).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 restoreFromLocalStorage()).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 restoreFromLocalStorage()).toEqual(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
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(MatrixCryptoAes, "encryptAES").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();
|
|
});
|
|
});
|
|
});
|