diff --git a/spec/unit/autodiscovery.spec.ts b/spec/unit/autodiscovery.spec.ts index 2a8f080ed20..e2e2c30824f 100644 --- a/spec/unit/autodiscovery.spec.ts +++ b/spec/unit/autodiscovery.spec.ts @@ -18,7 +18,7 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; import { AutoDiscovery } from "../../src/autodiscovery"; -import { OidcDiscoveryError } from "../../src/oidc/validate"; +import { OidcError } from "../../src/oidc/error"; describe("AutoDiscovery", function () { const getHttpBackend = (): MockHttpBackend => { @@ -400,7 +400,7 @@ describe("AutoDiscovery", function () { }, "m.authentication": { state: "IGNORE", - error: OidcDiscoveryError.NotSupported, + error: OidcError.NotSupported, }, }; @@ -441,7 +441,7 @@ describe("AutoDiscovery", function () { }, "m.authentication": { state: "IGNORE", - error: OidcDiscoveryError.NotSupported, + error: OidcError.NotSupported, }, }; @@ -485,7 +485,7 @@ describe("AutoDiscovery", function () { }, "m.authentication": { state: "FAIL_ERROR", - error: OidcDiscoveryError.Misconfigured, + error: OidcError.Misconfigured, }, }; @@ -719,7 +719,7 @@ describe("AutoDiscovery", function () { }, "m.authentication": { state: "IGNORE", - error: OidcDiscoveryError.NotSupported, + error: OidcError.NotSupported, }, }; @@ -775,7 +775,7 @@ describe("AutoDiscovery", function () { }, "m.authentication": { state: "IGNORE", - error: OidcDiscoveryError.NotSupported, + error: OidcError.NotSupported, }, }; diff --git a/spec/unit/oidc/register.spec.ts b/spec/unit/oidc/register.spec.ts new file mode 100644 index 00000000000..e9ad60463a7 --- /dev/null +++ b/spec/unit/oidc/register.spec.ts @@ -0,0 +1,84 @@ +/* +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 { OidcError } from "../../../src/oidc/error"; +import { registerOidcClient } from "../../../src/oidc/register"; + +describe("registerOidcClient()", () => { + const issuer = "https://auth.com/"; + const registrationEndpoint = "https://auth.com/register"; + const clientName = "Element"; + const baseUrl = "https://just.testing"; + const dynamicClientId = "xyz789"; + + const delegatedAuthConfig = { + issuer, + registrationEndpoint, + authorizationEndpoint: issuer + "auth", + tokenEndpoint: issuer + "token", + }; + beforeEach(() => { + fetchMockJest.mockClear(); + fetchMockJest.resetBehavior(); + }); + + it("should make correct request to register client", async () => { + fetchMockJest.post(registrationEndpoint, { + status: 200, + body: JSON.stringify({ client_id: dynamicClientId }), + }); + expect(await registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).toEqual(dynamicClientId); + expect(fetchMockJest).toHaveBeenCalledWith(registrationEndpoint, { + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ + client_name: clientName, + client_uri: baseUrl, + response_types: ["code"], + grant_types: ["authorization_code", "refresh_token"], + redirect_uris: [baseUrl], + id_token_signed_response_alg: "RS256", + token_endpoint_auth_method: "none", + application_type: "web", + }), + }); + }); + + it("should throw when registration request fails", async () => { + fetchMockJest.post(registrationEndpoint, { + status: 500, + }); + expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow( + OidcError.DynamicRegistrationFailed, + ); + }); + + it("should throw when registration response is invalid", async () => { + fetchMockJest.post(registrationEndpoint, { + status: 200, + // no clientId in response + body: "{}", + }); + expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow( + OidcError.DynamicRegistrationInvalid, + ); + }); +}); diff --git a/spec/unit/oidc/validate.spec.ts b/spec/unit/oidc/validate.spec.ts index 2ad62afc327..d5091ed89f9 100644 --- a/spec/unit/oidc/validate.spec.ts +++ b/spec/unit/oidc/validate.spec.ts @@ -16,11 +16,8 @@ limitations under the License. import { M_AUTHENTICATION } from "../../../src"; import { logger } from "../../../src/logger"; -import { - OidcDiscoveryError, - validateOIDCIssuerWellKnown, - validateWellKnownAuthentication, -} from "../../../src/oidc/validate"; +import { validateOIDCIssuerWellKnown, validateWellKnownAuthentication } from "../../../src/oidc/validate"; +import { OidcError } from "../../../src/oidc/error"; describe("validateWellKnownAuthentication()", () => { const baseWk = { @@ -29,7 +26,7 @@ describe("validateWellKnownAuthentication()", () => { }, }; it("should throw not supported error when wellKnown has no m.authentication section", () => { - expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcDiscoveryError.NotSupported); + expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcError.NotSupported); }); it("should throw misconfigured error when authentication issuer is not a string", () => { @@ -39,7 +36,7 @@ describe("validateWellKnownAuthentication()", () => { issuer: { url: "test.com" }, }, }; - expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured); + expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured); }); it("should throw misconfigured error when authentication account is not a string", () => { @@ -50,7 +47,7 @@ describe("validateWellKnownAuthentication()", () => { account: { url: "test" }, }, }; - expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured); + expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured); }); it("should throw misconfigured error when authentication account is false", () => { @@ -61,7 +58,7 @@ describe("validateWellKnownAuthentication()", () => { account: false, }, }; - expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured); + expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured); }); it("should return valid config when wk uses stable m.authentication", () => { @@ -137,7 +134,7 @@ describe("validateOIDCIssuerWellKnown", () => { it("should throw OP support error when wellKnown is not an object", () => { expect(() => { validateOIDCIssuerWellKnown([]); - }).toThrow(OidcDiscoveryError.OpSupport); + }).toThrow(OidcError.OpSupport); expect(logger.error).toHaveBeenCalledWith("Issuer configuration not found or malformed"); }); @@ -148,7 +145,7 @@ describe("validateOIDCIssuerWellKnown", () => { authorization_endpoint: undefined, response_types_supported: [], }); - }).toThrow(OidcDiscoveryError.OpSupport); + }).toThrow(OidcError.OpSupport); expect(logger.error).toHaveBeenCalledWith("OIDC issuer configuration: authorization_endpoint is invalid"); expect(logger.error).toHaveBeenCalledWith( "OIDC issuer configuration: response_types_supported is invalid. code is required.", @@ -194,6 +191,6 @@ describe("validateOIDCIssuerWellKnown", () => { ...validWk, [key]: value, }; - expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcDiscoveryError.OpSupport); + expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcError.OpSupport); }); }); diff --git a/src/autodiscovery.ts b/src/autodiscovery.ts index a43410bd630..f9cf0398c2b 100644 --- a/src/autodiscovery.ts +++ b/src/autodiscovery.ts @@ -18,12 +18,8 @@ limitations under the License. import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } from "./client"; import { logger } from "./logger"; import { MatrixError, Method, timeoutSignal } from "./http-api"; -import { - OidcDiscoveryError, - ValidatedIssuerConfig, - validateOIDCIssuerWellKnown, - validateWellKnownAuthentication, -} from "./oidc/validate"; +import { ValidatedIssuerConfig, validateOIDCIssuerWellKnown, validateWellKnownAuthentication } from "./oidc/validate"; +import { OidcError } from "./oidc/error"; // Dev note: Auto discovery is part of the spec. // See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery @@ -297,7 +293,7 @@ export class AutoDiscovery { if (issuerWellKnown.action !== AutoDiscoveryAction.SUCCESS) { logger.error("Failed to fetch issuer openid configuration"); - throw new Error(OidcDiscoveryError.General); + throw new Error(OidcError.General); } const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown.raw); @@ -310,15 +306,11 @@ export class AutoDiscovery { }; return delegatedAuthConfig; } catch (error) { - const errorMessage = (error as Error).message as unknown as OidcDiscoveryError; - const errorType = Object.values(OidcDiscoveryError).includes(errorMessage) - ? errorMessage - : OidcDiscoveryError.General; + const errorMessage = (error as Error).message as unknown as OidcError; + const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General; const state = - errorType === OidcDiscoveryError.NotSupported - ? AutoDiscoveryAction.IGNORE - : AutoDiscoveryAction.FAIL_ERROR; + errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR; return { state, diff --git a/src/oidc/error.ts b/src/oidc/error.ts new file mode 100644 index 00000000000..b77fbbf75f5 --- /dev/null +++ b/src/oidc/error.ts @@ -0,0 +1,25 @@ +/* +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. +*/ + +export enum OidcError { + NotSupported = "OIDC authentication not supported", + Misconfigured = "OIDC is misconfigured", + General = "Something went wrong with OIDC discovery", + OpSupport = "Configured OIDC OP does not support required functions", + DynamicRegistrationNotSupported = "Dynamic registration not supported", + DynamicRegistrationFailed = "Dynamic registration failed", + DynamicRegistrationInvalid = "Dynamic registration invalid response", +} diff --git a/src/oidc/register.ts b/src/oidc/register.ts new file mode 100644 index 00000000000..c09517ba09d --- /dev/null +++ b/src/oidc/register.ts @@ -0,0 +1,111 @@ +/* +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 { IDelegatedAuthConfig } from "../client"; +import { OidcError } from "./error"; +import { Method } from "../http-api"; +import { logger } from "../logger"; +import { ValidatedIssuerConfig } from "./validate"; + +/** + * Client metadata passed to registration endpoint + */ +export type OidcRegistrationClientMetadata = { + clientName: string; + clientUri: string; + redirectUris: string[]; +}; + +/** + * Make the client registration request + * @param registrationEndpoint - URL as returned from issuer ./well-known/openid-configuration + * @param clientMetadata - registration metadata + * @returns resolves to the registered client id when registration is successful + * @throws when registration request fails, or response is invalid + */ +const doRegistration = async ( + registrationEndpoint: string, + clientMetadata: OidcRegistrationClientMetadata, +): Promise => { + // https://openid.net/specs/openid-connect-registration-1_0.html + const metadata = { + client_name: clientMetadata.clientName, + client_uri: clientMetadata.clientUri, + response_types: ["code"], + grant_types: ["authorization_code", "refresh_token"], + redirect_uris: clientMetadata.redirectUris, + id_token_signed_response_alg: "RS256", + token_endpoint_auth_method: "none", + application_type: "web", + }; + const headers = { + "Accept": "application/json", + "Content-Type": "application/json", + }; + + try { + const response = await fetch(registrationEndpoint, { + method: Method.Post, + headers, + body: JSON.stringify(metadata), + }); + + if (response.status >= 400) { + throw new Error(OidcError.DynamicRegistrationFailed); + } + + const body = await response.json(); + const clientId = body["client_id"]; + if (!clientId || typeof clientId !== "string") { + throw new Error(OidcError.DynamicRegistrationInvalid); + } + + return clientId; + } catch (error) { + if (Object.values(OidcError).includes((error as Error).message as OidcError)) { + throw error; + } else { + logger.error("Dynamic registration request failed", error); + throw new Error(OidcError.DynamicRegistrationFailed); + } + } +}; + +/** + * Attempts dynamic registration against the configured registration endpoint + * @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/' + * @returns Promise resolved with registered clientId + * @throws when registration is not supported, on failed request or invalid response + */ +export const registerOidcClient = async ( + delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig, + clientName: string, + baseUrl: string, +): Promise => { + const clientMetadata = { + clientName, + clientUri: baseUrl, + redirectUris: [baseUrl], + }; + if (!delegatedAuthConfig.registrationEndpoint) { + throw new Error(OidcError.DynamicRegistrationNotSupported); + } + const clientId = await doRegistration(delegatedAuthConfig.registrationEndpoint, clientMetadata); + + return clientId; +}; diff --git a/src/oidc/validate.ts b/src/oidc/validate.ts index 1a5f672b4a7..09ecf5e609d 100644 --- a/src/oidc/validate.ts +++ b/src/oidc/validate.ts @@ -16,13 +16,13 @@ limitations under the License. import { IClientWellKnown, IDelegatedAuthConfig, M_AUTHENTICATION } from "../client"; import { logger } from "../logger"; +import { OidcError } from "./error"; -export enum OidcDiscoveryError { - NotSupported = "OIDC authentication not supported", - Misconfigured = "OIDC is misconfigured", - General = "Something went wrong with OIDC discovery", - OpSupport = "Configured OIDC OP does not support required functions", -} +/** + * re-export for backwards compatibility + * @deprecated use OidcError + */ +export { OidcError as OidcDiscoveryError }; export type ValidatedIssuerConfig = { authorizationEndpoint: string; @@ -41,7 +41,7 @@ export const validateWellKnownAuthentication = (wellKnown: IClientWellKnown): ID const authentication = M_AUTHENTICATION.findIn(wellKnown); if (!authentication) { - throw new Error(OidcDiscoveryError.NotSupported); + throw new Error(OidcError.NotSupported); } if ( @@ -54,7 +54,7 @@ export const validateWellKnownAuthentication = (wellKnown: IClientWellKnown): ID }; } - throw new Error(OidcDiscoveryError.Misconfigured); + throw new Error(OidcError.Misconfigured); }; const isRecord = (value: unknown): value is Record => @@ -93,7 +93,7 @@ const requiredArrayValue = (wellKnown: Record, key: string, val export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuerConfig => { if (!isRecord(wellKnown)) { logger.error("Issuer configuration not found or malformed"); - throw new Error(OidcDiscoveryError.OpSupport); + throw new Error(OidcError.OpSupport); } const isInvalid = [ @@ -114,5 +114,5 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer } logger.error("Issuer configuration not valid"); - throw new Error(OidcDiscoveryError.OpSupport); + throw new Error(OidcError.OpSupport); };