Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Implementation of MSC3824 to make the client OIDC-aware (#8681)
Browse files Browse the repository at this point in the history
  • Loading branch information
hughns authored Jan 27, 2023
1 parent 32bd350 commit d698193
Show file tree
Hide file tree
Showing 11 changed files with 240 additions and 29 deletions.
11 changes: 7 additions & 4 deletions src/BasePlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { encodeUnpaddedBase64 } from "matrix-js-sdk/src/crypto/olmlib";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { SSOAction } from "matrix-js-sdk/src/@types/auth";

import dis from "./dispatcher/dispatcher";
import BaseEventIndexManager from "./indexing/BaseEventIndexManager";
Expand Down Expand Up @@ -308,9 +309,9 @@ export default abstract class BasePlatform {
return null;
}

protected getSSOCallbackUrl(fragmentAfterLogin: string): URL {
protected getSSOCallbackUrl(fragmentAfterLogin = ""): URL {
const url = new URL(window.location.href);
url.hash = fragmentAfterLogin || "";
url.hash = fragmentAfterLogin;
return url;
}

Expand All @@ -319,13 +320,15 @@ export default abstract class BasePlatform {
* @param {MatrixClient} mxClient the matrix client using which we should start the flow
* @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
* @param {string} fragmentAfterLogin the hash to pass to the app during sso callback.
* @param {SSOAction} action the SSO flow to indicate to the IdP, optional.
* @param {string} idpId The ID of the Identity Provider being targeted, optional.
*/
public startSingleSignOn(
mxClient: MatrixClient,
loginType: "sso" | "cas",
fragmentAfterLogin: string,
fragmentAfterLogin?: string,
idpId?: string,
action?: SSOAction,
): void {
// persist hs url and is url for when the user is returned to the app with the login token
localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl());
Expand All @@ -336,7 +339,7 @@ export default abstract class BasePlatform {
localStorage.setItem(SSO_IDP_ID_KEY, idpId);
}
const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin);
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId); // redirect to SSO
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId, action); // redirect to SSO
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/Lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes";
import { QueryDict } from "matrix-js-sdk/src/utils";
import { logger } from "matrix-js-sdk/src/logger";
import { SSOAction } from "matrix-js-sdk/src/@types/auth";

import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg";
import SecurityCustomisations from "./customisations/Security";
Expand Down Expand Up @@ -248,7 +249,7 @@ export function attemptTokenLogin(
idBaseUrl: identityServer,
});
const idpId = localStorage.getItem(SSO_IDP_ID_KEY) || undefined;
PlatformPeg.get().startSingleSignOn(cli, "sso", fragmentAfterLogin, idpId);
PlatformPeg.get()?.startSingleSignOn(cli, "sso", fragmentAfterLogin, idpId, SSOAction.LOGIN);
}
},
});
Expand Down
12 changes: 8 additions & 4 deletions src/Login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ limitations under the License.
import { createClient } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
import { ILoginParams, LoginFlow } from "matrix-js-sdk/src/@types/auth";
import { DELEGATED_OIDC_COMPATIBILITY, ILoginParams, LoginFlow } from "matrix-js-sdk/src/@types/auth";

import { IMatrixClientCreds } from "./MatrixClientPeg";
import SecurityCustomisations from "./customisations/Security";
Expand All @@ -32,7 +32,6 @@ export default class Login {
private hsUrl: string;
private isUrl: string;
private fallbackHsUrl: string;
// TODO: Flows need a type in JS SDK
private flows: Array<LoginFlow>;
private defaultDeviceDisplayName: string;
private tempClient: MatrixClient;
Expand Down Expand Up @@ -81,8 +80,13 @@ export default class Login {

public async getFlows(): Promise<Array<LoginFlow>> {
const client = this.createTemporaryClient();
const { flows } = await client.loginFlows();
this.flows = flows;
const { flows }: { flows: LoginFlow[] } = await client.loginFlows();
// If an m.login.sso flow is present which is also flagged as being for MSC3824 OIDC compatibility then we only
// return that flow as (per MSC3824) it is the only one that the user should be offered to give the best experience
const oidcCompatibilityFlow = flows.find(
(f) => f.type === "m.login.sso" && DELEGATED_OIDC_COMPATIBILITY.findIn(f),
);
this.flows = oidcCompatibilityFlow ? [oidcCompatibilityFlow] : flows;
return this.flows;
}

Expand Down
4 changes: 3 additions & 1 deletion src/components/structures/auth/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import React, { ReactNode } from "react";
import { ConnectionError, MatrixError } from "matrix-js-sdk/src/http-api";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { ISSOFlow, LoginFlow } from "matrix-js-sdk/src/@types/auth";
import { ISSOFlow, LoginFlow, SSOAction } from "matrix-js-sdk/src/@types/auth";

import { _t, _td } from "../../../languageHandler";
import Login from "../../../Login";
Expand Down Expand Up @@ -345,6 +345,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
this.loginLogic.createTemporaryClient(),
ssoKind,
this.props.fragmentAfterLogin,
SSOAction.REGISTER,
);
} else {
// Don't intercept - just go through to the register page
Expand Down Expand Up @@ -549,6 +550,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find((flow) => flow.type === "m.login.password")}
action={SSOAction.LOGIN}
/>
);
};
Expand Down
3 changes: 2 additions & 1 deletion src/components/structures/auth/Registration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import React, { Fragment, ReactNode } from "react";
import { IRequestTokenResponse, MatrixClient } from "matrix-js-sdk/src/client";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { ISSOFlow } from "matrix-js-sdk/src/@types/auth";
import { ISSOFlow, SSOAction } from "matrix-js-sdk/src/@types/auth";

