From 328db8fdfd215411d853d38139fff9fc94cfe624 Mon Sep 17 00:00:00 2001 From: Kerry Date: Thu, 22 Jun 2023 22:15:44 +1200 Subject: [PATCH] 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> --- src/IConfigOptions.ts | 8 + src/Login.ts | 78 +++++++++- src/components/structures/auth/Login.tsx | 75 ++++++---- src/utils/AutoDiscoveryUtils.tsx | 11 +- src/utils/ValidatedServerConfig.ts | 10 +- src/utils/oidc/error.ts | 24 +++ src/utils/oidc/registerClient.ts | 60 ++++++++ .../components/structures/auth/Login-test.tsx | 140 +++++++++++++++++- test/test-utils/test-utils.ts | 9 +- test/utils/oidc/registerClient-test.ts | 86 +++++++++++ 10 files changed, 456 insertions(+), 45 deletions(-) create mode 100644 src/utils/oidc/error.ts create mode 100644 src/utils/oidc/registerClient.ts create mode 100644 test/utils/oidc/registerClient-test.ts diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index 9efb490cc1..286942476b 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -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; } export interface ISsoRedirectOptions { diff --git a/src/Login.ts b/src/Login.ts index 569363eeb0..27d3690ee9 100644 --- a/src/Login.ts +++ b/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 = []; + private flows: Array = []; 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> { + public async getFlows(): Promise> { + // 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 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 => { + 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 diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 3299cae3fc..ea816d35d7 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -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 { private unmounted = false; + private oidcNativeFlowEnabled = false; private loginLogic!: Login; private readonly stepRendererMap: Record ReactNode>; @@ -117,6 +119,9 @@ export default class LoginComponent extends React.PureComponent 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 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 } }; - private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig): Promise { - 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): Promise { // 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 } catch (e) { this.setState({ busy: false, - ...AutoDiscoveryUtils.authComponentStateForError(e), + ...AutoDiscoveryUtils.authComponentStateForError(e as Error), }); } + } + + private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig): Promise { + 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 }); } - 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]) { diff --git a/src/utils/AutoDiscoveryUtils.tsx b/src/utils/AutoDiscoveryUtils.tsx index 5f1a044847..96fe807a12 100644 --- a/src/utils/AutoDiscoveryUtils.tsx +++ b/src/utils/AutoDiscoveryUtils.tsx @@ -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 { diff --git a/src/utils/ValidatedServerConfig.ts b/src/utils/ValidatedServerConfig.ts index 4b58b1ef90..cb3edf3a9c 100644 --- a/src/utils/ValidatedServerConfig.ts +++ b/src/utils/ValidatedServerConfig.ts @@ -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; } diff --git a/src/utils/oidc/error.ts b/src/utils/oidc/error.ts new file mode 100644 index 0000000000..0b4633ab01 --- /dev/null +++ b/src/utils/oidc/error.ts @@ -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", +} diff --git a/src/utils/oidc/registerClient.ts b/src/utils/oidc/registerClient.ts new file mode 100644 index 0000000000..83dc77bd1d --- /dev/null +++ b/src/utils/oidc/registerClient.ts @@ -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 | 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 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, +): Promise => { + 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); +}; diff --git a/test/components/structures/auth/Login-test.tsx b/test/components/structures/auth/Login-test.tsx index 8eb25d4909..19faecaf97 100644 --- a/test/components/structures/auth/Login-test.tsx +++ b/test/components/structures/auth/Login-test.tsx @@ -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; @@ -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 ( {}} 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(); + }); + }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 9c08547538..3451f17a59 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -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; } diff --git a/test/utils/oidc/registerClient-test.ts b/test/utils/oidc/registerClient-test.ts new file mode 100644 index 0000000000..06f67671d0 --- /dev/null +++ b/test/utils/oidc/registerClient-test.ts @@ -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, + ); + }); +});