[Labs] Sign in with QR on new EW using generated QR for MSC4108 v2024 (#33184)

* PoC Sign in with QR on new EW using generated QR for MSC4108 v2024

* Revert package.json changes

* Prettier

* Fix i18n

* Tidy up

* Remove unused state variable

* Iterate tests

* Partial revert

* Iterate

* Wire up qr_login route

* Iterate UI

* Fix React dev mode double rendering issue

* Fix react key warning

* Hide flow header on login

* Re-roll qr code on channel expiry

* Switch to AbortSignal

* Improve auto-retry QR UX

* Ensure we only show sign in with QR button if enabled

* XXX: enable labs flag on Netlify builds

* Tweak QR code sizing

* Move qr login flow into a dialog to match designs

* Fix null deviceId

* Remove duplicate log

* Iterate

* Fix tests

* Fix types

* Fix tests

* Fix tests

* Make Netlify more useful

* Make Netlify more useful v2

* Update copy

* Refactor QR link flow to use new SDK methods

Requires https://github.com/matrix-org/matrix-js-sdk/pull/5283
For element-hq/wat-internal#188
Split out from https://github.com/element-hq/element-web/pull/33184

* Link to js-sdk branch

* Update tests

* Simplify

* Revert js-sdk linking

* Iterate

* Iterate

* Refactor to handle most of the TODOs

* Remove unused code

* Remove unused code

* Use js-sdk isSignInWithQRAvailable API to simplify code

* Restore app-test.ts

* Improve coverage

* Improve coverage

* Remove unused prop/state

* Iterate

* Fix tests

* Iterate

* Tests

* Handle TODOs

* Docs

* Remove redundant call to crossSignDevice()

* Workaround to remove training slash on the serverName before auto-discovery

* Revert "Workaround to remove training slash on the serverName before auto-discovery"

This reverts commit 0335a8fdd1b8e8d949ab7fca17c76f8fab335b58.

* setLoggedIn not to be used with OIDC flows as it clears storage

as per docs on setLoggedIn we should use restoreSessionFromStorage

* Don't show the security_code_prompt unconditionally(i.e. for the web logging in mobile flow)

* Update LoginWithQRFlow-test.tsx.snap

* Update MatrixChat-test from setLoggedInSpy to restoreSessionSpy

* Add todo for server switch

* Add todo about handling base URL or server name

* Handle server name or base URL being returned

* Format

* Fix loading state height

* Handle the homeserver URL differing during QR code login

* Comments

* Comments

* Register OIDC client ID after homeserver swap

* Make QrLoginDialog async to minimise the impact on bundle size

* Handle unsupported HS earlier in the flow

* Iterate

* Delint

* Fix test

* Discard changes to apps/web/element.io/develop/config.json

---------

Co-authored-by: Hugh Nimmo-Smith <hughns@element.io>
Co-authored-by: David Langley <langley.dave@gmail.com>
This commit is contained in:
Michael Telatynski
2026-06-04 14:50:17 +01:00
committed by GitHub
parent d1a6137c90
commit 486fa57b68
24 changed files with 3041 additions and 570 deletions
+12 -7
View File
@@ -602,9 +602,10 @@ legend {
.mx_UnknownIdentityUsersWarningDialog button,
.mx_ThreadActionBar button,
.mx_HistoryActionBar button,
[data-kind],
[class|="maplibregl"]
),
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton),
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton, [data-kind]),
.mx_Dialog input[type="submit"],
.mx_Dialog_buttons input[type="submit"] {
@mixin mx_DialogButton;
@@ -630,7 +631,8 @@ legend {
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button,
.mx_UnknownIdentityUsersWarningDialog button
.mx_UnknownIdentityUsersWarningDialog button,
[data-kind]
):last-child {
margin-right: 0px;
}
@@ -647,15 +649,16 @@ legend {
.mx_EncryptionUserSettingsTab button,
.mx_InviteDialog_section button,
.mx_InviteDialog_editor button,
.mx_UnknownIdentityUsersWarningDialog button
.mx_UnknownIdentityUsersWarningDialog button,
[data-kind]
):focus,
.mx_Dialog input[type="submit"]:focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton, [data-kind]):focus,
.mx_Dialog_buttons input[type="submit"]:focus {
filter: brightness($focus-brightness);
}
.mx_Dialog button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton, [class|="maplibregl"]),
.mx_Dialog button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton, [class|="maplibregl"], [data-kind]),
.mx_Dialog input[type="submit"].mx_Dialog_primary,
.mx_Dialog_buttons
button:not(
@@ -666,7 +669,8 @@ legend {
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button,
.mx_UnknownIdentityUsersWarningDialog button
.mx_UnknownIdentityUsersWarningDialog button,
[data-kind]
),
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
color: var(--cpd-color-text-on-solid-primary);
@@ -710,7 +714,8 @@ legend {
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button,
.mx_UnknownIdentityUsersWarningDialog button
.mx_UnknownIdentityUsersWarningDialog button,
[data-kind]
):disabled,
.mx_Dialog input[type="submit"]:disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):disabled,
+6 -57
View File
@@ -22,42 +22,23 @@ Please see LICENSE files in the repository root for full details.
margin-right: $spacing-12;
}
.mx_AuthPage .mx_LoginWithQR {
.mx_AccessibleButton {
display: block !important;
}
.mx_LoginWithQR_dialog .mx_Dialog {
padding: var(--cpd-space-12x) var(--cpd-space-12x) var(--cpd-space-10x);
.mx_AccessibleButton + .mx_AccessibleButton {
margin-top: $spacing-8;
.mx_LoginWithQR {
width: 500px;
}
font-size: $font-15px;
}
.mx_LoginWithQR {
min-height: 350px;
display: flex;
flex-direction: column;
min-height: 500px;
font: var(--cpd-font-body-md-regular);
h1 {
font-size: $font-24px;
margin-bottom: 0;
svg {
&.normal {
color: $secondary-content;
}
&.error {
color: $alert;
}
&.success {
color: $accent;
}
height: 1.3em;
margin-right: $spacing-8;
vertical-align: middle;
}
}
h2 {
@@ -134,34 +115,6 @@ Please see LICENSE files in the repository root for full details.
margin-bottom: var(--cpd-space-1x);
}
.mx_LoginWithQR_icon {
width: 56px;
height: 56px;
border-radius: 8px;
box-sizing: border-box;
padding: var(--cpd-space-3x);
gap: 10px;
background-color: var(--cpd-color-bg-subtle-secondary);
svg {
color: var(--cpd-color-icon-secondary);
}
&.mx_LoginWithQR_icon--success {
background-color: var(--cpd-color-bg-success-subtle);
svg {
color: var(--cpd-color-icon-success-primary);
}
}
&.mx_LoginWithQR_icon--critical {
background-color: var(--cpd-color-bg-critical-subtle);
svg {
color: var(--cpd-color-icon-critical-primary);
}
}
}
.mx_LoginWithQR_checkCode_input {
margin-bottom: var(--cpd-space-1x);
text-align: initial;
@@ -205,14 +158,10 @@ Please see LICENSE files in the repository root for full details.
p {
color: $secondary-content;
margin-bottom: var(--cpd-space-8x);
}
}
&.mx_LoginWithQR_error .mx_LoginWithQR_main {
max-width: 400px;
margin: 0 auto;
}
.mx_LoginWithQR_buttons {
display: flex;
flex-direction: column;
+42 -16
View File
@@ -58,7 +58,7 @@ import { Action } from "./dispatcher/actions";
import { type OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload";
import { SdkContextClass } from "./contexts/SDKContext";
import { messageForLoginError } from "./utils/ErrorUtils";
import { completeOidcLogin } from "./utils/oidc/authorize";
import { completeOidcLogin, type CompleteOidcLoginResponse } from "./utils/oidc/authorize";
import { getOidcErrorMessage } from "./utils/oidc/error";
import { type OidcClientStore } from "./stores/oidc/OidcClientStore";
import {
@@ -302,26 +302,16 @@ async function attemptOidcNativeLogin(
const { accessToken, refreshToken, homeserverUrl, identityServerUrl, idToken, clientId, issuer } =
await completeOidcLogin(urlParams, responseMode);
const {
user_id: userId,
device_id: deviceId,
is_guest: isGuest,
} = await getUserIdFromAccessToken(accessToken, homeserverUrl, identityServerUrl);
const credentials = {
await configureFromCompletedOAuthLogin({
accessToken,
refreshToken,
homeserverUrl,
identityServerUrl,
deviceId,
userId,
isGuest,
};
clientId,
issuer,
idToken,
});
logger.debug("Logged in via OIDC native flow");
await onSuccessfulDelegatedAuthLogin(credentials);
// this needs to happen after success handler which clears storages
persistOidcAuthenticatedSettings(clientId, issuer, idToken);
return true;
} catch (error) {
logger.error("Failed to login via OIDC", error);
@@ -331,6 +321,42 @@ async function attemptOidcNativeLogin(
}
}
/**
* Exchange the given OIDC credentials for {@link IMatrixClientCreds}, additionally persisting them to storage.
* @param creds the credentials from the OIDC flow
*/
export async function configureFromCompletedOAuthLogin({
accessToken,
refreshToken,
homeserverUrl,
identityServerUrl,
clientId,
issuer,
idToken,
}: Omit<CompleteOidcLoginResponse, "idTokenClaims">): Promise<IMatrixClientCreds> {
const {
user_id: userId,
device_id: deviceId,
is_guest: isGuest,
} = await getUserIdFromAccessToken(accessToken, homeserverUrl, identityServerUrl);
const credentials = {
accessToken,
refreshToken,
homeserverUrl,
identityServerUrl,
deviceId,
userId,
isGuest,
};
logger.debug("Logged in via OIDC native flow");
await onSuccessfulDelegatedAuthLogin(credentials);
// this needs to happen after success handler which clears storages
persistOidcAuthenticatedSettings(clientId, issuer, idToken);
return credentials;
}
/**
* Gets information about the owner of a given access token.
* @param accessToken
@@ -0,0 +1,65 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type FC, useMemo, useState } from "react";
import { RendezvousIntent } from "matrix-js-sdk/src/rendezvous";
import { createClient } from "matrix-js-sdk/src/matrix";
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import LoginWithQR, { type QrLoginCredentials } from "../../../components/views/auth/LoginWithQR.tsx";
import { Mode, Phase } from "../../../components/views/auth/LoginWithQR-types.ts";
import BaseDialog from "../../../components/views/dialogs/BaseDialog.tsx";
import { _t } from "../../../languageHandler.tsx";
interface Props {
/**
* The server config to use for QR code login
*/
serverConfig: ValidatedServerConfig;
/**
* Handler for when the dialog is to be closed
*/
onFinished(this: void): void;
/**
* Handler for successful completion of QR Login
* @param credentials - the credentials to log in with
*/
onLoggedIn(this: void, credentials: QrLoginCredentials): Promise<void>;
}
/**
* Dialog for facilitating the Login with QR flow, shown from DefaultWelcome.
*/
const QrLoginDialog: FC<Props> = ({ serverConfig, onLoggedIn, onFinished }) => {
const tempClient = useMemo(() => createClient({ baseUrl: serverConfig.hsUrl }), [serverConfig]);
const [phase, setPhase] = useState<Phase>();
const hasCancel = phase === Phase.ShowingQR;
return (
<BaseDialog
onFinished={onFinished}
hasCancel={hasCancel}
aria-label={_t("auth|sign_in_with_qr")}
fixedWidth={false}
>
<LoginWithQR
intent={RendezvousIntent.LOGIN_ON_NEW_DEVICE}
client={tempClient}
onFinished={onFinished}
onLoggedIn={onLoggedIn}
mode={Mode.Show}
onPhaseChange={setPhase}
/>
</BaseDialog>
);
};
export default QrLoginDialog;
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode } from "react";
import { WarningIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
interface ErrorMessageProps {
message: string | ReactNode | null;
@@ -18,7 +18,7 @@ interface ErrorMessageProps {
* Reserves two lines to display errors to prevent layout shifts when the error pops up.
*/
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ message }) => {
const icon = message ? <WarningIcon className="mx_Icon mx_Icon_16" /> : null;
const icon = message ? <ErrorSolidIcon width="20px" height="20px" /> : null;
return (
<div className="mx_ErrorMessage">
@@ -142,6 +142,8 @@ import { isOnlyAdmin } from "../../utils/membership";
import { ModuleApi } from "../../modules/Api.ts";
import { type IScreen } from "../../vector/routing.ts";
import { type URLParams } from "../../vector/url_utils.ts";
import { type QrLoginCredentials } from "../views/auth/LoginWithQR.tsx";
import { configureFromCompletedOAuthLogin } from "../../Lifecycle";
// legacy export
export { default as Views } from "../../Views";
@@ -827,6 +829,26 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.viewSomethingBehindModal();
break;
}
case Action.ViewQrLogin: {
if (this.isLoggedInViewPageDisplayed()) {
logger.warn("Ignoring payload due to unexpected call outside auth flows", payload);
} else {
Modal.createDialog(
lazy(() => import("../../async-components/views/dialogs/QrLoginDialog")),
{
serverConfig: this.getServerProperties().serverConfig,
onLoggedIn: this.onUserCompletedQrLoginFlow,
},
"mx_LoginWithQR_dialog",
false,
true,
);
// View the welcome or home page if we need something to look at
this.viewSomethingBehindModal();
}
break;
}
case "view_welcome_page":
this.viewWelcome();
break;
@@ -1858,6 +1880,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
params: params,
});
PerformanceMonitor.instance.start(PerformanceEntryNames.LOGIN);
} else if (screen === "qr_login") {
dis.fire(Action.ViewQrLogin);
} else if (screen === "forgot_password") {
dis.dispatch({
action: "start_password_recovery",
@@ -2131,6 +2155,36 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER);
};
/**
* After successful qr login, load & persist the credentials, as well as the secrets bundle.
*/
private onUserCompletedQrLoginFlow = async ({
secrets,
deviceId,
...tokenResponse
}: QrLoginCredentials): Promise<void> => {
// Persist credentials + OIDC settings, then hydrate the client from storage.
// setLoggedIn would clear storage and drop the OIDC settings; see its docstring.
await configureFromCompletedOAuthLogin(tokenResponse);
await Lifecycle.restoreSessionFromStorage();
if (secrets) {
const crypto = MatrixClientPeg.safeGet().getCrypto();
if (crypto?.importSecretsBundle) {
// This imports the secrets and cross-signs the device in one go
await crypto.importSecretsBundle(secrets);
} else {
logger.warn(
"Crypto not initialised or no importSecretsBundle() method, cannot import secrets from QR login",
);
}
} else {
logger.warn("No secrets received from QR login");
}
this.onShowPostLoginScreen();
};
/** Called when {@link Views.E2E_SETUP} or {@link Views.COMPLETE_SECURITY} have completed. */
private onCompleteSecurityE2eSetupFinished = async (): Promise<void> => {
const forceVerify = await this.shouldForceVerification();
@@ -2220,7 +2274,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
);
}
} else if (this.state.view === Views.WELCOME) {
view = <Welcome />;
view = <Welcome {...this.getServerProperties()} />;
} else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) {
const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail;
view = (
@@ -5,15 +5,32 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import React, { type JSX } from "react";
import { Button, Heading, Text } from "@vector-im/compound-web";
import { createClient } from "matrix-js-sdk/src/matrix";
import { QrCodeIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig.ts";
import { MatrixClientPeg } from "../../../MatrixClientPeg.ts";
import { isElementBranded } from "../../../branding.ts";
import { useFeatureEnabled } from "../../../hooks/useSettings.ts";
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig.ts";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo.ts";
import Spinner from "../elements/Spinner.tsx";
const DefaultWelcome: React.FC = () => {
interface Props {
/**
* The server config to use for QR code login
*/
serverConfig: ValidatedServerConfig;
}
/**
* Default welcome (/#/welcome) screen to render if not overridden by config.json
* Links out to Login, Registration, QR Login & Directory (if guest access is supported)
*/
const DefaultWelcome: React.FC<Props> = ({ serverConfig }) => {
const brand = SdkConfig.get("brand");
const branding = SdkConfig.getObject("branding");
const logoUrl = branding.get("auth_header_logo_url");
@@ -21,6 +38,45 @@ const DefaultWelcome: React.FC = () => {
const showGuestFunctions = !!MatrixClientPeg.get();
const isElement = isElementBranded();
const isQrLoginEnabled = useFeatureEnabled("feature_login_with_qr");
const showQrButton = useAsyncMemo(async () => {
if (!isQrLoginEnabled) return false;
const tempClient = createClient({
baseUrl: serverConfig.hsUrl,
});
// We do not use isSignInWithQRAvailable as we only need rendezvous at this point and are likely to be logging
// into a different server than this one, so whether this one supports DAG is irrelevant.
return tempClient.doesServerSupportUnstableFeature("org.matrix.msc4108");
}, [serverConfig, isQrLoginEnabled]);
const loading = isQrLoginEnabled && showQrButton === undefined;
let body: JSX.Element;
if (loading) {
body = <Spinner />;
} else {
body = (
<div className="mx_DefaultWelcome_buttons">
{showQrButton && (
<Button as="a" href="#/qr_login" kind="primary" size="md" Icon={QrCodeIcon}>
{_t("auth|sign_in_with_qr")}
</Button>
)}
<Button as="a" href="#/login" kind="primary" size="md">
{showQrButton ? _t("auth|sign_in_manually") : _t("action|sign_in")}
</Button>
<Button as="a" href="#/register" kind="secondary" size="md">
{_t("action|create_account")}
</Button>
{showGuestFunctions && (
<Button as="a" href="#/directory" kind="tertiary" size="md">
{_t("action|explore_rooms")}
</Button>
)}
</div>
);
}
return (
<div className="mx_DefaultWelcome">
<a href={branding.get("logo_link_url")} target="_blank" rel="noopener" className="mx_DefaultWelcome_logo">
@@ -31,19 +87,7 @@ const DefaultWelcome: React.FC = () => {
</Heading>
{isElement && <Text size="md">{_t("welcome|tagline_element")}</Text>}
<div className="mx_DefaultWelcome_buttons">
<Button as="a" href="#/login" kind="primary" size="md">
{_t("action|sign_in")}
</Button>
<Button as="a" href="#/register" kind="secondary" size="md">
{_t("action|create_account")}
</Button>
{showGuestFunctions && (
<Button as="a" href="#/directory" kind="tertiary" size="md">
{_t("action|explore_rooms")}
</Button>
)}
</div>
{body}
</div>
);
};
@@ -26,10 +26,24 @@ export enum Phase {
Error,
}
/**
* Enum representing the click actions (transitions) a user can take during the login with QR flow
*/
export enum Click {
/**
* Cancel the flow
*/
Cancel,
/**
* A specific case of cancellation when the existing device declines the login
*/
Decline,
/**
* Approve the login from the existing device
*/
Approve,
Back,
/**
* Initialise the flow & show QR code
*/
ShowQr,
}
@@ -15,27 +15,112 @@ import {
RendezvousError,
type RendezvousFailureReason,
RendezvousIntent,
signInByGeneratingQR,
} from "matrix-js-sdk/src/rendezvous";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { AutoDiscovery, MatrixClient, OAuthGrantType, type OidcClientConfig, type XOR } from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { Click, Mode, Phase } from "./LoginWithQR-types";
import LoginWithQRFlow from "./LoginWithQRFlow";
import { type CompleteOidcLoginResponse } from "../../../utils/oidc/authorize";
import { getOidcClientId } from "../../../utils/oidc/registerClient.ts";
import SdkConfig from "../../../SdkConfig.ts";
interface IProps {
export type QrLoginCredentials = Omit<CompleteOidcLoginResponse, "idTokenClaims"> &
Awaited<ReturnType<MSC4108SignInWithQR["shareSecrets"]>> & {
deviceId: string;
};
type BaseProps = {
/**
* The MatrixClient to use for the rendezvous communication with the other device.
*/
client: MatrixClient;
/**
* Whether to show a QR code or facilitate scanning one. Only Mode.Show is currently supported.
*/
mode: Mode;
onFinished(...args: any): void;
}
/**
* Callback when the internal phase state has changed
* @param phase - the new phase which is being entered
*/
onPhaseChange?(phase: Phase): void;
/**
* Callback when the flow is concluded
* @param success - whether it was successful
*/
onFinished(this: void, success?: boolean): void;
};
type Props = XOR<
{
/**
* Intent to facilitate logging into this device from an existing device
*/
intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE;
/**
* Callback for successful login
* @param credentials - the credentials to authenticate with
*/
onLoggedIn(credentials: QrLoginCredentials): Promise<void>;
},
{
/**
* Intent to facilitate logging into another device from this existing device
*/
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE;
}
> &
BaseProps;
interface IState {
/**
* The current phase of the flow
*/
phase: Phase;
/**
* The rendezvous channel in use
*/
rendezvous?: MSC4108SignInWithQR;
/**
* TODO
*/
verificationUri?: string;
/**
* TODO
*/
userCode?: string;
/**
* TODO
*/
checkCode?: string;
/**
* TODO
*/
failureReason?: FailureReason;
/**
* TODO
*/
loginServerDetails?: {
/**
* TODO
*/
homeserverUrl: string;
/**
* TODO
*/
identityServerUrl?: string;
/**
* TODO
*/
metadata: OidcClientConfig;
/**
* TODO
*/
clientId: string;
};
}
export enum LoginWithQRFailureReason {
@@ -45,34 +130,67 @@ export enum LoginWithQRFailureReason {
export type FailureReason = RendezvousFailureReason | LoginWithQRFailureReason;
/**
* Resolve a server name or baseURL to the homeserver & identity server URLs.
* @param serverNameOrBaseUrl the name or URL to resolve
* Whilst the 2024 version of MSC4108 says that we always get a server name, in practise the
* rust-sdk is currently misbehaving and we may receive a base URL instead. Additionally, the 2025
* version of MSC4108 will always give the base URL.
* As such, we should be resilient and support both formats until the spec and implementations have
* stabilised.
*/
async function resolveServerURLs(
serverNameOrBaseUrl: string,
): Promise<Pick<Partial<NonNullable<IState["loginServerDetails"]>>, "homeserverUrl" | "identityServerUrl">> {
if (serverNameOrBaseUrl.startsWith("http://") || serverNameOrBaseUrl.startsWith("https://")) {
// treat as base URL and skip discovery
return {
homeserverUrl: serverNameOrBaseUrl,
};
}
// treat as server name and do discovery
const clientConfig = await AutoDiscovery.findClientConfig(serverNameOrBaseUrl);
const homeserverUrl = clientConfig?.["m.homeserver"]?.base_url ?? undefined;
const identityServerUrl = clientConfig?.["m.identity_server"]?.base_url ?? undefined;
return {
homeserverUrl,
identityServerUrl,
};
}
/**
* A component that allows sign in and E2EE set up with a QR code.
*
* It implements `login.reciprocate` capabilities and showing QR codes.
* It implements `login.reciprocate` & `login.start` capabilities and showing QR codes.
* It does not implement any flows requiring the scanning of QR codes.
*
* This uses the unstable feature of MSC4108: https://github.com/matrix-org/matrix-spec-proposals/pull/4108
* Implements the v2024 version of MSC4108: https://github.com/matrix-org/matrix-spec-proposals/pull/4108
*/
export default class LoginWithQR extends React.Component<IProps, IState> {
export default class LoginWithQR extends React.Component<Props, IState> {
private finished = false;
private abortController?: AbortController;
public constructor(props: IProps) {
public constructor(props: Props) {
super(props);
this.state = {
phase: Phase.Loading,
};
}
private get ourIntent(): RendezvousIntent {
return RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE;
this.props.onPhaseChange?.(this.state.phase);
}
public componentDidMount(): void {
void this.updateMode(this.props.mode);
}
public componentDidUpdate(prevProps: Readonly<IProps>): void {
public componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<IState>): void {
if (prevState.phase !== this.state.phase) {
this.props.onPhaseChange?.(this.state.phase);
}
if (prevProps.mode !== this.props.mode) {
void this.updateMode(this.props.mode);
}
@@ -99,13 +217,19 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
private onFinished(success: boolean): void {
this.finished = true;
if (!success) {
this.abortController?.abort();
}
this.props.onFinished(success);
}
private generateAndShowCode = async (abortController: AbortController): Promise<void> => {
let rendezvous: MSC4108SignInWithQR;
try {
rendezvous = await linkNewDeviceByGeneratingQR(this.props.client, this.onFailure, abortController.signal);
rendezvous =
this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE
? await signInByGeneratingQR(this.props.client, this.onFailure, abortController.signal)
: await linkNewDeviceByGeneratingQR(this.props.client, this.onFailure, abortController.signal);
if (abortController.signal.aborted) return;
this.setState({
phase: Phase.ShowingQR,
@@ -120,7 +244,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
}
try {
if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
if (this.props.intent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
// MSC4108-Flow: NewScanned
await rendezvous.negotiateProtocols();
const { verificationUri } = await rendezvous.deviceAuthorizationGrant();
@@ -128,6 +252,43 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
phase: Phase.OutOfBandConfirmation,
verificationUri,
});
} else {
const { serverName } = await rendezvous.negotiateProtocols();
const { homeserverUrl, identityServerUrl } = await resolveServerURLs(serverName!);
if (!homeserverUrl) {
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown });
logger.error("Failed to discover homeserver URL");
throw new Error("Failed to discover homeserver URL");
}
let metadata: OidcClientConfig;
let clientId: string;
try {
// Create a new client as the homeserver URL may not be the same as we used for the secure channel
metadata = await new MatrixClient({ baseUrl: homeserverUrl }).getAuthMetadata();
if (!metadata.grant_types_supported.includes(OAuthGrantType.DeviceAuthorization)) {
throw new Error("Server does not support Device Authorization Grant");
}
clientId = await getOidcClientId(metadata, SdkConfig.get().oidc_static_clients);
} catch (e) {
this.setState({
phase: Phase.Error,
failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport,
});
logger.error("Failed to register OIDC Client ID", e);
throw new Error("Failed to register OIDC Client ID", { cause: e });
}
this.setState({
phase: Phase.OutOfBandConfirmation,
loginServerDetails: {
homeserverUrl,
identityServerUrl,
metadata,
clientId,
},
});
}
// we ask the user to confirm that the channel is secure
@@ -152,7 +313,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
}
try {
if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
if (this.props.intent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
// MSC4108-Flow: NewScanned
this.setState({ phase: Phase.Loading });
@@ -168,8 +329,41 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
// 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");
if (!this.state.loginServerDetails) {
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown });
throw new Error("Server details not found in state");
}
const { homeserverUrl, identityServerUrl, clientId, metadata } = this.state.loginServerDetails;
// Generate our new device ID
const deviceId = secureRandomString(10);
const { userCode } = await this.state.rendezvous.deviceAuthorizationGrant({
metadata,
clientId,
deviceId,
});
this.setState({ phase: Phase.WaitingForDevice, userCode });
const tokenResponse = await this.state.rendezvous.completeLoginOnNewDevice({ clientId });
if (tokenResponse) {
const { secrets } = await this.state.rendezvous.shareSecrets();
await this.props.onLoggedIn({
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
homeserverUrl,
clientId,
idToken: tokenResponse.id_token,
issuer: metadata!.issuer,
identityServerUrl,
secrets,
deviceId,
});
this.onFinished(true);
}
}
} catch (e: RendezvousError | unknown) {
logger.error("Error whilst approving sign in", e);
@@ -182,7 +376,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
private onFailure = async (reason: RendezvousFailureReason): Promise<void> => {
if (this.state.phase === Phase.Error) return; // Already in failed state
logger.info(`Rendezvous failed: ${reason}`);
logger.warn(`Rendezvous failed: ${reason}`);
// Generate a new rendezvous channel & qr code if we hit expiry whilst still showing the QR code
if (reason === ClientRendezvousFailureReason.Expired && this.state.phase === Phase.ShowingQR) {
@@ -196,7 +390,6 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
logger.warn("Failed to re-roll qr code on expiry", e);
}
}
this.setState({ phase: Phase.Error, failureReason: reason });
};
@@ -207,7 +400,6 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
verificationUri: undefined,
failureReason: undefined,
userCode: undefined,
checkCode: undefined,
});
}
@@ -215,19 +407,17 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
switch (type) {
case Click.Cancel:
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
this.reset();
this.onFinished(false);
break;
case Click.Approve:
await this.approveLogin(checkCode);
break;
case Click.Decline:
await this.state.rendezvous?.declineLoginOnExistingDevice();
this.reset();
this.onFinished(false);
break;
case Click.Back:
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
} else {
await this.state.rendezvous?.declineLoginOnExistingDevice();
}
this.onFinished(false);
break;
case Click.ShowQr:
@@ -244,7 +434,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
code={this.state.phase === Phase.ShowingQR ? this.state.rendezvous?.code : undefined}
failureReason={this.state.failureReason}
userCode={this.state.userCode}
checkCode={this.state.checkCode}
intent={this.props.intent}
/>
);
}
@@ -6,14 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, createRef, type ReactNode } from "react";
import { ClientRendezvousFailureReason, MSC4108FailureReason } from "matrix-js-sdk/src/rendezvous";
import React, { type ComponentProps, createRef, type JSX, type ReactNode } from "react";
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousIntent } from "matrix-js-sdk/src/rendezvous";
import ChevronLeftIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-left";
import CheckCircleSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/check-circle-solid";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
import { Heading, MFAInput, Text } from "@vector-im/compound-web";
import { BigIcon, Button, Heading, MFAInput, Text } from "@vector-im/compound-web";
import classNames from "classnames";
import { QrCodeIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { LockSolidIcon, MobileIcon, QrCodeIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
@@ -25,18 +25,38 @@ import { type FailureReason, LoginWithQRFailureReason } from "./LoginWithQR";
import { ErrorMessage } from "../../structures/ErrorMessage";
interface Props {
/**
* The phase the flow is to be rendered in
*/
phase: Phase;
/**
* The QR code data to render in Phase.ShowingQR
*/
code?: Uint8Array;
/**
* The intent mode of the flow (login or reciprocate).
*/
intent: RendezvousIntent;
/**
* State transition handler
* @param type the type of transition to take
* @param checkCodeEntered the check code entered, only present for Click.Approve
*/
onClick(type: Click, checkCodeEntered?: string): Promise<void>;
/**
* The failure reason to render in Phase.Error
*/
failureReason?: FailureReason;
/**
* The 6 digit user code to render in Phase.WaitingForDevice
*/
userCode?: string;
checkCode?: string;
}
/**
* A component that implements the UI for sign in and E2EE set up with a QR code.
* A component that implements the UI for sign in and E2EE set up by rendering a QR code in both login & reciprocate directions.
*
* This supports the unstable features of MSC4108
* Implements the v2024 version of MSC4108: https://github.com/matrix-org/matrix-spec-proposals/pull/4108
*/
export default class LoginWithQRFlow extends React.Component<Props> {
private checkCodeInput = createRef<HTMLInputElement>();
@@ -49,9 +69,9 @@ export default class LoginWithQRFlow extends React.Component<Props> {
};
private cancelButton = (): JSX.Element => (
<AccessibleButton data-testid="cancel-button" kind="primary_outline" onClick={this.handleClick(Click.Cancel)}>
<Button data-testid="cancel-button" kind="primary" size="lg" onClick={this.handleClick(Click.Cancel)}>
{_t("action|cancel")}
</AccessibleButton>
</Button>
);
private simpleSpinner = (description?: string): JSX.Element => {
@@ -74,9 +94,16 @@ export default class LoginWithQRFlow extends React.Component<Props> {
switch (this.props.phase) {
case Phase.Error: {
backButton = false;
buttons = (
<Button kind="primary" size="lg" onClick={this.handleClick(Click.ShowQr)}>
{this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE
? _t("auth|qr_code_login|start_over")
: _t("action|try_again")}
</Button>
);
let Icon = ErrorIcon;
let success: boolean | null = false;
let iconKind: ComponentProps<typeof BigIcon>["kind"] = "critical";
let title: string | undefined;
let message: ReactNode | undefined;
@@ -89,6 +116,9 @@ export default class LoginWithQRFlow extends React.Component<Props> {
case MSC4108FailureReason.UserCancelled:
title = _t("auth|qr_code_login|error_user_cancelled_title");
message = _t("auth|qr_code_login|error_user_cancelled");
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
buttons = this.cancelButton();
}
break;
case MSC4108FailureReason.AuthorizationExpired:
@@ -116,7 +146,7 @@ export default class LoginWithQRFlow extends React.Component<Props> {
break;
case ClientRendezvousFailureReason.OtherDeviceAlreadySignedIn:
success = true;
iconKind = "success";
Icon = CheckCircleSolidIcon;
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");
@@ -125,6 +155,9 @@ export default class LoginWithQRFlow extends React.Component<Props> {
case ClientRendezvousFailureReason.UserDeclined:
title = _t("auth|qr_code_login|error_user_declined_title");
message = _t("auth|qr_code_login|error_user_declined");
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
buttons = this.cancelButton();
}
break;
case LoginWithQRFailureReason.RateLimited:
@@ -138,11 +171,13 @@ export default class LoginWithQRFlow extends React.Component<Props> {
break;
case ClientRendezvousFailureReason.HomeserverLacksSupport:
success = null;
Icon = QrCodeIcon;
backButton = true;
title = _t("auth|qr_code_login|unsupported_heading");
message = _t("auth|qr_code_login|unsupported_explainer");
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
buttons = this.cancelButton();
}
break;
case MSC4108FailureReason.DeviceAlreadyExists:
@@ -158,14 +193,9 @@ export default class LoginWithQRFlow extends React.Component<Props> {
className = "mx_LoginWithQR_error";
main = (
<>
<div
className={classNames("mx_LoginWithQR_icon", {
"mx_LoginWithQR_icon--critical": success === false,
"mx_LoginWithQR_icon--success": success === true,
})}
>
<Icon width="32px" height="32px" />
</div>
<BigIcon kind={iconKind}>
<Icon />
</BigIcon>
<Heading as="h1" size="sm" weight="semibold">
{title}
</Heading>
@@ -178,6 +208,11 @@ export default class LoginWithQRFlow extends React.Component<Props> {
backButton = false;
main = (
<>
{this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE && (
<BigIcon>
<MobileIcon />
</BigIcon>
)}
<Heading as="h1" size="sm" weight="semibold">
{_t("auth|qr_code_login|check_code_heading")}
</Heading>
@@ -209,39 +244,62 @@ export default class LoginWithQRFlow extends React.Component<Props> {
buttons = (
<>
<AccessibleButton
<Button
data-testid="approve-login-button"
kind="primary"
size="lg"
onClick={this.handleClick(Click.Approve)}
>
{_t("action|continue")}
</AccessibleButton>
<AccessibleButton
</Button>
<Button
data-testid="decline-login-button"
kind="primary_outline"
kind="tertiary"
size="lg"
onClick={this.handleClick(Click.Decline)}
>
{_t("action|cancel")}
</AccessibleButton>
</Button>
</>
);
break;
case Phase.ShowingQR: {
const steps = [
_t("auth|qr_code_login|open_element_other_device", {
brand: SdkConfig.get().brand,
}),
_t("auth|qr_code_login|select_qr_code", {
scanQRCode: <strong>{_t("auth|qr_code_login|scan_qr_code")}</strong>,
}),
_t("auth|qr_code_login|point_the_camera"),
_t("auth|qr_code_login|follow_remaining_instructions"),
];
let steps: ReactNode[];
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
steps = [
_t("auth|qr_code_login|open_element_mobile_device", {
brand: SdkConfig.get().brand,
}),
_t("auth|qr_code_login|tap_avatar_link_new_device", {
linkNewDevice: <strong>{_t("settings|sessions|sign_in_with_qr")}</strong>,
}),
_t("auth|qr_code_login|choose_desktop_computer", {
desktopComputer: <strong>{_t("auth|qr_code_login|desktop_computer")}</strong>,
}),
_t("auth|qr_code_login|select_ready_to_scan", {
readyToScan: <strong>{_t("auth|qr_code_login|ready_to_scan")}</strong>,
}),
_t("auth|qr_code_login|follow_remaining_instructions"),
];
} else {
steps = [
_t("auth|qr_code_login|open_element_other_device", {
brand: SdkConfig.get().brand,
}),
_t("auth|qr_code_login|select_qr_code", {
scanQRCode: <strong>{_t("auth|qr_code_login|scan_qr_code")}</strong>,
}),
_t("auth|qr_code_login|point_the_camera"),
_t("auth|qr_code_login|follow_remaining_instructions"),
];
}
main = (
<>
<Heading as="h1" size="sm" weight="semibold">
{_t("auth|qr_code_login|scan_code_instruction")}
{this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE
? _t("auth|qr_code_login|scan_code_instruction")
: _t("auth|qr_code_login|scan_code_instruction_reciprocate")}
</Heading>
<div className="mx_LoginWithQR_qrWrapper">
{this.props.code ? (
@@ -252,7 +310,7 @@ export default class LoginWithQRFlow extends React.Component<Props> {
</div>
<ol>
{steps.map((step, i) => (
<li key={i}>{step}</li>
<li key={this.props.intent + i}>{step}</li>
))}
</ol>
</>
@@ -263,18 +321,27 @@ export default class LoginWithQRFlow extends React.Component<Props> {
main = this.simpleSpinner();
break;
case Phase.WaitingForDevice:
main = (
<>
{this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))}
{this.props.userCode ? (
<div>
<p>{_t("auth|qr_code_login|security_code")}</p>
<p>{_t("auth|qr_code_login|security_code_prompt")}</p>
<p>{this.props.userCode}</p>
</div>
) : null}
</>
);
main =
this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE ? (
<>
<BigIcon>
<LockSolidIcon />
</BigIcon>
<Heading as="h1" size="sm" weight="semibold">
{_t("auth|qr_code_login|security_code_title")}
</Heading>
<Text size="md">{_t("auth|qr_code_login|security_code_prompt")}</Text>
<MFAInput
className="mx_LoginWithQR_checkCode_input mx_no_textinput"
length={6}
value={this.props.userCode}
disabled
/>
{this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))}
</>
) : (
this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))
);
buttons = this.cancelButton();
break;
case Phase.Verifying:
@@ -284,12 +351,12 @@ export default class LoginWithQRFlow extends React.Component<Props> {
return (
<div data-testid="login-with-qr" className={classNames("mx_LoginWithQR", className)}>
{backButton ? (
{this.props.intent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE && backButton ? (
<div className="mx_LoginWithQR_heading">
<AccessibleButton
data-testid="back-button"
className="mx_LoginWithQR_BackButton"
onClick={this.handleClick(Click.Back)}
onClick={this.handleClick(Click.Cancel)}
title={_t("action|back")}
>
<ChevronLeftIcon />
@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
import React, { type ReactNode } from "react";
import classNames from "classnames";
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { Glass } from "@vector-im/compound-web";
import SdkConfig from "../../../SdkConfig";
@@ -18,8 +17,13 @@ import LanguageSelector from "./LanguageSelector";
import EmbeddedPage from "../../structures/EmbeddedPage";
import { MATRIX_LOGO_HTML } from "../../structures/static-page-vars";
import DefaultWelcome from "./DefaultWelcome.tsx";
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig.ts";
export default class Welcome extends React.PureComponent<EmptyObject> {
interface Props {
serverConfig: ValidatedServerConfig;
}
export default class Welcome extends React.PureComponent<Props> {
public render(): React.ReactNode {
const pagesConfig = SdkConfig.getObject("embedded_pages");
const pageUrl = pagesConfig?.get("welcome_url");
@@ -36,7 +40,7 @@ export default class Welcome extends React.PureComponent<EmptyObject> {
if (pageUrl) {
body = <EmbeddedPage className="mx_WelcomePage" url={pageUrl} replaceMap={replaceMap} />;
} else {
body = <DefaultWelcome />;
body = <DefaultWelcome serverConfig={this.props.serverConfig} />;
}
return (
@@ -59,9 +59,10 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
}
private getSettingValue(): boolean {
// If a level defined in props is overridden by a level at a high presedence, it gets disabled
// and we should show the overridding value.
// If a level defined in props is overridden by a level at a high precedence,
// or the setting lacks support for the desired level, it gets disabled and we should show the overriding value.
if (
!SettingsStore.doesSettingSupportLevel(this.props.name, this.props.level) ||
SettingsStore.settingIsOveriddenAtConfigLevel(this.props.name, this.props.roomId ?? null, this.props.level)
) {
return !!SettingsStore.getValue(this.props.name);
@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import React, { lazy, Suspense, useCallback, useContext, useEffect, useRef, useState } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { RendezvousIntent } from "matrix-js-sdk/src/rendezvous";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
@@ -263,7 +264,12 @@ const SessionManagerTab: React.FC<{
if (signInWithQrMode) {
return (
<Suspense fallback={<Spinner />}>
<LoginWithQR mode={signInWithQrMode} onFinished={onQrFinish} client={matrixClient} />
<LoginWithQR
mode={signInWithQrMode}
onFinished={onQrFinish}
client={matrixClient}
intent={RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE}
/>
</Suspense>
);
}
+5
View File
@@ -42,6 +42,11 @@ export enum Action {
*/
ViewUserDeviceSettings = "view_user_device_settings",
/**
* Opens the QR login flow. Only valid during authentication. No additional payload information required.
*/
ViewQrLogin = "view_qr_login",
/**
* Opens the room directory. No additional payload information required.
*/
+16 -4
View File
@@ -246,7 +246,9 @@
"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",
"choose_desktop_computer": "Choose \"%(desktopComputer)s\"",
"completing_setup": "Completing set up of your new device",
"desktop_computer": "Desktop computer",
"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",
@@ -267,16 +269,22 @@
"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 remaining instructions",
"open_element_mobile_device": "Open %(brand)s on your mobile device",
"open_element_other_device": "Open %(brand)s on your other device",
"point_the_camera": "Scan the QR code shown here",
"scan_code_instruction": "Scan the QR code with another device",
"ready_to_scan": "Ready to scan",
"scan_code_instruction": "Scan the QR code",
"scan_code_instruction_reciprocate": "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.",
"security_code_prompt": "Your account provider may ask for the following code to verify the sign in.",
"security_code_title": "Your verification code",
"select_qr_code": "Select \"%(scanQRCode)s\"",
"select_ready_to_scan": "Select \"%(readyToScan)s\" and scan the QR code shown here",
"start_over": "Start over",
"tap_avatar_link_new_device": "Tap your avatar and select \"%(linkNewDevice)s\"",
"unsupported_explainer": "Your account provider doesn't support signing into a new device with a QR code.",
"unsupported_heading": "QR code not supported",
"waiting_for_device": "Waiting for device to sign in"
"waiting_for_device": "Waiting for your other device"
},
"register_action": "Create Account",
"registration": {
@@ -335,8 +343,10 @@
"sign_in_description": "Use your account to continue.",
"sign_in_instead": "Sign in instead",
"sign_in_instead_prompt": "Already have an account? <a>Sign in here</a>",
"sign_in_manually": "Sign in manually",
"sign_in_or_register": "Sign In or Create Account",
"sign_in_or_register_description": "Use your account or create a new one to continue.",
"sign_in_with_qr": "Sign in with QR code",
"sign_in_with_sso": "Sign in with single sign-on",
"signing_in": "Signing In…",
"soft_logout": {
@@ -1518,6 +1528,7 @@
"bridge_state_manager": "This bridge is managed by <user />.",
"bridge_state_workspace": "Workspace: <networkLink/>",
"click_for_info": "Click for more info",
"config_only": "Can only be enabled via config.json.",
"currently_experimental": "Currently experimental.",
"custom_themes": "Support adding custom themes",
"dynamic_room_predecessors": "Dynamic room predecessors",
@@ -1562,6 +1573,7 @@
"leave_beta_reload": "Leaving the beta will reload %(brand)s.",
"location_share_live": "Live Location Sharing",
"location_share_live_description": "Temporary implementation. Locations persist in room history.",
"login_with_qr": "Log in with QR code",
"mjolnir": "New ways to ignore people",
"msc3531_hide_messages_pending_moderation": "Let moderators hide messages pending moderation.",
"new_room_list": "Enable new room list",
+9
View File
@@ -230,6 +230,7 @@ export interface Settings {
"feature_notifications": IFeature;
"feature_msc4362_encrypted_state_events": IFeature;
"feature_user_status": IFeature;
"feature_login_with_qr": IFeature;
// These are in the feature namespace but aren't actually features
"feature_hidebold": IBaseSetting<boolean>;
@@ -674,6 +675,14 @@ export const SETTINGS: Settings = {
default: false,
controller: new ReloadOnChangeController(),
},
"feature_login_with_qr": {
supportedLevels: [SettingLevel.CONFIG],
labsGroup: LabGroup.Ui,
displayName: _td("labs|login_with_qr"),
description: _td("labs|config_only"),
isFeature: true,
default: false,
},
/**
* With the transition to Compound we are moving to a base font size
* of 16px. We're taking the opportunity to move away from the `baseFontSize`
+30 -9
View File
@@ -76,24 +76,45 @@ const getCodeAndStateFromParams = (
return { code, state };
};
type CompleteOidcLoginResponse = {
// url of the homeserver selected during login
/**
* Return type for {@link completeOidcLogin}
* Contains all the credentials gathered from a successful OIDC login
*/
export type CompleteOidcLoginResponse = {
/**
* URL of the homeserver selected during login
*/
homeserverUrl: string;
// identity server url as discovered during login
/**
* Identity server URL as discovered during login
*/
identityServerUrl?: string;
// accessToken gained from OIDC token issuer
/**
* Access Token gained from OIDC token issuer
*/
accessToken: string;
// refreshToken gained from OIDC token issuer, when falsy token cannot be refreshed
/**
* Refresh Token gained from OIDC token issuer, when falsy token cannot be refreshed
*/
refreshToken?: string;
// idToken gained from OIDC token issuer
/**
* ID Token gained from OIDC token issuer
*/
idToken?: string;
// this client's id as registered with the OIDC issuer
/**
* This client's ID as registered with the OIDC issuer
*/
clientId: string;
// issuer used during authentication
/**
* Issuer used during authentication
*/
issuer: string;
// claims of the given access token; used during token refresh to validate new tokens
/**
* Claims of the given access token; used during token refresh to validate new tokens
*/
idTokenClaims: IdTokenClaims;
};
/**
* Attempt to complete authorization code flow to get an access token
* @param urlParams the parameters extracted from the app-load URI.
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import "fake-indexeddb/auto";
import React, { type ComponentProps } from "react";
import React, { type ComponentProps, createRef, type RefObject } from "react";
import { fireEvent, render, type RenderResult, screen, waitFor, within, act } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { ClientEvent, type MatrixClient, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix";
@@ -24,6 +24,8 @@ import {
UserVerificationStatus,
type CryptoApi,
} from "matrix-js-sdk/src/crypto-api";
import fetchMock from "@fetch-mock/jest";
import * as qrLogin from "matrix-js-sdk/src/rendezvous";
import MatrixChat from "../../../../src/components/structures/MatrixChat";
import * as StorageAccess from "../../../../src/utils/StorageAccess";
@@ -69,6 +71,8 @@ import { clearStorage } from "../../../../src/Lifecycle";
import RoomListStore from "../../../../src/stores/room-list/RoomListStore.ts";
import UserSettingsDialog from "../../../../src/components/views/dialogs/UserSettingsDialog.tsx";
import { SdkContextClass } from "../../../../src/contexts/SDKContext.ts";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc.ts";
import { type QrLoginCredentials } from "../../../../src/components/views/auth/LoginWithQR.tsx";
jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({
completeAuthorizationCodeGrant: jest.fn(),
@@ -82,6 +86,31 @@ jest.mock("../../../../src/theme");
/** The matrix versions our mock server claims to support */
const SERVER_SUPPORTED_MATRIX_VERSIONS = ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"];
function createMockCrypto(): CryptoApi {
return {
getVersion: jest.fn().mockReturnValue("Version 0"),
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
getUserDeviceInfo: jest.fn().mockReturnValue({
get: jest
.fn()
.mockReturnValue(
new Map([
[
"devid",
{ deviceId: "devid", dehydrated: false, getIdentityKey: jest.fn().mockReturnValue("k") },
],
]),
),
}),
getUserVerificationStatus: jest.fn().mockResolvedValue(new UserVerificationStatus(true, true, false)),
setDeviceIsolationMode: jest.fn(),
isDehydrationSupported: jest.fn().mockReturnValue(false),
getDeviceVerificationStatus: jest.fn().mockResolvedValue({ signedByOwner: true } as DeviceVerificationStatus),
isCrossSigningReady: jest.fn().mockReturnValue(false),
requestOwnUserVerification: jest.fn().mockResolvedValue({ cancel: jest.fn(), on: jest.fn() }),
} as any;
}
describe("<MatrixChat />", () => {
const userId = "@alice:server.org";
const deviceId = "qwertyui";
@@ -179,8 +208,11 @@ describe("<MatrixChat />", () => {
warning: "",
};
let defaultProps: ComponentProps<typeof MatrixChat>;
const getComponent = (props: Partial<ComponentProps<typeof MatrixChat>> = {}) => {
return render(<MatrixChat {...defaultProps} {...props} />);
const getComponent = (
props: Partial<ComponentProps<typeof MatrixChat>> = {},
ref?: RefObject<MatrixChat | null>,
) => {
return render(<MatrixChat {...defaultProps} {...props} ref={ref} />);
};
// make test results readable
@@ -311,6 +343,118 @@ describe("<MatrixChat />", () => {
);
});
describe("qr login", () => {
beforeEach(() => {
const authConfig = makeDelegatedAuthConfig();
defaultProps.config.validated_server_config!.delegatedAuthentication = authConfig;
fetchMock.post(authConfig.registration_endpoint!, { client_id: "abc123" });
mockPlatformPeg({
getOidcClientMetadata: jest.fn().mockReturnValue({
clientName: "App name",
clientUri: "https://company",
redirectUris: ["https://app"],
logoUri: "https://company/logo.png",
applicationType: "web",
}),
});
jest.spyOn(qrLogin, "signInByGeneratingQR").mockReturnValue(new Promise(() => {}));
});
it("should open QrLoginDialog on ViewQrLogin action", async () => {
getComponent();
defaultDispatcher.fire(Action.ViewQrLogin);
await expect(screen.findByRole("dialog", { name: "Sign in with QR code" })).resolves.toMatchSnapshot();
});
it("should ignore ViewQrLogin action when logged in", async () => {
await populateStorageForSession();
getComponent();
// wait for logged in view to load
await screen.findByLabelText("User menu");
defaultDispatcher.fire(Action.ViewQrLogin);
expect(screen.queryByRole("dialog", { name: "Sign in with QR code" })).not.toBeInTheDocument();
});
it("should fire ViewQrLogin action on 'qr_login' route", async () => {
const ref = createRef<MatrixChat>();
getComponent({}, ref);
ref.current!.showScreen("qr_login");
await expect(screen.findByRole("dialog", { name: "Sign in with QR code" })).resolves.toMatchSnapshot();
});
it("should handle qr login completed", async () => {
const qrCreds: QrLoginCredentials = {
accessToken: "at",
homeserverUrl: "https://homeserver",
clientId: "ci",
idToken: "it",
issuer: defaultProps.config.validated_server_config!.delegatedAuthentication!.issuer,
deviceId: "di",
secrets: {
cross_signing: {
master_key: "mk",
self_signing_key: "ssk",
user_signing_key: "usk",
},
},
};
mockClient.whoami.mockResolvedValue({ user_id: "@user:homeserver", device_id: qrCreds.deviceId });
mockClient.getCrypto.mockReturnValue({
...createMockCrypto(),
crossSignDevice: jest.fn().mockResolvedValue(undefined),
importSecretsBundle: jest.fn().mockResolvedValue(undefined),
});
getComponent();
const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
// Assert welcome screen
await screen.findByText("Welcome to Test");
// Open QR dialog so we can grab the onLoggedIn method
defaultDispatcher.fire(Action.ViewQrLogin, true);
expect(createDialogSpy).toHaveBeenCalledWith(
expect.anything(),
{
onLoggedIn: expect.any(Function),
serverConfig: defaultProps.config.validated_server_config,
},
"mx_LoginWithQR_dialog",
false,
true,
);
const { onLoggedIn } = createDialogSpy.mock.calls[0][1] as {
onLoggedIn(creds: QrLoginCredentials): Promise<void>;
};
const configureFromCompletedSpy = jest.spyOn(Lifecycle, "configureFromCompletedOAuthLogin");
const restoreSessionSpy = jest.spyOn(Lifecycle, "restoreSessionFromStorage");
const prom = onLoggedIn(qrCreds);
await waitFor(() =>
expect(configureFromCompletedSpy).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: qrCreds.accessToken,
homeserverUrl: qrCreds.homeserverUrl,
}),
),
);
await waitFor(() => expect(restoreSessionSpy).toHaveBeenCalled());
await waitFor(() =>
expect(mockClient.getCrypto()!.importSecretsBundle).toHaveBeenCalledWith(qrCreds.secrets),
);
await prom;
// initial sync
mockClient.emit(ClientEvent.Sync, SyncState.Prepared, null);
// wait for logged in view to load
await screen.findByLabelText("User menu");
});
});
describe("when query params have a OIDC params", () => {
const issuer = "https://auth.com/";
const homeserverUrl = "https://matrix.org";
@@ -1186,37 +1330,6 @@ describe("<MatrixChat />", () => {
await screen.findByRole("heading", { name: "Confirm your digital identity", level: 2 });
});
});
function createMockCrypto(): CryptoApi {
return {
getVersion: jest.fn().mockReturnValue("Version 0"),
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
getUserDeviceInfo: jest.fn().mockReturnValue({
get: jest.fn().mockReturnValue(
new Map([
[
"devid",
{
deviceId: "devid",
dehydrated: false,
getIdentityKey: jest.fn().mockReturnValue("k"),
},
],
]),
),
}),
getUserVerificationStatus: jest
.fn()
.mockResolvedValue(new UserVerificationStatus(true, true, false)),
setDeviceIsolationMode: jest.fn(),
isDehydrationSupported: jest.fn().mockReturnValue(false),
getDeviceVerificationStatus: jest
.fn()
.mockResolvedValue({ signedByOwner: true } as DeviceVerificationStatus),
isCrossSigningReady: jest.fn().mockReturnValue(false),
requestOwnUserVerification: jest.fn().mockResolvedValue({ cancel: jest.fn(), on: jest.fn() }),
} as any;
}
});
describe("showScreen", () => {
@@ -269,6 +269,114 @@ exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during sta
</div>
`;
exports[`<MatrixChat /> qr login should fire ViewQrLogin action on 'qr_login' route 1`] = `
<div
aria-label="Sign in with QR code"
class=""
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
/>
<div
class="mx_LoginWithQR"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_LoginWithQR_spinner"
>
<div>
<div
class="mx_Spinner"
>
<svg
aria-label="Loading…"
class="_icon_1855a_18"
data-testid="spinner"
fill="currentColor"
height="1em"
role="progressbar"
style="width: 32px; height: 32px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<MatrixChat /> qr login should open QrLoginDialog on ViewQrLogin action 1`] = `
<div
aria-label="Sign in with QR code"
class=""
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
/>
<div
class="mx_LoginWithQR"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_LoginWithQR_spinner"
>
<div>
<div
class="mx_Spinner"
>
<svg
aria-label="Loading…"
class="_icon_1855a_18"
data-testid="spinner"
fill="currentColor"
height="1em"
role="progressbar"
style="width: 32px; height: 32px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<MatrixChat /> should render spinner while app is loading 1`] = `
<div>
<div
@@ -28,6 +28,7 @@ import { UIFeature } from "../../../../../src/settings/UIFeature";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
import { type FeatureSettingKey } from "../../../../../src/settings/Settings.tsx";
import { mockOpenIdConfiguration } from "../../../../test-utils/oidc.ts";
mockPlatformPeg({
supportsSpellCheckSettings: jest.fn().mockReturnValue(false),
@@ -48,6 +49,7 @@ jest.mock("../../../../../src/settings/SettingsStore", () => ({
shouldHaveWarning: jest.fn(),
disabledMessage: jest.fn(),
settingIsOveriddenAtConfigLevel: jest.fn(),
doesSettingSupportLevel: jest.fn(),
}));
describe("<UserSettingsDialog />", () => {
@@ -71,6 +73,8 @@ describe("<UserSettingsDialog />", () => {
getIgnoredUsers: jest.fn().mockResolvedValue([]),
getPushers: jest.fn().mockResolvedValue([]),
getProfileInfo: jest.fn().mockResolvedValue({}),
getMediaConfig: jest.fn(),
getAuthMetadata: jest.fn().mockResolvedValue(mockOpenIdConfiguration()),
});
sdkContext = new SdkContextClass();
sdkContext.client = mockClient;
@@ -14,11 +14,22 @@ import {
MSC4108FailureReason,
MSC4108SignInWithQR,
RendezvousError,
RendezvousIntent,
} from "matrix-js-sdk/src/rendezvous";
import { HTTPError, type MatrixClient, MatrixHttpApi } from "matrix-js-sdk/src/matrix";
import { mockOpenIdConfiguration } from "matrix-js-sdk/src/testing";
import {
AutoDiscovery,
AutoDiscoveryAction,
HTTPError,
type MatrixClient,
MatrixHttpApi,
OAuthGrantType,
} from "matrix-js-sdk/src/matrix";
import fetchMock from "@fetch-mock/jest";
import LoginWithQR, { LoginWithQRFailureReason } from "../../../../../../src/components/views/auth/LoginWithQR";
import { Click, Mode, Phase } from "../../../../../../src/components/views/auth/LoginWithQR-types";
import { mockPlatformPeg } from "../../../../../test-utils";
jest.mock("matrix-js-sdk/src/rendezvous/transports");
jest.mock("matrix-js-sdk/src/rendezvous/channels");
@@ -50,6 +61,7 @@ function makeClient() {
getClientWellKnown: jest.fn().mockReturnValue({}),
getCrypto: jest.fn().mockReturnValue({}),
getDomain: jest.fn(),
getAuthMetadata: jest.fn().mockReturnValue(mockOpenIdConfiguration()),
} as unknown as MatrixClient);
cli.http = new MatrixHttpApi(cli, {
@@ -80,209 +92,313 @@ describe("<LoginWithQR />", () => {
});
afterEach(() => {
client = makeClient();
jest.clearAllMocks();
jest.useRealTimers();
cleanup();
});
describe("MSC4108", () => {
const getComponent = (props: {
client: MatrixClient;
onFinished?: () => void;
ref?: RefObject<LoginWithQR | null>;
}) => <LoginWithQR {...defaultProps} {...props} />;
test("render QR then back", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
jest.spyOn(MSC4108SignInWithQR.prototype, "generateCode");
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols");
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel");
const ref = createRef<LoginWithQR>();
render(getComponent({ client, onFinished, ref }));
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.ShowingQR,
onClick: expect.any(Function),
}),
describe("reciprocate", () => {
const getComponent = (props: {
client: MatrixClient;
onFinished?: () => void;
ref?: RefObject<LoginWithQR | null>;
}) => (
<LoginWithQR
{...defaultProps}
{...props}
intent={RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE}
/>
);
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
test("render QR then back", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
jest.spyOn(MSC4108SignInWithQR.prototype, "generateCode");
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols");
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel");
const ref = createRef<LoginWithQR>();
render(getComponent({ client, onFinished, ref }));
// back
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Back);
expect(onFinished).toHaveBeenCalledWith(false);
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
});
test("should open a new channel if expires before qr scan", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
const ref = createRef<LoginWithQR>();
render(getComponent({ client, onFinished, ref }));
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.ShowingQR,
onClick: expect.any(Function),
}),
);
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
// Expire the channel
rendezvous.onFailure!(ClientRendezvousFailureReason.Expired);
await jest.runAllTimersAsync();
await waitFor(() => expect(ref.current!.state.rendezvous).toBeDefined());
expect(ref.current!.state.rendezvous).not.toBe(rendezvous);
});
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("should show error if check code doesn't match", 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, "12");
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.OutOfBandConfirmation,
failureReason: LoginWithQRFailureReason.CheckCodeMismatch,
onClick: expect.any(Function),
}),
);
});
test("reciprocates login", async () => {
const ref = createRef<LoginWithQR>();
jest.spyOn(global.window, "open");
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({
verificationUri: "mock-verification-uri",
});
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");
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.shareSecrets).toHaveBeenCalled();
});
test("handles errors during protocol negotiation", async () => {
const ref = createRef<LoginWithQR>();
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
const err = new RendezvousError("Unknown Failure", MSC4108FailureReason.UnsupportedProtocol);
// @ts-ignore work-around for lazy mocks
err.code = MSC4108FailureReason.UnsupportedProtocol;
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockRejectedValue(err);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.ShowingQR,
onClick: expect.any(Function),
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
}),
),
);
);
await waitFor(() => {
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UnsupportedProtocol);
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
// back (cancel)
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Cancel);
expect(onFinished).toHaveBeenCalledWith(false);
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
});
test("should open a new channel if expires before qr scan", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
const ref = createRef<LoginWithQR>();
render(getComponent({ client, onFinished, ref }));
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.ShowingQR,
onClick: expect.any(Function),
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
}),
);
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
// Expire the channel
rendezvous.onFailure!(ClientRendezvousFailureReason.Expired);
await jest.runAllTimersAsync();
await waitFor(() => expect(ref.current!.state.rendezvous).toBeDefined());
expect(ref.current!.state.rendezvous).not.toBe(rendezvous);
});
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("should show error if check code doesn't match", 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),
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
}),
);
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Approve, "12");
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.OutOfBandConfirmation,
failureReason: LoginWithQRFailureReason.CheckCodeMismatch,
onClick: expect.any(Function),
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
}),
);
});
test("reciprocates login", async () => {
const ref = createRef<LoginWithQR>();
jest.spyOn(global.window, "open");
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({
verificationUri: "mock-verification-uri",
});
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.OutOfBandConfirmation,
onClick: expect.any(Function),
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
}),
);
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Approve);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.WaitingForDevice,
onClick: expect.any(Function),
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
}),
);
expect(global.window.open).toHaveBeenCalledWith("mock-verification-uri", "_blank");
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.shareSecrets).toHaveBeenCalled();
});
test("handles errors during protocol negotiation", async () => {
const ref = createRef<LoginWithQR>();
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
const err = new RendezvousError("Unknown Failure", MSC4108FailureReason.UnsupportedProtocol);
// @ts-ignore work-around for lazy mocks
err.code = MSC4108FailureReason.UnsupportedProtocol;
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockRejectedValue(err);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.ShowingQR,
}),
),
);
await waitFor(() => {
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UnsupportedProtocol);
});
});
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),
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
}),
);
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 () => {
const ref = createRef<LoginWithQR>();
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.OutOfBandConfirmation,
onClick: expect.any(Function),
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
}),
);
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Cancel);
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
});
});
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),
}),
describe("login", () => {
const getComponent = (props: {
client: MatrixClient;
onFinished?: () => void;
onLoggedIn?: () => Promise<void>;
ref?: RefObject<LoginWithQR | null>;
}) => (
<LoginWithQR
onLoggedIn={jest.fn()}
{...defaultProps}
{...props}
intent={RendezvousIntent.LOGIN_ON_NEW_DEVICE}
/>
);
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockRejectedValue(
new HTTPError("Internal Server Error", 500),
);
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Approve);
test("should handle qr login", async () => {
fetchMock.get("https://hs/_matrix/client/versions", {
unstable_features: {},
versions: ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"],
});
const authMetadata = {
...mockOpenIdConfiguration("https://auth.org/", [OAuthGrantType.DeviceAuthorization]),
jwks_uri: undefined,
};
fetchMock.get("https://hs/_matrix/client/unstable/org.matrix.msc2965/auth_metadata", authMetadata);
fetchMock.post(authMetadata.registration_endpoint!, {
client_id: "!client_id!",
});
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.Error,
failureReason: ClientRendezvousFailureReason.Unknown,
mockPlatformPeg({
getOidcClientMetadata: jest.fn().mockReturnValue({
clientName: "App name",
clientUri: "https://company",
redirectUris: ["https://app"],
logoUri: "https://company/logo.png",
applicationType: "web",
}),
),
);
});
});
test("handles user cancelling during reciprocation", async () => {
const ref = createRef<LoginWithQR>();
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.OutOfBandConfirmation,
onClick: expect.any(Function),
}),
);
const ref = createRef<LoginWithQR>();
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Cancel);
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockResolvedValue({
secrets: {
cross_signing: {
master_key: "mk",
self_signing_key: "ssk",
user_signing_key: "usk",
},
},
});
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({ serverName: "hs" });
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({
userCode: "123456",
});
jest.spyOn(MSC4108SignInWithQR.prototype, "completeLoginOnNewDevice").mockResolvedValue({
access_token: "token",
token_type: "Bearer",
});
jest.spyOn(AutoDiscovery, "findClientConfig").mockResolvedValue({
"m.homeserver": { base_url: "https://hs", state: AutoDiscoveryAction.SUCCESS },
"m.identity_server": { state: AutoDiscoveryAction.PROMPT },
});
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.OutOfBandConfirmation,
onClick: expect.any(Function),
intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE,
}),
);
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Approve);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.WaitingForDevice,
onClick: expect.any(Function),
intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE,
userCode: "123456",
}),
);
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.shareSecrets).toHaveBeenCalled();
});
});
});
});
@@ -8,11 +8,11 @@ Please see LICENSE files in the repository root for full details.
import { cleanup, fireEvent, render, screen } from "jest-matrix-react";
import React from "react";
import { ClientRendezvousFailureReason, MSC4108FailureReason } from "matrix-js-sdk/src/rendezvous";
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousIntent } from "matrix-js-sdk/src/rendezvous";
import { mockQRCodeRender, resetQRCodeMock, waitForQRCodeRender } from "../../../../../test-utils/qrcode";
import LoginWithQRFlow from "../../../../../../src/components/views/auth/LoginWithQRFlow";
import { LoginWithQRFailureReason, type FailureReason } from "../../../../../../src/components/views/auth/LoginWithQR";
import { type FailureReason, LoginWithQRFailureReason } from "../../../../../../src/components/views/auth/LoginWithQR";
import { Click, Phase } from "../../../../../../src/components/views/auth/LoginWithQR-types";
describe("<LoginWithQRFlow />", () => {
@@ -27,72 +27,106 @@ describe("<LoginWithQRFlow />", () => {
onClick?: () => Promise<void>;
failureReason?: FailureReason;
code?: Uint8Array;
intent: RendezvousIntent;
}) => <LoginWithQRFlow {...defaultProps} {...props} />;
beforeEach(() => {});
afterEach(() => {
resetQRCodeMock();
onClick.mockReset();
cleanup();
});
it("renders spinner while loading", async () => {
const { container } = render(getComponent({ phase: Phase.Loading }));
expect(container).toMatchSnapshot();
});
it("renders spinner whilst QR generating", async () => {
const { container } = render(getComponent({ phase: Phase.ShowingQR }));
expect(screen.getAllByTestId("spinner")).toHaveLength(1);
expect(container).toMatchSnapshot();
});
it("renders QR code", async () => {
mockQRCodeRender();
const { container } = render(
getComponent({ phase: Phase.ShowingQR, code: new TextEncoder().encode("mock-code") }),
);
// QR code is rendered async so we wait for it:
await waitForQRCodeRender();
expect(screen.getAllByAltText("QR Code")).toHaveLength(1);
expect(container).toMatchSnapshot();
});
it("renders spinner while signing in", async () => {
const { container } = render(getComponent({ phase: Phase.WaitingForDevice }));
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
expect(container).toMatchSnapshot();
fireEvent.click(screen.getByTestId("cancel-button"));
expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined);
});
it("renders spinner while verifying", async () => {
const { container } = render(getComponent({ phase: Phase.Verifying }));
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(MSC4108FailureReason),
...Object.values(LoginWithQRFailureReason),
...Object.values(ClientRendezvousFailureReason),
]) {
it(`renders ${failureReason}`, async () => {
describe.each([RendezvousIntent.LOGIN_ON_NEW_DEVICE, RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE])(
"%s",
(intent) => {
it("renders spinner while loading", async () => {
const { container } = render(
getComponent({
phase: Phase.Error,
failureReason,
phase: Phase.Loading,
intent,
}),
);
expect(screen.getAllByTestId("cancellation-message")).toHaveLength(1);
expect(container).toMatchSnapshot();
});
}
});
it("renders spinner whilst QR generating", async () => {
const { container } = render(
getComponent({
phase: Phase.ShowingQR,
intent,
}),
);
expect(screen.getByTestId("spinner")).toBeVisible();
expect(container).toMatchSnapshot();
});
it("renders QR code", async () => {
mockQRCodeRender();
const { container } = render(
getComponent({
phase: Phase.ShowingQR,
code: new TextEncoder().encode("mock-code"),
intent,
}),
);
// QR code is rendered async so we wait for it:
await waitForQRCodeRender();
expect(screen.getAllByAltText("QR Code")).toHaveLength(1);
expect(container).toMatchSnapshot();
});
it("renders spinner while signing in", async () => {
const { container } = render(
getComponent({
phase: Phase.WaitingForDevice,
intent,
}),
);
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
expect(container).toMatchSnapshot();
fireEvent.click(screen.getByTestId("cancel-button"));
expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined);
});
it("renders spinner while verifying", async () => {
const { container } = render(
getComponent({
phase: Phase.Verifying,
intent,
}),
);
expect(container).toMatchSnapshot();
});
it("renders check code confirmation", async () => {
const { container } = render(
getComponent({
phase: Phase.OutOfBandConfirmation,
intent,
}),
);
expect(container).toMatchSnapshot();
});
describe("errors", () => {
for (const failureReason of [
...Object.values(MSC4108FailureReason),
...Object.values(LoginWithQRFailureReason),
...Object.values(ClientRendezvousFailureReason),
]) {
it(`renders ${failureReason}`, async () => {
const { container } = render(
getComponent({
phase: Phase.Error,
failureReason,
intent,
}),
);
expect(screen.getAllByTestId("cancellation-message")).toHaveLength(1);
expect(container).toMatchSnapshot();
});
}
});
},
);
});