From 413e2e5c82b3668eac1f8478904e9e4edc06a21e Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 21 Nov 2025 19:07:22 +0000 Subject: [PATCH] Prototype implementation of MSC4108 version 2025 --- src/Lifecycle.ts | 62 ++++++++--- src/Login.ts | 45 +++++++- src/components/structures/MatrixChat.tsx | 6 +- src/components/structures/auth/Login.tsx | 86 ++++++++++++--- src/components/views/auth/LoginWithQR.tsx | 101 ++++++++++++++++-- src/components/views/auth/LoginWithQRFlow.tsx | 5 - .../settings/devices/LoginWithQRSection.tsx | 8 +- .../components/structures/UserMenu-test.tsx | 4 +- .../settings/devices/LoginWithQR-test.tsx | 2 +- 9 files changed, 259 insertions(+), 60 deletions(-) diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index dcd4ae9c00..67f76bc11e 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -297,26 +297,15 @@ async function attemptOidcNativeLogin(queryParams: QueryDict): Promise const { accessToken, refreshToken, homeserverUrl, identityServerUrl, idToken, clientId, issuer } = await completeOidcLogin(queryParams); - 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, - }; - - 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); + clientId, + issuer, + idToken, + }) return true; } catch (error) { logger.error("Failed to login via OIDC", error); @@ -326,6 +315,47 @@ async function attemptOidcNativeLogin(queryParams: QueryDict): Promise } } +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: guest, + } = await getUserIdFromAccessToken(accessToken, homeserverUrl, identityServerUrl); + + const credentials = { + accessToken, + refreshToken, + homeserverUrl, + identityServerUrl, + deviceId, + userId, + guest, + } satisfies IMatrixClientCreds; + + 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/src/Login.ts b/src/Login.ts index cbaab1cb34..c0f93c005c 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -15,6 +15,8 @@ import { type ILoginFlow, type LoginRequest, type OidcClientConfig, + DEVICE_AUTHORIZATION_GRANT_TYPE, + type IServerVersions, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; @@ -30,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; @@ -115,7 +122,17 @@ export default class Login { SdkConfig.get().oidc_static_clients, isRegistration, ); - return [oidcFlow]; + + let possibleQrFlow: LoginWithQrFlow | undefined; + try { + const versions = await this.createTemporaryClient().getVersions(); + // 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 = tryInitLoginWithQRFlow(this.delegatedAuthentication, versions, 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(error); } @@ -238,6 +255,30 @@ const tryInitOidcNativeFlow = async ( return flow; }; +const tryInitLoginWithQRFlow = ( + oidcClientConfig: OidcClientConfig, + versions: IServerVersions, + clientId: string, +): LoginWithQrFlow | undefined => { + const msc4108Supported = !!versions?.unstable_features?.["org.matrix.msc4108"]; + + const deviceAuthorizationGrantSupported = oidcClientConfig?.grant_types_supported.includes( + DEVICE_AUTHORIZATION_GRANT_TYPE, + ); + + const qrFeatureEnabled = !!deviceAuthorizationGrantSupported && msc4108Supported; + + console.log("qrFeatureEnabled", qrFeatureEnabled); + if (!qrFeatureEnabled) return undefined; + + const flow = { + type: "loginWithQrFlow", + clientId + } satisfies LoginWithQrFlow; + + return flow; +}; + /** * Send a login request to the given server, and format the response * as a MatrixClientCreds diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 0365b4b583..e3306b4d5f 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -2044,9 +2044,11 @@ 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); diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 9aca0046f2..8612d9328e 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -9,10 +9,12 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX, type ReactNode } from "react"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; -import { type SSOFlow, SSOAction } from "matrix-js-sdk/src/matrix"; +import { type SSOFlow, SSOAction, type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { QrCodeIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { secureRandomString } from "matrix-js-sdk/src/randomstring"; import { _t, UserFriendlyError } from "../../../languageHandler"; -import Login, { type ClientLoginFlow, type OidcNativeFlow } from "../../../Login"; +import Login, { type LoginWithQrFlow, type ClientLoginFlow, type OidcNativeFlow } from "../../../Login"; import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import AuthPage from "../../views/auth/AuthPage"; @@ -31,6 +33,9 @@ import AccessibleButton, { type ButtonEvent } from "../../views/elements/Accessi import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; import { filterBoolean } from "../../../utils/arrays"; import { startOidcLogin } from "../../../utils/oidc/authorize"; +import LoginWithQR from "../../views/auth/LoginWithQR"; +import { Mode } from "../../views/auth/LoginWithQR-types"; +import createMatrixClient from "../../../utils/createMatrixClient"; interface IProps { serverConfig: ValidatedServerConfig; @@ -47,7 +52,8 @@ interface IProps { // Called when the user has logged in. Params: // - The object returned by the login API - onLoggedIn(data: IMatrixClientCreds): void; + // - alreadySignedIn: true if the user was already signed in (e.g. QR login) and only the post login setup is needed + onLoggedIn(data: IMatrixClientCreds, alreadySignedIn?: boolean): void; // login shouldn't know or care how registration, password recovery, etc is done. onRegisterClick(): void; @@ -77,6 +83,9 @@ interface IState { serverIsAlive: boolean; serverErrorIsFatal: boolean; serverDeadError?: ReactNode; + + loginWithQrInProgress: boolean; + loginWithQrClient?: MatrixClient; } type OnPasswordLogin = { @@ -109,6 +118,8 @@ export default class LoginComponent extends React.PureComponent serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", + + loginWithQrInProgress: false, }; // map from login step type to a function which will render a control @@ -122,6 +133,7 @@ export default class LoginComponent extends React.PureComponent // eslint-disable-next-line @typescript-eslint/naming-convention "m.login.sso": () => this.renderSsoStep("sso"), "oidcNativeFlow": () => this.renderOidcNativeStep(), + "loginWithQrFlow": () => this.renderLoginWithQRStep(), }; } @@ -403,7 +415,7 @@ export default class LoginComponent extends React.PureComponent if (!this.state.flows) return null; // this is the ideal order we want to show the flows in - const order = ["oidcNativeFlow", "m.login.password", "m.login.sso"]; + const order = ["loginWithQrFlow", "oidcNativeFlow", "m.login.password", "m.login.sso"]; const flows = filterBoolean(order.map((type) => this.state.flows?.find((flow) => flow.type === type))); return ( @@ -456,6 +468,39 @@ export default class LoginComponent extends React.PureComponent ); }; + private startLoginWithQR = (): void => { + if (this.state.loginWithQrInProgress) return; + // pick our device ID + const deviceId = secureRandomString(10); + const loginWithQrClient = createMatrixClient({ baseUrl: this.loginLogic.getHomeserverUrl(), idBaseUrl: this.loginLogic.getIdentityServerUrl(), deviceId }); + this.setState({ loginWithQrInProgress: true, loginWithQrClient }); + }; + + private renderLoginWithQRStep = (): JSX.Element => { + return ( + <> +

or

+ + + {_t("Sign in with QR code")} + + + ); + }; + + private onLoginWithQRFinished = (success: boolean, credentials?: IMatrixClientCreds): void => { + if (credentials) { + this.props.onLoggedIn(credentials, true); + } else if (!success) { + this.state.loginWithQrClient?.stopClient(); + this.setState({ loginWithQrInProgress: false, loginWithQrClient: undefined }); + } + }; + private renderSsoStep = (loginType: "cas" | "sso"): JSX.Element => { const flow = this.state.flows?.find((flow) => flow.type === "m.login." + loginType) as SSOFlow; @@ -472,6 +517,10 @@ export default class LoginComponent extends React.PureComponent ); }; + private get qrClientId(): string { + return (this.state.flows?.find((flow) => flow.type === "loginWithQrFlow") as LoginWithQrFlow).clientId ?? ''; + } + public render(): React.ReactNode { const loader = this.isBusy() && !this.state.busyLoggingIn ? ( @@ -532,19 +581,22 @@ export default class LoginComponent extends React.PureComponent -

- {_t("action|sign_in")} - {loader} -

- {errorTextSection} - {serverDeadSection} - - {this.renderLoginComponentForFlows()} - {footer} + {this.state.loginWithQrInProgress ? (<>) : ( + <> +

+ {_t("action|sign_in")} + {loader} +

+ {errorTextSection} + {serverDeadSection} + + {this.renderLoginComponentForFlows()} + {footer} + )}
); diff --git a/src/components/views/auth/LoginWithQR.tsx b/src/components/views/auth/LoginWithQR.tsx index 1519a1fb45..c4b77b8a4d 100644 --- a/src/components/views/auth/LoginWithQR.tsx +++ b/src/components/views/auth/LoginWithQR.tsx @@ -18,16 +18,20 @@ import { RendezvousIntent, } from "matrix-js-sdk/src/rendezvous"; import { logger } from "matrix-js-sdk/src/logger"; -import { type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { QrCodeIntent } from "@matrix-org/matrix-sdk-crypto-wasm"; import { Click, Mode, Phase } from "./LoginWithQR-types"; import LoginWithQRFlow from "./LoginWithQRFlow"; +import { configureFromCompletedOAuthLogin, restoreSessionFromStorage } from "../../../Lifecycle"; +import { MatrixClientPeg, type IMatrixClientCreds } from "../../../MatrixClientPeg"; interface IProps { - client: MatrixClient; mode: Mode; - onFinished(...args: any): void; -} + client: MatrixClient; + clientId: string; + onFinished(success: boolean, credentials?: IMatrixClientCreds): void; +}; interface IState { phase: Phase; @@ -37,6 +41,8 @@ interface IState { userCode?: string; checkCode?: string; failureReason?: FailureReason; + homeserverBaseUrl?: string; + newClient?: MatrixClient; } export enum LoginWithQRFailureReason { @@ -65,7 +71,7 @@ export default class LoginWithQR extends React.Component { } private get ourIntent(): RendezvousIntent { - return RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; + return this.props.client.getUserId() ? RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE : RendezvousIntent.LOGIN_ON_NEW_DEVICE; } public componentDidMount(): void { @@ -99,20 +105,25 @@ export default class LoginWithQR extends React.Component { } } - 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 => { - let rendezvous: MSC4108SignInWithQR; + // init rust crypto as needed for the secure channel + const RustSdkCryptoJs = await import("@matrix-org/matrix-sdk-crypto-wasm"); + await RustSdkCryptoJs.initAsync(); + + let rendezvous: MSC4108SignInWithQR | undefined; + try { const transport = new MSC4108RendezvousSession({ onFailure: this.onFailure, client: this.props.client, }); await transport.send(""); - const channel = new MSC4108SecureChannel(transport, undefined, this.onFailure); + const channel = new MSC4108SecureChannel(transport, this.ourIntent === RendezvousIntent.LOGIN_ON_NEW_DEVICE ? QrCodeIntent.Login : QrCodeIntent.Reciprocate, undefined, this.onFailure); rendezvous = new MSC4108SignInWithQR(channel, false, this.props.client, this.onFailure); await rendezvous.generateCode(); @@ -136,6 +147,12 @@ export default class LoginWithQR extends React.Component { phase: Phase.OutOfBandConfirmation, verificationUri, }); + } else { + const { baseUrl } = await rendezvous.negotiateProtocols(); + this.setState({ + phase: Phase.OutOfBandConfirmation, + homeserverBaseUrl: baseUrl, + }); } // we ask the user to confirm that the channel is secure @@ -175,8 +192,70 @@ 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.homeserverBaseUrl) { + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown }); + throw new Error("Homeserver base URL not found in state"); + } + + if (new URL(this.props.client.baseUrl).toString() !== new URL(this.state.homeserverBaseUrl).toString()) { + // would need to switch homeservers + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown }); + logger.info(`Changing homeservers during new device login not yet supported: ${this.props.client.baseUrl} -> ${this.state.homeserverBaseUrl}`); + throw new Error("Changing homeservers during new device login not yet supported"); + } + + 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 datr = await this.state.rendezvous.completeLoginOnNewDevice({ + clientId: this.props.clientId, + }); + + if (datr) { + // PROTOTYPE: this is probably not the right way to do this + + // store and use the new credentials + const credentials = await configureFromCompletedOAuthLogin({ + accessToken: datr.access_token, + refreshToken: datr.refresh_token, + homeserverUrl: this.state.homeserverBaseUrl, + clientId: this.props.clientId, + idToken: datr.id_token ?? '', // is this actually optional? + 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) { + 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); + } else { + logger.warn("Crypto not initialised"); + } + } else { + logger.warn("No secrets received from QR login"); + } + + // PROTOTYPE: fudge to try and allow the self verification to complete before we change screen + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // done + this.onFinished(true, credentials); + } } } catch (e: RendezvousError | unknown) { logger.error("Error whilst approving sign in", e); diff --git a/src/components/views/auth/LoginWithQRFlow.tsx b/src/components/views/auth/LoginWithQRFlow.tsx index a432baac72..96298dc3e7 100644 --- a/src/components/views/auth/LoginWithQRFlow.tsx +++ b/src/components/views/auth/LoginWithQRFlow.tsx @@ -132,11 +132,6 @@ export default class LoginWithQRFlow extends React.Component { message = _t("auth|qr_code_login|error_rate_limited"); break; - case ClientRendezvousFailureReason.ETagMissing: - title = _t("error|something_went_wrong"); - message = _t("auth|qr_code_login|error_etag_missing"); - break; - case ClientRendezvousFailureReason.HomeserverLacksSupport: success = null; Icon = QrCodeIcon; diff --git a/src/components/views/settings/devices/LoginWithQRSection.tsx b/src/components/views/settings/devices/LoginWithQRSection.tsx index 523633c884..3d8d0a00c1 100644 --- a/src/components/views/settings/devices/LoginWithQRSection.tsx +++ b/src/components/views/settings/devices/LoginWithQRSection.tsx @@ -11,7 +11,7 @@ import { type IServerVersions, type OidcClientConfig, type MatrixClient, - DEVICE_CODE_SCOPE, + DEVICE_AUTHORIZATION_GRANT_TYPE, } 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"; @@ -28,7 +28,7 @@ interface IProps { isCrossSigningReady?: boolean; } -export function shouldShowQr( +export function shouldShowQrForReciprocate( cli: MatrixClient, isCrossSigningReady: boolean, oidcClientConfig?: OidcClientConfig, @@ -36,7 +36,7 @@ export function shouldShowQr( ): boolean { const msc4108Supported = !!versions?.unstable_features?.["org.matrix.msc4108"]; - const deviceAuthorizationGrantSupported = oidcClientConfig?.grant_types_supported.includes(DEVICE_CODE_SCOPE); + const deviceAuthorizationGrantSupported = oidcClientConfig?.grant_types_supported.includes(DEVICE_AUTHORIZATION_GRANT_TYPE); return ( !!deviceAuthorizationGrantSupported && @@ -48,7 +48,7 @@ export function shouldShowQr( const LoginWithQRSection: React.FC = ({ onShowQr, versions, oidcClientConfig, isCrossSigningReady }) => { const cli = useMatrixClientContext(); - const offerShowQr = shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions); + const offerShowQr = shouldShowQrForReciprocate(cli, !!isCrossSigningReady, oidcClientConfig, versions); return ( diff --git a/test/unit-tests/components/structures/UserMenu-test.tsx b/test/unit-tests/components/structures/UserMenu-test.tsx index 0bc9015c69..e18f0cceb1 100644 --- a/test/unit-tests/components/structures/UserMenu-test.tsx +++ b/test/unit-tests/components/structures/UserMenu-test.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { render, screen, waitFor } from "jest-matrix-react"; -import { DEVICE_CODE_SCOPE, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; +import { DEVICE_AUTHORIZATION_GRANT_TYPE, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; import { type CryptoApi } from "matrix-js-sdk/src/crypto-api"; import { mocked } from "jest-mock"; import fetchMock from "fetch-mock-jest"; @@ -113,7 +113,7 @@ describe("", () => { it("should render 'Link new device' button in OIDC native mode", async () => { sdkContext.client = stubClient(); const openIdMetadata = mockOpenIdConfiguration("https://issuer/"); - openIdMetadata.grant_types_supported.push(DEVICE_CODE_SCOPE); + openIdMetadata.grant_types_supported.push(DEVICE_AUTHORIZATION_GRANT_TYPE); fetchMock.get("https://issuer/.well-known/openid-configuration", openIdMetadata); fetchMock.get("https://issuer/jwks", { status: 200, diff --git a/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx b/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx index d9e9b3e391..87d7f8ac9a 100644 --- a/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx +++ b/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx @@ -36,7 +36,7 @@ function makeClient() { getUser: jest.fn(), isGuest: jest.fn().mockReturnValue(false), isUserIgnored: jest.fn(), - getUserId: jest.fn(), + getUserId: jest.fn().mockReturnValue("@user:server"), on: jest.fn(), isSynapseAdministrator: jest.fn().mockResolvedValue(false), isRoomEncrypted: jest.fn().mockReturnValue(false),