[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();
});
}
});
},
);
});
+7
View File
@@ -133,3 +133,10 @@ Requires [MSC4429](https://github.com/matrix-org/matrix-spec-proposals/pull/4429
**Warning** This feature has options which are not backwards compatible, disabling
it may have unintended consequences.
## Log in with QR (`feature_login_with_qr`)
Enables logging in to Element Web/Desktop by using Element X to scan a QR code.
Requires server support, based on the v2024 edition of [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108).
Can only be configured via config.json, as labs flags cannot be set until after login, and are cleared on log out. The UI option is disabled.
Note, this does not affect the existing `Link new device` flow using the same MSC.