diff --git a/apps/web/src/components/views/auth/LoginWithQR.tsx b/apps/web/src/components/views/auth/LoginWithQR.tsx index 4e1906b25e5..808e42e66bd 100644 --- a/apps/web/src/components/views/auth/LoginWithQR.tsx +++ b/apps/web/src/components/views/auth/LoginWithQR.tsx @@ -9,9 +9,8 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { ClientRendezvousFailureReason, + linkNewDeviceByGeneratingQR, MSC4108FailureReason, - MSC4108RendezvousSession, - MSC4108SecureChannel, MSC4108SignInWithQR, RendezvousError, type RendezvousFailureReason, @@ -55,6 +54,7 @@ export type FailureReason = RendezvousFailureReason | LoginWithQRFailureReason; */ export default class LoginWithQR extends React.Component { private finished = false; + private abortController?: AbortController; public constructor(props: IProps) { super(props); @@ -69,35 +69,31 @@ export default class LoginWithQR extends React.Component { } public componentDidMount(): void { - this.updateMode(this.props.mode).then(() => {}); + void this.updateMode(this.props.mode); } public componentDidUpdate(prevProps: Readonly): 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 { - 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(); } } @@ -106,24 +102,18 @@ export default class LoginWithQR extends React.Component { this.props.onFinished(success); } - private generateAndShowCode = async (): Promise => { + private generateAndShowCode = async (abortController: AbortController): Promise => { 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; @@ -142,8 +132,9 @@ export default class LoginWithQR extends React.Component { // 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, ); } @@ -210,6 +201,7 @@ export default class LoginWithQR extends React.Component { }; public reset(): void { + this.abortController?.abort(); this.setState({ rendezvous: undefined, verificationUri: undefined, diff --git a/apps/web/src/components/views/settings/devices/LoginWithQRSection.tsx b/apps/web/src/components/views/settings/devices/LoginWithQRSection.tsx index 523633c8845..f7f73b90cab 100644 --- a/apps/web/src/components/views/settings/devices/LoginWithQRSection.tsx +++ b/apps/web/src/components/views/settings/devices/LoginWithQRSection.tsx @@ -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 { + 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 = ({ onShowQr, versions, oidcClientConfig, isCrossSigningReady }) => { +const LoginWithQRSection: React.FC = ({ onShowQr, isCrossSigningReady }) => { const cli = useMatrixClientContext(); - const offerShowQr = shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions); + const offerShowQr = useAsyncMemo( + () => shouldShowQrForLinkNewDevice(cli, !!isCrossSigningReady), + [cli, isCrossSigningReady], + false, + ); return ( diff --git a/apps/web/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/apps/web/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 8cf234ca122..01aba5ccdb0 100644 --- a/apps/web/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/apps/web/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -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], @@ -279,12 +271,7 @@ const SessionManagerTab: React.FC<{ return ( - + ( }); function makeClient() { - return mocked({ + const cli = mocked({ getUser: jest.fn(), isGuest: jest.fn().mockReturnValue(false), isUserIgnored: jest.fn(), @@ -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(): Promise { @@ -62,13 +71,12 @@ describe("", () => { legacy: true, mode: Mode.Show, onFinished: jest.fn(), - }; + } as const; beforeEach(() => { mockedFlow.mockReset(); jest.resetAllMocks(); client = makeClient(); - jest.useFakeTimers(); }); afterEach(() => { @@ -79,14 +87,20 @@ describe("", () => { }); describe("MSC4108", () => { - const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => ( - - ); + const getComponent = (props: { + client: MatrixClient; + onFinished?: () => void; + ref?: RefObject; + }) => ; 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(); + render(getComponent({ client, onFinished, ref })); await waitFor(() => expect(mockedFlow).toHaveBeenLastCalledWith({ @@ -95,7 +109,7 @@ describe("", () => { }), ); - const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0]; + const rendezvous = ref.current!.state.rendezvous!; expect(rendezvous.generateCode).toHaveBeenCalled(); expect(rendezvous.negotiateProtocols).toHaveBeenCalled(); @@ -109,7 +123,8 @@ describe("", () => { 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(); + render(getComponent({ client, onFinished, ref })); await waitFor(() => expect(mockedFlow).toHaveBeenLastCalledWith({ @@ -118,15 +133,15 @@ describe("", () => { }), ); - 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 () => { @@ -168,9 +183,11 @@ describe("", () => { }); test("reciprocates login", async () => { + const ref = createRef(); 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", @@ -193,10 +210,14 @@ describe("", () => { }), ); 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(); + 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 @@ -211,7 +232,7 @@ describe("", () => { ); await waitFor(() => { - const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0]; + const rendezvous = ref.current!.state.rendezvous!; expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UnsupportedProtocol); }); }); @@ -244,7 +265,8 @@ describe("", () => { }); test("handles user cancelling during reciprocation", async () => { - render(getComponent({ client })); + const ref = createRef(); + render(getComponent({ client, ref })); jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({}); jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({}); jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({}); @@ -259,7 +281,7 @@ describe("", () => { 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); }); });