diff --git a/spec/unit/autodiscovery.spec.ts b/spec/unit/autodiscovery.spec.ts index f6db5327c7b..2a8f080ed20 100644 --- a/spec/unit/autodiscovery.spec.ts +++ b/spec/unit/autodiscovery.spec.ts @@ -18,6 +18,7 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; import { AutoDiscovery } from "../../src/autodiscovery"; +import { OidcDiscoveryError } from "../../src/oidc/validate"; describe("AutoDiscovery", function () { const getHttpBackend = (): MockHttpBackend => { @@ -368,7 +369,7 @@ describe("AutoDiscovery", function () { }, ); - it("should return SUCCESS when .well-known has a verifiably accurate base_url for " + "m.homeserver", function () { + it("should return SUCCESS when .well-known has a verifiably accurate base_url for m.homeserver", function () { const httpBackend = getHttpBackend(); httpBackend .when("GET", "/_matrix/client/versions") @@ -397,6 +398,10 @@ describe("AutoDiscovery", function () { error: null, base_url: null, }, + "m.authentication": { + state: "IGNORE", + error: OidcDiscoveryError.NotSupported, + }, }; expect(conf).toEqual(expected); @@ -434,6 +439,54 @@ describe("AutoDiscovery", function () { error: null, base_url: null, }, + "m.authentication": { + state: "IGNORE", + error: OidcDiscoveryError.NotSupported, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return SUCCESS with authentication error when authentication config is invalid", function () { + const httpBackend = getHttpBackend(); + httpBackend + .when("GET", "/_matrix/client/versions") + .check((req) => { + expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions"); + }) + .respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + // Note: we also expect this test to trim the trailing slash + base_url: "https://chat.example.org/", + }, + "m.authentication": { + invalid: true, + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "SUCCESS", + error: null, + base_url: "https://chat.example.org", + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + "m.authentication": { + state: "FAIL_ERROR", + error: OidcDiscoveryError.Misconfigured, + }, }; expect(conf).toEqual(expected); @@ -625,7 +678,7 @@ describe("AutoDiscovery", function () { }, ); - it("should return SUCCESS when the identity server configuration is " + "verifiably accurate", function () { + it("should return SUCCESS when the identity server configuration is verifiably accurate", function () { const httpBackend = getHttpBackend(); httpBackend .when("GET", "/_matrix/client/versions") @@ -664,6 +717,10 @@ describe("AutoDiscovery", function () { error: null, base_url: "https://identity.example.org", }, + "m.authentication": { + state: "IGNORE", + error: OidcDiscoveryError.NotSupported, + }, }; expect(conf).toEqual(expected); @@ -671,7 +728,7 @@ describe("AutoDiscovery", function () { ]); }); - it("should return SUCCESS and preserve non-standard keys from the " + ".well-known response", function () { + it("should return SUCCESS and preserve non-standard keys from the .well-known response", function () { const httpBackend = getHttpBackend(); httpBackend .when("GET", "/_matrix/client/versions") @@ -716,6 +773,10 @@ describe("AutoDiscovery", function () { "org.example.custom.property": { cupcakes: "yes", }, + "m.authentication": { + state: "IGNORE", + error: OidcDiscoveryError.NotSupported, + }, }; expect(conf).toEqual(expected); diff --git a/spec/unit/oidc/validate.spec.ts b/spec/unit/oidc/validate.spec.ts new file mode 100644 index 00000000000..2ad62afc327 --- /dev/null +++ b/spec/unit/oidc/validate.spec.ts @@ -0,0 +1,199 @@ +/* +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 { M_AUTHENTICATION } from "../../../src"; +import { logger } from "../../../src/logger"; +import { + OidcDiscoveryError, + validateOIDCIssuerWellKnown, + validateWellKnownAuthentication, +} from "../../../src/oidc/validate"; + +describe("validateWellKnownAuthentication()", () => { + const baseWk = { + "m.homeserver": { + base_url: "https://hs.org", + }, + }; + it("should throw not supported error when wellKnown has no m.authentication section", () => { + expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcDiscoveryError.NotSupported); + }); + + it("should throw misconfigured error when authentication issuer is not a string", () => { + const wk = { + ...baseWk, + [M_AUTHENTICATION.stable!]: { + issuer: { url: "test.com" }, + }, + }; + expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured); + }); + + it("should throw misconfigured error when authentication account is not a string", () => { + const wk = { + ...baseWk, + [M_AUTHENTICATION.stable!]: { + issuer: "test.com", + account: { url: "test" }, + }, + }; + expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured); + }); + + it("should throw misconfigured error when authentication account is false", () => { + const wk = { + ...baseWk, + [M_AUTHENTICATION.stable!]: { + issuer: "test.com", + account: false, + }, + }; + expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured); + }); + + it("should return valid config when wk uses stable m.authentication", () => { + const wk = { + ...baseWk, + [M_AUTHENTICATION.stable!]: { + issuer: "test.com", + account: "account.com", + }, + }; + expect(validateWellKnownAuthentication(wk)).toEqual({ + issuer: "test.com", + account: "account.com", + }); + }); + + it("should return valid config when m.authentication account is missing", () => { + const wk = { + ...baseWk, + [M_AUTHENTICATION.stable!]: { + issuer: "test.com", + }, + }; + expect(validateWellKnownAuthentication(wk)).toEqual({ + issuer: "test.com", + }); + }); + + it("should remove unexpected properties", () => { + const wk = { + ...baseWk, + [M_AUTHENTICATION.stable!]: { + issuer: "test.com", + somethingElse: "test", + }, + }; + expect(validateWellKnownAuthentication(wk)).toEqual({ + issuer: "test.com", + }); + }); + + it("should return valid config when wk uses unstable prefix for m.authentication", () => { + const wk = { + ...baseWk, + [M_AUTHENTICATION.unstable!]: { + issuer: "test.com", + account: "account.com", + }, + }; + expect(validateWellKnownAuthentication(wk)).toEqual({ + issuer: "test.com", + account: "account.com", + }); + }); +}); + +describe("validateOIDCIssuerWellKnown", () => { + const validWk: any = { + authorization_endpoint: "https://test.org/authorize", + token_endpoint: "https://authorize.org/token", + registration_endpoint: "https://authorize.org/regsiter", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + }; + beforeEach(() => { + // stub to avoid console litter + jest.spyOn(logger, "error") + .mockClear() + .mockImplementation(() => {}); + }); + + it("should throw OP support error when wellKnown is not an object", () => { + expect(() => { + validateOIDCIssuerWellKnown([]); + }).toThrow(OidcDiscoveryError.OpSupport); + expect(logger.error).toHaveBeenCalledWith("Issuer configuration not found or malformed"); + }); + + it("should log all errors before throwing", () => { + expect(() => { + validateOIDCIssuerWellKnown({ + ...validWk, + authorization_endpoint: undefined, + response_types_supported: [], + }); + }).toThrow(OidcDiscoveryError.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.", + ); + }); + + it("should return validated issuer config", () => { + expect(validateOIDCIssuerWellKnown(validWk)).toEqual({ + authorizationEndpoint: validWk.authorization_endpoint, + tokenEndpoint: validWk.token_endpoint, + registrationEndpoint: validWk.registration_endpoint, + }); + }); + + it("should return validated issuer config without registrationendpoint", () => { + const wk = { ...validWk }; + delete wk.registration_endpoint; + expect(validateOIDCIssuerWellKnown(wk)).toEqual({ + authorizationEndpoint: validWk.authorization_endpoint, + tokenEndpoint: validWk.token_endpoint, + registrationEndpoint: undefined, + }); + }); + + type TestCase = [string, any]; + it.each([ + ["authorization_endpoint", undefined], + ["authorization_endpoint", { not: "a string" }], + ["token_endpoint", undefined], + ["token_endpoint", { not: "a string" }], + ["registration_endpoint", { not: "a string" }], + ["response_types_supported", undefined], + ["response_types_supported", "not an array"], + ["response_types_supported", ["doesnt include code"]], + ["grant_types_supported", undefined], + ["grant_types_supported", "not an array"], + ["grant_types_supported", ["doesnt include authorization_code"]], + ["code_challenge_methods_supported", undefined], + ["code_challenge_methods_supported", "not an array"], + ["code_challenge_methods_supported", ["doesnt include S256"]], + ])("should throw OP support error when %s is %s", (key, value) => { + const wk = { + ...validWk, + [key]: value, + }; + expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcDiscoveryError.OpSupport); + }); +}); diff --git a/src/autodiscovery.ts b/src/autodiscovery.ts index 5a7294b29ab..a43410bd630 100644 --- a/src/autodiscovery.ts +++ b/src/autodiscovery.ts @@ -15,9 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IClientWellKnown, IWellKnownConfig } from "./client"; +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"; // Dev note: Auto discovery is part of the spec. // See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery @@ -42,14 +48,18 @@ enum AutoDiscoveryError { InvalidJson = "Invalid JSON", } -interface WellKnownConfig extends Omit { +interface AutoDiscoveryState { state: AutoDiscoveryAction; error?: IWellKnownConfig["error"] | null; } +interface WellKnownConfig extends Omit, AutoDiscoveryState {} + +interface DelegatedAuthConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig, AutoDiscoveryState {} export interface ClientConfig extends Omit { "m.homeserver": WellKnownConfig; "m.identity_server": WellKnownConfig; + "m.authentication"?: DelegatedAuthConfig | AutoDiscoveryState; } /** @@ -170,7 +180,7 @@ export class AutoDiscovery { } // Step 3: Make sure the homeserver URL points to a homeserver. - const hsVersions = await this.fetchWellKnownObject(`${hsUrl}/_matrix/client/versions`); + const hsVersions = await this.fetchWellKnownObject(`${hsUrl}/_matrix/client/versions`); if (!hsVersions?.raw?.["versions"]) { logger.error("Invalid /versions response"); clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER; @@ -256,10 +266,67 @@ export class AutoDiscovery { } }); + const authConfig = await this.validateDiscoveryAuthenticationConfig(wellknown); + clientConfig[M_AUTHENTICATION.stable!] = authConfig; + // Step 8: Give the config to the caller (finally) return Promise.resolve(clientConfig); } + /** + * Validate delegated auth configuration + * - m.authentication config is present and valid + * - delegated auth issuer openid-configuration is reachable + * - delegated auth issuer openid-configuration is configured correctly for us + * When successful, DelegatedAuthConfig will be returned with endpoints used for delegated auth + * Any errors are caught, and AutoDiscoveryState returned with error + * @param wellKnown - configuration object as returned + * by the .well-known auto-discovery endpoint + * @returns Config or failure result + */ + public static async validateDiscoveryAuthenticationConfig( + wellKnown: IClientWellKnown, + ): Promise { + try { + const homeserverAuthenticationConfig = validateWellKnownAuthentication(wellKnown); + + const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl( + homeserverAuthenticationConfig.issuer, + )}/.well-known/openid-configuration`; + const issuerWellKnown = await this.fetchWellKnownObject(issuerOpenIdConfigUrl); + + if (issuerWellKnown.action !== AutoDiscoveryAction.SUCCESS) { + logger.error("Failed to fetch issuer openid configuration"); + throw new Error(OidcDiscoveryError.General); + } + + const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown.raw); + + const delegatedAuthConfig: DelegatedAuthConfig = { + state: AutoDiscoveryAction.SUCCESS, + error: null, + ...homeserverAuthenticationConfig, + ...validatedIssuerConfig, + }; + 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 state = + errorType === OidcDiscoveryError.NotSupported + ? AutoDiscoveryAction.IGNORE + : AutoDiscoveryAction.FAIL_ERROR; + + return { + state, + error: errorType, + }; + } + } + /** * Attempts to automatically discover client configuration information * prior to logging in. Such information includes the homeserver URL @@ -308,7 +375,8 @@ export class AutoDiscovery { // Step 1: Actually request the .well-known JSON file and make sure it // at least has a homeserver definition. - const wellknown = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`); + const domainWithProtocol = domain.includes("://") ? domain : `https://${domain}`; + const wellknown = await this.fetchWellKnownObject(`${domainWithProtocol}/.well-known/matrix/client`); if (!wellknown || wellknown.action !== AutoDiscoveryAction.SUCCESS) { logger.error("No response or error when parsing .well-known"); if (wellknown.reason) logger.error(wellknown.reason); @@ -412,7 +480,9 @@ export class AutoDiscovery { * @returns Promise which resolves to the returned state. * @internal */ - private static async fetchWellKnownObject(url: string): Promise { + private static async fetchWellKnownObject( + url: string, + ): Promise>> { let response: Response; try { diff --git a/src/client.ts b/src/client.ts index d7abcdae93a..44da2056bca 100644 --- a/src/client.ts +++ b/src/client.ts @@ -592,8 +592,8 @@ export interface IClientWellKnown { [M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965 } -export interface IWellKnownConfig { - raw?: IClientWellKnown; +export interface IWellKnownConfig { + raw?: T; action?: AutoDiscoveryAction; reason?: string; error?: Error | string; diff --git a/src/oidc/validate.ts b/src/oidc/validate.ts new file mode 100644 index 00000000000..1a5f672b4a7 --- /dev/null +++ b/src/oidc/validate.ts @@ -0,0 +1,118 @@ +/* +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 { IClientWellKnown, IDelegatedAuthConfig, M_AUTHENTICATION } from "../client"; +import { logger } from "../logger"; + +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", +} + +export type ValidatedIssuerConfig = { + authorizationEndpoint: string; + tokenEndpoint: string; + registrationEndpoint?: string; +}; + +/** + * Validates MSC2965 m.authentication config + * Returns valid configuration + * @param wellKnown - client well known as returned from ./well-known/client/matrix + * @returns config - when present and valid + * @throws when config is not found or invalid + */ +export const validateWellKnownAuthentication = (wellKnown: IClientWellKnown): IDelegatedAuthConfig => { + const authentication = M_AUTHENTICATION.findIn(wellKnown); + + if (!authentication) { + throw new Error(OidcDiscoveryError.NotSupported); + } + + if ( + typeof authentication.issuer === "string" && + (!authentication.hasOwnProperty("account") || typeof authentication.account === "string") + ) { + return { + issuer: authentication.issuer, + account: authentication.account, + }; + } + + throw new Error(OidcDiscoveryError.Misconfigured); +}; + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); +const requiredStringProperty = (wellKnown: Record, key: string): boolean => { + if (!wellKnown[key] || !optionalStringProperty(wellKnown, key)) { + logger.error(`OIDC issuer configuration: ${key} is invalid`); + return false; + } + return true; +}; +const optionalStringProperty = (wellKnown: Record, key: string): boolean => { + if (!!wellKnown[key] && typeof wellKnown[key] !== "string") { + logger.error(`OIDC issuer configuration: ${key} is invalid`); + return false; + } + return true; +}; +const requiredArrayValue = (wellKnown: Record, key: string, value: any): boolean => { + const array = wellKnown[key]; + if (!array || !Array.isArray(array) || !array.includes(value)) { + logger.error(`OIDC issuer configuration: ${key} is invalid. ${value} is required.`); + return false; + } + return true; +}; + +/** + * Validates issue `.well-known/openid-configuration` + * As defined in RFC5785 https://openid.net/specs/openid-connect-discovery-1_0.html + * validates that OP is compatible with Element's OIDC flow + * @param wellKnown - json object + * @returns valid issuer config + * @throws Error - when issuer config is not found or is invalid + */ +export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuerConfig => { + if (!isRecord(wellKnown)) { + logger.error("Issuer configuration not found or malformed"); + throw new Error(OidcDiscoveryError.OpSupport); + } + + const isInvalid = [ + requiredStringProperty(wellKnown, "authorization_endpoint"), + requiredStringProperty(wellKnown, "token_endpoint"), + optionalStringProperty(wellKnown, "registration_endpoint"), + requiredArrayValue(wellKnown, "response_types_supported", "code"), + requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"), + requiredArrayValue(wellKnown, "code_challenge_methods_supported", "S256"), + ].some((isValid) => !isValid); + + if (!isInvalid) { + return { + authorizationEndpoint: wellKnown["authorization_endpoint"], + tokenEndpoint: wellKnown["token_endpoint"], + registrationEndpoint: wellKnown["registration_endpoint"], + } as ValidatedIssuerConfig; + } + + logger.error("Issuer configuration not valid"); + throw new Error(OidcDiscoveryError.OpSupport); +};