diff --git a/apps/web/element.io/develop/config.json b/apps/web/element.io/develop/config.json index 2db410355c5..a62b377f177 100644 --- a/apps/web/element.io/develop/config.json +++ b/apps/web/element.io/develop/config.json @@ -49,7 +49,8 @@ "threadsActivityCentre": true, "feature_video_rooms": true, "feature_group_calls": true, - "feature_element_call_video_rooms": true + "feature_element_call_video_rooms": true, + "feature_login_with_qr": true }, "setting_defaults": { "RustCrypto.staged_rollout_percent": 100, diff --git a/apps/web/res/css/views/auth/_LoginWithQR.pcss b/apps/web/res/css/views/auth/_LoginWithQR.pcss index e4e41d496e4..b1db9ea42fc 100644 --- a/apps/web/res/css/views/auth/_LoginWithQR.pcss +++ b/apps/web/res/css/views/auth/_LoginWithQR.pcss @@ -34,45 +34,45 @@ Please see LICENSE files in the repository root for full details. font-size: $font-15px; } -.mx_UserSettingsDialog .mx_LoginWithQR { +.mx_LoginWithQR { + min-height: 350px; + display: flex; + flex-direction: column; 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 { margin-top: $spacing-24; } - .mx_QRCode { - margin: $spacing-28 0; - } - .mx_LoginWithQR_qrWrapper { display: flex; - } -} + padding: $spacing-28 0; -.mx_LoginWithQR { - min-height: 350px; - display: flex; - flex-direction: column; - - h1 > svg { - &.normal { - color: $secondary-content; - } - &.error { - color: $alert; - } - &.success { - color: $accent; + .mx_Spinner { + /* Match the size of the QR code to prevent jumps */ + height: 196px; + width: 196px; } - height: 1.3em; - margin-right: $spacing-8; - vertical-align: middle; } .mx_LoginWithQR_confirmationDigits { diff --git a/apps/web/src/Lifecycle.ts b/apps/web/src/Lifecycle.ts index 32d113adc80..f8c92c4374a 100644 --- a/apps/web/src/Lifecycle.ts +++ b/apps/web/src/Lifecycle.ts @@ -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,46 @@ async function attemptOidcNativeLogin( } } +export async function configureFromCompletedOAuthLogin({ + accessToken, + refreshToken, + homeserverUrl, + identityServerUrl, + clientId, + issuer, + idToken, +}: { + accessToken: string; + refreshToken?: string; + homeserverUrl: string; + identityServerUrl?: string; + clientId: string; + issuer: string; + idToken: string; +}): Promise { + 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 diff --git a/apps/web/src/Login.ts b/apps/web/src/Login.ts index 34b6513ad1f..7693420713f 100644 --- a/apps/web/src/Login.ts +++ b/apps/web/src/Login.ts @@ -18,6 +18,7 @@ import { type ISSOFlow, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; +import { isSignInWithQRAvailable } from "matrix-js-sdk/src/rendezvous"; import { type IMatrixClientCreds } from "./MatrixClientPeg"; import { ModuleRunner } from "./modules/ModuleRunner"; @@ -31,7 +32,12 @@ import { isUserRegistrationSupported } from "./utils/oidc/isUserRegistrationSupp * LoginFlow type use the client API /login endpoint * OidcNativeFlow is specific to this client */ -export type ClientLoginFlow = LoginFlow | OidcNativeFlow; +export type ClientLoginFlow = LoginFlow | OidcNativeFlow | LoginWithQrFlow; + +export interface LoginWithQrFlow { + type: "loginWithQrFlow"; + clientId: string; +} interface ILoginOptions { defaultDeviceDisplayName?: string; @@ -116,7 +122,17 @@ export default class Login { SdkConfig.get().oidc_static_clients, isRegistration, ); - return [oidcFlow]; + let possibleQrFlow: LoginWithQrFlow | undefined; + try { + // TODO: this seems wasteful + const tempClient = this.createTemporaryClient(); + // we reuse the clientId from the oidcFlow for QR login + // it might be that we later find that the homeserver is different and we initialise a new client + possibleQrFlow = await tryInitLoginWithQRFlow(tempClient, oidcFlow.clientId); + } catch (e) { + logger.warn("Could not fetch server versions for login with QR support, assuming unsupported", e); + } + return possibleQrFlow ? [possibleQrFlow, oidcFlow] : [oidcFlow]; } catch (error) { logger.error("Failed to get oidc native flow", error); } @@ -288,3 +304,20 @@ export async function sendLoginRequest( return creds; } + +const tryInitLoginWithQRFlow = async ( + tempClient: MatrixClient, + clientId: string, +): Promise => { + // This could fail because the server doesn't support the API or it requires authentication + const canUseServer = await isSignInWithQRAvailable(tempClient); + + if (!canUseServer) return undefined; + + const flow = { + type: "loginWithQrFlow", + clientId, + } satisfies LoginWithQrFlow; + + return flow; +}; diff --git a/apps/web/src/PosthogTrackers.ts b/apps/web/src/PosthogTrackers.ts index 19bb900b7c7..e0e879307c2 100644 --- a/apps/web/src/PosthogTrackers.ts +++ b/apps/web/src/PosthogTrackers.ts @@ -28,6 +28,7 @@ const notLoggedInMap: Record, ScreenName> = { [Views.CONFIRM_LOCK_THEFT]: "ConfirmStartup", [Views.WELCOME]: "Welcome", [Views.LOGIN]: "Login", + [Views.QR_LOGIN]: "Login", // XXX: we should get a new analytics identifier for this [Views.REGISTER]: "Register", [Views.FORGOT_PASSWORD]: "ForgotPassword", [Views.COMPLETE_SECURITY]: "CompleteSecurity", diff --git a/apps/web/src/Views.ts b/apps/web/src/Views.ts index ccb0d93e050..16fbb3e1376 100644 --- a/apps/web/src/Views.ts +++ b/apps/web/src/Views.ts @@ -100,6 +100,9 @@ enum Views { // we are showing the login view LOGIN, + // we are showing the login with QR code view + QR_LOGIN, + // we are showing the registration view REGISTER, diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index 14e726e5322..9f504b7f9ba 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -142,11 +142,21 @@ 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 QrLogin from "./auth/QrLogin.tsx"; // legacy export export { default as Views } from "../../Views"; -const AUTH_SCREENS = ["register", "mobile_register", "login", "forgot_password", "start_sso", "start_cas", "welcome"]; +const AUTH_SCREENS = [ + "register", + "mobile_register", + "login", + "qr_login", + "forgot_password", + "start_sso", + "start_cas", + "welcome", +]; // Actions that are redirected through the onboarding process prior to being // re-dispatched. NOTE: some actions are non-trivial and would require @@ -736,6 +746,12 @@ export default class MatrixChat extends React.PureComponent { } this.viewLogin(); break; + case "start_qr_login": + this.setStateForNewView({ + view: Views.QR_LOGIN, + }); + this.notifyNewScreen("qr_login"); + break; case "start_password_recovery": this.setStateForNewView({ view: Views.FORGOT_PASSWORD, @@ -1857,6 +1873,12 @@ export default class MatrixChat extends React.PureComponent { params: params, }); PerformanceMonitor.instance.start(PerformanceEntryNames.LOGIN); + } else if (screen === "qr_login") { + dis.dispatch({ + action: "start_qr_login", + params: params, + }); + PerformanceMonitor.instance.start(PerformanceEntryNames.LOGIN); } else if (screen === "forgot_password") { dis.dispatch({ action: "start_password_recovery", @@ -2121,9 +2143,14 @@ export default class MatrixChat extends React.PureComponent { * Note: SSO users (and any others using token login) currently do not pass through * this, as they instead jump straight into the app after `attemptTokenLogin`. */ - private onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds): Promise => { + private onUserCompletedLoginFlow = async ( + credentials: IMatrixClientCreds, + alreadySignedIn = false, + ): Promise => { // Create and start the client - await Lifecycle.setLoggedIn(credentials); + if (!alreadySignedIn) { + await Lifecycle.setLoggedIn(credentials); + } await this.postLoginSetup(); PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN); @@ -2150,7 +2177,9 @@ export default class MatrixChat extends React.PureComponent { if ( initialScreenAfterLogin && // XXX: workaround for https://github.com/vector-im/element-web/issues/11643 causing a login-loop - !["welcome", "login", "register", "start_sso", "start_cas"].includes(initialScreenAfterLogin.screen) + !["welcome", "login", "qr_login", "register", "start_sso", "start_cas"].includes( + initialScreenAfterLogin.screen, + ) ) { fragmentAfterLogin = `/${initialScreenAfterLogin.screen}`; } @@ -2219,7 +2248,7 @@ export default class MatrixChat extends React.PureComponent { ); } } else if (this.state.view === Views.WELCOME) { - view = ; + view = ; } else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) { const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail; view = ( @@ -2262,6 +2291,16 @@ export default class MatrixChat extends React.PureComponent { {...this.getServerProperties()} /> ); + } else if (this.state.view === Views.QR_LOGIN && SettingsStore.getValue("feature_login_with_qr")) { + view = ( + + ); } else if (this.state.view === Views.SOFT_LOGOUT) { view = ( = ({ serverConfig, onLoggedIn }) => { + const tempClient = useMemo(() => createClient({ baseUrl: serverConfig.hsUrl }), [serverConfig]); + const onFinished = useCallback( + (success: boolean, credentials?: IMatrixClientCreds) => { + if (success) { + onLoggedIn(credentials!, true); + } else { + // TODO handle + } + }, + [onLoggedIn], + ); + const clientId = useAsyncMemo( + () => getOidcClientId(serverConfig.delegatedAuthentication!, SdkConfig.get().oidc_static_clients), + [serverConfig], + ); + + let body: JSX.Element; + if (clientId === undefined) { + body = ; + } else { + body = ( + + ); + } + + return ( + + + {body} + + ); +}; + +export default QrLogin; diff --git a/apps/web/src/components/views/auth/DefaultWelcome.tsx b/apps/web/src/components/views/auth/DefaultWelcome.tsx index 348b9c0a4f4..a52722ebf36 100644 --- a/apps/web/src/components/views/auth/DefaultWelcome.tsx +++ b/apps/web/src/components/views/auth/DefaultWelcome.tsx @@ -5,15 +5,26 @@ 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 { isSignInWithQRAvailable } from "matrix-js-sdk/src/rendezvous"; +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 { + serverConfig: ValidatedServerConfig; +} + +const DefaultWelcome: React.FC = ({ serverConfig }) => { const brand = SdkConfig.get("brand"); const branding = SdkConfig.getObject("branding"); const logoUrl = branding.get("auth_header_logo_url"); @@ -21,19 +32,30 @@ const DefaultWelcome: React.FC = () => { const showGuestFunctions = !!MatrixClientPeg.get(); const isElement = isElementBranded(); - return ( -
- - {brand} - - - {isElement ? _t("welcome|title_element") : _t("welcome|title_generic", { brand })} - - {isElement && {_t("welcome|tagline_element")}} + const isQrLoginEnabled = useFeatureEnabled("feature_login_with_qr"); + const showQrButton = useAsyncMemo(async () => { + if (!isQrLoginEnabled) return false; + const tempClient = createClient({ + baseUrl: serverConfig.hsUrl, + }); + return isSignInWithQRAvailable(tempClient); + }, [serverConfig, isQrLoginEnabled]); + + const loading = isQrLoginEnabled && showQrButton === undefined; + let body: JSX.Element; + if (loading) { + body = ; + } else { + body = (
+ {showQrButton && ( + + )} )}
+ ); + } + + return ( +
+ + {brand} + + + {isElement ? _t("welcome|title_element") : _t("welcome|title_generic", { brand })} + + {isElement && {_t("welcome|tagline_element")}} + + {body}
); }; diff --git a/apps/web/src/components/views/auth/LoginWithQR.tsx b/apps/web/src/components/views/auth/LoginWithQR.tsx index 1519a1fb453..cedaa43d4be 100644 --- a/apps/web/src/components/views/auth/LoginWithQR.tsx +++ b/apps/web/src/components/views/auth/LoginWithQR.tsx @@ -9,34 +9,49 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { ClientRendezvousFailureReason, + linkNewDeviceByGeneratingQR, MSC4108FailureReason, - MSC4108RendezvousSession, - MSC4108SecureChannel, MSC4108SignInWithQR, RendezvousError, type RendezvousFailureReason, 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, type MatrixClient, type XOR } from "matrix-js-sdk/src/matrix"; +import { sleep } from "matrix-js-sdk/src/utils"; import { Click, Mode, Phase } from "./LoginWithQR-types"; import LoginWithQRFlow from "./LoginWithQRFlow"; +import { configureFromCompletedOAuthLogin, restoreSessionFromStorage } from "../../../Lifecycle"; +import { type IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; -interface IProps { +type BaseProps = { client: MatrixClient; + onFinished(success: boolean, credentials?: IMatrixClientCreds): void; mode: Mode; - onFinished(...args: any): void; -} +}; + +type Props = XOR< + { + intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE; + clientId: string; + }, + { + intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; + } +> & + BaseProps; interface IState { phase: Phase; rendezvous?: MSC4108SignInWithQR; - mediaPermissionError?: boolean; verificationUri?: string; userCode?: string; checkCode?: string; failureReason?: FailureReason; + homeserverName?: string; + newClient?: MatrixClient; } export enum LoginWithQRFailureReason { @@ -53,10 +68,11 @@ export type FailureReason = RendezvousFailureReason | LoginWithQRFailureReason; * * This uses the unstable feature of MSC4108: https://github.com/matrix-org/matrix-spec-proposals/pull/4108 */ -export default class LoginWithQR extends React.Component { +export default class LoginWithQR extends React.Component { private finished = false; + private abortController?: AbortController; - public constructor(props: IProps) { + public constructor(props: Props) { super(props); this.state = { @@ -64,71 +80,62 @@ export default class LoginWithQR extends React.Component { }; } - private get ourIntent(): RendezvousIntent { - return RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; - } - public componentDidMount(): void { - this.updateMode(this.props.mode).then(() => {}); + void this.updateMode(this.props.mode); } - public componentDidUpdate(prevProps: Readonly): void { + public componentDidUpdate(prevProps: Readonly): void { if (prevProps.mode !== this.props.mode) { - this.updateMode(this.props.mode).then(() => {}); + void this.updateMode(this.props.mode); } } - private async updateMode(mode: Mode): Promise { - this.setState({ phase: Phase.Loading }); - if (this.state.rendezvous) { - const rendezvous = this.state.rendezvous; - rendezvous.onFailure = undefined; - this.setState({ rendezvous: undefined }); + private async updateMode(mode: Mode, showLoading = true): Promise { + this.abortController?.abort(); + this.abortController = new AbortController(); + this.setState({ rendezvous: undefined }); + if (showLoading) { + this.setState({ phase: Phase.Loading }); } + if (mode === Mode.Show) { - await this.generateAndShowCode(); + await this.generateAndShowCode(this.abortController); } } public componentWillUnmount(): void { - if (this.state.rendezvous && !this.finished) { - // eslint-disable-next-line react/no-direct-mutation-state - this.state.rendezvous.onFailure = undefined; - // calling cancel will call close() as well to clean up the resources - this.state.rendezvous.cancel(MSC4108FailureReason.UserCancelled); + if (!this.finished) { + this.abortController?.abort(); } } - private onFinished(success: boolean): void { + private onFinished(success: boolean, credentials?: IMatrixClientCreds): void { this.finished = true; - this.props.onFinished(success); + this.props.onFinished(success, credentials); } - private generateAndShowCode = async (): Promise => { + private generateAndShowCode = async (abortController: AbortController): Promise => { let rendezvous: MSC4108SignInWithQR; try { - const transport = new MSC4108RendezvousSession({ - onFailure: this.onFailure, - client: this.props.client, - }); - await transport.send(""); - const channel = new MSC4108SecureChannel(transport, undefined, this.onFailure); - rendezvous = new MSC4108SignInWithQR(channel, false, this.props.client, this.onFailure); - - await rendezvous.generateCode(); + rendezvous = + 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, rendezvous, failureReason: undefined, }); } catch (e) { + if (abortController.signal.aborted) return; logger.error("Error whilst generating QR code", e); this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport }); return; } 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(); @@ -136,12 +143,19 @@ export default class LoginWithQR extends React.Component { phase: Phase.OutOfBandConfirmation, verificationUri, }); + } else { + const { serverName } = await rendezvous.negotiateProtocols(); + this.setState({ + phase: Phase.OutOfBandConfirmation, + homeserverName: serverName, + }); } // we ask the user to confirm that the channel is secure } catch (e: RendezvousError | unknown) { + if (abortController.signal.aborted) return; logger.error("Error whilst approving login", e); - await rendezvous?.cancel( + await rendezvous.cancel( e instanceof RendezvousError ? (e.code as MSC4108FailureReason) : ClientRendezvousFailureReason.Unknown, ); } @@ -159,7 +173,7 @@ export default class LoginWithQR extends React.Component { } 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 }); @@ -175,8 +189,80 @@ export default class LoginWithQR extends React.Component { // 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.homeserverName) { + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown }); + throw new Error("Homeserver name not found in state"); + } + + // in the 2025 version we would check if the homeserver is on a different base URL, but for the 2024 version + // we can't do this as the temporary client doesn't know the server name. + + const metadata = await this.props.client.getAuthMetadata(); + const deviceId = this.props.client.getDeviceId()!; + const { userCode } = await this.state.rendezvous.deviceAuthorizationGrant({ + metadata, + clientId: this.props.clientId, + deviceId, + }); + this.setState({ phase: Phase.WaitingForDevice, userCode }); + + const tokenResponse = await this.state.rendezvous.completeLoginOnNewDevice({ + clientId: this.props.clientId, + }); + + if (tokenResponse) { + // the 2024 version of the spec only gives the server name, but the 2025 version will give the base URL + // so, we do a discovery for now. + const homeserverUrl = (await AutoDiscovery.findClientConfig(this.state.homeserverName))?.[ + "m.homeserver" + ]?.base_url; + + 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"); + } + + // TODO: this is not the right way to do this + + // store and use the new credentials + const credentials = await configureFromCompletedOAuthLogin({ + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + homeserverUrl, + clientId: this.props.clientId, + idToken: tokenResponse.id_token ?? "", // I'm not sure the idToken is actually required + issuer: metadata!.issuer, + identityServerUrl: undefined, // PROTOTYPE: we should have stored this from before + }); + + const { secrets } = await this.state.rendezvous.shareSecrets(); + + await restoreSessionFromStorage(); + + if (secrets) { + const crypto = MatrixClientPeg.safeGet().getCrypto(); + if (crypto?.importSecretsBundle) { + await crypto.importSecretsBundle(secrets); + // it should be sufficient to just upload the device keys with the signature + // but this seems to do the job for now + await crypto.crossSignDevice(deviceId); + + // PROTOTYPE: this is a fudge to bypass the complete security step + window.location.reload(); + } else { + logger.warn("Crypto not initialised"); + logger.warn( + "Crypto not initialised or no importSecretsBundle() method, cannot import secrets from QR login", + ); + } + } else { + logger.warn("No secrets received from QR login"); + } + + // done + this.onFinished(true, credentials); + } } } catch (e: RendezvousError | unknown) { logger.error("Error whilst approving sign in", e); @@ -187,20 +273,34 @@ export default class LoginWithQR extends React.Component { } }; - private onFailure = (reason: RendezvousFailureReason): void => { + private onFailure = async (reason: RendezvousFailureReason): Promise => { 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) { + try { + this.reset(); + // Add a sleep to make the UX looks less flickery and more intentional + await sleep(1000); + await this.updateMode(Mode.Show, false); + return; + } catch (e) { + logger.warn("Failed to re-roll qr code on expiry", e); + } + } + this.setState({ phase: Phase.Error, failureReason: reason }); }; public reset(): void { + this.abortController?.abort(); this.setState({ rendezvous: undefined, verificationUri: undefined, failureReason: undefined, userCode: undefined, checkCode: undefined, - mediaPermissionError: false, }); } @@ -238,6 +338,7 @@ export default class LoginWithQR extends React.Component { failureReason={this.state.failureReason} userCode={this.state.userCode} checkCode={this.state.checkCode} + intent={this.props.intent} /> ); } diff --git a/apps/web/src/components/views/auth/LoginWithQRFlow.tsx b/apps/web/src/components/views/auth/LoginWithQRFlow.tsx index a432baac727..dc0dfe0a21a 100644 --- a/apps/web/src/components/views/auth/LoginWithQRFlow.tsx +++ b/apps/web/src/components/views/auth/LoginWithQRFlow.tsx @@ -6,8 +6,8 @@ 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, { 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"; @@ -27,6 +27,7 @@ import { ErrorMessage } from "../../structures/ErrorMessage"; interface Props { phase: Phase; code?: Uint8Array; + intent: RendezvousIntent; onClick(type: Click, checkCodeEntered?: string): Promise; failureReason?: FailureReason; userCode?: string; @@ -68,7 +69,7 @@ export default class LoginWithQRFlow extends React.Component { public render(): React.ReactNode { let main: JSX.Element | undefined; let buttons: JSX.Element | undefined; - let backButton = true; + let backButton = this.props.intent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; let className = ""; switch (this.props.phase) { @@ -226,39 +227,55 @@ export default class LoginWithQRFlow extends React.Component { ); break; - case Phase.ShowingQR: - if (this.props.code) { - const data = this.props.code; - - main = ( - <> - - {_t("auth|qr_code_login|scan_code_instruction")} - -
- -
-
    -
  1. - {_t("auth|qr_code_login|open_element_other_device", { - brand: SdkConfig.get().brand, - })} -
  2. -
  3. - {_t("auth|qr_code_login|select_qr_code", { - scanQRCode: {_t("auth|qr_code_login|scan_qr_code")}, - })} -
  4. -
  5. {_t("auth|qr_code_login|point_the_camera")}
  6. -
  7. {_t("auth|qr_code_login|follow_remaining_instructions")}
  8. -
- - ); + case Phase.ShowingQR: { + let steps: string[]; + 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: {_t("user_menu|link_new_device")}, + }), + _t("auth|qr_code_login|select_ready_to_scan", { + readyToScan: {_t("auth|qr_code_login|ready_to_scan")}, + }), + _t("auth|qr_code_login|follow_remaining_instructions"), + ]; } else { - main = this.simpleSpinner(); - buttons = this.cancelButton(); + steps = [ + _t("auth|qr_code_login|open_element_other_device", { + brand: SdkConfig.get().brand, + }), + _t("auth|qr_code_login|select_qr_code", { + scanQRCode: {_t("auth|qr_code_login|scan_qr_code")}, + }), + _t("auth|qr_code_login|point_the_camera"), + _t("auth|qr_code_login|follow_remaining_instructions"), + ]; } + + main = ( + <> + + {_t("auth|qr_code_login|scan_code_instruction")} + +
+ {this.props.code ? ( + + ) : ( + + )} +
+
    + {steps.map((step, i) => ( +
  1. {step}
  2. + ))} +
+ + ); break; + } case Phase.Loading: main = this.simpleSpinner(); break; diff --git a/apps/web/src/components/views/auth/Welcome.tsx b/apps/web/src/components/views/auth/Welcome.tsx index f8c00b2127d..fc2c63c4364 100644 --- a/apps/web/src/components/views/auth/Welcome.tsx +++ b/apps/web/src/components/views/auth/Welcome.tsx @@ -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 { +interface Props { + serverConfig: ValidatedServerConfig; +} + +export default class Welcome extends React.PureComponent { 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 { if (pageUrl) { body = ; } else { - body = ; + body = ; } return ( diff --git a/apps/web/src/components/views/settings/devices/LoginWithQRSection.tsx b/apps/web/src/components/views/settings/devices/LoginWithQRSection.tsx index 523633c8845..f7f73b90cab 100644 --- a/apps/web/src/components/views/settings/devices/LoginWithQRSection.tsx +++ b/apps/web/src/components/views/settings/devices/LoginWithQRSection.tsx @@ -7,48 +7,35 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { - type IServerVersions, - type OidcClientConfig, - type MatrixClient, - DEVICE_CODE_SCOPE, -} from "matrix-js-sdk/src/matrix"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import QrCodeIcon from "@vector-im/compound-design-tokens/assets/web/icons/qr-code"; import { Text } from "@vector-im/compound-web"; +import { isSignInWithQRAvailable } from "matrix-js-sdk/src/rendezvous"; import { _t } from "../../../../languageHandler"; import AccessibleButton from "../../elements/AccessibleButton"; import { SettingsSubsection } from "../shared/SettingsSubsection"; import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; interface IProps { onShowQr: () => void; - versions?: IServerVersions; - oidcClientConfig?: OidcClientConfig; isCrossSigningReady?: boolean; } -export function shouldShowQr( - cli: MatrixClient, - isCrossSigningReady: boolean, - oidcClientConfig?: OidcClientConfig, - versions?: IServerVersions, -): boolean { - const msc4108Supported = !!versions?.unstable_features?.["org.matrix.msc4108"]; +export async function shouldShowQrForLinkNewDevice(cli: MatrixClient, isCrossSigningReady: boolean): Promise { + const doesServerHaveSupport = await isSignInWithQRAvailable(cli); - const deviceAuthorizationGrantSupported = oidcClientConfig?.grant_types_supported.includes(DEVICE_CODE_SCOPE); - - return ( - !!deviceAuthorizationGrantSupported && - msc4108Supported && - !!cli.getCrypto()?.exportSecretsBundle && - isCrossSigningReady - ); + return doesServerHaveSupport && !!cli.getCrypto()?.exportSecretsBundle && isCrossSigningReady; } -const LoginWithQRSection: React.FC = ({ onShowQr, versions, oidcClientConfig, isCrossSigningReady }) => { +const LoginWithQRSection: React.FC = ({ onShowQr, isCrossSigningReady }) => { const cli = useMatrixClientContext(); - const offerShowQr = shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions); + const offerShowQr = useAsyncMemo( + () => shouldShowQrForLinkNewDevice(cli, !!isCrossSigningReady), + [cli, isCrossSigningReady], + false, + ); return ( diff --git a/apps/web/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/apps/web/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 8cf234ca122..6f7db4227e8 100644 --- a/apps/web/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/apps/web/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -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"; @@ -162,14 +163,6 @@ const SessionManagerTab: React.FC<{ const disableMultipleSignout = !!accountManagement?.endpoint; const userId = matrixClient?.getUserId(); const currentUserMember = (userId && matrixClient?.getUser(userId)) || undefined; - const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]); - const oidcClientConfig = useAsyncMemo(async () => { - try { - return await matrixClient?.getAuthMetadata(); - } catch (e) { - logger.error("Failed to discover OIDC metadata", e); - } - }, [matrixClient]); const isCrossSigningReady = useAsyncMemo( async () => matrixClient.getCrypto()?.isCrossSigningReady() ?? false, [matrixClient], @@ -271,7 +264,12 @@ const SessionManagerTab: React.FC<{ if (signInWithQrMode) { return ( }> - + ); } @@ -279,12 +277,7 @@ const SessionManagerTab: React.FC<{ return ( - + Sign in here", + "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_prompt": "Got an account? Sign in", + "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": { @@ -1565,6 +1571,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", diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index 67d0a31e14a..3b3936e9e92 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -228,6 +228,7 @@ export interface Settings { "feature_ask_to_join": IFeature; "feature_notifications": IFeature; "feature_msc4362_encrypted_state_events": IFeature; + "feature_login_with_qr": IFeature; // These are in the feature namespace but aren't actually features "feature_hidebold": IBaseSetting; @@ -689,6 +690,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|under_active_development"), + 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` diff --git a/apps/web/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx b/apps/web/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx index 6582bd4d3a8..37a9fb7f7ed 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx +++ b/apps/web/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx @@ -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), @@ -71,6 +72,8 @@ describe("", () => { 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; diff --git a/apps/web/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx b/apps/web/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx index d9e9b3e391f..85fc2df07bc 100644 --- a/apps/web/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx +++ b/apps/web/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx @@ -8,21 +8,22 @@ Please see LICENSE files in the repository root for full details. import { cleanup, render, waitFor } from "jest-matrix-react"; import { mocked, type MockedObject } from "jest-mock"; -import React from "react"; +import React, { createRef, type RefObject } from "react"; import { ClientRendezvousFailureReason, MSC4108FailureReason, MSC4108SignInWithQR, RendezvousError, + RendezvousIntent, } from "matrix-js-sdk/src/rendezvous"; -import { HTTPError, type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { HTTPError, type MatrixClient, MatrixHttpApi } from "matrix-js-sdk/src/matrix"; import LoginWithQR from "../../../../../../src/components/views/auth/LoginWithQR"; import { Click, Mode, Phase } from "../../../../../../src/components/views/auth/LoginWithQR-types"; -jest.mock("matrix-js-sdk/src/rendezvous"); jest.mock("matrix-js-sdk/src/rendezvous/transports"); jest.mock("matrix-js-sdk/src/rendezvous/channels"); +jest.mock("matrix-js-sdk/src/rendezvous/channels/MSC4108SecureChannel.ts"); const mockedFlow = jest.fn(); @@ -32,7 +33,7 @@ jest.mock("../../../../../../src/components/views/auth/LoginWithQRFlow", () => ( }); function makeClient() { - return mocked({ + const cli = mocked({ getUser: jest.fn(), isGuest: jest.fn().mockReturnValue(false), isUserIgnored: jest.fn(), @@ -49,7 +50,16 @@ function makeClient() { }, getClientWellKnown: jest.fn().mockReturnValue({}), getCrypto: jest.fn().mockReturnValue({}), + getDomain: jest.fn(), } as unknown as MatrixClient); + + cli.http = new MatrixHttpApi(cli, { + baseUrl: "https://server/", + prefix: "prefix", + onlyData: true, + }) as any; + + return cli; } function unresolvedPromise(): Promise { @@ -62,7 +72,8 @@ describe("", () => { legacy: true, mode: Mode.Show, onFinished: jest.fn(), - }; + intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE, + } as const; beforeEach(() => { mockedFlow.mockReset(); @@ -78,14 +89,20 @@ describe("", () => { }); describe("MSC4108", () => { - const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => ( - - ); + const getComponent = (props: { + client: MatrixClient; + onFinished?: () => void; + ref?: RefObject; + }) => ; test("render QR then back", async () => { const onFinished = jest.fn(); jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise()); - render(getComponent({ client, onFinished })); + jest.spyOn(MSC4108SignInWithQR.prototype, "generateCode"); + jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols"); + jest.spyOn(MSC4108SignInWithQR.prototype, "cancel"); + const ref = createRef(); + render(getComponent({ client, onFinished, ref })); await waitFor(() => expect(mockedFlow).toHaveBeenLastCalledWith({ @@ -94,14 +111,14 @@ describe("", () => { }), ); - const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0]; + const rendezvous = ref.current!.state.rendezvous!; expect(rendezvous.generateCode).toHaveBeenCalled(); expect(rendezvous.negotiateProtocols).toHaveBeenCalled(); // back const onClick = mockedFlow.mock.calls[0][0].onClick; await onClick(Click.Back); - expect(onFinished).toHaveBeenCalledWith(false); + expect(onFinished).toHaveBeenCalledWith(false, undefined); expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled); }); @@ -144,7 +161,8 @@ describe("", () => { }); test("handles errors during protocol negotiation", async () => { - render(getComponent({ client })); + const ref = createRef(); + render(getComponent({ client, ref })); jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue(); const err = new RendezvousError("Unknown Failure", MSC4108FailureReason.UnsupportedProtocol); // @ts-ignore work-around for lazy mocks @@ -159,7 +177,7 @@ describe("", () => { ); await waitFor(() => { - const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0]; + const rendezvous = ref.current!.state.rendezvous!; expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UnsupportedProtocol); }); }); @@ -192,7 +210,8 @@ describe("", () => { }); test("handles user cancelling during reciprocation", async () => { - render(getComponent({ client })); + const ref = createRef(); + render(getComponent({ client, ref })); jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({}); jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({}); jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({}); @@ -207,7 +226,7 @@ describe("", () => { const onClick = mockedFlow.mock.calls[0][0].onClick; await onClick(Click.Cancel); - const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0]; + const rendezvous = ref.current!.state.rendezvous!; expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled); }); });