1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-23 17:02:25 +03:00

OIDC: Validate m.authentication configuration (#3419)

* validate m.authentication, fetch issuer wellknown

* move validation functions into separate file

* test validateWellKnownAuthentication

* test validateOIDCIssuerWellKnown

* add authentication cases to autodiscovery tests

* test invalid authentication config on wk

* improve types

* test case for account:false

* use hasOwnProperty in validateWellKnownAuthentication

* comments

* make registration_endpoint optional
This commit is contained in:
Kerry
2023-06-12 09:32:44 +12:00
committed by GitHub
parent 2766146c49
commit c66850e897
5 changed files with 458 additions and 10 deletions

View File

@@ -18,6 +18,7 @@ limitations under the License.
import MockHttpBackend from "matrix-mock-request"; import MockHttpBackend from "matrix-mock-request";
import { AutoDiscovery } from "../../src/autodiscovery"; import { AutoDiscovery } from "../../src/autodiscovery";
import { OidcDiscoveryError } from "../../src/oidc/validate";
describe("AutoDiscovery", function () { describe("AutoDiscovery", function () {
const getHttpBackend = (): MockHttpBackend => { 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(); const httpBackend = getHttpBackend();
httpBackend httpBackend
.when("GET", "/_matrix/client/versions") .when("GET", "/_matrix/client/versions")
@@ -397,6 +398,10 @@ describe("AutoDiscovery", function () {
error: null, error: null,
base_url: null, base_url: null,
}, },
"m.authentication": {
state: "IGNORE",
error: OidcDiscoveryError.NotSupported,
},
}; };
expect(conf).toEqual(expected); expect(conf).toEqual(expected);
@@ -434,6 +439,54 @@ describe("AutoDiscovery", function () {
error: null, error: null,
base_url: 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); 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(); const httpBackend = getHttpBackend();
httpBackend httpBackend
.when("GET", "/_matrix/client/versions") .when("GET", "/_matrix/client/versions")
@@ -664,6 +717,10 @@ describe("AutoDiscovery", function () {
error: null, error: null,
base_url: "https://identity.example.org", base_url: "https://identity.example.org",
}, },
"m.authentication": {
state: "IGNORE",
error: OidcDiscoveryError.NotSupported,
},
}; };
expect(conf).toEqual(expected); 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(); const httpBackend = getHttpBackend();
httpBackend httpBackend
.when("GET", "/_matrix/client/versions") .when("GET", "/_matrix/client/versions")
@@ -716,6 +773,10 @@ describe("AutoDiscovery", function () {
"org.example.custom.property": { "org.example.custom.property": {
cupcakes: "yes", cupcakes: "yes",
}, },
"m.authentication": {
state: "IGNORE",
error: OidcDiscoveryError.NotSupported,
},
}; };
expect(conf).toEqual(expected); expect(conf).toEqual(expected);

View File

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

View File

@@ -15,9 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { IClientWellKnown, IWellKnownConfig } from "./client"; import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } from "./client";
import { logger } from "./logger"; import { logger } from "./logger";
import { MatrixError, Method, timeoutSignal } from "./http-api"; import { MatrixError, Method, timeoutSignal } from "./http-api";
import {
OidcDiscoveryError,
ValidatedIssuerConfig,
validateOIDCIssuerWellKnown,
validateWellKnownAuthentication,
} from "./oidc/validate";
// Dev note: Auto discovery is part of the spec. // Dev note: Auto discovery is part of the spec.
// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery // See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
@@ -42,14 +48,18 @@ enum AutoDiscoveryError {
InvalidJson = "Invalid JSON", InvalidJson = "Invalid JSON",
} }
interface WellKnownConfig extends Omit<IWellKnownConfig, "error"> { interface AutoDiscoveryState {
state: AutoDiscoveryAction; state: AutoDiscoveryAction;
error?: IWellKnownConfig["error"] | null; error?: IWellKnownConfig["error"] | null;
} }
interface WellKnownConfig extends Omit<IWellKnownConfig, "error">, AutoDiscoveryState {}
interface DelegatedAuthConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig, AutoDiscoveryState {}
export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> { export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
"m.homeserver": WellKnownConfig; "m.homeserver": WellKnownConfig;
"m.identity_server": 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. // 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<IServerVersions>(`${hsUrl}/_matrix/client/versions`);
if (!hsVersions?.raw?.["versions"]) { if (!hsVersions?.raw?.["versions"]) {
logger.error("Invalid /versions response"); logger.error("Invalid /versions response");
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER; 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) // Step 8: Give the config to the caller (finally)
return Promise.resolve(clientConfig); 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<DelegatedAuthConfig | AutoDiscoveryState> {
try {
const homeserverAuthenticationConfig = validateWellKnownAuthentication(wellKnown);
const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl(
homeserverAuthenticationConfig.issuer,
)}/.well-known/openid-configuration`;
const issuerWellKnown = await this.fetchWellKnownObject<unknown>(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 * Attempts to automatically discover client configuration information
* prior to logging in. Such information includes the homeserver URL * 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 // Step 1: Actually request the .well-known JSON file and make sure it
// at least has a homeserver definition. // 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) { if (!wellknown || wellknown.action !== AutoDiscoveryAction.SUCCESS) {
logger.error("No response or error when parsing .well-known"); logger.error("No response or error when parsing .well-known");
if (wellknown.reason) logger.error(wellknown.reason); if (wellknown.reason) logger.error(wellknown.reason);
@@ -412,7 +480,9 @@ export class AutoDiscovery {
* @returns Promise which resolves to the returned state. * @returns Promise which resolves to the returned state.
* @internal * @internal
*/ */
private static async fetchWellKnownObject(url: string): Promise<IWellKnownConfig> { private static async fetchWellKnownObject<T = IWellKnownConfig>(
url: string,
): Promise<IWellKnownConfig<Partial<T>>> {
let response: Response; let response: Response;
try { try {

View File

@@ -592,8 +592,8 @@ export interface IClientWellKnown {
[M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965 [M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965
} }
export interface IWellKnownConfig { export interface IWellKnownConfig<T = IClientWellKnown> {
raw?: IClientWellKnown; raw?: T;
action?: AutoDiscoveryAction; action?: AutoDiscoveryAction;
reason?: string; reason?: string;
error?: Error | string; error?: Error | string;

118
src/oidc/validate.ts Normal file
View File

@@ -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<IDelegatedAuthConfig>(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<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
const requiredStringProperty = (wellKnown: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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);
};