Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,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-v2025-poc",
"matrix-widget-api": "^1.16.1",
"memoize-one": "^6.0.0",
"mime": "^4.0.4",
Expand Down
61 changes: 46 additions & 15 deletions apps/web/src/Lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,26 +297,16 @@ 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,
};
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);
Expand All @@ -326,6 +316,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
Expand Down
38 changes: 36 additions & 2 deletions apps/web/src/Login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type OidcClientConfig,
type ISSOFlow,
} from "matrix-js-sdk/src/matrix";
import { isSignInWithQRAvailable } from "matrix-js-sdk/src/rendezvous";
import { logger } from "matrix-js-sdk/src/logger";

import { type IMatrixClientCreds } from "./MatrixClientPeg";
Expand All @@ -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;
Expand Down Expand Up @@ -116,7 +122,18 @@ 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);
}
Expand Down Expand Up @@ -288,3 +305,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;
};
9 changes: 7 additions & 2 deletions apps/web/src/components/structures/MatrixChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2131,9 +2131,14 @@ 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);
Expand Down
100 changes: 81 additions & 19 deletions apps/web/src/components/structures/auth/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -79,6 +85,9 @@ interface IState {
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError?: ReactNode;

loginWithQrInProgress: boolean;
loginWithQrClient?: MatrixClient;
}

type OnPasswordLogin = {
Expand Down Expand Up @@ -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
Expand All @@ -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(),
};
}

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -451,7 +463,7 @@ class LoginComponent extends React.PureComponent<IProps, IState> {
);
}}
>
{_t("action|continue")}
{_t("Sign in manually")}
</Button>
);
};
Expand All @@ -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 ? (
Expand Down Expand Up @@ -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>
);
Expand Down
Loading
Loading