[Labs] Sign in with QR on new EW using generated QR for MSC4108 v2024 (#33184)
* PoC Sign in with QR on new EW using generated QR for MSC4108 v2024 * Revert package.json changes * Prettier * Fix i18n * Tidy up * Remove unused state variable * Iterate tests * Partial revert * Iterate * Wire up qr_login route * Iterate UI * Fix React dev mode double rendering issue * Fix react key warning * Hide flow header on login * Re-roll qr code on channel expiry * Switch to AbortSignal * Improve auto-retry QR UX * Ensure we only show sign in with QR button if enabled * XXX: enable labs flag on Netlify builds * Tweak QR code sizing * Move qr login flow into a dialog to match designs * Fix null deviceId * Remove duplicate log * Iterate * Fix tests * Fix types * Fix tests * Fix tests * Make Netlify more useful * Make Netlify more useful v2 * Update copy * Refactor QR link flow to use new SDK methods Requires https://github.com/matrix-org/matrix-js-sdk/pull/5283 For element-hq/wat-internal#188 Split out from https://github.com/element-hq/element-web/pull/33184 * Link to js-sdk branch * Update tests * Simplify * Revert js-sdk linking * Iterate * Iterate * Refactor to handle most of the TODOs * Remove unused code * Remove unused code * Use js-sdk isSignInWithQRAvailable API to simplify code * Restore app-test.ts * Improve coverage * Improve coverage * Remove unused prop/state * Iterate * Fix tests * Iterate * Tests * Handle TODOs * Docs * Remove redundant call to crossSignDevice() * Workaround to remove training slash on the serverName before auto-discovery * Revert "Workaround to remove training slash on the serverName before auto-discovery" This reverts commit 0335a8fdd1b8e8d949ab7fca17c76f8fab335b58. * setLoggedIn not to be used with OIDC flows as it clears storage as per docs on setLoggedIn we should use restoreSessionFromStorage * Don't show the security_code_prompt unconditionally(i.e. for the web logging in mobile flow) * Update LoginWithQRFlow-test.tsx.snap * Update MatrixChat-test from setLoggedInSpy to restoreSessionSpy * Add todo for server switch * Add todo about handling base URL or server name * Handle server name or base URL being returned * Format * Fix loading state height * Handle the homeserver URL differing during QR code login * Comments * Comments * Register OIDC client ID after homeserver swap * Make QrLoginDialog async to minimise the impact on bundle size * Handle unsupported HS earlier in the flow * Iterate * Delint * Fix test * Discard changes to apps/web/element.io/develop/config.json --------- Co-authored-by: Hugh Nimmo-Smith <hughns@element.io> Co-authored-by: David Langley <langley.dave@gmail.com>
This commit is contained in:
co-authored by
Hugh Nimmo-Smith
David Langley
parent
d1a6137c90
commit
486fa57b68
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import React, { type ComponentProps } from "react";
|
||||
import React, { type ComponentProps, createRef, type RefObject } from "react";
|
||||
import { fireEvent, render, type RenderResult, screen, waitFor, within, act } from "jest-matrix-react";
|
||||
import { type Mocked, mocked } from "jest-mock";
|
||||
import { ClientEvent, type MatrixClient, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix";
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
UserVerificationStatus,
|
||||
type CryptoApi,
|
||||
} from "matrix-js-sdk/src/crypto-api";
|
||||
import fetchMock from "@fetch-mock/jest";
|
||||
import * as qrLogin from "matrix-js-sdk/src/rendezvous";
|
||||
|
||||
import MatrixChat from "../../../../src/components/structures/MatrixChat";
|
||||
import * as StorageAccess from "../../../../src/utils/StorageAccess";
|
||||
@@ -69,6 +71,8 @@ import { clearStorage } from "../../../../src/Lifecycle";
|
||||
import RoomListStore from "../../../../src/stores/room-list/RoomListStore.ts";
|
||||
import UserSettingsDialog from "../../../../src/components/views/dialogs/UserSettingsDialog.tsx";
|
||||
import { SdkContextClass } from "../../../../src/contexts/SDKContext.ts";
|
||||
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc.ts";
|
||||
import { type QrLoginCredentials } from "../../../../src/components/views/auth/LoginWithQR.tsx";
|
||||
|
||||
jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({
|
||||
completeAuthorizationCodeGrant: jest.fn(),
|
||||
@@ -82,6 +86,31 @@ jest.mock("../../../../src/theme");
|
||||
/** The matrix versions our mock server claims to support */
|
||||
const SERVER_SUPPORTED_MATRIX_VERSIONS = ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"];
|
||||
|
||||
function createMockCrypto(): CryptoApi {
|
||||
return {
|
||||
getVersion: jest.fn().mockReturnValue("Version 0"),
|
||||
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
|
||||
getUserDeviceInfo: jest.fn().mockReturnValue({
|
||||
get: jest
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
"devid",
|
||||
{ deviceId: "devid", dehydrated: false, getIdentityKey: jest.fn().mockReturnValue("k") },
|
||||
],
|
||||
]),
|
||||
),
|
||||
}),
|
||||
getUserVerificationStatus: jest.fn().mockResolvedValue(new UserVerificationStatus(true, true, false)),
|
||||
setDeviceIsolationMode: jest.fn(),
|
||||
isDehydrationSupported: jest.fn().mockReturnValue(false),
|
||||
getDeviceVerificationStatus: jest.fn().mockResolvedValue({ signedByOwner: true } as DeviceVerificationStatus),
|
||||
isCrossSigningReady: jest.fn().mockReturnValue(false),
|
||||
requestOwnUserVerification: jest.fn().mockResolvedValue({ cancel: jest.fn(), on: jest.fn() }),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("<MatrixChat />", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const deviceId = "qwertyui";
|
||||
@@ -179,8 +208,11 @@ describe("<MatrixChat />", () => {
|
||||
warning: "",
|
||||
};
|
||||
let defaultProps: ComponentProps<typeof MatrixChat>;
|
||||
const getComponent = (props: Partial<ComponentProps<typeof MatrixChat>> = {}) => {
|
||||
return render(<MatrixChat {...defaultProps} {...props} />);
|
||||
const getComponent = (
|
||||
props: Partial<ComponentProps<typeof MatrixChat>> = {},
|
||||
ref?: RefObject<MatrixChat | null>,
|
||||
) => {
|
||||
return render(<MatrixChat {...defaultProps} {...props} ref={ref} />);
|
||||
};
|
||||
|
||||
// make test results readable
|
||||
@@ -311,6 +343,118 @@ describe("<MatrixChat />", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("qr login", () => {
|
||||
beforeEach(() => {
|
||||
const authConfig = makeDelegatedAuthConfig();
|
||||
defaultProps.config.validated_server_config!.delegatedAuthentication = authConfig;
|
||||
fetchMock.post(authConfig.registration_endpoint!, { client_id: "abc123" });
|
||||
mockPlatformPeg({
|
||||
getOidcClientMetadata: jest.fn().mockReturnValue({
|
||||
clientName: "App name",
|
||||
clientUri: "https://company",
|
||||
redirectUris: ["https://app"],
|
||||
logoUri: "https://company/logo.png",
|
||||
applicationType: "web",
|
||||
}),
|
||||
});
|
||||
jest.spyOn(qrLogin, "signInByGeneratingQR").mockReturnValue(new Promise(() => {}));
|
||||
});
|
||||
|
||||
it("should open QrLoginDialog on ViewQrLogin action", async () => {
|
||||
getComponent();
|
||||
defaultDispatcher.fire(Action.ViewQrLogin);
|
||||
await expect(screen.findByRole("dialog", { name: "Sign in with QR code" })).resolves.toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should ignore ViewQrLogin action when logged in", async () => {
|
||||
await populateStorageForSession();
|
||||
getComponent();
|
||||
// wait for logged in view to load
|
||||
await screen.findByLabelText("User menu");
|
||||
|
||||
defaultDispatcher.fire(Action.ViewQrLogin);
|
||||
expect(screen.queryByRole("dialog", { name: "Sign in with QR code" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should fire ViewQrLogin action on 'qr_login' route", async () => {
|
||||
const ref = createRef<MatrixChat>();
|
||||
getComponent({}, ref);
|
||||
ref.current!.showScreen("qr_login");
|
||||
await expect(screen.findByRole("dialog", { name: "Sign in with QR code" })).resolves.toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should handle qr login completed", async () => {
|
||||
const qrCreds: QrLoginCredentials = {
|
||||
accessToken: "at",
|
||||
homeserverUrl: "https://homeserver",
|
||||
clientId: "ci",
|
||||
idToken: "it",
|
||||
issuer: defaultProps.config.validated_server_config!.delegatedAuthentication!.issuer,
|
||||
deviceId: "di",
|
||||
secrets: {
|
||||
cross_signing: {
|
||||
master_key: "mk",
|
||||
self_signing_key: "ssk",
|
||||
user_signing_key: "usk",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockClient.whoami.mockResolvedValue({ user_id: "@user:homeserver", device_id: qrCreds.deviceId });
|
||||
mockClient.getCrypto.mockReturnValue({
|
||||
...createMockCrypto(),
|
||||
crossSignDevice: jest.fn().mockResolvedValue(undefined),
|
||||
importSecretsBundle: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
getComponent();
|
||||
|
||||
const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
|
||||
|
||||
// Assert welcome screen
|
||||
await screen.findByText("Welcome to Test");
|
||||
|
||||
// Open QR dialog so we can grab the onLoggedIn method
|
||||
defaultDispatcher.fire(Action.ViewQrLogin, true);
|
||||
expect(createDialogSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{
|
||||
onLoggedIn: expect.any(Function),
|
||||
serverConfig: defaultProps.config.validated_server_config,
|
||||
},
|
||||
"mx_LoginWithQR_dialog",
|
||||
false,
|
||||
true,
|
||||
);
|
||||
const { onLoggedIn } = createDialogSpy.mock.calls[0][1] as {
|
||||
onLoggedIn(creds: QrLoginCredentials): Promise<void>;
|
||||
};
|
||||
|
||||
const configureFromCompletedSpy = jest.spyOn(Lifecycle, "configureFromCompletedOAuthLogin");
|
||||
const restoreSessionSpy = jest.spyOn(Lifecycle, "restoreSessionFromStorage");
|
||||
const prom = onLoggedIn(qrCreds);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(configureFromCompletedSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessToken: qrCreds.accessToken,
|
||||
homeserverUrl: qrCreds.homeserverUrl,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(restoreSessionSpy).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(mockClient.getCrypto()!.importSecretsBundle).toHaveBeenCalledWith(qrCreds.secrets),
|
||||
);
|
||||
|
||||
await prom;
|
||||
|
||||
// initial sync
|
||||
mockClient.emit(ClientEvent.Sync, SyncState.Prepared, null);
|
||||
// wait for logged in view to load
|
||||
await screen.findByLabelText("User menu");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when query params have a OIDC params", () => {
|
||||
const issuer = "https://auth.com/";
|
||||
const homeserverUrl = "https://matrix.org";
|
||||
@@ -1186,37 +1330,6 @@ describe("<MatrixChat />", () => {
|
||||
await screen.findByRole("heading", { name: "Confirm your digital identity", level: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
function createMockCrypto(): CryptoApi {
|
||||
return {
|
||||
getVersion: jest.fn().mockReturnValue("Version 0"),
|
||||
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
|
||||
getUserDeviceInfo: jest.fn().mockReturnValue({
|
||||
get: jest.fn().mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
"devid",
|
||||
{
|
||||
deviceId: "devid",
|
||||
dehydrated: false,
|
||||
getIdentityKey: jest.fn().mockReturnValue("k"),
|
||||
},
|
||||
],
|
||||
]),
|
||||
),
|
||||
}),
|
||||
getUserVerificationStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new UserVerificationStatus(true, true, false)),
|
||||
setDeviceIsolationMode: jest.fn(),
|
||||
isDehydrationSupported: jest.fn().mockReturnValue(false),
|
||||
getDeviceVerificationStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ signedByOwner: true } as DeviceVerificationStatus),
|
||||
isCrossSigningReady: jest.fn().mockReturnValue(false),
|
||||
requestOwnUserVerification: jest.fn().mockResolvedValue({ cancel: jest.fn(), on: jest.fn() }),
|
||||
} as any;
|
||||
}
|
||||
});
|
||||
|
||||
describe("showScreen", () => {
|
||||
|
||||
+108
@@ -269,6 +269,114 @@ exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during sta
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<MatrixChat /> qr login should fire ViewQrLogin action on 'qr_login' route 1`] = `
|
||||
<div
|
||||
aria-label="Sign in with QR code"
|
||||
class=""
|
||||
data-focus-lock-disabled="false"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_Dialog_header"
|
||||
/>
|
||||
<div
|
||||
class="mx_LoginWithQR"
|
||||
data-testid="login-with-qr"
|
||||
>
|
||||
<div
|
||||
class="mx_LoginWithQR_main"
|
||||
>
|
||||
<div
|
||||
class="mx_LoginWithQR_spinner"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="mx_Spinner"
|
||||
>
|
||||
<svg
|
||||
aria-label="Loading…"
|
||||
class="_icon_1855a_18"
|
||||
data-testid="spinner"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
role="progressbar"
|
||||
style="width: 32px; height: 32px;"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_LoginWithQR_buttons"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<MatrixChat /> qr login should open QrLoginDialog on ViewQrLogin action 1`] = `
|
||||
<div
|
||||
aria-label="Sign in with QR code"
|
||||
class=""
|
||||
data-focus-lock-disabled="false"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_Dialog_header"
|
||||
/>
|
||||
<div
|
||||
class="mx_LoginWithQR"
|
||||
data-testid="login-with-qr"
|
||||
>
|
||||
<div
|
||||
class="mx_LoginWithQR_main"
|
||||
>
|
||||
<div
|
||||
class="mx_LoginWithQR_spinner"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="mx_Spinner"
|
||||
>
|
||||
<svg
|
||||
aria-label="Loading…"
|
||||
class="_icon_1855a_18"
|
||||
data-testid="spinner"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
role="progressbar"
|
||||
style="width: 32px; height: 32px;"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_LoginWithQR_buttons"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<MatrixChat /> should render spinner while app is loading 1`] = `
|
||||
<div>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user