From be59791db1605e136814d37d29dc9f6b68dcb440 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Fri, 13 Sep 2024 12:49:19 +0100
Subject: [PATCH] Add support for `org.matrix.cross_signing_reset` UIA stage
 flow (#34)

* Soften UIA fallback postMessage check to work cross-origin

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Do the same for the SSO UIA flow

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add support for `org.matrix.cross_signing_reset` UIA stage flow

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Check against MessageEvent::source instead

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* i18n

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove protected method

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
 src/components/structures/InteractiveAuth.tsx |  5 +-
 .../auth/InteractiveAuthEntryComponents.tsx   | 59 +++++++++++++++++--
 src/i18n/strings/en_EN.json                   |  2 +
 .../InteractiveAuthEntryComponents-test.tsx   | 48 ++++++++++++++-
 ...teractiveAuthEntryComponents-test.tsx.snap | 50 ++++++++++++++++
 5 files changed, 155 insertions(+), 9 deletions(-)

diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx
index 86cf6af665..91e52a1905 100644
--- a/src/components/structures/InteractiveAuth.tsx
+++ b/src/components/structures/InteractiveAuth.tsx
@@ -20,6 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger";
 
 import getEntryComponentForLoginType, {
     ContinueKind,
+    CustomAuthType,
     IStageComponent,
 } from "../views/auth/InteractiveAuthEntryComponents";
 import Spinner from "../views/elements/Spinner";
@@ -75,11 +76,11 @@ export interface InteractiveAuthProps<T> {
     // Called when the stage changes, or the stage's phase changes. First
     // argument is the stage, second is the phase. Some stages do not have
     // phases and will be counted as 0 (numeric).
-    onStagePhaseChange?(stage: AuthType | null, phase: number): void;
+    onStagePhaseChange?(stage: AuthType | CustomAuthType | null, phase: number): void;
 }
 
 interface IState {
-    authStage?: AuthType;
+    authStage?: CustomAuthType | AuthType;
     stageState?: IStageStatus;
     busy: boolean;
     errorText?: string;
diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx
index a0946564aa..7a15ee2095 100644
--- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx
+++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx
@@ -11,6 +11,8 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix";
 import { AuthType, AuthDict, IInputs, IStageStatus } from "matrix-js-sdk/src/interactive-auth";
 import { logger } from "matrix-js-sdk/src/logger";
 import React, { ChangeEvent, createRef, FormEvent, Fragment } from "react";
+import { Button, Text } from "@vector-im/compound-web";
+import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out";
 
 import EmailPromptIcon from "../../../../res/img/element-icons/email-prompt.svg";
 import { _t } from "../../../languageHandler";
@@ -21,6 +23,7 @@ import AccessibleButton, { AccessibleButtonKind, ButtonEvent } from "../elements
 import Field from "../elements/Field";
 import Spinner from "../elements/Spinner";
 import CaptchaForm from "./CaptchaForm";
+import { Flex } from "../../utils/Flex";
 
 /* This file contains a collection of components which are used by the
  * InteractiveAuth to prompt the user to enter the information needed
@@ -905,11 +908,11 @@ export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEn
     }
 }
 
-export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
-    private popupWindow: Window | null;
-    private fallbackButton = createRef<HTMLButtonElement>();
+export class FallbackAuthEntry<T = {}> extends React.Component<IAuthEntryProps & T> {
+    protected popupWindow: Window | null;
+    protected fallbackButton = createRef<HTMLButtonElement>();
 
-    public constructor(props: IAuthEntryProps) {
+    public constructor(props: IAuthEntryProps & T) {
         super(props);
 
         // we have to make the user click a button, as browsers will block
@@ -967,6 +970,50 @@ export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
     }
 }
 
+export enum CustomAuthType {
+    // Workaround for MAS requiring non-UIA authentication for resetting cross-signing.
+    MasCrossSigningReset = "org.matrix.cross_signing_reset",
+}
+
+export class MasUnlockCrossSigningAuthEntry extends FallbackAuthEntry<{
+    stageParams?: {
+        url?: string;
+    };
+}> {
+    public static LOGIN_TYPE = CustomAuthType.MasCrossSigningReset;
+
+    private onGoToAccountClick = (): void => {
+        if (!this.props.stageParams?.url) return;
+        this.popupWindow = window.open(this.props.stageParams.url, "_blank");
+    };
+
+    private onRetryClick = (): void => {
+        this.props.submitAuthDict({});
+    };
+
+    public render(): React.ReactNode {
+        return (
+            <div>
+                <Text>{_t("auth|uia|mas_cross_signing_reset_description")}</Text>
+                <Flex gap="var(--cpd-space-4x)">
+                    <Button
+                        Icon={PopOutIcon}
+                        onClick={this.onGoToAccountClick}
+                        autoFocus
+                        kind="primary"
+                        className="mx_Dialog_nonDialogButton"
+                    >
+                        {_t("auth|uia|mas_cross_signing_reset_cta")}
+                    </Button>
+                    <Button onClick={this.onRetryClick} kind="secondary" className="mx_Dialog_nonDialogButton">
+                        {_t("action|retry")}
+                    </Button>
+                </Flex>
+            </div>
+        );
+    }
+}
+
 export interface IStageComponentProps extends IAuthEntryProps {
     stageParams?: Record<string, any>;
     inputs?: IInputs;
@@ -983,8 +1030,10 @@ export interface IStageComponent extends React.ComponentClass<React.PropsWithRef
     focus?(): void;
 }
 
-export default function getEntryComponentForLoginType(loginType: AuthType): IStageComponent {
+export default function getEntryComponentForLoginType(loginType: AuthType | CustomAuthType): IStageComponent {
     switch (loginType) {
+        case CustomAuthType.MasCrossSigningReset:
+            return MasUnlockCrossSigningAuthEntry;
         case AuthType.Password:
             return PasswordAuthEntry;
         case AuthType.Recaptcha:
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 21addb3b98..e640bae337 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -369,6 +369,8 @@
             "email_resend_prompt": "Did not receive it? <a>Resend it</a>",
             "email_resent": "Resent!",
             "fallback_button": "Start authentication",
+            "mas_cross_signing_reset_cta": "Go to your account",
+            "mas_cross_signing_reset_description": "Reset your identity through your account provider and then come back and click “Retry”.",
             "msisdn": "A text message has been sent to %(msisdn)s",
             "msisdn_token_incorrect": "Token incorrect",
             "msisdn_token_prompt": "Please enter the code it contains:",
diff --git a/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx b/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx
index 62c02b0d58..1cbf799af7 100644
--- a/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx
+++ b/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx
@@ -7,11 +7,14 @@
  */
 
 import React from "react";
-import { render, screen, waitFor, act } from "@testing-library/react";
+import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
 import { AuthType } from "matrix-js-sdk/src/interactive-auth";
 import userEvent from "@testing-library/user-event";
 
-import { EmailIdentityAuthEntry } from "../../../../src/components/views/auth/InteractiveAuthEntryComponents";
+import {
+    EmailIdentityAuthEntry,
+    MasUnlockCrossSigningAuthEntry,
+} from "../../../../src/components/views/auth/InteractiveAuthEntryComponents";
 import { createTestClient } from "../../../test-utils";
 
 describe("<EmailIdentityAuthEntry/>", () => {
@@ -55,3 +58,44 @@ describe("<EmailIdentityAuthEntry/>", () => {
         await waitFor(() => expect(screen.queryByRole("button", { name: "Resend" })).toBeInTheDocument());
     });
 });
+
+describe("<MasUnlockCrossSigningAuthEntry/>", () => {
+    const renderAuth = (props = {}) => {
+        const matrixClient = createTestClient();
+
+        return render(
+            <MasUnlockCrossSigningAuthEntry
+                matrixClient={matrixClient}
+                loginType={AuthType.Email}
+                onPhaseChange={jest.fn()}
+                submitAuthDict={jest.fn()}
+                fail={jest.fn()}
+                clientSecret="my secret"
+                showContinue={true}
+                stageParams={{ url: "https://example.com" }}
+                {...props}
+            />,
+        );
+    };
+
+    test("should render", () => {
+        const { container } = renderAuth();
+        expect(container).toMatchSnapshot();
+    });
+
+    test("should open idp in new tab on click", async () => {
+        const spy = jest.spyOn(global.window, "open");
+        renderAuth();
+
+        fireEvent.click(screen.getByRole("button", { name: "Go to your account" }));
+        expect(spy).toHaveBeenCalledWith("https://example.com", "_blank");
+    });
+
+    test("should retry uia request on click", async () => {
+        const submitAuthDict = jest.fn();
+        renderAuth({ submitAuthDict });
+
+        fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+        expect(submitAuthDict).toHaveBeenCalledWith({});
+    });
+});
diff --git a/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap b/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap
index 65f86a35d2..16e5b3abc2 100644
--- a/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap
+++ b/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap
@@ -32,3 +32,53 @@ exports[`<EmailIdentityAuthEntry/> should render 1`] = `
   </div>
 </div>
 `;
+
+exports[`<MasUnlockCrossSigningAuthEntry/> should render 1`] = `
+<div>
+  <div>
+    <p
+      class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
+    >
+      Reset your identity through your account provider and then come back and click “Retry”.
+    </p>
+    <div
+      class="mx_Flex"
+      style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x);"
+    >
+      <button
+        class="_button_zt6rp_17 mx_Dialog_nonDialogButton _has-icon_zt6rp_61"
+        data-kind="primary"
+        data-size="lg"
+        role="button"
+        tabindex="0"
+      >
+        <svg
+          aria-hidden="true"
+          fill="currentColor"
+          height="20"
+          viewBox="0 0 24 24"
+          width="20"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <path
+            d="M5 3h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z"
+          />
+          <path
+            d="M15 3h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L17.586 5H15a1 1 0 1 1 0-2Z"
+          />
+        </svg>
+        Go to your account
+      </button>
+      <button
+        class="_button_zt6rp_17 mx_Dialog_nonDialogButton"
+        data-kind="secondary"
+        data-size="lg"
+        role="button"
+        tabindex="0"
+      >
+        Retry
+      </button>
+    </div>
+  </div>
+</div>
+`;