Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 18 additions & 26 deletions apps/web/src/components/views/auth/LoginWithQR.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@
import React from "react";
import {
ClientRendezvousFailureReason,
linkNewDeviceByGeneratingQR,
MSC4108FailureReason,
MSC4108RendezvousSession,
MSC4108SecureChannel,
MSC4108SignInWithQR,
RendezvousError,
type RendezvousFailureReason,
Expand Down Expand Up @@ -55,6 +54,7 @@
*/
export default class LoginWithQR extends React.Component<IProps, IState> {
private finished = false;
private abortController?: AbortController;

public constructor(props: IProps) {
super(props);
Expand All @@ -69,35 +69,31 @@
}

public componentDidMount(): void {
this.updateMode(this.props.mode).then(() => {});
void this.updateMode(this.props.mode);
}

public componentDidUpdate(prevProps: Readonly<IProps>): void {
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> {
if (this.state.rendezvous) {
const rendezvous = this.state.rendezvous;
rendezvous.onFailure = undefined;
this.setState({ rendezvous: undefined });
}
this.abortController?.abort();
this.abortController = new AbortController();
this.setState({ rendezvous: undefined });
if (showLoading) {
this.setState({ phase: Phase.Loading });
}

if (mode === Mode.Show) {
await this.generateAndShowCode();
await this.generateAndShowCode(this.abortController);
}
}

public componentWillUnmount(): void {
if (this.state.rendezvous && !this.finished) {
// eslint-disable-next-line react/no-direct-mutation-state
this.state.rendezvous.onFailure = undefined;
// calling cancel will call close() as well to clean up the resources
this.state.rendezvous.cancel(MSC4108FailureReason.UserCancelled);
if (!this.finished) {
this.abortController?.abort();
}
}

Expand All @@ -106,24 +102,18 @@
this.props.onFinished(success);
}

private generateAndShowCode = async (): Promise<void> => {
private generateAndShowCode = async (abortController: AbortController): Promise<void> => {

Check warning on line 105 in apps/web/src/components/views/auth/LoginWithQR.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'generateAndShowCode' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ3TbaE3fL7yzqghF7Cq&open=AZ3TbaE3fL7yzqghF7Cq&pullRequest=33309
let rendezvous: MSC4108SignInWithQR;
try {
const transport = new MSC4108RendezvousSession({
onFailure: this.onFailure,
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();
rendezvous = await linkNewDeviceByGeneratingQR(this.props.client, this.onFailure, abortController.signal);
if (abortController.signal.aborted) return;
this.setState({
phase: Phase.ShowingQR,
rendezvous,
failureReason: undefined,
});
} catch (e) {
if (abortController.signal.aborted) return;
logger.error("Error whilst generating QR code", e);
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport });
return;
Expand All @@ -142,8 +132,9 @@

// we ask the user to confirm that the channel is secure
} catch (e: RendezvousError | unknown) {
if (abortController.signal.aborted) return;
logger.error("Error whilst approving login", e);
await rendezvous?.cancel(
await rendezvous.cancel(
e instanceof RendezvousError ? (e.code as MSC4108FailureReason) : ClientRendezvousFailureReason.Unknown,
);
}
Expand Down Expand Up @@ -210,6 +201,7 @@
};

public reset(): void {
this.abortController?.abort();
this.setState({
rendezvous: undefined,
verificationUri: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,48 +7,35 @@ Please see LICENSE files in the repository root for full details.
*/

import React from "react";
import {
type IServerVersions,
type OidcClientConfig,
type MatrixClient,
DEVICE_CODE_SCOPE,
} from "matrix-js-sdk/src/matrix";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import QrCodeIcon from "@vector-im/compound-design-tokens/assets/web/icons/qr-code";
import { Text } from "@vector-im/compound-web";
import { isSignInWithQRAvailable } from "matrix-js-sdk/src/rendezvous";

import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
import { SettingsSubsection } from "../shared/SettingsSubsection";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";

interface IProps {
onShowQr: () => void;
versions?: IServerVersions;
oidcClientConfig?: OidcClientConfig;
isCrossSigningReady?: boolean;
}

export function shouldShowQr(
cli: MatrixClient,
isCrossSigningReady: boolean,
oidcClientConfig?: OidcClientConfig,
versions?: IServerVersions,
): boolean {
const msc4108Supported = !!versions?.unstable_features?.["org.matrix.msc4108"];
export async function shouldShowQrForLinkNewDevice(cli: MatrixClient, isCrossSigningReady: boolean): Promise<boolean> {
const doesServerHaveSupport = await isSignInWithQRAvailable(cli);

const deviceAuthorizationGrantSupported = oidcClientConfig?.grant_types_supported.includes(DEVICE_CODE_SCOPE);

return (
!!deviceAuthorizationGrantSupported &&
msc4108Supported &&
!!cli.getCrypto()?.exportSecretsBundle &&
isCrossSigningReady
);
return doesServerHaveSupport && !!cli.getCrypto()?.exportSecretsBundle && isCrossSigningReady;
}

const LoginWithQRSection: React.FC<IProps> = ({ onShowQr, versions, oidcClientConfig, isCrossSigningReady }) => {
const LoginWithQRSection: React.FC<IProps> = ({ onShowQr, isCrossSigningReady }) => {
const cli = useMatrixClientContext();
const offerShowQr = shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions);
const offerShowQr = useAsyncMemo(
() => shouldShowQrForLinkNewDevice(cli, !!isCrossSigningReady),
[cli, isCrossSigningReady],
false,
);

return (
<SettingsSubsection heading={_t("settings|sessions|sign_in_with_qr")}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,6 @@ const SessionManagerTab: React.FC<{
const disableMultipleSignout = !!accountManagement?.endpoint;
const userId = matrixClient?.getUserId();
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(
async () => matrixClient.getCrypto()?.isCrossSigningReady() ?? false,
[matrixClient],
Expand Down Expand Up @@ -279,12 +271,7 @@ const SessionManagerTab: React.FC<{
return (
<SettingsTab>
<SettingsSection>
<LoginWithQRSection
onShowQr={onShowQrClicked}
versions={clientVersions}
oidcClientConfig={oidcClientConfig}
isCrossSigningReady={isCrossSigningReady}
/>
<LoginWithQRSection onShowQr={onShowQrClicked} isCrossSigningReady={isCrossSigningReady} />
<SecurityRecommendations
devices={devices}
goToFilteredList={onGoToFilteredList}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,21 @@ Please see LICENSE files in the repository root for full details.

import { cleanup, render, waitFor } from "jest-matrix-react";
import { mocked, type MockedObject } from "jest-mock";
import React from "react";
import React, { createRef, type RefObject } from "react";
import {
ClientRendezvousFailureReason,
MSC4108FailureReason,
MSC4108SignInWithQR,
RendezvousError,
} 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 { 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/channels");
jest.mock("matrix-js-sdk/src/rendezvous/channels/MSC4108SecureChannel.ts");

const mockedFlow = jest.fn();

Expand All @@ -32,7 +32,7 @@ jest.mock("../../../../../../src/components/views/auth/LoginWithQRFlow", () => (
});

function makeClient() {
return mocked({
const cli = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
Expand All @@ -49,7 +49,16 @@ function makeClient() {
},
getClientWellKnown: jest.fn().mockReturnValue({}),
getCrypto: jest.fn().mockReturnValue({}),
getDomain: jest.fn(),
} 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> {
Expand All @@ -62,13 +71,12 @@ describe("<LoginWithQR />", () => {
legacy: true,
mode: Mode.Show,
onFinished: jest.fn(),
};
} as const;

beforeEach(() => {
mockedFlow.mockReset();
jest.resetAllMocks();
client = makeClient();
jest.useFakeTimers();
});

afterEach(() => {
Expand All @@ -79,14 +87,20 @@ describe("<LoginWithQR />", () => {
});

describe("MSC4108", () => {
const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => (
<LoginWithQR {...defaultProps} {...props} />
);
const getComponent = (props: {
client: MatrixClient;
onFinished?: () => void;
ref?: RefObject<LoginWithQR | null>;
}) => <LoginWithQR {...defaultProps} {...props} />;

test("render QR then back", async () => {
const onFinished = jest.fn();
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(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
Expand All @@ -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.negotiateProtocols).toHaveBeenCalled();

Expand All @@ -109,7 +123,8 @@ describe("<LoginWithQR />", () => {
test("should open a new channel if expires before qr scan", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
render(getComponent({ client, onFinished }));
const ref = createRef<LoginWithQR>();
render(getComponent({ client, onFinished, ref }));

await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
Expand All @@ -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.negotiateProtocols).toHaveBeenCalled();

// Expire the channel
const onFailure = mocked(MSC4108SignInWithQR).mock.calls[0][3];
onFailure!(ClientRendezvousFailureReason.Expired);
rendezvous.onFailure!(ClientRendezvousFailureReason.Expired);
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 () => {
Expand Down Expand Up @@ -168,9 +183,11 @@ describe("<LoginWithQR />", () => {
});

test("reciprocates login", async () => {
const ref = createRef<LoginWithQR>();
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, "deviceAuthorizationGrant").mockResolvedValue({
verificationUri: "mock-verification-uri",
Expand All @@ -193,10 +210,14 @@ describe("<LoginWithQR />", () => {
}),
);
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 () => {
render(getComponent({ client }));
const ref = createRef<LoginWithQR>();
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
const err = new RendezvousError("Unknown Failure", MSC4108FailureReason.UnsupportedProtocol);
// @ts-ignore work-around for lazy mocks
Expand All @@ -211,7 +232,7 @@ describe("<LoginWithQR />", () => {
);

await waitFor(() => {
const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0];
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UnsupportedProtocol);
});
});
Expand Down Expand Up @@ -244,7 +265,8 @@ describe("<LoginWithQR />", () => {
});

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, "deviceAuthorizationGrant").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
Expand All @@ -259,7 +281,7 @@ describe("<LoginWithQR />", () => {
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Cancel);

const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0];
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
});
});
Expand Down
Loading