You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-07-31 15:24:23 +03:00
OIDC: add dynamic client registration util function (#3481)
* rename OidcDiscoveryError to OidcError * oidc client registration functions * test registerOidcClient * tidy test file * reexport OidcDiscoveryError for backwards compatibility
This commit is contained in:
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
84
spec/unit/oidc/register.spec.ts
Normal file
84
spec/unit/oidc/register.spec.ts
Normal file
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
|
25
src/oidc/error.ts
Normal file
25
src/oidc/error.ts
Normal file
@ -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",
|
||||
}
|
111
src/oidc/register.ts
Normal file
111
src/oidc/register.ts
Normal file
@ -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<string> => {
|
||||
// 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<string> 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<string> => {
|
||||
const clientMetadata = {
|
||||
clientName,
|
||||
clientUri: baseUrl,
|
||||
redirectUris: [baseUrl],
|
||||
};
|
||||
if (!delegatedAuthConfig.registrationEndpoint) {
|
||||
throw new Error(OidcError.DynamicRegistrationNotSupported);
|
||||
}
|
||||
const clientId = await doRegistration(delegatedAuthConfig.registrationEndpoint, clientMetadata);
|
||||
|
||||
return clientId;
|
||||
};
|
@ -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<IDelegatedAuthConfig>(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<string, unknown> =>
|
||||
@ -93,7 +93,7 @@ const requiredArrayValue = (wellKnown: Record<string, unknown>, 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);
|
||||
};
|
||||
|
Reference in New Issue
Block a user