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

This commit is contained in:
Hugh Nimmo-Smith 2026-04-16 13:54:43 +01:00
parent e0cf78b5b8
commit 00c41264b5
8 changed files with 270 additions and 70 deletions

View File

@ -81,7 +81,7 @@
"lodash": "npm:lodash-es@^4.17.21",
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#hughns/msc4108-2024-signin-on-new-device",
"matrix-widget-api": "^1.16.1",
"memoize-one": "^6.0.0",
"mime": "^4.0.4",

View File

@ -297,7 +297,43 @@ async function attemptOidcNativeLogin(queryParams: QueryDict): Promise<boolean>
const { accessToken, refreshToken, homeserverUrl, identityServerUrl, idToken, clientId, issuer } =
await completeOidcLogin(queryParams);
const {
await configureFromCompletedOAuthLogin({
accessToken,
refreshToken,
homeserverUrl,
identityServerUrl,
clientId,
issuer,
idToken,
});
return true;
} catch (error) {
logger.error("Failed to login via OIDC", error);
onFailedDelegatedAuthLogin(getOidcErrorMessage(error as Error));
return false;
}
}
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<IMatrixClientCreds> {
const {
user_id: userId,
device_id: deviceId,
is_guest: isGuest,
@ -317,14 +353,8 @@ async function attemptOidcNativeLogin(queryParams: QueryDict): Promise<boolean>
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);
onFailedDelegatedAuthLogin(getOidcErrorMessage(error as Error));
return false;
return credentials;
}
}
/**
* Gets information about the owner of a given access token.

View File

@ -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<LoginWithQrFlow | undefined> => {
// 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;
};

View File

@ -2130,9 +2130,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* 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<void> => {
private onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds, alreadySignedIn = false): Promise<void> => {
// Create and start the client
await Lifecycle.setLoggedIn(credentials);
if (!alreadySignedIn) {
await Lifecycle.setLoggedIn(credentials);
}
await this.postLoginSetup();
PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN);

View File

@ -9,11 +9,13 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX, memo, 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, type MatrixClient, SSOAction } from "matrix-js-sdk/src/matrix";
import { Button } from "@vector-im/compound-web";
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";
@ -33,6 +35,9 @@ import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig
import { filterBoolean } from "../../../utils/arrays";
import { startOidcLogin } from "../../../utils/oidc/authorize";
import { ModuleApi } from "../../../modules/Api.ts";
import LoginWithQR from "../../views/auth/LoginWithQR.tsx";
import { Mode } from "../../views/auth/LoginWithQR-types.ts";
import createMatrixClient from "../../../utils/createMatrixClient.ts";
interface IProps {
serverConfig: ValidatedServerConfig;
@ -51,7 +56,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;
@ -79,6 +85,9 @@ interface IState {
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError?: ReactNode;
loginWithQrInProgress: boolean;
loginWithQrClient?: MatrixClient;
}
type OnPasswordLogin = {
@ -110,6 +119,8 @@ class LoginComponent extends React.PureComponent<IProps, IState> {
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
loginWithQrInProgress: false,
};
// map from login step type to a function which will render a control
@ -123,6 +134,7 @@ class LoginComponent extends React.PureComponent<IProps, IState> {
// eslint-disable-next-line @typescript-eslint/naming-convention
"m.login.sso": () => this.renderSsoStep("sso"),
"oidcNativeFlow": () => this.renderOidcNativeStep(),
"loginWithQrFlow": () => this.renderLoginWithQRStep(),
};
}
@ -402,7 +414,7 @@ class LoginComponent extends React.PureComponent<IProps, IState> {
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 (
@ -451,7 +463,7 @@ class LoginComponent extends React.PureComponent<IProps, IState> {
);
}}
>
{_t("action|continue")}
{_t("Sign in manually")}
</Button>
);
};
@ -472,6 +484,42 @@ class LoginComponent extends React.PureComponent<IProps, IState> {
);
};
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 (
<>
<Button className="mx_Login_fullWidthButton" kind="primary" size="sm" onClick={this.startLoginWithQR}>
<QrCodeIcon />
{_t("Sign in with QR code")}
</Button>
</>
);
};
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 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,20 +580,34 @@ class LoginComponent extends React.PureComponent<IProps, IState> {
<AuthPage>
<AuthHeader disableLanguageSelector={this.props.isSyncing || this.state.busyLoggingIn} />
<AuthBody>
<h1>
{_t("action|sign_in")}
{loader}
</h1>
{errorTextSection}
{serverDeadSection}
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
disabled={this.isBusy()}
/>
{this.renderLoginComponentForFlows()}
{this.props.children}
{footer}
{this.state.loginWithQrInProgress ? (
<>
<LoginWithQR
onFinished={this.onLoginWithQRFinished}
mode={Mode.Show}
client={this.state.loginWithQrClient!}
clientId={this.qrClientId}
/>
</>
) : (
<>
{" "}
<h1>
{_t("action|sign_in")}
{loader}
</h1>
{errorTextSection}
{serverDeadSection}
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
disabled={this.isBusy()}
/>
{this.renderLoginComponentForFlows()}
{this.props.children}
{footer}
</>
)}
</AuthBody>
</AuthPage>
);

View File

@ -9,24 +9,27 @@ 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 } from "matrix-js-sdk/src/matrix";
import { Click, Mode, Phase } from "./LoginWithQR-types";
import LoginWithQRFlow from "./LoginWithQRFlow";
import { configureFromCompletedOAuthLogin, restoreSessionFromStorage } from "../../../Lifecycle";
import { type IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps {
client: MatrixClient;
clientId: string;
mode: Mode;
onFinished(...args: any): void;
onFinished(success: boolean, credentials?: IMatrixClientCreds): void;
}
interface IState {
@ -37,6 +40,8 @@ interface IState {
userCode?: string;
checkCode?: string;
failureReason?: FailureReason;
homeserverName?: string;
newClient?: MatrixClient;
}
export enum LoginWithQRFailureReason {
@ -65,7 +70,9 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
}
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,23 +106,18 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
}
}
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<void> => {
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.ourIntent === RendezvousIntent.LOGIN_ON_NEW_DEVICE
? await signInByGeneratingQR(this.props.client, this.onFailure)
: await linkNewDeviceByGeneratingQR(this.props.client, this.onFailure);
this.setState({
phase: Phase.ShowingQR,
rendezvous,
@ -136,6 +138,12 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
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
@ -175,8 +183,75 @@ 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.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 datr = await this.state.rendezvous.completeLoginOnNewDevice({
clientId: this.props.clientId,
});
if (datr) {
// 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: datr.access_token,
refreshToken: datr.refresh_token,
homeserverUrl,
clientId: this.props.clientId,
idToken: datr.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);

View File

@ -11,15 +11,16 @@ import {
type IServerVersions,
type OidcClientConfig,
type MatrixClient,
DEVICE_CODE_SCOPE,
} 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;
@ -28,19 +29,16 @@ interface IProps {
isCrossSigningReady?: boolean;
}
export function shouldShowQr(
export async function shouldShowQrForLinkNewDevice(
cli: MatrixClient,
isCrossSigningReady: boolean,
oidcClientConfig?: OidcClientConfig,
versions?: IServerVersions,
): boolean {
const msc4108Supported = !!versions?.unstable_features?.["org.matrix.msc4108"];
const deviceAuthorizationGrantSupported = oidcClientConfig?.grant_types_supported.includes(DEVICE_CODE_SCOPE);
): Promise<boolean> {
const doesServerHaveSupport = await isSignInWithQRAvailable(cli);
return (
!!deviceAuthorizationGrantSupported &&
msc4108Supported &&
doesServerHaveSupport &&
!!cli.getCrypto()?.exportSecretsBundle &&
isCrossSigningReady
);
@ -48,7 +46,7 @@ export function shouldShowQr(
const LoginWithQRSection: React.FC<IProps> = ({ onShowQr, versions, oidcClientConfig, isCrossSigningReady }) => {
const cli = useMatrixClientContext();
const offerShowQr = shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions);
const offerShowQr = useAsyncMemo(() => shouldShowQrForLinkNewDevice(cli, !!isCrossSigningReady, oidcClientConfig, versions), [cli, isCrossSigningReady, oidcClientConfig, versions], false);
return (
<SettingsSubsection heading={_t("settings|sessions|sign_in_with_qr")}>

22
pnpm-lock.yaml generated
View File

@ -434,8 +434,8 @@ importers:
specifier: ^1.0.3
version: 1.0.3
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#develop
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/aed74c5a7259497de590600e2f25af4b39b7734b
specifier: github:matrix-org/matrix-js-sdk#hughns/msc4108-2024-signin-on-new-device
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3fa77611104e0ae11217056cc5b0dd62177617e3
matrix-widget-api:
specifier: ^1.17.0
version: 1.17.0
@ -9754,9 +9754,9 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/aed74c5a7259497de590600e2f25af4b39b7734b:
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/aed74c5a7259497de590600e2f25af4b39b7734b}
version: 41.2.0
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3fa77611104e0ae11217056cc5b0dd62177617e3:
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3fa77611104e0ae11217056cc5b0dd62177617e3}
version: 41.3.0
engines: {node: '>=22.0.0'}
matrix-web-i18n@3.6.0:
@ -10348,9 +10348,9 @@ packages:
resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==}
engines: {node: '>=16.17'}
p-retry@7.1.1:
resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==}
engines: {node: '>=20'}
p-retry@8.0.0:
resolution: {integrity: sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==}
engines: {node: '>=22'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
@ -23349,7 +23349,7 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/aed74c5a7259497de590600e2f25af4b39b7734b:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3fa77611104e0ae11217056cc5b0dd62177617e3:
dependencies:
'@babel/runtime': 7.28.6
'@matrix-org/matrix-sdk-crypto-wasm': 18.0.0
@ -23361,7 +23361,7 @@ snapshots:
matrix-events-sdk: 0.0.1
matrix-widget-api: 1.17.0
oidc-client-ts: 3.5.0
p-retry: 7.1.1
p-retry: 8.0.0
sdp-transform: 3.0.0
unhomoglyph: 1.0.6
uuid: 13.0.0
@ -24129,7 +24129,7 @@ snapshots:
is-network-error: 1.3.0
retry: 0.13.1
p-retry@7.1.1:
p-retry@8.0.0:
dependencies:
is-network-error: 1.3.0