import { _t, _td } from "../../../languageHandler";
import { messageForResourceLimitError } from "../../../utils/ErrorUtils";
Expand Down Expand Up @@ -539,6 +539,7 @@ export default class Registration extends React.Component<IProps, IState> {
flow={this.state.ssoFlow}
loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"}
fragmentAfterLogin={this.props.fragmentAfterLogin}
action={SSOAction.REGISTER}
/>
<h2 className="mx_AuthBody_centered">
{_t("%(ssoButtons)s Or %(usernamePassword)s", {
Expand Down
3 changes: 2 additions & 1 deletion src/components/structures/auth/SoftLogout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ limitations under the License.
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { Optional } from "matrix-events-sdk";
import { ISSOFlow, LoginFlow } from "matrix-js-sdk/src/@types/auth";
import { ISSOFlow, LoginFlow, SSOAction } from "matrix-js-sdk/src/@types/auth";

import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
Expand Down Expand Up @@ -256,6 +256,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find((flow) => flow.type === "m.login.password")}
action={SSOAction.LOGIN}
/>
</div>
);
Expand Down
57 changes: 41 additions & 16 deletions src/components/views/elements/SSOButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ import { chunk } from "lodash";
import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Signup } from "@matrix-org/analytics-events/types/typescript/Signup";
import { IdentityProviderBrand, IIdentityProvider, ISSOFlow } from "matrix-js-sdk/src/@types/auth";
import {
IdentityProviderBrand,
IIdentityProvider,
ISSOFlow,
DELEGATED_OIDC_COMPATIBILITY,
SSOAction,
} from "matrix-js-sdk/src/@types/auth";

import PlatformPeg from "../../../PlatformPeg";
import AccessibleButton from "./AccessibleButton";
Expand All @@ -28,9 +34,10 @@ import AccessibleTooltipButton from "./AccessibleTooltipButton";
import { mediaFromMxc } from "../../../customisations/Media";
import { PosthogAnalytics } from "../../../PosthogAnalytics";

interface ISSOButtonProps extends Omit<IProps, "flow"> {
interface ISSOButtonProps extends IProps {
idp?: IIdentityProvider;
mini?: boolean;
action?: SSOAction;
}

const getIcon = (brand: IdentityProviderBrand | string): string | null => {
Expand Down Expand Up @@ -79,20 +86,29 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
idp,
primary,
mini,
action,
flow,
...props
}) => {
const label = idp ? _t("Continue with %(provider)s", { provider: idp.name }) : _t("Sign in with single sign-on");
let label: string;
if (idp) {
label = _t("Continue with %(provider)s", { provider: idp.name });
} else if (DELEGATED_OIDC_COMPATIBILITY.findIn<boolean>(flow)) {
label = _t("Continue");
} else {
label = _t("Sign in with single sign-on");
}

const onClick = (): void => {
const authenticationType = getAuthenticationType(idp?.brand ?? "");
PosthogAnalytics.instance.setAuthenticationType(authenticationType);
PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id);
PlatformPeg.get()?.startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id, action);
};

