Refactor QR link flow to use new SDK methods (#33309)

* 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

* Use js-sdk isSignInWithQRAvailable API to simplify code
This commit is contained in:
Michael Telatynski
2026-04-30 07:43:59 +00:00
committed by GitHub
parent aeefdfd751
commit 98b56a3d2f
4 changed files with 74 additions and 86 deletions
@@ -9,9 +9,8 @@ Please see LICENSE files in the repository root for full details.
import React from "react"; import React from "react";
import { import {
ClientRendezvousFailureReason, ClientRendezvousFailureReason,
linkNewDeviceByGeneratingQR,
MSC4108FailureReason, MSC4108FailureReason,
MSC4108RendezvousSession,
MSC4108SecureChannel,
MSC4108SignInWithQR, MSC4108SignInWithQR,
RendezvousError, RendezvousError,
type RendezvousFailureReason, type RendezvousFailureReason,
@@ -55,6 +54,7 @@ export type FailureReason = RendezvousFailureReason | LoginWithQRFailureReason;
*/ */
export default class LoginWithQR extends React.Component<IProps, IState> { export default class LoginWithQR extends React.Component<IProps, IState> {
private finished = false; private finished = false;
private abortController?: AbortController;
public constructor(props: IProps) { public constructor(props: IProps) {
super(props); super(props);
@@ -69,35 +69,31 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
} }
public componentDidMount(): void { public componentDidMount(): void {
this.updateMode(this.props.mode).then(() => {}); void this.updateMode(this.props.mode);
} }
public componentDidUpdate(prevProps: Readonly<IProps>): void { public componentDidUpdate(prevProps: Readonly<IProps>): void {
if (prevProps.mode !== this.props.mode) { if (prevProps.mode !== this.props.mode) {
this.updateMode(this.props.mode).then(() => {}); void this.updateMode(this.props.mode);
} }
} }
private async updateMode(mode: Mode, showLoading = true): Promise<void> { private async updateMode(mode: Mode, showLoading = true): Promise<void> {
if (this.state.rendezvous) { this.abortController?.abort();
const rendezvous = this.state.rendezvous; this.abortController = new AbortController();
rendezvous.onFailure = undefined; this.setState({ rendezvous: undefined });
this.setState({ rendezvous: undefined });
}
if (showLoading) { if (showLoading) {
this.setState({ phase: Phase.Loading }); this.setState({ phase: Phase.Loading });
} }
if (mode === Mode.Show) { if (mode === Mode.Show) {
await this.generateAndShowCode(); await this.generateAndShowCode(this.abortController);
} }
} }
public componentWillUnmount(): void { public componentWillUnmount(): void {
if (this.state.rendezvous && !this.finished) { if (!this.finished) {
// eslint-disable-next-line react/no-direct-mutation-state this.abortController?.abort();
this.state.rendezvous.onFailure = undefined;
// calling cancel will call close() as well to clean up the resources
this.state.rendezvous.cancel(MSC4108FailureReason.UserCancelled);
} }
} }
@@ -106,24 +102,18 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
this.props.onFinished(success); this.props.onFinished(success);
} }
private generateAndShowCode = async (): Promise<void> => { private generateAndShowCode = async (abortController: AbortController): Promise<void> => {
let rendezvous: MSC4108SignInWithQR; let rendezvous: MSC4108SignInWithQR;
try { try {
const transport = new MSC4108RendezvousSession({ rendezvous = await linkNewDeviceByGeneratingQR(this.props.client, this.onFailure, abortController.signal);
onFailure: this.onFailure, if (abortController.signal.aborted) return;
client: this.props.client,
});
await transport.send("");
const channel = new MSC4108SecureChannel(transport, undefined, this.onFailure);
rendezvous = new MSC4108SignInWithQR(channel, false, this.props.client, this.onFailure);
await rendezvous.generateCode();
this.setState({ this.setState({
phase: Phase.ShowingQR, phase: Phase.ShowingQR,
rendezvous, rendezvous,
failureReason: undefined, failureReason: undefined,
}); });
} catch (e) { } catch (e) {
if (abortController.signal.aborted) return;
logger.error("Error whilst generating QR code", e); logger.error("Error whilst generating QR code", e);
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport }); this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport });
return; return;
@@ -142,8 +132,9 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
// we ask the user to confirm that the channel is secure // we ask the user to confirm that the channel is secure
} catch (e: RendezvousError | unknown) { } catch (e: RendezvousError | unknown) {
if (abortController.signal.aborted) return;
logger.error("Error whilst approving login", e); logger.error("Error whilst approving login", e);
await rendezvous?.cancel( await rendezvous.cancel(
e instanceof RendezvousError ? (e.code as MSC4108FailureReason) : ClientRendezvousFailureReason.Unknown, e instanceof RendezvousError ? (e.code as MSC4108FailureReason) : ClientRendezvousFailureReason.Unknown,
); );
} }
@@ -210,6 +201,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
}; };
public reset(): void { public reset(): void {
this.abortController?.abort();
this.setState({ this.setState({
rendezvous: undefined, rendezvous: undefined,
verificationUri: undefined, verificationUri: undefined,
@@ -7,48 +7,35 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React from "react";
import { import { type MatrixClient } from "matrix-js-sdk/src/matrix";
type IServerVersions,
type OidcClientConfig,
type MatrixClient,
DEVICE_CODE_SCOPE,
} from "matrix-js-sdk/src/matrix";
import QrCodeIcon from "@vector-im/compound-design-tokens/assets/web/icons/qr-code"; import QrCodeIcon from "@vector-im/compound-design-tokens/assets/web/icons/qr-code";
import { Text } from "@vector-im/compound-web"; import { Text } from "@vector-im/compound-web";
import { isSignInWithQRAvailable } from "matrix-js-sdk/src/rendezvous";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton"; import AccessibleButton from "../../elements/AccessibleButton";
import { SettingsSubsection } from "../shared/SettingsSubsection"; import { SettingsSubsection } from "../shared/SettingsSubsection";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
interface IProps { interface IProps {
onShowQr: () => void; onShowQr: () => void;
versions?: IServerVersions;
oidcClientConfig?: OidcClientConfig;
isCrossSigningReady?: boolean; isCrossSigningReady?: boolean;
} }
export function shouldShowQr( export async function shouldShowQrForLinkNewDevice(cli: MatrixClient, isCrossSigningReady: boolean): Promise<boolean> {
cli: MatrixClient, const doesServerHaveSupport = await isSignInWithQRAvailable(cli);
isCrossSigningReady: boolean,
oidcClientConfig?: OidcClientConfig,
versions?: IServerVersions,
): boolean {
const msc4108Supported = !!versions?.unstable_features?.["org.matrix.msc4108"];
const deviceAuthorizationGrantSupported = oidcClientConfig?.grant_types_supported.includes(DEVICE_CODE_SCOPE); return doesServerHaveSupport && !!cli.getCrypto()?.exportSecretsBundle && isCrossSigningReady;
return (
!!deviceAuthorizationGrantSupported &&
msc4108Supported &&
!!cli.getCrypto()?.exportSecretsBundle &&
isCrossSigningReady
);
} }
const LoginWithQRSection: React.FC<IProps> = ({ onShowQr, versions, oidcClientConfig, isCrossSigningReady }) => { const LoginWithQRSection: React.FC<IProps> = ({ onShowQr, isCrossSigningReady }) => {
const cli = useMatrixClientContext(); const cli = useMatrixClientContext();
const offerShowQr = shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions); const offerShowQr = useAsyncMemo(
() => shouldShowQrForLinkNewDevice(cli, !!isCrossSigningReady),
[cli, isCrossSigningReady],
false,
);
return ( return (
<SettingsSubsection heading={_t("settings|sessions|sign_in_with_qr")}> <SettingsSubsection heading={_t("settings|sessions|sign_in_with_qr")}>
@@ -162,14 +162,6 @@ const SessionManagerTab: React.FC<{
const disableMultipleSignout = !!accountManagement?.endpoint; const disableMultipleSignout = !!accountManagement?.endpoint;
const userId = matrixClient?.getUserId(); const userId = matrixClient?.getUserId();
const currentUserMember = (userId && matrixClient?.getUser(userId)) || undefined; const currentUserMember = (userId && matrixClient?.getUser(userId)) || undefined;
const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]);
const oidcClientConfig = useAsyncMemo(async () => {
try {
return await matrixClient?.getAuthMetadata();
} catch (e) {
logger.error("Failed to discover OIDC metadata", e);
}
}, [matrixClient]);
const isCrossSigningReady = useAsyncMemo( const isCrossSigningReady = useAsyncMemo(
async () => matrixClient.getCrypto()?.isCrossSigningReady() ?? false, async () => matrixClient.getCrypto()?.isCrossSigningReady() ?? false,
[matrixClient], [matrixClient],
@@ -279,12 +271,7 @@ const SessionManagerTab: React.FC<{
return ( return (
<SettingsTab> <SettingsTab>
<SettingsSection> <SettingsSection>
<LoginWithQRSection <LoginWithQRSection onShowQr={onShowQrClicked} isCrossSigningReady={isCrossSigningReady} />
onShowQr={onShowQrClicked}
versions={clientVersions}
oidcClientConfig={oidcClientConfig}
isCrossSigningReady={isCrossSigningReady}
/>
<SecurityRecommendations <SecurityRecommendations
devices={devices} devices={devices}
goToFilteredList={onGoToFilteredList} goToFilteredList={onGoToFilteredList}
@@ -8,21 +8,21 @@ Please see LICENSE files in the repository root for full details.
import { cleanup, render, waitFor } from "jest-matrix-react"; import { cleanup, render, waitFor } from "jest-matrix-react";
import { mocked, type MockedObject } from "jest-mock"; import { mocked, type MockedObject } from "jest-mock";
import React from "react"; import React, { createRef, type RefObject } from "react";
import { import {
ClientRendezvousFailureReason, ClientRendezvousFailureReason,
MSC4108FailureReason, MSC4108FailureReason,
MSC4108SignInWithQR, MSC4108SignInWithQR,
RendezvousError, RendezvousError,
} from "matrix-js-sdk/src/rendezvous"; } from "matrix-js-sdk/src/rendezvous";
import { HTTPError, type MatrixClient } from "matrix-js-sdk/src/matrix"; import { HTTPError, type MatrixClient, MatrixHttpApi } from "matrix-js-sdk/src/matrix";
import LoginWithQR, { LoginWithQRFailureReason } from "../../../../../../src/components/views/auth/LoginWithQR"; import LoginWithQR, { LoginWithQRFailureReason } from "../../../../../../src/components/views/auth/LoginWithQR";
import { Click, Mode, Phase } from "../../../../../../src/components/views/auth/LoginWithQR-types"; import { Click, Mode, Phase } from "../../../../../../src/components/views/auth/LoginWithQR-types";
jest.mock("matrix-js-sdk/src/rendezvous");
jest.mock("matrix-js-sdk/src/rendezvous/transports"); jest.mock("matrix-js-sdk/src/rendezvous/transports");
jest.mock("matrix-js-sdk/src/rendezvous/channels"); jest.mock("matrix-js-sdk/src/rendezvous/channels");
jest.mock("matrix-js-sdk/src/rendezvous/channels/MSC4108SecureChannel.ts");
const mockedFlow = jest.fn(); const mockedFlow = jest.fn();
@@ -32,7 +32,7 @@ jest.mock("../../../../../../src/components/views/auth/LoginWithQRFlow", () => (
}); });
function makeClient() { function makeClient() {
return mocked({ const cli = mocked({
getUser: jest.fn(), getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false), isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(), isUserIgnored: jest.fn(),
@@ -49,7 +49,16 @@ function makeClient() {
}, },
getClientWellKnown: jest.fn().mockReturnValue({}), getClientWellKnown: jest.fn().mockReturnValue({}),
getCrypto: jest.fn().mockReturnValue({}), getCrypto: jest.fn().mockReturnValue({}),
getDomain: jest.fn(),
} as unknown as MatrixClient); } as unknown as MatrixClient);
cli.http = new MatrixHttpApi(cli, {
baseUrl: "https://server/",
prefix: "prefix",
onlyData: true,
}) as any;
return cli;
} }
function unresolvedPromise<T>(): Promise<T> { function unresolvedPromise<T>(): Promise<T> {
@@ -62,13 +71,12 @@ describe("<LoginWithQR />", () => {
legacy: true, legacy: true,
mode: Mode.Show, mode: Mode.Show,
onFinished: jest.fn(), onFinished: jest.fn(),
}; } as const;
beforeEach(() => { beforeEach(() => {
mockedFlow.mockReset(); mockedFlow.mockReset();
jest.resetAllMocks(); jest.resetAllMocks();
client = makeClient(); client = makeClient();
jest.useFakeTimers();
}); });
afterEach(() => { afterEach(() => {
@@ -79,14 +87,20 @@ describe("<LoginWithQR />", () => {
}); });
describe("MSC4108", () => { describe("MSC4108", () => {
const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => ( const getComponent = (props: {
<LoginWithQR {...defaultProps} {...props} /> client: MatrixClient;
); onFinished?: () => void;
ref?: RefObject<LoginWithQR | null>;
}) => <LoginWithQR {...defaultProps} {...props} />;
test("render QR then back", async () => { test("render QR then back", async () => {
const onFinished = jest.fn(); const onFinished = jest.fn();
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise()); jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
render(getComponent({ client, onFinished })); jest.spyOn(MSC4108SignInWithQR.prototype, "generateCode");
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols");
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel");
const ref = createRef<LoginWithQR>();
render(getComponent({ client, onFinished, ref }));
await waitFor(() => await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({ expect(mockedFlow).toHaveBeenLastCalledWith({
@@ -95,7 +109,7 @@ describe("<LoginWithQR />", () => {
}), }),
); );
const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0]; const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.generateCode).toHaveBeenCalled(); expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.negotiateProtocols).toHaveBeenCalled(); expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
@@ -109,7 +123,8 @@ describe("<LoginWithQR />", () => {
test("should open a new channel if expires before qr scan", async () => { test("should open a new channel if expires before qr scan", async () => {
const onFinished = jest.fn(); const onFinished = jest.fn();
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise()); jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
render(getComponent({ client, onFinished })); const ref = createRef<LoginWithQR>();
render(getComponent({ client, onFinished, ref }));
await waitFor(() => await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({ expect(mockedFlow).toHaveBeenLastCalledWith({
@@ -118,15 +133,15 @@ describe("<LoginWithQR />", () => {
}), }),
); );
const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0]; const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.generateCode).toHaveBeenCalled(); expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.negotiateProtocols).toHaveBeenCalled(); expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
// Expire the channel // Expire the channel
const onFailure = mocked(MSC4108SignInWithQR).mock.calls[0][3]; rendezvous.onFailure!(ClientRendezvousFailureReason.Expired);
onFailure!(ClientRendezvousFailureReason.Expired);
await jest.runAllTimersAsync(); await jest.runAllTimersAsync();
await waitFor(() => expect(mocked(MSC4108SignInWithQR).mock.instances).toHaveLength(2)); await waitFor(() => expect(ref.current!.state.rendezvous).toBeDefined());
expect(ref.current!.state.rendezvous).not.toBe(rendezvous);
}); });
test("failed to connect", async () => { test("failed to connect", async () => {
@@ -168,9 +183,11 @@ describe("<LoginWithQR />", () => {
}); });
test("reciprocates login", async () => { test("reciprocates login", async () => {
const ref = createRef<LoginWithQR>();
jest.spyOn(global.window, "open"); jest.spyOn(global.window, "open");
render(getComponent({ client })); render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({}); jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({ jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({
verificationUri: "mock-verification-uri", verificationUri: "mock-verification-uri",
@@ -193,10 +210,14 @@ describe("<LoginWithQR />", () => {
}), }),
); );
expect(global.window.open).toHaveBeenCalledWith("mock-verification-uri", "_blank"); expect(global.window.open).toHaveBeenCalledWith("mock-verification-uri", "_blank");
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.shareSecrets).toHaveBeenCalled();
}); });
test("handles errors during protocol negotiation", async () => { test("handles errors during protocol negotiation", async () => {
render(getComponent({ client })); const ref = createRef<LoginWithQR>();
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue(); jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
const err = new RendezvousError("Unknown Failure", MSC4108FailureReason.UnsupportedProtocol); const err = new RendezvousError("Unknown Failure", MSC4108FailureReason.UnsupportedProtocol);
// @ts-ignore work-around for lazy mocks // @ts-ignore work-around for lazy mocks
@@ -211,7 +232,7 @@ describe("<LoginWithQR />", () => {
); );
await waitFor(() => { await waitFor(() => {
const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0]; const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UnsupportedProtocol); expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UnsupportedProtocol);
}); });
}); });
@@ -244,7 +265,8 @@ describe("<LoginWithQR />", () => {
}); });
test("handles user cancelling during reciprocation", async () => { test("handles user cancelling during reciprocation", async () => {
render(getComponent({ client })); const ref = createRef<LoginWithQR>();
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({}); jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({}); jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({}); jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
@@ -259,7 +281,7 @@ describe("<LoginWithQR />", () => {
const onClick = mockedFlow.mock.calls[0][0].onClick; const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Cancel); await onClick(Click.Cancel);
const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0]; const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled); expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
}); });
}); });