OIDC: register (#11727)

* 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
This commit is contained in:
Kerry 2023-10-12 10:44:46 +13:00 committed by GitHub
parent a80cf58aa3
commit 5d169afb8a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 215 additions and 41 deletions

View file

@ -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
@ -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(
@ -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.
@ -99,7 +109,12 @@ 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 {
@ -107,6 +122,7 @@ export default class Login {
this.delegatedAuthentication,
SdkConfig.get().brand,
SdkConfig.get().oidc_static_clients,
isRegistration,
);
return [oidcFlow];
} catch (error) {
@ -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 = {

View file

@ -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";
@ -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")) {
@ -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);
@ -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,
});
}
@ -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
@ -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) {

View file

@ -35,11 +35,14 @@ 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,
@ -47,6 +50,7 @@ export const startOidcLogin = async (
homeserverUrl,
identityServerUrl,
nonce,
prompt,
});
window.location.href = authorizationUrl;

View file

@ -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");
};

View file

@ -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";
@ -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();
@ -85,7 +85,7 @@ describe("Login", function () {
function getRawComponent(
hsUrl = "https://matrix.org",
isUrl = "https://vector.im",
delegatedAuthentication?: ValidatedDelegatedAuthConfig,
delegatedAuthentication?: OidcClientConfig,
) {
return (
<Login
@ -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));
}
@ -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(
@ -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…"));
@ -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…"));
@ -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…"));

View file

@ -17,13 +17,21 @@ limitations under the License.
import React from "react";
import { fireEvent, render, screen, waitForElementToBeRemoved } from "@testing-library/react";
import { createClient, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { createClient, MatrixClient, MatrixError, OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { mocked, MockedObject } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import SdkConfig, { DEFAULTS } from "../../../../src/SdkConfig";
import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils";
import { getMockClientWithEventEmitter, mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils";
import Registration from "../../../../src/components/structures/auth/Registration";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { Features } from "../../../../src/settings/Settings";
import { startOidcLogin } from "../../../../src/utils/oidc/authorize";
jest.mock("../../../../src/utils/oidc/authorize", () => ({
startOidcLogin: jest.fn(),
}));
jest.mock("matrix-js-sdk/src/matrix", () => ({
...jest.requireActual("matrix-js-sdk/src/matrix"),
@ -32,18 +40,18 @@ jest.mock("matrix-js-sdk/src/matrix", () => ({
jest.useFakeTimers();
describe("Registration", function () {
const registerRequest = jest.fn();
const mockClient = mocked({
registerRequest,
loginFlows: jest.fn(),
getVersions: jest.fn().mockResolvedValue({ versions: ["v1.1"] }),
} as unknown as MatrixClient);
let mockClient!: MockedObject<MatrixClient>;
beforeEach(function () {
SdkConfig.put({
...DEFAULTS,
disable_custom_urls: true,
});
mockClient = getMockClientWithEventEmitter({
registerRequest: jest.fn(),
loginFlows: jest.fn(),
getVersions: jest.fn().mockResolvedValue({ versions: ["v1.1"] }),
});
mockClient.registerRequest.mockRejectedValueOnce(
new MatrixError(
{
@ -52,12 +60,13 @@ describe("Registration", function () {
401,
),
);
mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] });
mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.password" }] });
mocked(createClient).mockImplementation((opts) => {
mockClient.idBaseUrl = opts.idBaseUrl;
mockClient.baseUrl = opts.baseUrl;
return mockClient;
});
fetchMock.catch(404);
fetchMock.get("https://matrix.org/_matrix/client/versions", {
unstable_features: {},
versions: ["v1.1"],
@ -68,6 +77,7 @@ describe("Registration", function () {
});
afterEach(function () {
jest.restoreAllMocks();
fetchMock.restore();
SdkConfig.reset(); // we touch the config, so clean up
unmockPlatformPeg();
@ -80,12 +90,15 @@ describe("Registration", function () {
onServerConfigChange: jest.fn(),
};
function getRawComponent(hsUrl = "https://matrix.org", isUrl = "https://vector.im") {
return <Registration {...defaultProps} serverConfig={mkServerConfig(hsUrl, isUrl)} />;
const defaultHsUrl = "https://matrix.org";
const defaultIsUrl = "https://vector.im";
function getRawComponent(hsUrl = defaultHsUrl, isUrl = defaultIsUrl, authConfig?: OidcClientConfig) {
return <Registration {...defaultProps} serverConfig={mkServerConfig(hsUrl, isUrl, authConfig)} />;
}
function getComponent(hsUrl?: string, isUrl?: string) {
return render(getRawComponent(hsUrl, isUrl));
function getComponent(hsUrl?: string, isUrl?: string, authConfig?: OidcClientConfig) {
return render(getRawComponent(hsUrl, isUrl, authConfig));
}
it("should show server picker", async function () {
@ -121,7 +134,7 @@ describe("Registration", function () {
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(registerRequest.mock.instances[0].baseUrl).toBe("https://matrix.org");
expect(mockClient.baseUrl).toBe("https://matrix.org");
fetchMock.get("https://server2/_matrix/client/versions", {
unstable_features: {},
@ -131,6 +144,69 @@ describe("Registration", function () {
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(registerRequest.mock.instances[1].baseUrl).toBe("https://server2");
expect(mockClient.baseUrl).toBe("https://server2");
});
describe("when delegated authentication is configured and enabled", () => {
const authConfig = makeDelegatedAuthConfig();
const clientId = "test-client-id";
// @ts-ignore
authConfig.metadata["prompt_values_supported"] = ["create"];
beforeEach(() => {
// mock a statically registered client to avoid dynamic registration
SdkConfig.put({
oidc_static_clients: {
[authConfig.issuer]: {
client_id: clientId,
},
},
});
});
describe("when oidc native flow is not enabled in settings", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("should display user/pass registration form", async () => {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
expect(mockClient.loginFlows).toHaveBeenCalled();
expect(mockClient.registerRequest).toHaveBeenCalled();
});
});
describe("when oidc native flow is enabled in settings", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((key) => key === Features.OidcNativeFlow);
});
it("should display oidc-native continue button", async () => {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// no form
expect(container.querySelector("form")).toBeFalsy();
expect(screen.getByText("Continue")).toBeTruthy();
});
it("should start OIDC login flow as registration on button click", async () => {
getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(screen.getByText("Continue"));
expect(startOidcLogin).toHaveBeenCalledWith(
authConfig,
clientId,
defaultHsUrl,
defaultIsUrl,
// isRegistration
true,
);
});
});
});
});

View file

@ -37,6 +37,7 @@ import {
RelationType,
JoinRule,
IEventDecryptionResult,
OidcClientConfig,
} from "matrix-js-sdk/src/matrix";
import { normalize } from "matrix-js-sdk/src/utils";
import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
@ -47,7 +48,7 @@ import { MapperOpts } from "matrix-js-sdk/src/event-mapper";
import type { GroupCall } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg as peg } from "../../src/MatrixClientPeg";
import { ValidatedDelegatedAuthConfig, ValidatedServerConfig } from "../../src/utils/ValidatedServerConfig";
import { ValidatedServerConfig } from "../../src/utils/ValidatedServerConfig";
import { EnhancedMap } from "../../src/utils/maps";
import { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient";
import MatrixClientBackedSettingsHandler from "../../src/settings/handlers/MatrixClientBackedSettingsHandler";
@ -653,7 +654,7 @@ export function mkStubRoom(
export function mkServerConfig(
hsUrl: string,
isUrl: string,
delegatedAuthentication?: ValidatedDelegatedAuthConfig,
delegatedAuthentication?: OidcClientConfig,
): ValidatedServerConfig {
return {
hsUrl,