let icon;
let brandClass;
const brandIcon = idp ? getIcon(idp.brand) : null;
if (brandIcon) {
let icon: JSX.Element | undefined;
let brandClass: string | undefined;
const brandIcon = idp?.brand ? getIcon(idp.brand) : null;
if (idp?.brand && brandIcon) {
const brandName = idp.brand.split(".").pop();
brandClass = `mx_SSOButton_brand_${brandName}`;
icon = <img src={brandIcon} height="24" width="24" alt={brandName} />;
Expand All @@ -101,12 +117,16 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
icon = <img src={src} height="24" width="24" alt={idp.name} />;
}

const classes = classNames("mx_SSOButton", {
[brandClass]: brandClass,
mx_SSOButton_mini: mini,
mx_SSOButton_default: !idp,
mx_SSOButton_primary: primary,
});
const brandPart = brandClass ? { [brandClass]: brandClass } : undefined;
const classes = classNames(
"mx_SSOButton",
{
mx_SSOButton_mini: mini,
mx_SSOButton_default: !idp,
mx_SSOButton_primary: primary,
},
brandPart,
);

if (mini) {
// TODO fallback icon
Expand All @@ -128,14 +148,15 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
interface IProps {
matrixClient: MatrixClient;
flow: ISSOFlow;
loginType?: "sso" | "cas";
loginType: "sso" | "cas";
fragmentAfterLogin?: string;
primary?: boolean;
action?: SSOAction;
}

const MAX_PER_ROW = 6;

const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary }) => {
const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary, action }) => {
const providers = flow.identity_providers || [];
if (providers.length < 2) {
return (
Expand All @@ -146,6 +167,8 @@ const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentA
fragmentAfterLogin={fragmentAfterLogin}
idp={providers[0]}
primary={primary}
action={action}
flow={flow}
/>
</div>
);
Expand All @@ -167,6 +190,8 @@ const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentA
idp={idp}
mini={true}
primary={primary}
action={action}
flow={flow}
/>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import React from "react";
import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types";
import { IThreepid } from "matrix-js-sdk/src/@types/threepids";
import { logger } from "matrix-js-sdk/src/logger";
import { IDelegatedAuthConfig, M_AUTHENTICATION } from "matrix-js-sdk/src/matrix";

import { _t } from "../../../../../languageHandler";
import ProfileSettings from "../../ProfileSettings";
Expand Down Expand Up @@ -79,6 +80,7 @@ interface IState {
loading3pids: boolean; // whether or not the emails and msisdns have been loaded
canChangePassword: boolean;
idServerName: string;
externalAccountManagementUrl?: string;
}

export default class GeneralUserSettingsTab extends React.Component<IProps, IState> {
Expand Down Expand Up @@ -106,6 +108,7 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
loading3pids: true, // whether or not the emails and msisdns have been loaded
canChangePassword: false,
idServerName: null,
externalAccountManagementUrl: undefined,
};

this.dispatcherRef = dis.register(this.onAction);
Expand Down Expand Up @@ -161,7 +164,10 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
// the enabled flag value.
const canChangePassword = !changePasswordCap || changePasswordCap["enabled"] !== false;

this.setState({ serverSupportsSeparateAddAndBind, canChangePassword });
const delegatedAuthConfig = M_AUTHENTICATION.findIn<IDelegatedAuthConfig | undefined>(cli.getClientWellKnown());
const externalAccountManagementUrl = delegatedAuthConfig?.account;

this.setState({ serverSupportsSeparateAddAndBind, canChangePassword, externalAccountManagementUrl });
}

private async getThreepidState(): Promise<void> {
Expand Down Expand Up @@ -348,9 +354,37 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
passwordChangeForm = null;
}

let externalAccountManagement: JSX.Element | undefined;
if (this.state.externalAccountManagementUrl) {
const { hostname } = new URL(this.state.externalAccountManagementUrl);

externalAccountManagement = (
<>
<p className="mx_SettingsTab_subsectionText" data-testid="external-account-management-outer">
{_t(
"Your account details are managed separately at <code>%(hostname)s</code>.",
{ hostname },
{ code: (sub) => <code>{sub}</code> },
)}
</p>
<AccessibleButton
onClick={null}
element="a"
kind="primary"
target="_blank"
rel="noreferrer noopener"
href={this.state.externalAccountManagementUrl}
data-testid="external-account-management-link"
>
{_t("Manage account")}
</AccessibleButton>
</>
);
}
return (
<div className="mx_SettingsTab_section mx_GeneralUserSettingsTab_accountSection">
<span className="mx_SettingsTab_subheading">{_t("Account")}</span>
{externalAccountManagement}
<p className="mx_SettingsTab_subsectionText">{passwordChangeText}</p>
{passwordChangeForm}
{threepidSection}
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1532,6 +1532,8 @@
"Email addresses": "Email addresses",
"Phone numbers": "Phone numbers",
"Set a new account password...": "Set a new account password...",
"Your account details are managed separately at <code>%(hostname)s</code>.": "Your account details are managed separately at <code>%(hostname)s</code>.",
"Manage account": "Manage account",
"Account": "Account",
"Language and region": "Language and region",
"Spell check": "Spell check",
Expand Down
Loading

0 comments on commit d698193

Please sign in to comment.