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>pull/28788/head^2
							parent
							
								
									35f8c525aa
								
							
						
					
					
						commit
						328db8fdfd
					
				|  | @ -194,6 +194,14 @@ export interface IConfigOptions { | |||
|         existing_issues_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 { | ||||
|  |  | |||
							
								
								
									
										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 { MatrixClient } from "matrix-js-sdk/src/client"; | ||||
| 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 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 { | ||||
|     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 { | ||||
|     private flows: Array<LoginFlow> = []; | ||||
|     private flows: Array<ClientLoginFlow> = []; | ||||
|     private readonly defaultDeviceDisplayName?: string; | ||||
|     private readonly delegatedAuthentication?: ValidatedDelegatedAuthConfig; | ||||
|     private tempClient: MatrixClient | null = null; // memoize
 | ||||
| 
 | ||||
|     public constructor( | ||||
|  | @ -40,6 +59,7 @@ export default class Login { | |||
|         opts: ILoginOptions, | ||||
|     ) { | ||||
|         this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName; | ||||
|         this.delegatedAuthentication = opts.delegatedAuthentication; | ||||
|     } | ||||
| 
 | ||||
|     public getHomeserverUrl(): string { | ||||
|  | @ -75,7 +95,22 @@ export default class Login { | |||
|         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 { 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
 | ||||
|  | @ -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 | ||||
|  * as a MatrixClientCreds | ||||
|  |  | |||
|  | @ -17,10 +17,10 @@ limitations under the License. | |||
| import React, { ReactNode } from "react"; | ||||
| import classNames from "classnames"; | ||||
| 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 Login from "../../../Login"; | ||||
| import Login, { ClientLoginFlow } from "../../../Login"; | ||||
| import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils"; | ||||
| import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; | ||||
| import AuthPage from "../../views/auth/AuthPage"; | ||||
|  | @ -38,6 +38,7 @@ import AuthHeader from "../../views/auth/AuthHeader"; | |||
| import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; | ||||
| import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; | ||||
| import { filterBoolean } from "../../../utils/arrays"; | ||||
| import { Features } from "../../../settings/Settings"; | ||||
| 
 | ||||
| // 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.
 | ||||
|  | @ -84,7 +85,7 @@ interface IState { | |||
|     // can we attempt to log in or are there validation errors?
 | ||||
|     canTryLogin: boolean; | ||||
| 
 | ||||
|     flows?: LoginFlow[]; | ||||
|     flows?: ClientLoginFlow[]; | ||||
| 
 | ||||
|     // used for preserving form values when changing homeserver
 | ||||
|     username: string; | ||||
|  | @ -110,6 +111,7 @@ type OnPasswordLogin = { | |||
|  */ | ||||
| export default class LoginComponent extends React.PureComponent<IProps, IState> { | ||||
|     private unmounted = false; | ||||
|     private oidcNativeFlowEnabled = false; | ||||
|     private loginLogic!: Login; | ||||
| 
 | ||||
|     private readonly stepRendererMap: Record<string, () => ReactNode>; | ||||
|  | @ -117,6 +119,9 @@ export default class LoginComponent extends React.PureComponent<IProps, IState> | |||
|     public constructor(props: IProps) { | ||||
|         super(props); | ||||
| 
 | ||||
|         // only set on a config level, so we don't need to watch
 | ||||
|         this.oidcNativeFlowEnabled = SettingsStore.getValue(Features.OidcNativeFlow); | ||||
| 
 | ||||
|         this.state = { | ||||
|             busy: false, | ||||
|             errorText: null, | ||||
|  | @ -156,7 +161,10 @@ export default class LoginComponent extends React.PureComponent<IProps, IState> | |||
|     public componentDidUpdate(prevProps: IProps): void { | ||||
|         if ( | ||||
|             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
 | ||||
|             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> { | ||||
|         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; | ||||
| 
 | ||||
|         const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, { | ||||
|             defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, | ||||
|         }); | ||||
|         this.loginLogic = loginLogic; | ||||
| 
 | ||||
|         this.setState({ | ||||
|             busy: true, | ||||
|             loginIncorrect: false, | ||||
|         }); | ||||
| 
 | ||||
|     private async checkServerLiveliness({ | ||||
|         hsUrl, | ||||
|         isUrl, | ||||
|     }: Pick<ValidatedServerConfig, "hsUrl" | "isUrl">): Promise<void> { | ||||
|         // Do a quick liveliness check on the URLs
 | ||||
|         try { | ||||
|             const { warning } = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); | ||||
|  | @ -361,9 +351,38 @@ export default class LoginComponent extends React.PureComponent<IProps, IState> | |||
|         } catch (e) { | ||||
|             this.setState({ | ||||
|                 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 | ||||
|             .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
 | ||||
|         // for login and loginLogic doesn't support it so we can ignore it.
 | ||||
|         if (!this.stepRendererMap[flow.type]) { | ||||
|  |  | |||
|  | @ -16,14 +16,13 @@ limitations under the License. | |||
| 
 | ||||
| import React, { ReactNode } from "react"; | ||||
| 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 { IClientWellKnown } from "matrix-js-sdk/src/matrix"; | ||||
| import { ValidatedIssuerConfig } from "matrix-js-sdk/src/oidc/validate"; | ||||
| 
 | ||||
| import { _t, UserFriendlyError } from "../languageHandler"; | ||||
| import SdkConfig from "../SdkConfig"; | ||||
| import { ValidatedServerConfig } from "./ValidatedServerConfig"; | ||||
| import { ValidatedDelegatedAuthConfig, ValidatedServerConfig } from "./ValidatedServerConfig"; | ||||
| 
 | ||||
| const LIVELINESS_DISCOVERY_ERRORS: string[] = [ | ||||
|     AutoDiscovery.ERROR_INVALID_HOMESERVER, | ||||
|  | @ -266,14 +265,14 @@ export default class AutoDiscoveryUtils { | |||
|         if (discoveryResult[M_AUTHENTICATION.stable!]?.state === AutoDiscovery.SUCCESS) { | ||||
|             const { authorizationEndpoint, registrationEndpoint, tokenEndpoint, account, issuer } = discoveryResult[ | ||||
|                 M_AUTHENTICATION.stable! | ||||
|             ] as IDelegatedAuthConfig & ValidatedIssuerConfig; | ||||
|             delegatedAuthentication = { | ||||
|             ] as ValidatedDelegatedAuthConfig; | ||||
|             delegatedAuthentication = Object.freeze({ | ||||
|                 authorizationEndpoint, | ||||
|                 registrationEndpoint, | ||||
|                 tokenEndpoint, | ||||
|                 account, | ||||
|                 issuer, | ||||
|             }; | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         return { | ||||
|  |  | |||
|  | @ -17,6 +17,8 @@ limitations under the License. | |||
| import { IDelegatedAuthConfig } from "matrix-js-sdk/src/client"; | ||||
| import { ValidatedIssuerConfig } from "matrix-js-sdk/src/oidc/validate"; | ||||
| 
 | ||||
| export type ValidatedDelegatedAuthConfig = IDelegatedAuthConfig & ValidatedIssuerConfig; | ||||
| 
 | ||||
| export interface ValidatedServerConfig { | ||||
|     hsUrl: string; | ||||
|     hsName: string; | ||||
|  | @ -30,5 +32,11 @@ export interface ValidatedServerConfig { | |||
| 
 | ||||
|     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; | ||||
| } | ||||
|  |  | |||
|  | @ -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", | ||||
| } | ||||
|  | @ -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 { fireEvent, render, screen, waitForElementToBeRemoved } from "@testing-library/react"; | ||||
| import { mocked, MockedObject } from "jest-mock"; | ||||
| import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix"; | ||||
| import fetchMock from "fetch-mock-jest"; | ||||
| 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 { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils"; | ||||
| import Login from "../../../../src/components/structures/auth/Login"; | ||||
| 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.useRealTimers(); | ||||
| 
 | ||||
| const oidcStaticClientsConfig = { | ||||
|     "https://staticallyregisteredissuer.org/": "static-clientId-123", | ||||
| }; | ||||
| 
 | ||||
| describe("Login", function () { | ||||
|     let platform: MockedObject<BasePlatform>; | ||||
| 
 | ||||
|  | @ -42,6 +52,7 @@ describe("Login", function () { | |||
|         SdkConfig.put({ | ||||
|             brand: "test-brand", | ||||
|             disable_custom_urls: true, | ||||
|             oidc_static_client_ids: oidcStaticClientsConfig, | ||||
|         }); | ||||
|         mockClient.login.mockClear().mockResolvedValue({}); | ||||
|         mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] }); | ||||
|  | @ -51,6 +62,7 @@ describe("Login", function () { | |||
|             return mockClient; | ||||
|         }); | ||||
|         fetchMock.resetBehavior(); | ||||
|         fetchMock.resetHistory(); | ||||
|         fetchMock.get("https://matrix.org/_matrix/client/versions", { | ||||
|             unstable_features: {}, | ||||
|             versions: [], | ||||
|  | @ -66,10 +78,14 @@ describe("Login", function () { | |||
|         unmockPlatformPeg(); | ||||
|     }); | ||||
| 
 | ||||
|     function getRawComponent(hsUrl = "https://matrix.org", isUrl = "https://vector.im") { | ||||
|     function getRawComponent( | ||||
|         hsUrl = "https://matrix.org", | ||||
|         isUrl = "https://vector.im", | ||||
|         delegatedAuthentication?: ValidatedDelegatedAuthConfig, | ||||
|     ) { | ||||
|         return ( | ||||
|             <Login | ||||
|                 serverConfig={mkServerConfig(hsUrl, isUrl)} | ||||
|                 serverConfig={mkServerConfig(hsUrl, isUrl, delegatedAuthentication)} | ||||
|                 onLoggedIn={() => {}} | ||||
|                 onRegisterClick={() => {}} | ||||
|                 onServerConfigChange={() => {}} | ||||
|  | @ -77,8 +93,8 @@ describe("Login", function () { | |||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     function getComponent(hsUrl?: string, isUrl?: string) { | ||||
|         return render(getRawComponent(hsUrl, isUrl)); | ||||
|     function getComponent(hsUrl?: string, isUrl?: string, delegatedAuthentication?: ValidatedDelegatedAuthConfig) { | ||||
|         return render(getRawComponent(hsUrl, isUrl, delegatedAuthentication)); | ||||
|     } | ||||
| 
 | ||||
|     it("should show form with change server link", async () => { | ||||
|  | @ -190,6 +206,7 @@ describe("Login", function () { | |||
|             versions: [], | ||||
|         }); | ||||
|         rerender(getRawComponent("https://server2")); | ||||
|         await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…")); | ||||
| 
 | ||||
|         fireEvent.click(container.querySelector(".mx_SSOButton")!); | ||||
|         expect(platform.startSingleSignOn.mock.calls[1][0].baseUrl).toBe("https://server2"); | ||||
|  | @ -319,4 +336,117 @@ describe("Login", function () { | |||
|         // error cleared
 | ||||
|         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 { 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 { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient"; | ||||
| import MatrixClientBackedSettingsHandler from "../../src/settings/handlers/MatrixClientBackedSettingsHandler"; | ||||
|  | @ -620,12 +620,17 @@ export function mkStubRoom( | |||
|     } as unknown as Room; | ||||
| } | ||||
| 
 | ||||
| export function mkServerConfig(hsUrl: string, isUrl: string): ValidatedServerConfig { | ||||
| export function mkServerConfig( | ||||
|     hsUrl: string, | ||||
|     isUrl: string, | ||||
|     delegatedAuthentication?: ValidatedDelegatedAuthConfig, | ||||
| ): ValidatedServerConfig { | ||||
|     return { | ||||
|         hsUrl, | ||||
|         hsName: "TEST_ENVIRONMENT", | ||||
|         hsNameIsDifferent: false, // yes, we lie
 | ||||
|         isUrl, | ||||
|         delegatedAuthentication, | ||||
|     } as ValidatedServerConfig; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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…
	
		Reference in New Issue
	
	 Kerry
						Kerry