[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:
Michael Telatynski
2026-06-04 13:50:17 +00:00
committed by GitHub
co-authored by Hugh Nimmo-Smith David Langley
parent d1a6137c90
commit 486fa57b68
24 changed files with 3041 additions and 570 deletions
@@ -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", () => {