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

Commit

Permalink
OIDC: register (#11727)
Browse files Browse the repository at this point in the history
* update uses of ValidatedDelegatedAuthConfig to broader OidcClientConfig type

* add OIDC register flow to registration page

* pass prompt param to auth url creation

* update type

* lint

* test registration oidc button

* fix: reference state inside setState

* comment
  • Loading branch information
Kerry authored Oct 11, 2023
1 parent a80cf58 commit 5d169af
Show file tree
Hide file tree
Showing 7 changed files with 215 additions and 41 deletions.
32 changes: 27 additions & 5 deletions src/Login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ import {
DELEGATED_OIDC_COMPATIBILITY,
ILoginFlow,
LoginRequest,
OidcClientConfig,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";

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";
import { isUserRegistrationSupported } from "./utils/oidc/isUserRegistrationSupported";

/**
* Login flows supported by this client
Expand All @@ -47,13 +48,13 @@ interface ILoginOptions {
* 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;
delegatedAuthentication?: OidcClientConfig;
}

export default class Login {
private flows: Array<ClientLoginFlow> = [];
private readonly defaultDeviceDisplayName?: string;
private readonly delegatedAuthentication?: ValidatedDelegatedAuthConfig;
private delegatedAuthentication?: OidcClientConfig;
private tempClient: MatrixClient | null = null; // memoize

public constructor(
Expand Down Expand Up @@ -84,6 +85,15 @@ export default class Login {
this.isUrl = isUrl;
}

/**
* Set delegated authentication config, clears tempClient.
* @param delegatedAuthentication delegated auth config, from ValidatedServerConfig
*/
public setDelegatedAuthentication(delegatedAuthentication?: OidcClientConfig): void {
this.tempClient = null; // clear memoization
this.delegatedAuthentication = delegatedAuthentication;
}

/**
* Get a temporary MatrixClient, which can be used for login or register
* requests.
Expand All @@ -99,14 +109,20 @@ export default class Login {
return this.tempClient;
}

public async getFlows(): Promise<Array<ClientLoginFlow>> {
/**
* Get supported login flows
* @param isRegistration OPTIONAL used to verify registration is supported in delegated authentication config
* @returns Promise that resolves to supported login flows
*/
public async getFlows(isRegistration?: boolean): 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_clients,
isRegistration,
);
return [oidcFlow];
} catch (error) {
Expand Down Expand Up @@ -209,14 +225,20 @@ export interface OidcNativeFlow extends ILoginFlow {
* @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
* @param isRegistration true when we are attempting registration
* @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,
delegatedAuthConfig: OidcClientConfig,
brand: string,
oidcStaticClients?: IConfigOptions["oidc_static_clients"],
isRegistration?: boolean,
): Promise<OidcNativeFlow> => {
// if registration is not supported, bail before attempting to get the clientId
if (isRegistration && !isUserRegistrationSupported(delegatedAuthConfig)) {
throw new Error("Registration is not supported by OP");
}
const clientId = await getOidcClientId(delegatedAuthConfig, brand, window.location.origin, oidcStaticClients);

const flow = {
Expand Down
56 changes: 51 additions & 5 deletions src/components/structures/auth/Registration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import * as Lifecycle from "../../../Lifecycle";
import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg";
import AuthPage from "../../views/auth/AuthPage";
import Login from "../../../Login";
import Login, { OidcNativeFlow } from "../../../Login";
import dis from "../../../dispatcher/dispatcher";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from "../../views/elements/ServerPicker";
Expand All @@ -52,6 +52,8 @@ import { AuthHeaderDisplay } from "./header/AuthHeaderDisplay";
import { AuthHeaderProvider } from "./header/AuthHeaderProvider";
import SettingsStore from "../../../settings/SettingsStore";
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import { Features } from "../../../settings/Settings";
import { startOidcLogin } from "../../../utils/oidc/authorize";

const debuglog = (...args: any[]): void => {
if (SettingsStore.getValue("debug_registration")) {
Expand Down Expand Up @@ -123,12 +125,17 @@ interface IState {
// the SSO flow definition, this is fetched from /login as that's the only
// place it is exposed.
ssoFlow?: SSOFlow;
// the OIDC native login flow, when supported and enabled
// if present, must be used for registration
oidcNativeFlow?: OidcNativeFlow;
}

export default class Registration extends React.Component<IProps, IState> {
private readonly loginLogic: Login;
// `replaceClient` tracks latest serverConfig to spot when it changes under the async method which fetches flows
private latestServerConfig?: ValidatedServerConfig;
// cache value from settings store
private oidcNativeFlowEnabled = false;

public constructor(props: IProps) {
super(props);
Expand All @@ -147,9 +154,14 @@ export default class Registration extends React.Component<IProps, IState> {
serverDeadError: "",
};

const { hsUrl, isUrl } = this.props.serverConfig;
// only set on a config level, so we don't need to watch
this.oidcNativeFlowEnabled = SettingsStore.getValue(Features.OidcNativeFlow);

const { hsUrl, isUrl, delegatedAuthentication } = this.props.serverConfig;
this.loginLogic = new Login(hsUrl, isUrl, null, {
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
// if native OIDC is enabled in the client pass the server's delegated auth settings
delegatedAuthentication: this.oidcNativeFlowEnabled ? delegatedAuthentication : undefined,
});
}

Expand Down Expand Up @@ -219,22 +231,38 @@ export default class Registration extends React.Component<IProps, IState> {

this.loginLogic.setHomeserverUrl(hsUrl);
this.loginLogic.setIdentityServerUrl(isUrl);
// if native OIDC is enabled in the client pass the server's delegated auth settings
const delegatedAuthentication = this.oidcNativeFlowEnabled ? serverConfig.delegatedAuthentication : undefined;

this.loginLogic.setDelegatedAuthentication(delegatedAuthentication);

let ssoFlow: SSOFlow | undefined;
let oidcNativeFlow: OidcNativeFlow | undefined;
try {
const loginFlows = await this.loginLogic.getFlows();
const loginFlows = await this.loginLogic.getFlows(true);
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
ssoFlow = loginFlows.find((f) => f.type === "m.login.sso" || f.type === "m.login.cas") as SSOFlow;
oidcNativeFlow = loginFlows.find((f) => f.type === "oidcNativeFlow") as OidcNativeFlow;
} catch (e) {
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
logger.error("Failed to get login flows to check for SSO support", e);
}

this.setState({
this.setState(({ flows }) => ({
matrixClient: cli,
ssoFlow,
oidcNativeFlow,
// if we are using oidc native we won't continue with flow discovery on HS
// so set an empty array to indicate flows are no longer loading
flows: oidcNativeFlow ? [] : flows,
busy: false,
});
}));

// don't need to check with homeserver for login flows
// since we are going to use OIDC native flow
if (oidcNativeFlow) {
return;
}

try {
// We do the first registration request ourselves to discover whether we need to
Expand Down Expand Up @@ -513,6 +541,24 @@ export default class Registration extends React.Component<IProps, IState> {
<Spinner />
</div>
);
} else if (this.state.matrixClient && this.state.oidcNativeFlow) {
return (
<AccessibleButton
className="mx_Login_fullWidthButton"
kind="primary"
onClick={async () => {
await startOidcLogin(
this.props.serverConfig.delegatedAuthentication!,
this.state.oidcNativeFlow!.clientId,
this.props.serverConfig.hsUrl,
this.props.serverConfig.isUrl,
true /* isRegistration */,
);
}}
>
{_t("action|continue")}
</AccessibleButton>
);
} else if (this.state.matrixClient && this.state.flows.length) {
let ssoSection: JSX.Element | undefined;
if (this.state.ssoFlow) {
Expand Down
4 changes: 4 additions & 0 deletions src/utils/oidc/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,22 @@ export const startOidcLogin = async (
clientId: string,
homeserverUrl: string,
identityServerUrl?: string,
isRegistration?: boolean,
): Promise<void> => {
const redirectUri = window.location.origin;

const nonce = randomString(10);

const prompt = isRegistration ? "create" : undefined;

const authorizationUrl = await generateOidcAuthorizationUrl({
metadata: delegatedAuthConfig.metadata,
redirectUri,
clientId,
homeserverUrl,
identityServerUrl,
nonce,
prompt,
});

window.location.href = authorizationUrl;
Expand Down
30 changes: 30 additions & 0 deletions src/utils/oidc/isUserRegistrationSupported.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
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 { OidcClientConfig } from "matrix-js-sdk/src/matrix";

/**
* Check the create prompt is supported by the OP, if so, we can do a registration flow
* https://openid.net/specs/openid-connect-prompt-create-1_0.html
* @param delegatedAuthConfig config as returned from discovery
* @returns whether user registration is supported
*/
export const isUserRegistrationSupported = (delegatedAuthConfig: OidcClientConfig): boolean => {
// The OidcMetadata type from oidc-client-ts does not include `prompt_values_supported`
// even though it is part of the OIDC spec, so cheat TS here to access it
const supportedPrompts = (delegatedAuthConfig.metadata as Record<string, unknown>)["prompt_values_supported"];
return Array.isArray(supportedPrompts) && supportedPrompts?.includes("create");
};
21 changes: 8 additions & 13 deletions test/components/structures/auth/Login-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import React from "react";
import { fireEvent, render, screen, waitForElementToBeRemoved } from "@testing-library/react";
import { mocked, MockedObject } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import { DELEGATED_OIDC_COMPATIBILITY, IdentityProviderBrand } from "matrix-js-sdk/src/matrix";
import { DELEGATED_OIDC_COMPATIBILITY, IdentityProviderBrand, OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import * as Matrix from "matrix-js-sdk/src/matrix";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
Expand All @@ -29,8 +29,8 @@ 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 { makeDelegatedAuthConfig } from "../../../test-utils/oidc";

jest.useRealTimers();

Expand Down Expand Up @@ -85,7 +85,7 @@ describe("Login", function () {
function getRawComponent(
hsUrl = "https://matrix.org",
isUrl = "https://vector.im",
delegatedAuthentication?: ValidatedDelegatedAuthConfig,
delegatedAuthentication?: OidcClientConfig,
) {
return (
<Login
Expand All @@ -97,7 +97,7 @@ describe("Login", function () {
);
}

function getComponent(hsUrl?: string, isUrl?: string, delegatedAuthentication?: ValidatedDelegatedAuthConfig) {
function getComponent(hsUrl?: string, isUrl?: string, delegatedAuthentication?: OidcClientConfig) {
return render(getRawComponent(hsUrl, isUrl, delegatedAuthentication));
}

Expand Down Expand Up @@ -377,12 +377,7 @@ describe("Login", function () {
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",
};
const delegatedAuth = makeDelegatedAuthConfig(issuer);
beforeEach(() => {
jest.spyOn(logger, "error");
jest.spyOn(SettingsStore, "getValue").mockImplementation(
Expand Down Expand Up @@ -412,7 +407,7 @@ describe("Login", function () {
it("should attempt to register oidc client", async () => {
// dont mock, spy so we can check config values were correctly passed
jest.spyOn(registerClientUtils, "getOidcClientId");
fetchMock.post(delegatedAuth.registrationEndpoint, { status: 500 });
fetchMock.post(delegatedAuth.registrationEndpoint!, { status: 500 });
getComponent(hsUrl, isUrl, delegatedAuth);

await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
Expand All @@ -429,7 +424,7 @@ describe("Login", function () {
});

it("should fallback to normal login when client registration fails", async () => {
fetchMock.post(delegatedAuth.registrationEndpoint, { status: 500 });
fetchMock.post(delegatedAuth.registrationEndpoint!, { status: 500 });
getComponent(hsUrl, isUrl, delegatedAuth);

await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
Expand All @@ -446,7 +441,7 @@ describe("Login", function () {

// short term during active development, UI will be added in next PRs
it("should show continue button when oidc native flow is correctly configured", async () => {
fetchMock.post(delegatedAuth.registrationEndpoint, { client_id: "abc123" });
fetchMock.post(delegatedAuth.registrationEndpoint!, { client_id: "abc123" });
getComponent(hsUrl, isUrl, delegatedAuth);

await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
Expand Down
Loading

0 comments on commit 5d169af

Please sign in to comment.