OIDC: Check static client registration and add login flow (#11088)
* util functions to get static client id * check static client ids in login flow * remove dead code * add trailing slash * comment error enum * spacing * PR tidying * more comments * add ValidatedDelegatedAuthConfig type * Update src/Login.ts Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Update src/Login.ts Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Update src/utils/ValidatedServerConfig.ts Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * rename oidc_static_clients to oidc_static_client_ids * comment --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
parent
35f8c525aa
commit
328db8fdfd
10 changed files with 456 additions and 45 deletions
|
@ -194,6 +194,14 @@ export interface IConfigOptions {
|
||||||
existing_issues_url: string;
|
existing_issues_url: string;
|
||||||
new_issue_url: string;
|
new_issue_url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for OIDC issuers where a static client_id has been issued for the app.
|
||||||
|
* Otherwise dynamic client registration is attempted.
|
||||||
|
* The issuer URL must have a trailing `/`.
|
||||||
|
* OPTIONAL
|
||||||
|
*/
|
||||||
|
oidc_static_client_ids?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISsoRedirectOptions {
|
export interface ISsoRedirectOptions {
|
||||||
|
|
78
src/Login.ts
78
src/Login.ts
|
@ -19,18 +19,37 @@ limitations under the License.
|
||||||
import { createClient } from "matrix-js-sdk/src/matrix";
|
import { createClient } from "matrix-js-sdk/src/matrix";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { DELEGATED_OIDC_COMPATIBILITY, ILoginParams, LoginFlow } from "matrix-js-sdk/src/@types/auth";
|
import { DELEGATED_OIDC_COMPATIBILITY, ILoginFlow, ILoginParams, LoginFlow } from "matrix-js-sdk/src/@types/auth";
|
||||||
|
|
||||||
import { IMatrixClientCreds } from "./MatrixClientPeg";
|
import { IMatrixClientCreds } from "./MatrixClientPeg";
|
||||||
import SecurityCustomisations from "./customisations/Security";
|
import SecurityCustomisations from "./customisations/Security";
|
||||||
|
import { ValidatedDelegatedAuthConfig } from "./utils/ValidatedServerConfig";
|
||||||
|
import { getOidcClientId } from "./utils/oidc/registerClient";
|
||||||
|
import { IConfigOptions } from "./IConfigOptions";
|
||||||
|
import SdkConfig from "./SdkConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login flows supported by this client
|
||||||
|
* LoginFlow type use the client API /login endpoint
|
||||||
|
* OidcNativeFlow is specific to this client
|
||||||
|
*/
|
||||||
|
export type ClientLoginFlow = LoginFlow | OidcNativeFlow;
|
||||||
|
|
||||||
interface ILoginOptions {
|
interface ILoginOptions {
|
||||||
defaultDeviceDisplayName?: string;
|
defaultDeviceDisplayName?: string;
|
||||||
|
/**
|
||||||
|
* Delegated auth config from server's .well-known.
|
||||||
|
*
|
||||||
|
* If this property is set, we will attempt an OIDC login using the delegated auth settings.
|
||||||
|
* The caller is responsible for checking that OIDC is enabled in the labs settings.
|
||||||
|
*/
|
||||||
|
delegatedAuthentication?: ValidatedDelegatedAuthConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Login {
|
export default class Login {
|
||||||
private flows: Array<LoginFlow> = [];
|
private flows: Array<ClientLoginFlow> = [];
|
||||||
private readonly defaultDeviceDisplayName?: string;
|
private readonly defaultDeviceDisplayName?: string;
|
||||||
|
private readonly delegatedAuthentication?: ValidatedDelegatedAuthConfig;
|
||||||
private tempClient: MatrixClient | null = null; // memoize
|
private tempClient: MatrixClient | null = null; // memoize
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
@ -40,6 +59,7 @@ export default class Login {
|
||||||
opts: ILoginOptions,
|
opts: ILoginOptions,
|
||||||
) {
|
) {
|
||||||
this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
|
this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
|
||||||
|
this.delegatedAuthentication = opts.delegatedAuthentication;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getHomeserverUrl(): string {
|
public getHomeserverUrl(): string {
|
||||||
|
@ -75,7 +95,22 @@ export default class Login {
|
||||||
return this.tempClient;
|
return this.tempClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFlows(): Promise<Array<LoginFlow>> {
|
public async getFlows(): Promise<Array<ClientLoginFlow>> {
|
||||||
|
// try to use oidc native flow if we have delegated auth config
|
||||||
|
if (this.delegatedAuthentication) {
|
||||||
|
try {
|
||||||
|
const oidcFlow = await tryInitOidcNativeFlow(
|
||||||
|
this.delegatedAuthentication,
|
||||||
|
SdkConfig.get().brand,
|
||||||
|
SdkConfig.get().oidc_static_client_ids,
|
||||||
|
);
|
||||||
|
return [oidcFlow];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidc native flow not supported, continue with matrix login
|
||||||
const client = this.createTemporaryClient();
|
const client = this.createTemporaryClient();
|
||||||
const { flows }: { flows: LoginFlow[] } = await client.loginFlows();
|
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
|
// If an m.login.sso flow is present which is also flagged as being for MSC3824 OIDC compatibility then we only
|
||||||
|
@ -151,6 +186,43 @@ export default class Login {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the OIDC native login flow
|
||||||
|
* Separate from js-sdk's `LoginFlow` as this does not use the same /login flow
|
||||||
|
* to which that type belongs.
|
||||||
|
*/
|
||||||
|
export interface OidcNativeFlow extends ILoginFlow {
|
||||||
|
type: "oidcNativeFlow";
|
||||||
|
// this client's id as registered with the configured OIDC OP
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Prepares an OidcNativeFlow for logging into the server.
|
||||||
|
*
|
||||||
|
* Finds a static clientId for configured issuer, or attempts dynamic registration with the OP, and wraps the
|
||||||
|
* results.
|
||||||
|
*
|
||||||
|
* @param delegatedAuthConfig Auth config from ValidatedServerConfig
|
||||||
|
* @param clientName Client name to register with the OP, eg 'Element', used during client registration with OP
|
||||||
|
* @param staticOidcClientIds static client config from config.json, used during client registration with OP
|
||||||
|
* @returns Promise<OidcNativeFlow> when oidc native authentication flow is supported and correctly configured
|
||||||
|
* @throws when client can't register with OP, or any unexpected error
|
||||||
|
*/
|
||||||
|
const tryInitOidcNativeFlow = async (
|
||||||
|
delegatedAuthConfig: ValidatedDelegatedAuthConfig,
|
||||||
|
brand: string,
|
||||||
|
oidcStaticClientIds?: IConfigOptions["oidc_static_client_ids"],
|
||||||
|
): Promise<OidcNativeFlow> => {
|
||||||
|
const clientId = await getOidcClientId(delegatedAuthConfig, brand, window.location.origin, oidcStaticClientIds);
|
||||||
|
|
||||||
|
const flow = {
|
||||||
|
type: "oidcNativeFlow",
|
||||||
|
clientId,
|
||||||
|
} as OidcNativeFlow;
|
||||||
|
|
||||||
|
return flow;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a login request to the given server, and format the response
|
* Send a login request to the given server, and format the response
|
||||||
* as a MatrixClientCreds
|
* as a MatrixClientCreds
|
||||||
|
|
|
@ -17,10 +17,10 @@ limitations under the License.
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { ISSOFlow, LoginFlow, SSOAction } from "matrix-js-sdk/src/@types/auth";
|
import { ISSOFlow, SSOAction } from "matrix-js-sdk/src/@types/auth";
|
||||||
|
|
||||||
import { _t, _td, UserFriendlyError } from "../../../languageHandler";
|
import { _t, _td, UserFriendlyError } from "../../../languageHandler";
|
||||||
import Login from "../../../Login";
|
import Login, { ClientLoginFlow } from "../../../Login";
|
||||||
import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils";
|
import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils";
|
||||||
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
||||||
import AuthPage from "../../views/auth/AuthPage";
|
import AuthPage from "../../views/auth/AuthPage";
|
||||||
|
@ -38,6 +38,7 @@ import AuthHeader from "../../views/auth/AuthHeader";
|
||||||
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
|
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||||
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
|
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
|
||||||
import { filterBoolean } from "../../../utils/arrays";
|
import { filterBoolean } from "../../../utils/arrays";
|
||||||
|
import { Features } from "../../../settings/Settings";
|
||||||
|
|
||||||
// These are used in several places, and come from the js-sdk's autodiscovery
|
// These are used in several places, and come from the js-sdk's autodiscovery
|
||||||
// stuff. We define them here so that they'll be picked up by i18n.
|
// stuff. We define them here so that they'll be picked up by i18n.
|
||||||
|
@ -84,7 +85,7 @@ interface IState {
|
||||||
// can we attempt to log in or are there validation errors?
|
// can we attempt to log in or are there validation errors?
|
||||||
canTryLogin: boolean;
|
canTryLogin: boolean;
|
||||||
|
|
||||||
flows?: LoginFlow[];
|
flows?: ClientLoginFlow[];
|
||||||
|
|
||||||
// used for preserving form values when changing homeserver
|
// used for preserving form values when changing homeserver
|
||||||
username: string;
|
username: string;
|
||||||
|
@ -110,6 +111,7 @@ type OnPasswordLogin = {
|
||||||
*/
|
*/
|
||||||
export default class LoginComponent extends React.PureComponent<IProps, IState> {
|
export default class LoginComponent extends React.PureComponent<IProps, IState> {
|
||||||
private unmounted = false;
|
private unmounted = false;
|
||||||
|
private oidcNativeFlowEnabled = false;
|
||||||
private loginLogic!: Login;
|
private loginLogic!: Login;
|
||||||
|
|
||||||
private readonly stepRendererMap: Record<string, () => ReactNode>;
|
private readonly stepRendererMap: Record<string, () => ReactNode>;
|
||||||
|
@ -117,6 +119,9 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
||||||
public constructor(props: IProps) {
|
public constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
// only set on a config level, so we don't need to watch
|
||||||
|
this.oidcNativeFlowEnabled = SettingsStore.getValue(Features.OidcNativeFlow);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
busy: false,
|
busy: false,
|
||||||
errorText: null,
|
errorText: null,
|
||||||
|
@ -156,7 +161,10 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
||||||
public componentDidUpdate(prevProps: IProps): void {
|
public componentDidUpdate(prevProps: IProps): void {
|
||||||
if (
|
if (
|
||||||
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
|
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
|
||||||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
|
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl ||
|
||||||
|
// delegatedAuthentication is only set by buildValidatedConfigFromDiscovery and won't be modified
|
||||||
|
// so shallow comparison is fine
|
||||||
|
prevProps.serverConfig.delegatedAuthentication !== this.props.serverConfig.delegatedAuthentication
|
||||||
) {
|
) {
|
||||||
// Ensure that we end up actually logging in to the right place
|
// Ensure that we end up actually logging in to the right place
|
||||||
this.initLoginLogic(this.props.serverConfig);
|
this.initLoginLogic(this.props.serverConfig);
|
||||||
|
@ -322,28 +330,10 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig): Promise<void> {
|
private async checkServerLiveliness({
|
||||||
let isDefaultServer = false;
|
hsUrl,
|
||||||
if (
|
isUrl,
|
||||||
this.props.serverConfig.isDefault &&
|
}: Pick<ValidatedServerConfig, "hsUrl" | "isUrl">): Promise<void> {
|
||||||
hsUrl === this.props.serverConfig.hsUrl &&
|
|
||||||
isUrl === this.props.serverConfig.isUrl
|
|
||||||
) {
|
|
||||||
isDefaultServer = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl! : null;
|
|
||||||
|
|
||||||
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
|
|
||||||
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
|
|
||||||
});
|
|
||||||
this.loginLogic = loginLogic;
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
busy: true,
|
|
||||||
loginIncorrect: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Do a quick liveliness check on the URLs
|
// Do a quick liveliness check on the URLs
|
||||||
try {
|
try {
|
||||||
const { warning } = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
const { warning } = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
||||||
|
@ -361,9 +351,38 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.setState({
|
this.setState({
|
||||||
busy: false,
|
busy: false,
|
||||||
...AutoDiscoveryUtils.authComponentStateForError(e),
|
...AutoDiscoveryUtils.authComponentStateForError(e as Error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig): Promise<void> {
|
||||||
|
let isDefaultServer = false;
|
||||||
|
if (
|
||||||
|
this.props.serverConfig.isDefault &&
|
||||||
|
hsUrl === this.props.serverConfig.hsUrl &&
|
||||||
|
isUrl === this.props.serverConfig.isUrl
|
||||||
|
) {
|
||||||
|
isDefaultServer = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl! : null;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
busy: true,
|
||||||
|
loginIncorrect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.checkServerLiveliness({ hsUrl, isUrl });
|
||||||
|
|
||||||
|
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
|
||||||
|
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
|
||||||
|
// if native OIDC is enabled in the client pass the server's delegated auth settings
|
||||||
|
delegatedAuthentication: this.oidcNativeFlowEnabled
|
||||||
|
? this.props.serverConfig.delegatedAuthentication
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
this.loginLogic = loginLogic;
|
||||||
|
|
||||||
loginLogic
|
loginLogic
|
||||||
.getFlows()
|
.getFlows()
|
||||||
|
@ -401,7 +420,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSupportedFlow = (flow: LoginFlow): boolean => {
|
private isSupportedFlow = (flow: ClientLoginFlow): boolean => {
|
||||||
// technically the flow can have multiple steps, but no one does this
|
// technically the flow can have multiple steps, but no one does this
|
||||||
// for login and loginLogic doesn't support it so we can ignore it.
|
// for login and loginLogic doesn't support it so we can ignore it.
|
||||||
if (!this.stepRendererMap[flow.type]) {
|
if (!this.stepRendererMap[flow.type]) {
|
||||||
|
|
|
@ -16,14 +16,13 @@ limitations under the License.
|
||||||
|
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
import { AutoDiscovery, ClientConfig } from "matrix-js-sdk/src/autodiscovery";
|
import { AutoDiscovery, ClientConfig } from "matrix-js-sdk/src/autodiscovery";
|
||||||
import { IDelegatedAuthConfig, M_AUTHENTICATION } from "matrix-js-sdk/src/client";
|
import { M_AUTHENTICATION } from "matrix-js-sdk/src/client";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { IClientWellKnown } from "matrix-js-sdk/src/matrix";
|
import { IClientWellKnown } from "matrix-js-sdk/src/matrix";
|
||||||
import { ValidatedIssuerConfig } from "matrix-js-sdk/src/oidc/validate";
|
|
||||||
|
|
||||||
import { _t, UserFriendlyError } from "../languageHandler";
|
import { _t, UserFriendlyError } from "../languageHandler";
|
||||||
import SdkConfig from "../SdkConfig";
|
import SdkConfig from "../SdkConfig";
|
||||||
import { ValidatedServerConfig } from "./ValidatedServerConfig";
|
import { ValidatedDelegatedAuthConfig, ValidatedServerConfig } from "./ValidatedServerConfig";
|
||||||
|
|
||||||
const LIVELINESS_DISCOVERY_ERRORS: string[] = [
|
const LIVELINESS_DISCOVERY_ERRORS: string[] = [
|
||||||
AutoDiscovery.ERROR_INVALID_HOMESERVER,
|
AutoDiscovery.ERROR_INVALID_HOMESERVER,
|
||||||
|
@ -266,14 +265,14 @@ export default class AutoDiscoveryUtils {
|
||||||
if (discoveryResult[M_AUTHENTICATION.stable!]?.state === AutoDiscovery.SUCCESS) {
|
if (discoveryResult[M_AUTHENTICATION.stable!]?.state === AutoDiscovery.SUCCESS) {
|
||||||
const { authorizationEndpoint, registrationEndpoint, tokenEndpoint, account, issuer } = discoveryResult[
|
const { authorizationEndpoint, registrationEndpoint, tokenEndpoint, account, issuer } = discoveryResult[
|
||||||
M_AUTHENTICATION.stable!
|
M_AUTHENTICATION.stable!
|
||||||
] as IDelegatedAuthConfig & ValidatedIssuerConfig;
|
] as ValidatedDelegatedAuthConfig;
|
||||||
delegatedAuthentication = {
|
delegatedAuthentication = Object.freeze({
|
||||||
authorizationEndpoint,
|
authorizationEndpoint,
|
||||||
registrationEndpoint,
|
registrationEndpoint,
|
||||||
tokenEndpoint,
|
tokenEndpoint,
|
||||||
account,
|
account,
|
||||||
issuer,
|
issuer,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -17,6 +17,8 @@ limitations under the License.
|
||||||
import { IDelegatedAuthConfig } from "matrix-js-sdk/src/client";
|
import { IDelegatedAuthConfig } from "matrix-js-sdk/src/client";
|
||||||
import { ValidatedIssuerConfig } from "matrix-js-sdk/src/oidc/validate";
|
import { ValidatedIssuerConfig } from "matrix-js-sdk/src/oidc/validate";
|
||||||
|
|
||||||
|
export type ValidatedDelegatedAuthConfig = IDelegatedAuthConfig & ValidatedIssuerConfig;
|
||||||
|
|
||||||
export interface ValidatedServerConfig {
|
export interface ValidatedServerConfig {
|
||||||
hsUrl: string;
|
hsUrl: string;
|
||||||
hsName: string;
|
hsName: string;
|
||||||
|
@ -30,5 +32,11 @@ export interface ValidatedServerConfig {
|
||||||
|
|
||||||
warning: string | Error;
|
warning: string | Error;
|
||||||
|
|
||||||
delegatedAuthentication?: IDelegatedAuthConfig & ValidatedIssuerConfig;
|
/**
|
||||||
|
* Config related to delegated authentication
|
||||||
|
* Included when delegated auth is configured and valid, otherwise undefined
|
||||||
|
* From homeserver .well-known m.authentication, and issuer's .well-known/openid-configuration
|
||||||
|
* Used for OIDC native flow authentication
|
||||||
|
*/
|
||||||
|
delegatedAuthentication?: ValidatedDelegatedAuthConfig;
|
||||||
}
|
}
|
||||||
|
|
24
src/utils/oidc/error.ts
Normal file
24
src/utils/oidc/error.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OIDC error strings, intended for logging
|
||||||
|
*/
|
||||||
|
export enum OidcClientError {
|
||||||
|
DynamicRegistrationNotSupported = "Dynamic registration not supported",
|
||||||
|
DynamicRegistrationFailed = "Dynamic registration failed",
|
||||||
|
DynamicRegistrationInvalid = "Dynamic registration invalid response",
|
||||||
|
}
|
60
src/utils/oidc/registerClient.ts
Normal file
60
src/utils/oidc/registerClient.ts
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
|
import { ValidatedDelegatedAuthConfig } from "../ValidatedServerConfig";
|
||||||
|
import { OidcClientError } from "./error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the statically configured clientId for the issuer
|
||||||
|
* @param issuer delegated auth OIDC issuer
|
||||||
|
* @param staticOidcClients static client config from config.json
|
||||||
|
* @returns clientId if found, otherwise undefined
|
||||||
|
*/
|
||||||
|
const getStaticOidcClientId = (issuer: string, staticOidcClients?: Record<string, string>): string | undefined => {
|
||||||
|
// static_oidc_clients are configured with a trailing slash
|
||||||
|
const issuerWithTrailingSlash = issuer.endsWith("/") ? issuer : issuer + "/";
|
||||||
|
return staticOidcClients?.[issuerWithTrailingSlash];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the clientId for an OIDC OP
|
||||||
|
* Checks statically configured clientIds first
|
||||||
|
* @param delegatedAuthConfig Auth config from ValidatedServerConfig
|
||||||
|
* @param clientName Client name to register with the OP, eg 'Element'
|
||||||
|
* @param baseUrl URL of the home page of the Client, eg 'https://app.element.io/'
|
||||||
|
* @param staticOidcClients static client config from config.json
|
||||||
|
* @returns Promise<string> resolves with clientId
|
||||||
|
* @throws if no clientId is found
|
||||||
|
*/
|
||||||
|
export const getOidcClientId = async (
|
||||||
|
delegatedAuthConfig: ValidatedDelegatedAuthConfig,
|
||||||
|
// these are used in the following PR
|
||||||
|
_clientName: string,
|
||||||
|
_baseUrl: string,
|
||||||
|
staticOidcClients?: Record<string, string>,
|
||||||
|
): Promise<string> => {
|
||||||
|
const staticClientId = getStaticOidcClientId(delegatedAuthConfig.issuer, staticOidcClients);
|
||||||
|
if (staticClientId) {
|
||||||
|
logger.debug(`Using static clientId for issuer ${delegatedAuthConfig.issuer}`);
|
||||||
|
return staticClientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO attempt dynamic registration
|
||||||
|
logger.error("Dynamic registration not yet implemented.");
|
||||||
|
throw new Error(OidcClientError.DynamicRegistrationNotSupported);
|
||||||
|
};
|
|
@ -17,19 +17,29 @@ limitations under the License.
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { fireEvent, render, screen, waitForElementToBeRemoved } from "@testing-library/react";
|
import { fireEvent, render, screen, waitForElementToBeRemoved } from "@testing-library/react";
|
||||||
import { mocked, MockedObject } from "jest-mock";
|
import { mocked, MockedObject } from "jest-mock";
|
||||||
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
|
||||||
import fetchMock from "fetch-mock-jest";
|
import fetchMock from "fetch-mock-jest";
|
||||||
import { DELEGATED_OIDC_COMPATIBILITY, IdentityProviderBrand } from "matrix-js-sdk/src/@types/auth";
|
import { DELEGATED_OIDC_COMPATIBILITY, IdentityProviderBrand } from "matrix-js-sdk/src/@types/auth";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import SdkConfig from "../../../../src/SdkConfig";
|
import SdkConfig from "../../../../src/SdkConfig";
|
||||||
import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils";
|
import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils";
|
||||||
import Login from "../../../../src/components/structures/auth/Login";
|
import Login from "../../../../src/components/structures/auth/Login";
|
||||||
import BasePlatform from "../../../../src/BasePlatform";
|
import BasePlatform from "../../../../src/BasePlatform";
|
||||||
|
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||||
|
import { Features } from "../../../../src/settings/Settings";
|
||||||
|
import { ValidatedDelegatedAuthConfig } from "../../../../src/utils/ValidatedServerConfig";
|
||||||
|
import * as registerClientUtils from "../../../../src/utils/oidc/registerClient";
|
||||||
|
import { OidcClientError } from "../../../../src/utils/oidc/error";
|
||||||
|
|
||||||
jest.mock("matrix-js-sdk/src/matrix");
|
jest.mock("matrix-js-sdk/src/matrix");
|
||||||
|
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
|
|
||||||
|
const oidcStaticClientsConfig = {
|
||||||
|
"https://staticallyregisteredissuer.org/": "static-clientId-123",
|
||||||
|
};
|
||||||
|
|
||||||
describe("Login", function () {
|
describe("Login", function () {
|
||||||
let platform: MockedObject<BasePlatform>;
|
let platform: MockedObject<BasePlatform>;
|
||||||
|
|
||||||
|
@ -42,6 +52,7 @@ describe("Login", function () {
|
||||||
SdkConfig.put({
|
SdkConfig.put({
|
||||||
brand: "test-brand",
|
brand: "test-brand",
|
||||||
disable_custom_urls: true,
|
disable_custom_urls: true,
|
||||||
|
oidc_static_client_ids: oidcStaticClientsConfig,
|
||||||
});
|
});
|
||||||
mockClient.login.mockClear().mockResolvedValue({});
|
mockClient.login.mockClear().mockResolvedValue({});
|
||||||
mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] });
|
mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] });
|
||||||
|
@ -51,6 +62,7 @@ describe("Login", function () {
|
||||||
return mockClient;
|
return mockClient;
|
||||||
});
|
});
|
||||||
fetchMock.resetBehavior();
|
fetchMock.resetBehavior();
|
||||||
|
fetchMock.resetHistory();
|
||||||
fetchMock.get("https://matrix.org/_matrix/client/versions", {
|
fetchMock.get("https://matrix.org/_matrix/client/versions", {
|
||||||
unstable_features: {},
|
unstable_features: {},
|
||||||
versions: [],
|
versions: [],
|
||||||
|
@ -66,10 +78,14 @@ describe("Login", function () {
|
||||||
unmockPlatformPeg();
|
unmockPlatformPeg();
|
||||||
});
|
});
|
||||||
|
|
||||||
function getRawComponent(hsUrl = "https://matrix.org", isUrl = "https://vector.im") {
|
function getRawComponent(
|
||||||
|
hsUrl = "https://matrix.org",
|
||||||
|
isUrl = "https://vector.im",
|
||||||
|
delegatedAuthentication?: ValidatedDelegatedAuthConfig,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<Login
|
<Login
|
||||||
serverConfig={mkServerConfig(hsUrl, isUrl)}
|
serverConfig={mkServerConfig(hsUrl, isUrl, delegatedAuthentication)}
|
||||||
onLoggedIn={() => {}}
|
onLoggedIn={() => {}}
|
||||||
onRegisterClick={() => {}}
|
onRegisterClick={() => {}}
|
||||||
onServerConfigChange={() => {}}
|
onServerConfigChange={() => {}}
|
||||||
|
@ -77,8 +93,8 @@ describe("Login", function () {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getComponent(hsUrl?: string, isUrl?: string) {
|
function getComponent(hsUrl?: string, isUrl?: string, delegatedAuthentication?: ValidatedDelegatedAuthConfig) {
|
||||||
return render(getRawComponent(hsUrl, isUrl));
|
return render(getRawComponent(hsUrl, isUrl, delegatedAuthentication));
|
||||||
}
|
}
|
||||||
|
|
||||||
it("should show form with change server link", async () => {
|
it("should show form with change server link", async () => {
|
||||||
|
@ -190,6 +206,7 @@ describe("Login", function () {
|
||||||
versions: [],
|
versions: [],
|
||||||
});
|
});
|
||||||
rerender(getRawComponent("https://server2"));
|
rerender(getRawComponent("https://server2"));
|
||||||
|
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
|
||||||
|
|
||||||
fireEvent.click(container.querySelector(".mx_SSOButton")!);
|
fireEvent.click(container.querySelector(".mx_SSOButton")!);
|
||||||
expect(platform.startSingleSignOn.mock.calls[1][0].baseUrl).toBe("https://server2");
|
expect(platform.startSingleSignOn.mock.calls[1][0].baseUrl).toBe("https://server2");
|
||||||
|
@ -319,4 +336,117 @@ describe("Login", function () {
|
||||||
// error cleared
|
// error cleared
|
||||||
expect(screen.queryByText("Your test-brand is misconfigured")).not.toBeInTheDocument();
|
expect(screen.queryByText("Your test-brand is misconfigured")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("OIDC native flow", () => {
|
||||||
|
const hsUrl = "https://matrix.org";
|
||||||
|
const isUrl = "https://vector.im";
|
||||||
|
const issuer = "https://test.com/";
|
||||||
|
const delegatedAuth = {
|
||||||
|
issuer,
|
||||||
|
registrationEndpoint: issuer + "register",
|
||||||
|
tokenEndpoint: issuer + "token",
|
||||||
|
authorizationEndpoint: issuer + "authorization",
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(logger, "error");
|
||||||
|
jest.spyOn(SettingsStore, "getValue").mockImplementation(
|
||||||
|
(settingName) => settingName === Features.OidcNativeFlow,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.spyOn(logger, "error").mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not attempt registration when oidc native flow setting is disabled", async () => {
|
||||||
|
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
|
||||||
|
|
||||||
|
getComponent(hsUrl, isUrl, delegatedAuth);
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
|
||||||
|
|
||||||
|
// continued with normal setup
|
||||||
|
expect(mockClient.loginFlows).toHaveBeenCalled();
|
||||||
|
// normal password login rendered
|
||||||
|
expect(screen.getByLabelText("Username")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should attempt to register oidc client", async () => {
|
||||||
|
// dont mock, spy so we can check config values were correctly passed
|
||||||
|
jest.spyOn(registerClientUtils, "getOidcClientId");
|
||||||
|
getComponent(hsUrl, isUrl, delegatedAuth);
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
|
||||||
|
|
||||||
|
// called with values from config
|
||||||
|
expect(registerClientUtils.getOidcClientId).toHaveBeenCalledWith(
|
||||||
|
delegatedAuth,
|
||||||
|
"test-brand",
|
||||||
|
"http://localhost",
|
||||||
|
oidcStaticClientsConfig,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fallback to normal login when client does not have static clientId", async () => {
|
||||||
|
getComponent(hsUrl, isUrl, delegatedAuth);
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(new Error(OidcClientError.DynamicRegistrationNotSupported));
|
||||||
|
|
||||||
|
// continued with normal setup
|
||||||
|
expect(mockClient.loginFlows).toHaveBeenCalled();
|
||||||
|
// normal password login rendered
|
||||||
|
expect(screen.getByLabelText("Username")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// short term during active development, UI will be added in next PRs
|
||||||
|
it("should show error when oidc native flow is correctly configured but not supported by UI", async () => {
|
||||||
|
const delegatedAuthWithStaticClientId = {
|
||||||
|
...delegatedAuth,
|
||||||
|
issuer: "https://staticallyregisteredissuer.org/",
|
||||||
|
};
|
||||||
|
getComponent(hsUrl, isUrl, delegatedAuthWithStaticClientId);
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
|
||||||
|
|
||||||
|
// did not continue with matrix login
|
||||||
|
expect(mockClient.loginFlows).not.toHaveBeenCalled();
|
||||||
|
// no oidc native UI yet
|
||||||
|
expect(
|
||||||
|
screen.getByText("This homeserver doesn't offer any login flows which are supported by this client."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oidc-aware flows still work while the oidc-native feature flag is disabled
|
||||||
|
*/
|
||||||
|
it("should show oidc-aware flow for oidc-enabled homeserver when oidc native flow setting is disabled", async () => {
|
||||||
|
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
|
||||||
|
mockClient.loginFlows.mockResolvedValue({
|
||||||
|
flows: [
|
||||||
|
{
|
||||||
|
type: "m.login.sso",
|
||||||
|
[DELEGATED_OIDC_COMPATIBILITY.name]: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "m.login.password",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = getComponent(hsUrl, isUrl, delegatedAuth);
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
|
||||||
|
|
||||||
|
// continued with normal setup
|
||||||
|
expect(mockClient.loginFlows).toHaveBeenCalled();
|
||||||
|
// oidc-aware 'continue' button displayed
|
||||||
|
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
|
||||||
|
expect(ssoButtons.length).toBe(1);
|
||||||
|
expect(ssoButtons[0].textContent).toBe("Continue");
|
||||||
|
// no password form visible
|
||||||
|
expect(container.querySelector("form")).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -48,7 +48,7 @@ import { MapperOpts } from "matrix-js-sdk/src/event-mapper";
|
||||||
|
|
||||||
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
import { MatrixClientPeg as peg } from "../../src/MatrixClientPeg";
|
import { MatrixClientPeg as peg } from "../../src/MatrixClientPeg";
|
||||||
import { ValidatedServerConfig } from "../../src/utils/ValidatedServerConfig";
|
import { ValidatedDelegatedAuthConfig, ValidatedServerConfig } from "../../src/utils/ValidatedServerConfig";
|
||||||
import { EnhancedMap } from "../../src/utils/maps";
|
import { EnhancedMap } from "../../src/utils/maps";
|
||||||
import { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient";
|
import { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient";
|
||||||
import MatrixClientBackedSettingsHandler from "../../src/settings/handlers/MatrixClientBackedSettingsHandler";
|
import MatrixClientBackedSettingsHandler from "../../src/settings/handlers/MatrixClientBackedSettingsHandler";
|
||||||
|
@ -620,12 +620,17 @@ export function mkStubRoom(
|
||||||
} as unknown as Room;
|
} as unknown as Room;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mkServerConfig(hsUrl: string, isUrl: string): ValidatedServerConfig {
|
export function mkServerConfig(
|
||||||
|
hsUrl: string,
|
||||||
|
isUrl: string,
|
||||||
|
delegatedAuthentication?: ValidatedDelegatedAuthConfig,
|
||||||
|
): ValidatedServerConfig {
|
||||||
return {
|
return {
|
||||||
hsUrl,
|
hsUrl,
|
||||||
hsName: "TEST_ENVIRONMENT",
|
hsName: "TEST_ENVIRONMENT",
|
||||||
hsNameIsDifferent: false, // yes, we lie
|
hsNameIsDifferent: false, // yes, we lie
|
||||||
isUrl,
|
isUrl,
|
||||||
|
delegatedAuthentication,
|
||||||
} as ValidatedServerConfig;
|
} as ValidatedServerConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
86
test/utils/oidc/registerClient-test.ts
Normal file
86
test/utils/oidc/registerClient-test.ts
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
/*
|
||||||
|
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fetchMockJest from "fetch-mock-jest";
|
||||||
|
|
||||||
|
import { OidcClientError } from "../../../src/utils/oidc/error";
|
||||||
|
import { getOidcClientId } from "../../../src/utils/oidc/registerClient";
|
||||||
|
|
||||||
|
describe("getOidcClientId()", () => {
|
||||||
|
const issuer = "https://auth.com/";
|
||||||
|
const registrationEndpoint = "https://auth.com/register";
|
||||||
|
const clientName = "Element";
|
||||||
|
const baseUrl = "https://just.testing";
|
||||||
|
const dynamicClientId = "xyz789";
|
||||||
|
const staticOidcClients = {
|
||||||
|
[issuer]: "abc123",
|
||||||
|
};
|
||||||
|
const delegatedAuthConfig = {
|
||||||
|
issuer,
|
||||||
|
registrationEndpoint,
|
||||||
|
authorizationEndpoint: issuer + "auth",
|
||||||
|
tokenEndpoint: issuer + "token",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMockJest.mockClear();
|
||||||
|
fetchMockJest.resetBehavior();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return static clientId when configured", async () => {
|
||||||
|
expect(await getOidcClientId(delegatedAuthConfig, clientName, baseUrl, staticOidcClients)).toEqual(
|
||||||
|
staticOidcClients[issuer],
|
||||||
|
);
|
||||||
|
// didn't try to register
|
||||||
|
expect(fetchMockJest).toHaveFetchedTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when no static clientId is configured and no registration endpoint", async () => {
|
||||||
|
const authConfigWithoutRegistration = {
|
||||||
|
...delegatedAuthConfig,
|
||||||
|
issuer: "https://issuerWithoutStaticClientId.org/",
|
||||||
|
registrationEndpoint: undefined,
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
async () => await getOidcClientId(authConfigWithoutRegistration, clientName, baseUrl, staticOidcClients),
|
||||||
|
).rejects.toThrow(OidcClientError.DynamicRegistrationNotSupported);
|
||||||
|
// didn't try to register
|
||||||
|
expect(fetchMockJest).toHaveFetchedTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle when staticOidcClients object is falsy", async () => {
|
||||||
|
const authConfigWithoutRegistration = {
|
||||||
|
...delegatedAuthConfig,
|
||||||
|
registrationEndpoint: undefined,
|
||||||
|
};
|
||||||
|
expect(async () => await getOidcClientId(authConfigWithoutRegistration, clientName, baseUrl)).rejects.toThrow(
|
||||||
|
OidcClientError.DynamicRegistrationNotSupported,
|
||||||
|
);
|
||||||
|
// didn't try to register
|
||||||
|
expect(fetchMockJest).toHaveFetchedTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw while dynamic registration is not implemented", async () => {
|
||||||
|
fetchMockJest.post(registrationEndpoint, {
|
||||||
|
status: 200,
|
||||||
|
body: JSON.stringify({ client_id: dynamicClientId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(async () => await getOidcClientId(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
|
||||||
|
OidcClientError.DynamicRegistrationNotSupported,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Add table
Add a link
Reference in a new issue