Prototype implementation of MSC4108 version 2025

This commit is contained in:
Hugh Nimmo-Smith 2025-11-21 19:07:22 +00:00
parent f5e56cc8d5
commit 413e2e5c82
9 changed files with 259 additions and 60 deletions

View File

@ -297,26 +297,15 @@ async function attemptOidcNativeLogin(queryParams: QueryDict): Promise<boolean>
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<boolean>
}
}
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: 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

View File

@ -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

View File

@ -2044,9 +2044,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,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<IProps, IState>
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<IProps, IState>
// 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<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 (
@ -456,6 +468,39 @@ export default 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 (
<>
<p className="mx_Login_withQR_or">or</p>
<AccessibleButton
className="mx_Login_withQR"
kind="primary_outline"
onClick={this.startLoginWithQR}
>
<QrCodeIcon />
{_t("Sign in with QR code")}
</AccessibleButton>
</>
);
};
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<IProps, IState>
);
};
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<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()}
{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()}
{footer}
</>)}
</AuthBody>
</AuthPage>
);

View File

@ -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<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,20 +105,25 @@ 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;
// 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<IProps, IState> {
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<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.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);

View File

@ -132,11 +132,6 @@ export default class LoginWithQRFlow extends React.Component<Props> {
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;

View File

@ -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<IProps> = ({ onShowQr, versions, oidcClientConfig, isCrossSigningReady }) => {
const cli = useMatrixClientContext();
const offerShowQr = shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions);
const offerShowQr = shouldShowQrForReciprocate(cli, !!isCrossSigningReady, oidcClientConfig, versions);
return (
<SettingsSubsection heading={_t("settings|sessions|sign_in_with_qr")}>

View File

@ -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("<UserMenu>", () => {
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,

View File

@ -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),