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

Implementation of MSC3824 to make the client OIDC-aware #8681

Merged
merged 20 commits into from
Jan 27, 2023
Merged
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
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();
hughns marked this conversation as resolved.
Show resolved Hide resolved
// 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;
hughns marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -1531,6 +1531,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