diff --git a/.eslintrc.js b/.eslintrc.js index caeeca403d..4bec4e8320 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -98,8 +98,6 @@ module.exports = { "!matrix-js-sdk/src/secret-storage", "!matrix-js-sdk/src/room-hierarchy", "!matrix-js-sdk/src/rendezvous", - "!matrix-js-sdk/src/rendezvous/transports", - "!matrix-js-sdk/src/rendezvous/channels", "!matrix-js-sdk/src/indexeddb-worker", "!matrix-js-sdk/src/pushprocessor", "!matrix-js-sdk/src/extensible_events_v1", diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 070ac5f854..6e225467af 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -27,7 +27,7 @@ jobs: cache: "yarn" - name: Install Deps - run: "./scripts/ci/install-deps.sh --ignore-scripts" + run: "./scripts/ci/install-deps.sh" - name: Typecheck run: "yarn run lint:types" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7089569f73..3815c4fb4c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,7 +47,7 @@ jobs: cache: "yarn" - name: Install Deps - run: "./scripts/ci/install-deps.sh --ignore-scripts" + run: "./scripts/ci/install-deps.sh" env: JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index f25c15e48e..5f8a6a70a1 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -207,6 +207,10 @@ limitations under the License. .mx_UserMenu_iconSignOut::before { mask-image: url("$(res)/img/element-icons/leave.svg"); } + + .mx_UserMenu_iconQr::before { + mask-image: url("@vector-im/compound-design-tokens/icons/qr-code.svg"); + } } .mx_UserMenu_CustomStatusSection { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 5e50f68b48..4049248111 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -764,7 +764,7 @@ export default class MatrixChat extends React.PureComponent { const tabPayload = payload as OpenToTabPayload; Modal.createDialog( UserSettingsDialog, - { initialTabId: tabPayload.initialTabId as UserTab, sdkContext: this.stores }, + { ...payload.props, initialTabId: tabPayload.initialTabId as UserTab, sdkContext: this.stores }, /*className=*/ undefined, /*isPriority=*/ false, /*isStatic=*/ true, diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 2e8b5d91a3..0e6b17ccc0 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React, { createRef, ReactNode } from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { discoverAndValidateOIDCIssuerWellKnown, Room } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -52,6 +52,8 @@ import { Icon as LiveIcon } from "../../../res/img/compound/live-8px.svg"; import { VoiceBroadcastRecording, VoiceBroadcastRecordingsStoreEvent } from "../../voice-broadcast"; import { SDKContext } from "../../contexts/SDKContext"; import { shouldShowFeedback } from "../../utils/Feedback"; +import { shouldShowQr } from "../views/settings/devices/LoginWithQRSection"; +import { Features } from "../../settings/Settings"; interface IProps { isPanelCollapsed: boolean; @@ -66,6 +68,8 @@ interface IState { isHighContrast: boolean; selectedSpace?: Room | null; showLiveAvatarAddon: boolean; + showQrLogin: boolean; + supportsQrLogin: boolean; } const toRightOf = (rect: PartialDOMRect): MenuProps => { @@ -103,6 +107,8 @@ export default class UserMenu extends React.Component { isHighContrast: this.isUserOnHighContrastTheme(), selectedSpace: SpaceStore.instance.activeSpaceRoom, showLiveAvatarAddon: this.context.voiceBroadcastRecordingsStore.hasCurrent(), + showQrLogin: false, + supportsQrLogin: false, }; OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); @@ -126,6 +132,7 @@ export default class UserMenu extends React.Component { ); this.dispatcherRef = defaultDispatcher.register(this.onAction); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); + this.checkQrLoginSupport(); } public componentWillUnmount(): void { @@ -140,6 +147,29 @@ export default class UserMenu extends React.Component { ); } + private checkQrLoginSupport = async (): Promise => { + if (!this.context.client || !SettingsStore.getValue(Features.OidcNativeFlow)) return; + + const { issuer } = await this.context.client.getAuthIssuer().catch(() => ({ issuer: undefined })); + if (issuer) { + const [oidcClientConfig, versions, wellKnown, isCrossSigningReady] = await Promise.all([ + discoverAndValidateOIDCIssuerWellKnown(issuer), + this.context.client.getVersions(), + this.context.client.waitForClientWellKnown(), + this.context.client.getCrypto()?.isCrossSigningReady(), + ]); + + const supportsQrLogin = shouldShowQr( + this.context.client, + !!isCrossSigningReady, + oidcClientConfig, + versions, + wellKnown, + ); + this.setState({ supportsQrLogin, showQrLogin: true }); + } + }; + private isUserOnDarkTheme(): boolean { if (SettingsStore.getValue("use_system_theme")) { return window.matchMedia("(prefers-color-scheme: dark)").matches; @@ -237,11 +267,11 @@ export default class UserMenu extends React.Component { SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab }; - private onSettingsOpen = (ev: ButtonEvent, tabId?: string): void => { + private onSettingsOpen = (ev: ButtonEvent, tabId?: string, props?: Record): void => { ev.preventDefault(); ev.stopPropagation(); - const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId }; + const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId, props }; defaultDispatcher.dispatch(payload); this.setState({ contextMenuPosition: null }); // also close the menu }; @@ -363,9 +393,33 @@ export default class UserMenu extends React.Component { ); } + let linkNewDeviceButton: JSX.Element | undefined; + if (this.state.showQrLogin) { + const extraProps: Omit< + React.ComponentProps, + "iconClassname" | "label" | "onClick" + > = {}; + if (!this.state.supportsQrLogin) { + extraProps.disabled = true; + extraProps.title = _t("user_menu|link_new_device_not_supported"); + extraProps.caption = _t("user_menu|link_new_device_not_supported_caption"); + extraProps.placement = "right"; + } + + linkNewDeviceButton = ( + this.onSettingsOpen(e, UserTab.SessionManager, { showMsc4108QrCode: true })} + /> + ); + } + let primaryOptionList = ( {homeButton} + {linkNewDeviceButton} { + private finished = false; + public constructor(props: IProps) { super(props); @@ -70,6 +94,10 @@ export default class LoginWithQR extends React.Component { }; } + private get ourIntent(): RendezvousIntent { + return RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; + } + public componentDidMount(): void { this.updateMode(this.props.mode).then(() => {}); } @@ -85,27 +113,36 @@ export default class LoginWithQR extends React.Component { if (this.state.rendezvous) { const rendezvous = this.state.rendezvous; rendezvous.onFailure = undefined; - await rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled); + if (rendezvous instanceof MSC3906Rendezvous) { + await rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled); + } this.setState({ rendezvous: undefined }); } if (mode === Mode.Show) { - await this.generateCode(); + await this.generateAndShowCode(); } } public componentWillUnmount(): void { - if (this.state.rendezvous) { + 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(LegacyRendezvousFailureReason.UserCancelled).then(() => {}); + if (this.state.rendezvous instanceof MSC3906Rendezvous) { + this.state.rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled); + } else { + this.state.rendezvous.cancel(MSC4108FailureReason.UserCancelled); + } } } - private approveLogin = async (): Promise => { - if (!this.state.rendezvous) { + private async legacyApproveLogin(): Promise { + if (!(this.state.rendezvous instanceof MSC3906Rendezvous)) { throw new Error("Rendezvous not found"); } + if (!this.props.client) { + throw new Error("No client to approve login with"); + } this.setState({ phase: Phase.Loading }); try { @@ -125,7 +162,7 @@ export default class LoginWithQR extends React.Component { } if (!this.props.client.getCrypto()) { // no E2EE to set up - this.props.onFinished(true); + this.onFinished(true); return; } this.setState({ phase: Phase.Verifying }); @@ -136,7 +173,7 @@ export default class LoginWithQR extends React.Component { } finally { this.setState({ rendezvous: undefined }); } - this.props.onFinished(true); + this.onFinished(true); } catch (e) { logger.error("Error whilst approving sign in", e); if (e instanceof HTTPError && e.httpStatus === 429) { @@ -144,27 +181,38 @@ export default class LoginWithQR extends React.Component { this.setState({ phase: Phase.Error, failureReason: LoginWithQRFailureReason.RateLimited }); return; } - this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.Unknown }); + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown }); } - }; + } - private generateCode = async (): Promise => { - let rendezvous: MSC3906Rendezvous; + private onFinished(success: boolean): void { + this.finished = true; + this.props.onFinished(success); + } + + private generateAndShowCode = async (): Promise => { + let rendezvous: MSC4108SignInWithQR | MSC3906Rendezvous; try { - const fallbackRzServer = this.props.client.getClientWellKnown()?.["io.element.rendezvous"]?.server; - const transport = new MSC3886SimpleHttpRendezvousTransport({ - onFailure: this.onFailure, - client: this.props.client, - fallbackRzServer, - }); + const fallbackRzServer = this.props.client?.getClientWellKnown()?.["io.element.rendezvous"]?.server; - const channel = new MSC3903ECDHv2RendezvousChannel( - transport, - undefined, - this.onFailure, - ); - - rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure); + if (this.props.legacy) { + const transport = new MSC3886SimpleHttpRendezvousTransport({ + onFailure: this.onFailure, + client: this.props.client, + fallbackRzServer, + }); + const channel = new MSC3903ECDHv2RendezvousChannel(transport, undefined, this.onFailure); + rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure); + } else { + const transport = new MSC4108RendezvousSession({ + onFailure: this.onFailure, + client: this.props.client, + fallbackRzServer, + }); + 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({ @@ -174,23 +222,84 @@ export default class LoginWithQR extends React.Component { }); } catch (e) { logger.error("Error whilst generating QR code", e); - this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.HomeserverLacksSupport }); + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport }); return; } try { - const confirmationDigits = await rendezvous.startAfterShowingCode(); - this.setState({ phase: Phase.Connected, confirmationDigits }); - } catch (e) { - logger.error("Error whilst doing QR login", e); - // only set to error phase if it hasn't already been set by onFailure or similar - if (this.state.phase !== Phase.Error) { - this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.Unknown }); + if (rendezvous instanceof MSC3906Rendezvous) { + const confirmationDigits = await rendezvous.startAfterShowingCode(); + this.setState({ phase: Phase.LegacyConnected, confirmationDigits }); + } else if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) { + // MSC4108-Flow: NewScanned + await rendezvous.negotiateProtocols(); + const { verificationUri } = await rendezvous.deviceAuthorizationGrant(); + this.setState({ + phase: Phase.OutOfBandConfirmation, + verificationUri, + }); + } + + // we ask the user to confirm that the channel is secure + } catch (e: RendezvousError | unknown) { + logger.error("Error whilst approving login", e); + if (rendezvous instanceof MSC3906Rendezvous) { + // only set to error phase if it hasn't already been set by onFailure or similar + if (this.state.phase !== Phase.Error) { + this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.Unknown }); + } + } else { + await rendezvous?.cancel( + e instanceof RendezvousError + ? (e.code as MSC4108FailureReason) + : ClientRendezvousFailureReason.Unknown, + ); } } }; - private onFailure = (reason: LegacyRendezvousFailureReason): void => { + private approveLogin = async (checkCode: string | undefined): Promise => { + if (!(this.state.rendezvous instanceof MSC4108SignInWithQR)) { + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown }); + throw new Error("Rendezvous not found"); + } + + if (!this.state.lastScannedCode && this.state.rendezvous?.checkCode !== checkCode) { + this.setState({ failureReason: LoginWithQRFailureReason.CheckCodeMismatch }); + return; + } + + try { + if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) { + // MSC4108-Flow: NewScanned + this.setState({ phase: Phase.Loading }); + + if (this.state.verificationUri) { + window.open(this.state.verificationUri, "_blank"); + } + + this.setState({ phase: Phase.WaitingForDevice }); + + // send secrets + await this.state.rendezvous.shareSecrets(); + + // done + this.onFinished(true); + } else { + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown }); + throw new Error("New device flows around OIDC are not yet implemented"); + } + } catch (e: RendezvousError | unknown) { + logger.error("Error whilst approving sign in", e); + this.setState({ + phase: Phase.Error, + failureReason: e instanceof RendezvousError ? e.code : ClientRendezvousFailureReason.Unknown, + }); + } + }; + + private onFailure = (reason: RendezvousFailureReason): void => { + if (this.state.phase === Phase.Error) return; // Already in failed state logger.info(`Rendezvous failed: ${reason}`); this.setState({ phase: Phase.Error, failureReason: reason }); }; @@ -199,44 +308,72 @@ export default class LoginWithQR extends React.Component { this.setState({ rendezvous: undefined, confirmationDigits: undefined, + verificationUri: undefined, failureReason: undefined, + userCode: undefined, + checkCode: undefined, + homeserverBaseUrl: undefined, + lastScannedCode: undefined, + mediaPermissionError: false, }); } - private onClick = async (type: Click): Promise => { + private onClick = async (type: Click, checkCode?: string): Promise => { switch (type) { case Click.Cancel: - await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled); + if (this.state.rendezvous instanceof MSC3906Rendezvous) { + await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled); + } else { + await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled); + } this.reset(); - this.props.onFinished(false); + this.onFinished(false); break; case Click.Approve: - await this.approveLogin(); + await (this.props.legacy ? this.legacyApproveLogin() : this.approveLogin(checkCode)); break; case Click.Decline: await this.state.rendezvous?.declineLoginOnExistingDevice(); this.reset(); - this.props.onFinished(false); - break; - case Click.TryAgain: - this.reset(); - await this.updateMode(this.props.mode); + this.onFinished(false); break; case Click.Back: - await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled); - this.props.onFinished(false); + if (this.state.rendezvous instanceof MSC3906Rendezvous) { + await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled); + } else { + await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled); + } + this.onFinished(false); + break; + case Click.ShowQr: + await this.updateMode(Mode.Show); break; } }; public render(): React.ReactNode { + if (this.state.rendezvous instanceof MSC3906Rendezvous) { + return ( + + ); + } + return ( ); } diff --git a/src/components/views/auth/LoginWithQRFlow.tsx b/src/components/views/auth/LoginWithQRFlow.tsx index c8a786992a..036dc1b451 100644 --- a/src/components/views/auth/LoginWithQRFlow.tsx +++ b/src/components/views/auth/LoginWithQRFlow.tsx @@ -14,12 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode } from "react"; -import { LegacyRendezvousFailureReason } from "matrix-js-sdk/src/rendezvous"; +import React, { createRef, ReactNode } from "react"; +import { + ClientRendezvousFailureReason, + LegacyRendezvousFailureReason, + MSC4108FailureReason, +} from "matrix-js-sdk/src/rendezvous"; import { Icon as ChevronLeftIcon } from "@vector-im/compound-design-tokens/icons/chevron-left.svg"; import { Icon as CheckCircleSolidIcon } from "@vector-im/compound-design-tokens/icons/check-circle-solid.svg"; import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error.svg"; -import { Heading, Text } from "@vector-im/compound-web"; +import { Heading, MFAInput, Text } from "@vector-im/compound-web"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; @@ -30,13 +34,24 @@ import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg"; import { Click, Phase } from "./LoginWithQR-types"; import SdkConfig from "../../../SdkConfig"; import { FailureReason, LoginWithQRFailureReason } from "./LoginWithQR"; +import { XOR } from "../../../@types/common"; +import { ErrorMessage } from "../../structures/ErrorMessage"; + +/** + * @deprecated the MSC3906 implementation is deprecated in favour of MSC4108. + */ +interface MSC3906Props extends Pick { + code?: string; + confirmationDigits?: string; +} interface Props { phase: Phase; - code?: string; - onClick(type: Click): Promise; + code?: Uint8Array; + onClick(type: Click, checkCodeEntered?: string): Promise; failureReason?: FailureReason; - confirmationDigits?: string; + userCode?: string; + checkCode?: string; } // n.b MSC3886/MSC3903/MSC3906 that this is based on are now closed. @@ -46,17 +61,19 @@ interface Props { /** * A component that implements the UI for sign in and E2EE set up with a QR code. * - * This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906 + * This supports the unstable features of MSC3906 and MSC4108 */ -export default class LoginWithQRFlow extends React.Component { - public constructor(props: Props) { +export default class LoginWithQRFlow extends React.Component> { + private checkCodeInput = createRef(); + + public constructor(props: XOR) { super(props); } private handleClick = (type: Click): ((e: React.FormEvent) => Promise) => { return async (e: React.FormEvent): Promise => { e.preventDefault(); - await this.props.onClick(type); + await this.props.onClick(type, type === Click.Approve ? this.checkCodeInput.current?.value : undefined); }; }; @@ -90,24 +107,26 @@ export default class LoginWithQRFlow extends React.Component { let message: ReactNode | undefined; switch (this.props.failureReason) { - case LegacyRendezvousFailureReason.UnsupportedAlgorithm: - case LegacyRendezvousFailureReason.UnsupportedTransport: - case LegacyRendezvousFailureReason.HomeserverLacksSupport: + case MSC4108FailureReason.UnsupportedProtocol: + case LegacyRendezvousFailureReason.UnsupportedProtocol: title = _t("auth|qr_code_login|error_unsupported_protocol_title"); message = _t("auth|qr_code_login|error_unsupported_protocol"); break; + case MSC4108FailureReason.UserCancelled: case LegacyRendezvousFailureReason.UserCancelled: title = _t("auth|qr_code_login|error_user_cancelled_title"); message = _t("auth|qr_code_login|error_user_cancelled"); break; + case MSC4108FailureReason.AuthorizationExpired: + case ClientRendezvousFailureReason.Expired: case LegacyRendezvousFailureReason.Expired: title = _t("auth|qr_code_login|error_expired_title"); message = _t("auth|qr_code_login|error_expired"); break; - case LegacyRendezvousFailureReason.InvalidCode: + case ClientRendezvousFailureReason.InsecureChannelDetected: title = _t("auth|qr_code_login|error_insecure_channel_detected_title"); message = ( <> @@ -125,13 +144,13 @@ export default class LoginWithQRFlow extends React.Component { ); break; - case LegacyRendezvousFailureReason.OtherDeviceAlreadySignedIn: + case ClientRendezvousFailureReason.OtherDeviceAlreadySignedIn: success = true; title = _t("auth|qr_code_login|error_other_device_already_signed_in_title"); message = _t("auth|qr_code_login|error_other_device_already_signed_in"); break; - case LegacyRendezvousFailureReason.UserDeclined: + case ClientRendezvousFailureReason.UserDeclined: title = _t("auth|qr_code_login|error_user_declined_title"); message = _t("auth|qr_code_login|error_user_declined"); break; @@ -141,8 +160,16 @@ export default class LoginWithQRFlow extends React.Component { message = _t("auth|qr_code_login|error_rate_limited"); break; - case LegacyRendezvousFailureReason.OtherDeviceNotSignedIn: - case LegacyRendezvousFailureReason.Unknown: + case ClientRendezvousFailureReason.ETagMissing: + title = _t("error|something_went_wrong"); + message = _t("auth|qr_code_login|error_etag_missing"); + break; + + case MSC4108FailureReason.DeviceAlreadyExists: + case MSC4108FailureReason.DeviceNotFound: + case MSC4108FailureReason.UnexpectedMessageReceived: + case ClientRendezvousFailureReason.OtherDeviceNotSignedIn: + case ClientRendezvousFailureReason.Unknown: default: title = _t("error|something_went_wrong"); message = _t("auth|qr_code_login|error_unexpected"); @@ -150,18 +177,6 @@ export default class LoginWithQRFlow extends React.Component { } className = "mx_LoginWithQR_error"; backButton = false; - buttons = ( - <> - - {_t("action|try_again")} - - {this.cancelButton()} - - ); main = ( <>
{ ); break; } - case Phase.Connected: + case Phase.LegacyConnected: backButton = false; main = ( <> @@ -213,9 +228,62 @@ export default class LoginWithQRFlow extends React.Component { ); break; + case Phase.OutOfBandConfirmation: + backButton = false; + main = ( + <> + + {_t("auth|qr_code_login|check_code_heading")} + + {_t("auth|qr_code_login|check_code_explainer")} + + + + + ); + + buttons = ( + <> + + {_t("action|continue")} + + + {_t("action|cancel")} + + + ); + break; case Phase.ShowingQR: if (this.props.code) { - const data = Buffer.from(this.props.code ?? ""); + const data = + typeof this.props.code !== "string" ? this.props.code : Buffer.from(this.props.code ?? ""); main = ( <> @@ -249,12 +317,19 @@ export default class LoginWithQRFlow extends React.Component { case Phase.Loading: main = this.simpleSpinner(); break; - case Phase.Connecting: - main = this.simpleSpinner(_t("auth|qr_code_login|connecting")); - buttons = this.cancelButton(); - break; case Phase.WaitingForDevice: - main = this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device")); + main = ( + <> + {this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))} + {this.props.userCode ? ( +
+

{_t("auth|qr_code_login|security_code")}

+

{_t("auth|qr_code_login|security_code_prompt")}

+

{this.props.userCode}

+
+ ) : null} + + ); buttons = this.cancelButton(); break; case Phase.Verifying: diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx index bb97b36fc9..afcbc182d0 100644 --- a/src/components/views/dialogs/UserSettingsDialog.tsx +++ b/src/components/views/dialogs/UserSettingsDialog.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { useState } from "react"; import TabbedView, { Tab, useActiveTabWithDefault } from "../../structures/TabbedView"; import { _t, _td } from "../../../languageHandler"; @@ -41,6 +41,7 @@ import { useSettingValue } from "../../../hooks/useSettings"; interface IProps { initialTabId?: UserTab; + showMsc4108QrCode?: boolean; sdkContext: SdkContextClass; onFinished(): void; } @@ -80,6 +81,8 @@ function titleForTabID(tabId: UserTab): React.ReactNode { export default function UserSettingsDialog(props: IProps): JSX.Element { const voipEnabled = useSettingValue(UIFeature.Voip); const mjolnirEnabled = useSettingValue("feature_mjolnir"); + // store this prop in state as changing tabs back and forth should clear it + const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode); const getTabs = (): NonEmptyArray> => { const tabs: Tab[] = []; @@ -98,7 +101,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { UserTab.SessionManager, _td("settings|sessions|title"), "mx_UserSettingsDialog_sessionsIcon", - , + , undefined, ), ); @@ -205,7 +208,12 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { return tabs as NonEmptyArray>; }; - const [activeTabId, setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.General, props.initialTabId); + const [activeTabId, _setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.General, props.initialTabId); + const setActiveTabId = (tabId: UserTab): void => { + _setActiveTabId(tabId); + // Clear this so switching away from the tab and back to it will not show the QR code again + setShowMsc4108QrCode(false); + }; return ( // XXX: SDKContext is provided within the LoggedInView subtree. diff --git a/src/components/views/settings/devices/LoginWithQRSection.tsx b/src/components/views/settings/devices/LoginWithQRSection.tsx index b83668b6b8..9c7ed9efe6 100644 --- a/src/components/views/settings/devices/LoginWithQRSection.tsx +++ b/src/components/views/settings/devices/LoginWithQRSection.tsx @@ -21,18 +21,26 @@ import { GET_LOGIN_TOKEN_CAPABILITY, Capabilities, IClientWellKnown, + OidcClientConfig, + MatrixClient, + DEVICE_CODE_SCOPE, } from "matrix-js-sdk/src/matrix"; import { Icon as QrCodeIcon } from "@vector-im/compound-design-tokens/icons/qr-code.svg"; import { _t } from "../../../../languageHandler"; import AccessibleButton from "../../elements/AccessibleButton"; import SettingsSubsection from "../shared/SettingsSubsection"; +import SettingsStore from "../../../../settings/SettingsStore"; +import { Features } from "../../../../settings/Settings"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; interface IProps { onShowQr: () => void; versions?: IServerVersions; capabilities?: Capabilities; wellKnown?: IClientWellKnown; + oidcClientConfig?: OidcClientConfig; + isCrossSigningReady?: boolean; } function shouldShowQrLegacy( @@ -50,8 +58,40 @@ function shouldShowQrLegacy( return getLoginTokenSupported && msc3886Supported; } -const LoginWithQRSection: React.FC = ({ onShowQr, versions, capabilities, wellKnown }) => { - const offerShowQr = shouldShowQrLegacy(versions, wellKnown, capabilities); +export function shouldShowQr( + cli: MatrixClient, + isCrossSigningReady: boolean, + oidcClientConfig?: OidcClientConfig, + versions?: IServerVersions, + wellKnown?: IClientWellKnown, +): boolean { + const msc4108Supported = + !!versions?.unstable_features?.["org.matrix.msc4108"] || !!wellKnown?.["io.element.rendezvous"]?.server; + + const deviceAuthorizationGrantSupported = + oidcClientConfig?.metadata?.grant_types_supported.includes(DEVICE_CODE_SCOPE); + + return ( + deviceAuthorizationGrantSupported && + msc4108Supported && + SettingsStore.getValue(Features.OidcNativeFlow) && + !!cli.getCrypto()?.exportSecretsBundle && + isCrossSigningReady + ); +} + +const LoginWithQRSection: React.FC = ({ + onShowQr, + versions, + capabilities, + wellKnown, + oidcClientConfig, + isCrossSigningReady, +}) => { + const cli = useMatrixClientContext(); + const offerShowQr = oidcClientConfig + ? shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions, wellKnown) + : shouldShowQrLegacy(versions, wellKnown, capabilities); // don't show anything if no method is available if (!offerShowQr) { diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index ec1d658b5b..ee51d0680f 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { discoverAndValidateOIDCIssuerWellKnown, MatrixClient } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../../../languageHandler"; @@ -32,7 +32,6 @@ import { ExtendedDevice } from "../../devices/types"; import { deleteDevicesWithInteractiveAuth } from "../../devices/deleteDevices"; import SettingsTab from "../SettingsTab"; import LoginWithQRSection from "../../devices/LoginWithQRSection"; -import LoginWithQR from "../../../auth/LoginWithQR"; import { Mode } from "../../../auth/LoginWithQR-types"; import { useAsyncMemo } from "../../../../../hooks/useAsyncMemo"; import QuestionDialog from "../../../dialogs/QuestionDialog"; @@ -41,6 +40,10 @@ import { OtherSessionsSectionHeading } from "../../devices/OtherSessionsSectionH import { SettingsSection } from "../../shared/SettingsSection"; import { OidcLogoutDialog } from "../../../dialogs/oidc/OidcLogoutDialog"; import { SDKContext } from "../../../../../contexts/SDKContext"; +import Spinner from "../../../elements/Spinner"; + +// We import `LoginWithQR` asynchronously to avoid importing the entire Rust Crypto WASM into the main bundle. +const LoginWithQR = lazy(() => import("../../../auth/LoginWithQR")); const confirmSignOut = async (sessionsToSignOutCount: number): Promise => { const { finished } = Modal.createDialog(QuestionDialog, { @@ -148,7 +151,9 @@ const useSignOut = ( }; }; -const SessionManagerTab: React.FC = () => { +const SessionManagerTab: React.FC<{ + showMsc4108QrCode?: boolean; +}> = ({ showMsc4108QrCode }) => { const { devices, dehydratedDeviceId, @@ -186,6 +191,20 @@ const SessionManagerTab: React.FC = () => { const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]); const capabilities = useAsyncMemo(async () => matrixClient?.getCapabilities(), [matrixClient]); const wellKnown = useMemo(() => matrixClient?.getClientWellKnown(), [matrixClient]); + const oidcClientConfig = useAsyncMemo(async () => { + try { + const authIssuer = await matrixClient?.getAuthIssuer(); + if (authIssuer) { + return discoverAndValidateOIDCIssuerWellKnown(authIssuer.issuer); + } + } catch (e) { + logger.error("Failed to discover OIDC metadata", e); + } + }, [matrixClient]); + const isCrossSigningReady = useAsyncMemo( + async () => matrixClient.getCrypto()?.isCrossSigningReady() ?? false, + [matrixClient], + ); const onDeviceExpandToggle = (deviceId: ExtendedDevice["device_id"]): void => { if (expandedDeviceIds.includes(deviceId)) { @@ -268,7 +287,7 @@ const SessionManagerTab: React.FC = () => { } : undefined; - const [signInWithQrMode, setSignInWithQrMode] = useState(); + const [signInWithQrMode, setSignInWithQrMode] = useState(showMsc4108QrCode ? Mode.Show : null); const onQrFinish = useCallback(() => { setSignInWithQrMode(null); @@ -279,7 +298,16 @@ const SessionManagerTab: React.FC = () => { }, [setSignInWithQrMode]); if (signInWithQrMode) { - return ; + return ( + }> + + + ); } return ( @@ -290,6 +318,8 @@ const SessionManagerTab: React.FC = () => { versions={clientVersions} capabilities={capabilities} wellKnown={wellKnown} + oidcClientConfig={oidcClientConfig} + isCrossSigningReady={isCrossSigningReady} /> ; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 7db9435c00..d730488771 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -246,9 +246,13 @@ "phone_optional_label": "Phone (optional)", "qr_code_login": { "approve_access_warning": "By approving access for this device, it will have full access to your account.", + "check_code_explainer": "This will verify that the connection to your other device is secure.", + "check_code_heading": "Enter the number shown on your other device", + "check_code_input_label": "2-digit code", + "check_code_mismatch": "The numbers don't match", "completing_setup": "Completing set up of your new device", "confirm_code_match": "Check that the code below matches with your other device:", - "connecting": "Connecting…", + "error_etag_missing": "An unexpected error occurred. This may be due to a browser extension, proxy server, or server misconfiguration.", "error_expired": "Sign in expired. Please try again.", "error_expired_title": "The sign in was not completed in time", "error_insecure_channel_detected": "A secure connection could not be made to the new device. Your existing devices are still safe and you don't need to worry about them.", @@ -265,13 +269,15 @@ "error_unsupported_protocol_title": "Other device not compatible", "error_user_cancelled": "The sign in was cancelled on the other device.", "error_user_cancelled_title": "Sign in request cancelled", - "error_user_declined": "You declined the request from your other device to sign in.", + "error_user_declined": "You or the account provider declined the sign in request.", "error_user_declined_title": "Sign in declined", - "follow_remaining_instructions": "Follow the instructions to link your other device", + "follow_remaining_instructions": "Follow the remaining instructions", "open_element_other_device": "Open %(brand)s on your other device", - "point_the_camera": "Point the camera at the QR code shown here", + "point_the_camera": "Scan the QR code shown here", "scan_code_instruction": "Scan the QR code with another device", "scan_qr_code": "Sign in with QR code", + "security_code": "Security code", + "security_code_prompt": "If asked, enter the code below on your other device.", "select_qr_code": "Select \"%(scanQRCode)s\"", "sign_in_new_device": "Sign in new device", "waiting_for_device": "Waiting for device to sign in" @@ -3788,6 +3794,9 @@ "verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices." }, "user_menu": { + "link_new_device": "Link new device", + "link_new_device_not_supported": "Not supported", + "link_new_device_not_supported_caption": "You need to sign in manually", "settings": "All settings", "switch_theme_dark": "Switch to dark mode", "switch_theme_light": "Switch to light mode" diff --git a/test/components/structures/UserMenu-test.tsx b/test/components/structures/UserMenu-test.tsx index 22addc5a35..24b75a87d1 100644 --- a/test/components/structures/UserMenu-test.tsx +++ b/test/components/structures/UserMenu-test.tsx @@ -16,8 +16,10 @@ limitations under the License. import React from "react"; import { act, render, RenderResult, screen, waitFor } from "@testing-library/react"; -import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { DEVICE_CODE_SCOPE, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { CryptoApi } from "matrix-js-sdk/src/crypto-api"; import { mocked } from "jest-mock"; +import fetchMock from "fetch-mock-jest"; import UnwrappedUserMenu from "../../../src/components/structures/UserMenu"; import { stubClient, wrapInSdkContext } from "../../test-utils"; @@ -31,6 +33,12 @@ import { TestSdkContext } from "../../TestSdkContext"; import defaultDispatcher from "../../../src/dispatcher/dispatcher"; import LogoutDialog from "../../../src/components/views/dialogs/LogoutDialog"; import Modal from "../../../src/Modal"; +import SettingsStore from "../../../src/settings/SettingsStore"; +import { Features } from "../../../src/settings/Settings"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { mockOpenIdConfiguration } from "../../test-utils/oidc"; +import { Action } from "../../../src/dispatcher/actions"; +import { UserTab } from "../../../src/components/views/dialogs/UserTab"; describe("", () => { let client: MatrixClient; @@ -177,4 +185,48 @@ describe("", () => { }); }); }); + + it("should render 'Link new device' button in OIDC native mode", async () => { + sdkContext.client = stubClient(); + mocked(sdkContext.client.getAuthIssuer).mockResolvedValue({ issuer: "https://issuer/" }); + const openIdMetadata = mockOpenIdConfiguration("https://issuer/"); + openIdMetadata.grant_types_supported.push(DEVICE_CODE_SCOPE); + fetchMock.get("https://issuer/.well-known/openid-configuration", openIdMetadata); + fetchMock.get("https://issuer/jwks", { + status: 200, + headers: { + "Content-Type": "application/json", + }, + keys: [], + }); + mocked(sdkContext.client.getVersions).mockResolvedValue({ + versions: [], + unstable_features: { + "org.matrix.msc4108": true, + }, + }); + mocked(sdkContext.client.waitForClientWellKnown).mockResolvedValue({}); + mocked(sdkContext.client.getCrypto).mockReturnValue({ + isCrossSigningReady: jest.fn().mockResolvedValue(true), + exportSecretsBundle: jest.fn().mockResolvedValue({}), + } as unknown as CryptoApi); + await SettingsStore.setValue(Features.OidcNativeFlow, null, SettingLevel.DEVICE, true); + const spy = jest.spyOn(defaultDispatcher, "dispatch"); + + const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); + render(); + + screen.getByRole("button", { name: /User menu/i }).click(); + await expect(screen.findByText("Link new device")).resolves.toBeInTheDocument(); + + // Assert the QR code is shown directly + screen.getByRole("menuitem", { name: "Link new device" }).click(); + await waitFor(() => { + expect(spy).toHaveBeenCalledWith({ + action: Action.ViewUserSettings, + initialTabId: UserTab.SessionManager, + props: { showMsc4108QrCode: true }, + }); + }); + }); }); diff --git a/test/components/views/settings/devices/LoginWithQR-test.tsx b/test/components/views/settings/devices/LoginWithQR-test.tsx index 727ab6a017..6e7636d0cf 100644 --- a/test/components/views/settings/devices/LoginWithQR-test.tsx +++ b/test/components/views/settings/devices/LoginWithQR-test.tsx @@ -17,7 +17,13 @@ limitations under the License. import { cleanup, render, waitFor } from "@testing-library/react"; import { MockedObject, mocked } from "jest-mock"; import React from "react"; -import { MSC3906Rendezvous, LegacyRendezvousFailureReason } from "matrix-js-sdk/src/rendezvous"; +import { + MSC3906Rendezvous, + LegacyRendezvousFailureReason, + ClientRendezvousFailureReason, + MSC4108SignInWithQR, + MSC4108FailureReason, +} from "matrix-js-sdk/src/rendezvous"; import { HTTPError, LoginTokenPostResponse } from "matrix-js-sdk/src/matrix"; import LoginWithQR from "../../../../../src/components/views/auth/LoginWithQR"; @@ -65,6 +71,7 @@ function unresolvedPromise(): Promise { describe("", () => { let client!: MockedObject; const defaultProps = { + legacy: true, mode: Mode.Show, onFinished: jest.fn(), }; @@ -72,29 +79,10 @@ describe("", () => { const mockRendezvousCode = "mock-rendezvous-code"; const newDeviceId = "new-device-id"; - const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => ( - - - - ); - beforeEach(() => { mockedFlow.mockReset(); jest.resetAllMocks(); client = makeClient(); - jest.spyOn(MSC3906Rendezvous.prototype, "generateCode").mockResolvedValue(); - // @ts-ignore - // workaround for https://github.com/facebook/jest/issues/9675 - MSC3906Rendezvous.prototype.code = mockRendezvousCode; - jest.spyOn(MSC3906Rendezvous.prototype, "cancel").mockResolvedValue(); - jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockResolvedValue(mockConfirmationDigits); - jest.spyOn(MSC3906Rendezvous.prototype, "declineLoginOnExistingDevice").mockResolvedValue(); - jest.spyOn(MSC3906Rendezvous.prototype, "approveLoginOnExistingDevice").mockResolvedValue(newDeviceId); - jest.spyOn(MSC3906Rendezvous.prototype, "verifyNewDeviceOnExistingDevice").mockResolvedValue(undefined); - client.requestLoginToken.mockResolvedValue({ - login_token: "token", - expires_in_ms: 1000 * 1000, - } as LoginTokenPostResponse); // we force the type here so that it works with versions of js-sdk that don't have r1 support yet }); afterEach(() => { @@ -104,279 +92,374 @@ describe("", () => { cleanup(); }); - test("no homeserver support", async () => { - // simulate no support - jest.spyOn(MSC3906Rendezvous.prototype, "generateCode").mockRejectedValue(""); - render(getComponent({ client })); - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith({ - phase: Phase.Error, - failureReason: LegacyRendezvousFailureReason.HomeserverLacksSupport, - onClick: expect.any(Function), - }), + describe("MSC3906", () => { + const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => ( + + + ); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - expect(rendezvous.generateCode).toHaveBeenCalled(); - }); - test("failed to connect", async () => { - jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockRejectedValue(""); - render(getComponent({ client })); - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith({ - phase: Phase.Error, - failureReason: LegacyRendezvousFailureReason.Unknown, - onClick: expect.any(Function), - }), - ); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - expect(rendezvous.generateCode).toHaveBeenCalled(); - expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); - }); - - test("render QR then cancel and try again", async () => { - const onFinished = jest.fn(); - jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockImplementation(() => unresolvedPromise()); - render(getComponent({ client, onFinished })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith( - expect.objectContaining({ - phase: Phase.ShowingQR, - }), - ), - ); - // display QR code - expect(mockedFlow).toHaveBeenLastCalledWith({ - phase: Phase.ShowingQR, - code: mockRendezvousCode, - onClick: expect.any(Function), - }); - expect(rendezvous.generateCode).toHaveBeenCalled(); - expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); - - // cancel - const onClick = mockedFlow.mock.calls[0][0].onClick; - await onClick(Click.Cancel); - expect(onFinished).toHaveBeenCalledWith(false); - expect(rendezvous.cancel).toHaveBeenCalledWith(LegacyRendezvousFailureReason.UserCancelled); - - // try again - onClick(Click.TryAgain); - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith( - expect.objectContaining({ - phase: Phase.ShowingQR, - }), - ), - ); - // display QR code - expect(mockedFlow).toHaveBeenLastCalledWith({ - phase: Phase.ShowingQR, - code: mockRendezvousCode, - onClick: expect.any(Function), - }); - }); - - test("render QR then back", async () => { - const onFinished = jest.fn(); - jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockReturnValue(unresolvedPromise()); - render(getComponent({ client, onFinished })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith( - expect.objectContaining({ - phase: Phase.ShowingQR, - }), - ), - ); - // display QR code - expect(mockedFlow).toHaveBeenLastCalledWith({ - phase: Phase.ShowingQR, - code: mockRendezvousCode, - onClick: expect.any(Function), - }); - expect(rendezvous.generateCode).toHaveBeenCalled(); - expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); - - // back - const onClick = mockedFlow.mock.calls[0][0].onClick; - await onClick(Click.Back); - expect(onFinished).toHaveBeenCalledWith(false); - expect(rendezvous.cancel).toHaveBeenCalledWith(LegacyRendezvousFailureReason.UserCancelled); - }); - - test("render QR then decline", async () => { - const onFinished = jest.fn(); - render(getComponent({ client, onFinished })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith( - expect.objectContaining({ - phase: Phase.Connected, - }), - ), - ); - expect(mockedFlow).toHaveBeenLastCalledWith({ - phase: Phase.Connected, - confirmationDigits: mockConfirmationDigits, - onClick: expect.any(Function), + beforeEach(() => { + jest.spyOn(MSC3906Rendezvous.prototype, "generateCode").mockResolvedValue(); + // @ts-ignore + // workaround for https://github.com/facebook/jest/issues/9675 + MSC3906Rendezvous.prototype.code = mockRendezvousCode; + jest.spyOn(MSC3906Rendezvous.prototype, "cancel").mockResolvedValue(); + jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockResolvedValue(mockConfirmationDigits); + jest.spyOn(MSC3906Rendezvous.prototype, "declineLoginOnExistingDevice").mockResolvedValue(); + jest.spyOn(MSC3906Rendezvous.prototype, "approveLoginOnExistingDevice").mockResolvedValue(newDeviceId); + jest.spyOn(MSC3906Rendezvous.prototype, "verifyNewDeviceOnExistingDevice").mockResolvedValue(undefined); + client.requestLoginToken.mockResolvedValue({ + login_token: "token", + expires_in_ms: 1000 * 1000, + } as LoginTokenPostResponse); // we force the type here so that it works with versions of js-sdk that don't have r1 support yet }); - // decline - const onClick = mockedFlow.mock.calls[0][0].onClick; - await onClick(Click.Decline); - expect(onFinished).toHaveBeenCalledWith(false); - - expect(rendezvous.generateCode).toHaveBeenCalled(); - expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); - expect(rendezvous.declineLoginOnExistingDevice).toHaveBeenCalled(); - }); - - test("approve - no crypto", async () => { - (client as any).crypto = undefined; - (client as any).getCrypto = () => undefined; - const onFinished = jest.fn(); - render(getComponent({ client, onFinished })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith( - expect.objectContaining({ - phase: Phase.Connected, - }), - ), - ); - expect(mockedFlow).toHaveBeenLastCalledWith({ - phase: Phase.Connected, - confirmationDigits: mockConfirmationDigits, - onClick: expect.any(Function), - }); - expect(rendezvous.generateCode).toHaveBeenCalled(); - expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); - - // approve - const onClick = mockedFlow.mock.calls[0][0].onClick; - await onClick(Click.Approve); - - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith( - expect.objectContaining({ - phase: Phase.WaitingForDevice, - }), - ), - ); - - expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token"); - - expect(onFinished).toHaveBeenCalledWith(true); - }); - - test("approve + verifying", async () => { - const onFinished = jest.fn(); - jest.spyOn(MSC3906Rendezvous.prototype, "verifyNewDeviceOnExistingDevice").mockImplementation(() => - unresolvedPromise(), - ); - render(getComponent({ client, onFinished })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith( - expect.objectContaining({ - phase: Phase.Connected, - }), - ), - ); - expect(mockedFlow).toHaveBeenLastCalledWith({ - phase: Phase.Connected, - confirmationDigits: mockConfirmationDigits, - onClick: expect.any(Function), - }); - expect(rendezvous.generateCode).toHaveBeenCalled(); - expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); - - // approve - const onClick = mockedFlow.mock.calls[0][0].onClick; - onClick(Click.Approve); - - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith( - expect.objectContaining({ - phase: Phase.Verifying, - }), - ), - ); - - expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token"); - expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled(); - // expect(onFinished).toHaveBeenCalledWith(true); - }); - - test("approve + verify", async () => { - const onFinished = jest.fn(); - render(getComponent({ client, onFinished })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith( - expect.objectContaining({ - phase: Phase.Connected, - }), - ), - ); - expect(mockedFlow).toHaveBeenLastCalledWith({ - phase: Phase.Connected, - confirmationDigits: mockConfirmationDigits, - onClick: expect.any(Function), - }); - expect(rendezvous.generateCode).toHaveBeenCalled(); - expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); - - // approve - const onClick = mockedFlow.mock.calls[0][0].onClick; - await onClick(Click.Approve); - expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token"); - expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled(); - expect(rendezvous.close).toHaveBeenCalled(); - expect(onFinished).toHaveBeenCalledWith(true); - }); - - test("approve - rate limited", async () => { - mocked(client.requestLoginToken).mockRejectedValue(new HTTPError("rate limit reached", 429)); - const onFinished = jest.fn(); - render(getComponent({ client, onFinished })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith( - expect.objectContaining({ - phase: Phase.Connected, - }), - ), - ); - expect(mockedFlow).toHaveBeenLastCalledWith({ - phase: Phase.Connected, - confirmationDigits: mockConfirmationDigits, - onClick: expect.any(Function), - }); - expect(rendezvous.generateCode).toHaveBeenCalled(); - expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); - - // approve - const onClick = mockedFlow.mock.calls[0][0].onClick; - await onClick(Click.Approve); - - // the 429 error should be handled and mapped - await waitFor(() => - expect(mockedFlow).toHaveBeenLastCalledWith( - expect.objectContaining({ + test("no homeserver support", async () => { + // simulate no support + jest.spyOn(MSC3906Rendezvous.prototype, "generateCode").mockRejectedValue(""); + render(getComponent({ client })); + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith({ phase: Phase.Error, - failureReason: "rate_limited", + failureReason: LegacyRendezvousFailureReason.HomeserverLacksSupport, + onClick: expect.any(Function), }), - ), + ); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + expect(rendezvous.generateCode).toHaveBeenCalled(); + }); + + test("failed to connect", async () => { + jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockRejectedValue(""); + render(getComponent({ client })); + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.Error, + failureReason: ClientRendezvousFailureReason.Unknown, + onClick: expect.any(Function), + }), + ); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); + }); + + test("render QR then back", async () => { + const onFinished = jest.fn(); + jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockReturnValue(unresolvedPromise()); + render(getComponent({ client, onFinished })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: Phase.ShowingQR, + }), + ), + ); + // display QR code + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.ShowingQR, + code: mockRendezvousCode, + onClick: expect.any(Function), + }); + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); + + // back + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Back); + expect(onFinished).toHaveBeenCalledWith(false); + expect(rendezvous.cancel).toHaveBeenCalledWith(LegacyRendezvousFailureReason.UserCancelled); + }); + + test("render QR then decline", async () => { + const onFinished = jest.fn(); + render(getComponent({ client, onFinished })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: Phase.LegacyConnected, + }), + ), + ); + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.LegacyConnected, + confirmationDigits: mockConfirmationDigits, + onClick: expect.any(Function), + }); + + // decline + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Decline); + expect(onFinished).toHaveBeenCalledWith(false); + + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); + expect(rendezvous.declineLoginOnExistingDevice).toHaveBeenCalled(); + }); + + test("approve - no crypto", async () => { + (client as any).crypto = undefined; + (client as any).getCrypto = () => undefined; + const onFinished = jest.fn(); + render(getComponent({ client, onFinished })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: Phase.LegacyConnected, + }), + ), + ); + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.LegacyConnected, + confirmationDigits: mockConfirmationDigits, + onClick: expect.any(Function), + }); + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); + + // approve + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Approve); + + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: Phase.WaitingForDevice, + }), + ), + ); + + expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token"); + + expect(onFinished).toHaveBeenCalledWith(true); + }); + + test("approve + verifying", async () => { + const onFinished = jest.fn(); + jest.spyOn(MSC3906Rendezvous.prototype, "verifyNewDeviceOnExistingDevice").mockImplementation(() => + unresolvedPromise(), + ); + render(getComponent({ client, onFinished })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: Phase.LegacyConnected, + }), + ), + ); + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.LegacyConnected, + confirmationDigits: mockConfirmationDigits, + onClick: expect.any(Function), + }); + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); + + // approve + const onClick = mockedFlow.mock.calls[0][0].onClick; + onClick(Click.Approve); + + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: Phase.Verifying, + }), + ), + ); + + expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token"); + expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled(); + // expect(onFinished).toHaveBeenCalledWith(true); + }); + + test("approve + verify", async () => { + const onFinished = jest.fn(); + render(getComponent({ client, onFinished })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: Phase.LegacyConnected, + }), + ), + ); + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.LegacyConnected, + confirmationDigits: mockConfirmationDigits, + onClick: expect.any(Function), + }); + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); + + // approve + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Approve); + expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token"); + expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled(); + expect(rendezvous.close).toHaveBeenCalled(); + expect(onFinished).toHaveBeenCalledWith(true); + }); + + test("approve - rate limited", async () => { + mocked(client.requestLoginToken).mockRejectedValue(new HTTPError("rate limit reached", 429)); + const onFinished = jest.fn(); + render(getComponent({ client, onFinished })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: Phase.LegacyConnected, + }), + ), + ); + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.LegacyConnected, + confirmationDigits: mockConfirmationDigits, + onClick: expect.any(Function), + }); + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); + + // approve + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Approve); + + // the 429 error should be handled and mapped + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: Phase.Error, + failureReason: "rate_limited", + }), + ), + ); + }); + }); + + describe("MSC4108", () => { + const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => ( + + + ); + + test("render QR then back", async () => { + const onFinished = jest.fn(); + jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise()); + render(getComponent({ client, onFinished })); + + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.ShowingQR, + onClick: expect.any(Function), + }), + ); + + const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0]; + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.negotiateProtocols).toHaveBeenCalled(); + + // back + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Back); + expect(onFinished).toHaveBeenCalledWith(false); + expect(rendezvous.cancel).toHaveBeenCalledWith(LegacyRendezvousFailureReason.UserCancelled); + }); + + test("failed to connect", async () => { + render(getComponent({ client })); + jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({}); + jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockRejectedValue( + new HTTPError("Internal Server Error", 500), + ); + const fn = jest.spyOn(MSC4108SignInWithQR.prototype, "cancel"); + await waitFor(() => expect(fn).toHaveBeenLastCalledWith(ClientRendezvousFailureReason.Unknown)); + }); + + test("reciprocates login", async () => { + jest.spyOn(global.window, "open"); + + render(getComponent({ client })); + jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({}); + jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({ + verificationUri: "mock-verification-uri", + }); + + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.OutOfBandConfirmation, + onClick: expect.any(Function), + }), + ); + + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Approve); + + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.WaitingForDevice, + onClick: expect.any(Function), + }), + ); + expect(global.window.open).toHaveBeenCalledWith("mock-verification-uri", "_blank"); + }); + + test("handles errors during reciprocation", async () => { + render(getComponent({ client })); + jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({}); + jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({}); + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.OutOfBandConfirmation, + onClick: expect.any(Function), + }), + ); + + jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockRejectedValue( + new HTTPError("Internal Server Error", 500), + ); + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Approve); + + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: Phase.Error, + failureReason: ClientRendezvousFailureReason.Unknown, + }), + ), + ); + }); + + test("handles user cancelling during reciprocation", async () => { + render(getComponent({ client })); + jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({}); + jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({}); + jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({}); + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.OutOfBandConfirmation, + onClick: expect.any(Function), + }), + ); + + jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue(); + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Cancel); + + const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0]; + expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled); + }); }); }); diff --git a/test/components/views/settings/devices/LoginWithQRFlow-test.tsx b/test/components/views/settings/devices/LoginWithQRFlow-test.tsx index 91004abc26..1f896b28b7 100644 --- a/test/components/views/settings/devices/LoginWithQRFlow-test.tsx +++ b/test/components/views/settings/devices/LoginWithQRFlow-test.tsx @@ -16,7 +16,11 @@ limitations under the License. import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import React from "react"; -import { LegacyRendezvousFailureReason } from "matrix-js-sdk/src/rendezvous"; +import { + ClientRendezvousFailureReason, + LegacyRendezvousFailureReason, + MSC4108FailureReason, +} from "matrix-js-sdk/src/rendezvous"; import LoginWithQRFlow from "../../../../../src/components/views/auth/LoginWithQRFlow"; import { LoginWithQRFailureReason, FailureReason } from "../../../../../src/components/views/auth/LoginWithQR"; @@ -54,7 +58,7 @@ describe("", () => { expect(screen.getAllByTestId("cancel-button")).toHaveLength(1); expect(container).toMatchSnapshot(); fireEvent.click(screen.getByTestId("cancel-button")); - expect(onClick).toHaveBeenCalledWith(Click.Cancel); + expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined); }); it("renders QR code", async () => { @@ -64,24 +68,16 @@ describe("", () => { expect(container).toMatchSnapshot(); }); - it("renders spinner while connecting", async () => { - const { container } = render(getComponent({ phase: Phase.Connecting })); - expect(screen.getAllByTestId("cancel-button")).toHaveLength(1); - expect(container).toMatchSnapshot(); - fireEvent.click(screen.getByTestId("cancel-button")); - expect(onClick).toHaveBeenCalledWith(Click.Cancel); - }); - it("renders code when connected", async () => { - const { container } = render(getComponent({ phase: Phase.Connected, confirmationDigits: "mock-digits" })); + const { container } = render(getComponent({ phase: Phase.LegacyConnected, confirmationDigits: "mock-digits" })); expect(screen.getAllByText("mock-digits")).toHaveLength(1); expect(screen.getAllByTestId("decline-login-button")).toHaveLength(1); expect(screen.getAllByTestId("approve-login-button")).toHaveLength(1); expect(container).toMatchSnapshot(); fireEvent.click(screen.getByTestId("decline-login-button")); - expect(onClick).toHaveBeenCalledWith(Click.Decline); + expect(onClick).toHaveBeenCalledWith(Click.Decline, undefined); fireEvent.click(screen.getByTestId("approve-login-button")); - expect(onClick).toHaveBeenCalledWith(Click.Approve); + expect(onClick).toHaveBeenCalledWith(Click.Approve, undefined); }); it("renders spinner while signing in", async () => { @@ -89,7 +85,7 @@ describe("", () => { expect(screen.getAllByTestId("cancel-button")).toHaveLength(1); expect(container).toMatchSnapshot(); fireEvent.click(screen.getByTestId("cancel-button")); - expect(onClick).toHaveBeenCalledWith(Click.Cancel); + expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined); }); it("renders spinner while verifying", async () => { @@ -97,10 +93,17 @@ describe("", () => { expect(container).toMatchSnapshot(); }); + it("renders check code confirmation", async () => { + const { container } = render(getComponent({ phase: Phase.OutOfBandConfirmation })); + expect(container).toMatchSnapshot(); + }); + describe("errors", () => { for (const failureReason of [ ...Object.values(LegacyRendezvousFailureReason), + ...Object.values(MSC4108FailureReason), ...Object.values(LoginWithQRFailureReason), + ...Object.values(ClientRendezvousFailureReason), ]) { it(`renders ${failureReason}`, async () => { const { container } = render( @@ -110,10 +113,7 @@ describe("", () => { }), ); expect(screen.getAllByTestId("cancellation-message")).toHaveLength(1); - expect(screen.getAllByTestId("try-again-button")).toHaveLength(1); expect(container).toMatchSnapshot(); - fireEvent.click(screen.getByTestId("try-again-button")); - expect(onClick).toHaveBeenCalledWith(Click.TryAgain); }); } }); diff --git a/test/components/views/settings/devices/LoginWithQRSection-test.tsx b/test/components/views/settings/devices/LoginWithQRSection-test.tsx index 8dc78bfd28..027aeed45b 100644 --- a/test/components/views/settings/devices/LoginWithQRSection-test.tsx +++ b/test/components/views/settings/devices/LoginWithQRSection-test.tsx @@ -18,11 +18,17 @@ import { render } from "@testing-library/react"; import { mocked } from "jest-mock"; import { IClientWellKnown, IServerVersions, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "matrix-js-sdk/src/matrix"; import React from "react"; +import fetchMock from "fetch-mock-jest"; import LoginWithQRSection from "../../../../../src/components/views/settings/devices/LoginWithQRSection"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; function makeClient(wellKnown: IClientWellKnown) { + const crypto = mocked({ + supportsSecretsForQrLogin: jest.fn().mockReturnValue(true), + isCrossSigningReady: jest.fn().mockReturnValue(true), + }); + return mocked({ getUser: jest.fn(), isGuest: jest.fn().mockReturnValue(false), @@ -38,6 +44,7 @@ function makeClient(wellKnown: IClientWellKnown) { on: jest.fn(), }, getClientWellKnown: jest.fn().mockReturnValue(wellKnown), + getCrypto: jest.fn().mockReturnValue(crypto), } as unknown as MatrixClient); } @@ -53,68 +60,105 @@ describe("", () => { jest.spyOn(MatrixClientPeg, "get").mockReturnValue(makeClient({})); }); - const defaultProps = { - onShowQr: () => {}, - versions: makeVersions({}), - wellKnown: {}, - }; + describe("MSC3906", () => { + const defaultProps = { + onShowQr: () => {}, + versions: makeVersions({}), + wellKnown: {}, + }; - const getComponent = (props = {}) => ; + const getComponent = (props = {}) => ; - describe("should not render", () => { - it("no support at all", () => { - const { container } = render(getComponent()); - expect(container).toMatchSnapshot(); + describe("should not render", () => { + it("no support at all", () => { + const { container } = render(getComponent()); + expect(container).toMatchSnapshot(); + }); + + it("only get_login_token enabled", async () => { + const { container } = render( + getComponent({ capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } } }), + ); + expect(container).toMatchSnapshot(); + }); + + it("MSC3886 + get_login_token disabled", async () => { + const { container } = render( + getComponent({ + versions: makeVersions({ "org.matrix.msc3886": true }), + capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: false } }, + }), + ); + expect(container).toMatchSnapshot(); + }); }); - it("only get_login_token enabled", async () => { - const { container } = render( - getComponent({ capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } } }), - ); - expect(container).toMatchSnapshot(); - }); + describe("should render panel", () => { + it("get_login_token + MSC3886", async () => { + const { container } = render( + getComponent({ + versions: makeVersions({ + "org.matrix.msc3886": true, + }), + capabilities: { + [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true }, + }, + }), + ); + expect(container).toMatchSnapshot(); + }); - it("MSC3886 + get_login_token disabled", async () => { - const { container } = render( - getComponent({ - versions: makeVersions({ "org.matrix.msc3886": true }), - capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: false } }, - }), - ); - expect(container).toMatchSnapshot(); + it("get_login_token + .well-known", async () => { + const wellKnown = { + "io.element.rendezvous": { + server: "https://rz.local", + }, + }; + jest.spyOn(MatrixClientPeg, "get").mockReturnValue(makeClient(wellKnown)); + const { container } = render( + getComponent({ + versions: makeVersions({}), + capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } }, + wellKnown, + }), + ); + expect(container).toMatchSnapshot(); + }); }); }); - describe("should render panel", () => { - it("get_login_token + MSC3886", async () => { - const { container } = render( - getComponent({ - versions: makeVersions({ - "org.matrix.msc3886": true, - }), - capabilities: { - [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true }, - }, - }), - ); - expect(container).toMatchSnapshot(); - }); - - it("get_login_token + .well-known", async () => { - const wellKnown = { - "io.element.rendezvous": { - server: "https://rz.local", - }, + describe("MSC4108", () => { + describe("MSC4108", () => { + const defaultProps = { + onShowQr: () => {}, + versions: makeVersions({ "org.matrix.msc4108": true }), + wellKnown: {}, }; - jest.spyOn(MatrixClientPeg, "get").mockReturnValue(makeClient(wellKnown)); - const { container } = render( - getComponent({ - versions: makeVersions({}), - capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } }, - wellKnown, - }), - ); - expect(container).toMatchSnapshot(); + + const getComponent = (props = {}) => ; + + let client: MatrixClient; + beforeEach(() => { + client = makeClient({}); + jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client); + }); + + test("no homeserver support", async () => { + const { container } = render(getComponent({ versions: makeVersions({ "org.matrix.msc4108": false }) })); + expect(container.textContent).toBe(""); // show nothing + }); + + test("no support in crypto", async () => { + client.getCrypto()!.exportSecretsBundle = undefined; + const { container } = render(getComponent({ client })); + expect(container.textContent).toBe(""); // show nothing + }); + + test("failed to connect", async () => { + fetchMock.catch(500); + const { container } = render(getComponent({ client })); + expect(container.textContent).toBe(""); // show nothing + }); }); }); }); diff --git a/test/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap index 0698d0363f..56873e510b 100644 --- a/test/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap +++ b/test/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap @@ -1,6 +1,40 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` errors renders data_mismatch 1`] = ` +exports[` errors renders authorization_expired 1`] = ` +
+
+
+
+
+
+

+ The sign in was not completed in time +

+

+ Sign in expired. Please try again. +

+
+
+
+
+`; + +exports[` errors renders check_code_mismatch 1`] = `
errors renders data_mismatch 1`] = `
+
+
+`; + +exports[` errors renders device_already_exists 1`] = ` +
+
+
- Try again +
-
- Cancel -
+ Something went wrong! + +

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
+
+
+`; + +exports[` errors renders device_not_found 1`] = ` +
+
+
+
+
+
+

+ Something went wrong! +

+

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
+
+
+
+`; + +exports[` errors renders etag_missing 1`] = ` +
+
+
+
+
+
+

+ Something went wrong! +

+

+ An unexpected error occurred. This may be due to a browser extension, proxy server, or server misconfiguration. +

+
+
`; @@ -80,24 +199,41 @@ exports[` errors renders expired 1`] = `
+
+
+`; + +exports[` errors renders expired 2`] = ` +
+
+
- Try again +
-
- Cancel -
+ The sign in was not completed in time + +

+ Sign in expired. Please try again. +

+
`; @@ -121,39 +257,56 @@ exports[` errors renders homeserver_lacks_support 1`] = `

- Other device not compatible + Something went wrong!

- This device does not support signing in to the other device with a QR code. + An unexpected error occurred. The request to connect your other device has been cancelled.

-
- Try again -
-
- Cancel -
-
+ />
`; -exports[` errors renders invalid_code 1`] = ` +exports[` errors renders homeserver_lacks_support 2`] = ` +
+
+
+
+
+
+

+ Something went wrong! +

+

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
+
+
+
+`; + +exports[` errors renders insecure_channel_detected 1`] = `
errors renders invalid_code 1`] = `
+
+
+`; + +exports[` errors renders invalid_code 1`] = ` +
+
+
- Try again +
-
- Cancel -
+ Something went wrong! + +

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
`; @@ -246,24 +416,7 @@ exports[` errors renders other_device_already_signed_in 1`] =
-
- Try again -
-
- Cancel -
-
+ />
`; @@ -297,24 +450,7 @@ exports[` errors renders other_device_not_signed_in 1`] = `
-
- Try again -
-
- Cancel -
-
+ />
`; @@ -348,24 +484,41 @@ exports[` errors renders rate_limited 1`] = `
+
+
+`; + +exports[` errors renders unexpected_message_received 1`] = ` +
+
+
- Try again +
-
- Cancel -
+ Something went wrong! + +

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
`; @@ -399,24 +552,41 @@ exports[` errors renders unknown 1`] = `
+
+
+`; + +exports[` errors renders unknown 2`] = ` +
+
+
- Try again +
-
- Cancel -
+ Something went wrong! + +

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
`; @@ -440,39 +610,22 @@ exports[` errors renders unsupported_algorithm 1`] = `

- Other device not compatible + Something went wrong!

- This device does not support signing in to the other device with a QR code. + An unexpected error occurred. The request to connect your other device has been cancelled.

-
- Try again -
-
- Cancel -
-
+ />
`; -exports[` errors renders unsupported_transport 1`] = ` +exports[` errors renders unsupported_protocol 1`] = `
errors renders unsupported_transport 1`] = `
+
+
+`; + +exports[` errors renders unsupported_protocol 2`] = ` +
+
+
- Try again +
-
- Cancel -
+ Other device not compatible + +

+ This device does not support signing in to the other device with a QR code. +

+
`; @@ -552,24 +722,41 @@ exports[` errors renders user_cancelled 1`] = `
+
+
+`; + +exports[` errors renders user_cancelled 2`] = ` +
+
+
- Try again +
-
- Cancel -
+ Sign in request cancelled + +

+ The sign in was cancelled on the other device. +

+
`; @@ -598,29 +785,46 @@ exports[` errors renders user_declined 1`] = `

- You declined the request from your other device to sign in. + You or the account provider declined the sign in request.

+
+
+`; + +exports[` errors renders user_declined 2`] = ` +
+
+
- Try again +
-
- Cancel -
+ Sign in declined + +

+ You or the account provider declined the sign in request. +

+
`; @@ -686,10 +890,10 @@ exports[` renders QR code 1`] = `
  • - Point the camera at the QR code shown here + Scan the QR code shown here
  • - Follow the instructions to link your other device + Follow the remaining instructions
  • @@ -700,6 +904,80 @@ exports[` renders QR code 1`] = `
    `; +exports[` renders check code confirmation 1`] = ` +
    +
    +
    +

    + Enter the number shown on your other device +

    +

    + This will verify that the connection to your other device is secure. +

    + +
    + + +
    +`; + exports[` renders code when connected 1`] = `
    renders code when connected 1`] = `
    `; -exports[` renders spinner while connecting 1`] = ` -
    -
    -
    -
    -
    -
    -
    - Sessions - / - Link new device -
    -
    -
    -
    -
    -
    -
    -
    -

    - Connecting… -

    -
    -
    -
    -
    -
    - Cancel -
    -
    -
    -
    -`; - exports[` renders spinner while loading 1`] = `
    should not render MSC3886 + get_login_token disabled 1`] = `
    `; +exports[` MSC3906 should not render MSC3886 + get_login_token disabled 1`] = `
    `; -exports[` should not render no support at all 1`] = `
    `; +exports[` MSC3906 should not render no support at all 1`] = `
    `; -exports[` should not render only get_login_token enabled 1`] = `
    `; +exports[` MSC3906 should not render only get_login_token enabled 1`] = `
    `; -exports[` should render panel get_login_token + .well-known 1`] = ` +exports[` MSC3906 should render panel get_login_token + .well-known 1`] = `
    should render panel get_login_token + .well-know
    `; -exports[` should render panel get_login_token + MSC3886 1`] = ` +exports[` MSC3906 should render panel get_login_token + MSC3886 1`] = `
    ", () => { getDeviceVerificationStatus: jest.fn(), getUserDeviceInfo: jest.fn(), requestDeviceVerification: jest.fn().mockResolvedValue(mockVerificationRequest), + supportsSecretsForQrLogin: jest.fn().mockReturnValue(false), + isCrossSigningReady: jest.fn().mockReturnValue(true), } as unknown as CryptoApi); let mockClient!: MockedObject; @@ -127,7 +132,9 @@ describe("", () => { const defaultProps = {}; const getComponent = (props = {}): React.ReactElement => ( - + + + ); @@ -207,6 +214,7 @@ describe("", () => { getPushers: jest.fn(), setPusher: jest.fn(), setLocalNotificationSettings: jest.fn(), + getAuthIssuer: jest.fn().mockReturnValue(new Promise(() => {})), }); jest.clearAllMocks(); jest.spyOn(logger, "error").mockRestore(); @@ -1664,7 +1672,7 @@ describe("", () => { expect(checkbox.getAttribute("aria-checked")).toEqual("false"); }); - describe("QR code login", () => { + describe("MSC3906 QR code login", () => { const settingsValueSpy = jest.spyOn(SettingsStore, "getValue"); beforeEach(() => { @@ -1694,13 +1702,71 @@ describe("", () => { }); it("enters qr code login section when show QR code button clicked", async () => { - const { getByText, getByTestId } = render(getComponent()); + const { getByText, findByTestId } = render(getComponent()); // wait for versions call to settle await flushPromises(); fireEvent.click(getByText("Show QR code")); - expect(getByTestId("login-with-qr")).toBeTruthy(); + await expect(findByTestId("login-with-qr")).resolves.toBeTruthy(); + }); + }); + + describe("MSC4108 QR code login", () => { + const settingsValueSpy = jest.spyOn(SettingsStore, "getValue"); + const issuer = "https://issuer.org"; + const openIdConfiguration = mockOpenIdConfiguration(issuer); + + beforeEach(() => { + settingsValueSpy.mockClear().mockReturnValue(true); + // enable server support for qr login + mockClient.getVersions.mockResolvedValue({ + versions: [], + unstable_features: { + "org.matrix.msc4108": true, + }, + }); + mockClient.getCapabilities.mockResolvedValue({ + [GET_LOGIN_TOKEN_CAPABILITY.name]: { + enabled: true, + }, + }); + mockClient.getAuthIssuer.mockResolvedValue({ issuer }); + mockCrypto.exportSecretsBundle = jest.fn(); + fetchMock.mock(`${issuer}/.well-known/openid-configuration`, { + ...openIdConfiguration, + grant_types_supported: [ + ...openIdConfiguration.grant_types_supported, + "urn:ietf:params:oauth:grant-type:device_code", + ], + }); + fetchMock.mock(openIdConfiguration.jwks_uri!, { + status: 200, + headers: { + "Content-Type": "application/json", + }, + keys: [], + }); + }); + + it("renders qr code login section", async () => { + const { getByText } = render(getComponent()); + + // wait for versions call to settle + await flushPromises(); + + expect(getByText("Link new device")).toBeTruthy(); + expect(getByText("Show QR code")).toBeTruthy(); + }); + + it("enters qr code login section when show QR code button clicked", async () => { + const { getByText, findByTestId } = render(getComponent()); + // wait for versions call to settle + await flushPromises(); + + fireEvent.click(getByText("Show QR code")); + + await expect(findByTestId("login-with-qr")).resolves.toBeTruthy(); }); }); }); diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index be3f3425c2..00f5aa3f7b 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -17,7 +17,7 @@ limitations under the License. import EventEmitter from "events"; import { MethodLikeKeys, mocked, MockedObject, PropertyLikeKeys } from "jest-mock"; import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; -import { MatrixClient, Room, User } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, Room, MatrixError, User } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; @@ -136,6 +136,7 @@ export const mockClientMethodsServer = (): Partial, unknown>> => ({ getRooms: jest.fn().mockReturnValue(rooms), + getRoom: jest.fn((roomId) => rooms.find((r) => r.roomId === roomId) ?? null), }); diff --git a/test/test-utils/oidc.ts b/test/test-utils/oidc.ts index 1a064fe5e7..c6d9b4b45d 100644 --- a/test/test-utils/oidc.ts +++ b/test/test-utils/oidc.ts @@ -45,6 +45,7 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated token_endpoint: issuer + "token", authorization_endpoint: issuer + "auth", registration_endpoint: issuer + "registration", + device_authorization_endpoint: issuer + "device", jwks_uri: issuer + "jwks", response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index a9e61e3f36..a63a85867b 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -272,6 +272,7 @@ export function createTestClient(): MatrixClient { baseUrl: "https://matrix-client.matrix.org", matrixRTC: createStubMatrixRTC(), isFallbackICEServerAllowed: jest.fn().mockReturnValue(false), + getAuthIssuer: jest.fn(), } as unknown as MatrixClient; client.reEmitter = new ReEmitter(client);