Skip to content

Commit df78d7c

Browse files
author
Kerry
authored
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
1 parent 80fec81 commit df78d7c

File tree

7 files changed

+251
-42
lines changed

7 files changed

+251
-42
lines changed

spec/unit/autodiscovery.spec.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ limitations under the License.
1818
import MockHttpBackend from "matrix-mock-request";
1919

2020
import { AutoDiscovery } from "../../src/autodiscovery";
21-
import { OidcDiscoveryError } from "../../src/oidc/validate";
21+
import { OidcError } from "../../src/oidc/error";
2222

2323
describe("AutoDiscovery", function () {
2424
const getHttpBackend = (): MockHttpBackend => {
@@ -400,7 +400,7 @@ describe("AutoDiscovery", function () {
400400
},
401401
"m.authentication": {
402402
state: "IGNORE",
403-
error: OidcDiscoveryError.NotSupported,
403+
error: OidcError.NotSupported,
404404
},
405405
};
406406

@@ -441,7 +441,7 @@ describe("AutoDiscovery", function () {
441441
},
442442
"m.authentication": {
443443
state: "IGNORE",
444-
error: OidcDiscoveryError.NotSupported,
444+
error: OidcError.NotSupported,
445445
},
446446
};
447447

@@ -485,7 +485,7 @@ describe("AutoDiscovery", function () {
485485
},
486486
"m.authentication": {
487487
state: "FAIL_ERROR",
488-
error: OidcDiscoveryError.Misconfigured,
488+
error: OidcError.Misconfigured,
489489
},
490490
};
491491

@@ -719,7 +719,7 @@ describe("AutoDiscovery", function () {
719719
},
720720
"m.authentication": {
721721
state: "IGNORE",
722-
error: OidcDiscoveryError.NotSupported,
722+
error: OidcError.NotSupported,
723723
},
724724
};
725725

@@ -775,7 +775,7 @@ describe("AutoDiscovery", function () {
775775
},
776776
"m.authentication": {
777777
state: "IGNORE",
778-
error: OidcDiscoveryError.NotSupported,
778+
error: OidcError.NotSupported,
779779
},
780780
};
781781

spec/unit/oidc/register.spec.ts

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import fetchMockJest from "fetch-mock-jest";
18+
19+
import { OidcError } from "../../../src/oidc/error";
20+
import { registerOidcClient } from "../../../src/oidc/register";
21+
22+
describe("registerOidcClient()", () => {
23+
const issuer = "https://auth.com/";
24+
const registrationEndpoint = "https://auth.com/register";
25+
const clientName = "Element";
26+
const baseUrl = "https://just.testing";
27+
const dynamicClientId = "xyz789";
28+
29+
const delegatedAuthConfig = {
30+
issuer,
31+
registrationEndpoint,
32+
authorizationEndpoint: issuer + "auth",
33+
tokenEndpoint: issuer + "token",
34+
};
35+
beforeEach(() => {
36+
fetchMockJest.mockClear();
37+
fetchMockJest.resetBehavior();
38+
});
39+
40+
it("should make correct request to register client", async () => {
41+
fetchMockJest.post(registrationEndpoint, {
42+
status: 200,
43+
body: JSON.stringify({ client_id: dynamicClientId }),
44+
});
45+
expect(await registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).toEqual(dynamicClientId);
46+
expect(fetchMockJest).toHaveBeenCalledWith(registrationEndpoint, {
47+
headers: {
48+
"Accept": "application/json",
49+
"Content-Type": "application/json",
50+
},
51+
method: "POST",
52+
body: JSON.stringify({
53+
client_name: clientName,
54+
client_uri: baseUrl,
55+
response_types: ["code"],
56+
grant_types: ["authorization_code", "refresh_token"],
57+
redirect_uris: [baseUrl],
58+
id_token_signed_response_alg: "RS256",
59+
token_endpoint_auth_method: "none",
60+
application_type: "web",
61+
}),
62+
});
63+
});
64+
65+
it("should throw when registration request fails", async () => {
66+
fetchMockJest.post(registrationEndpoint, {
67+
status: 500,
68+
});
69+
expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
70+
OidcError.DynamicRegistrationFailed,
71+
);
72+
});
73+
74+
it("should throw when registration response is invalid", async () => {
75+
fetchMockJest.post(registrationEndpoint, {
76+
status: 200,
77+
// no clientId in response
78+
body: "{}",
79+
});
80+
expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
81+
OidcError.DynamicRegistrationInvalid,
82+
);
83+
});
84+
});

spec/unit/oidc/validate.spec.ts

+9-12
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,8 @@ limitations under the License.
1616

1717
import { M_AUTHENTICATION } from "../../../src";
1818
import { logger } from "../../../src/logger";
19-
import {
20-
OidcDiscoveryError,
21-
validateOIDCIssuerWellKnown,
22-
validateWellKnownAuthentication,
23-
} from "../../../src/oidc/validate";
19+
import { validateOIDCIssuerWellKnown, validateWellKnownAuthentication } from "../../../src/oidc/validate";
20+
import { OidcError } from "../../../src/oidc/error";
2421

2522
describe("validateWellKnownAuthentication()", () => {
2623
const baseWk = {
@@ -29,7 +26,7 @@ describe("validateWellKnownAuthentication()", () => {
2926
},
3027
};
3128
it("should throw not supported error when wellKnown has no m.authentication section", () => {
32-
expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcDiscoveryError.NotSupported);
29+
expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcError.NotSupported);
3330
});
3431

3532
it("should throw misconfigured error when authentication issuer is not a string", () => {
@@ -39,7 +36,7 @@ describe("validateWellKnownAuthentication()", () => {
3936
issuer: { url: "test.com" },
4037
},
4138
};
42-
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured);
39+
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
4340
});
4441

4542
it("should throw misconfigured error when authentication account is not a string", () => {
@@ -50,7 +47,7 @@ describe("validateWellKnownAuthentication()", () => {
5047
account: { url: "test" },
5148
},
5249
};
53-
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured);
50+
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
5451
});
5552

5653
it("should throw misconfigured error when authentication account is false", () => {
@@ -61,7 +58,7 @@ describe("validateWellKnownAuthentication()", () => {
6158
account: false,
6259
},
6360
};
64-
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured);
61+
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
6562
});
6663

6764
it("should return valid config when wk uses stable m.authentication", () => {
@@ -137,7 +134,7 @@ describe("validateOIDCIssuerWellKnown", () => {
137134
it("should throw OP support error when wellKnown is not an object", () => {
138135
expect(() => {
139136
validateOIDCIssuerWellKnown([]);
140-
}).toThrow(OidcDiscoveryError.OpSupport);
137+
}).toThrow(OidcError.OpSupport);
141138
expect(logger.error).toHaveBeenCalledWith("Issuer configuration not found or malformed");
142139
});
143140

@@ -148,7 +145,7 @@ describe("validateOIDCIssuerWellKnown", () => {
148145
authorization_endpoint: undefined,
149146
response_types_supported: [],
150147
});
151-
}).toThrow(OidcDiscoveryError.OpSupport);
148+
}).toThrow(OidcError.OpSupport);
152149
expect(logger.error).toHaveBeenCalledWith("OIDC issuer configuration: authorization_endpoint is invalid");
153150
expect(logger.error).toHaveBeenCalledWith(
154151
"OIDC issuer configuration: response_types_supported is invalid. code is required.",
@@ -194,6 +191,6 @@ describe("validateOIDCIssuerWellKnown", () => {
194191
...validWk,
195192
[key]: value,
196193
};
197-
expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcDiscoveryError.OpSupport);
194+
expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcError.OpSupport);
198195
});
199196
});

src/autodiscovery.ts

+6-14
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,8 @@ limitations under the License.
1818
import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } from "./client";
1919
import { logger } from "./logger";
2020
import { MatrixError, Method, timeoutSignal } from "./http-api";
21-
import {
22-
OidcDiscoveryError,
23-
ValidatedIssuerConfig,
24-
validateOIDCIssuerWellKnown,
25-
validateWellKnownAuthentication,
26-
} from "./oidc/validate";
21+
import { ValidatedIssuerConfig, validateOIDCIssuerWellKnown, validateWellKnownAuthentication } from "./oidc/validate";
22+
import { OidcError } from "./oidc/error";
2723

2824
// Dev note: Auto discovery is part of the spec.
2925
// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
@@ -297,7 +293,7 @@ export class AutoDiscovery {
297293

298294
if (issuerWellKnown.action !== AutoDiscoveryAction.SUCCESS) {
299295
logger.error("Failed to fetch issuer openid configuration");
300-
throw new Error(OidcDiscoveryError.General);
296+
throw new Error(OidcError.General);
301297
}
302298

303299
const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown.raw);
@@ -310,15 +306,11 @@ export class AutoDiscovery {
310306
};
311307
return delegatedAuthConfig;
312308
} catch (error) {
313-
const errorMessage = (error as Error).message as unknown as OidcDiscoveryError;
314-
const errorType = Object.values(OidcDiscoveryError).includes(errorMessage)
315-
? errorMessage
316-
: OidcDiscoveryError.General;
309+
const errorMessage = (error as Error).message as unknown as OidcError;
310+
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
317311

318312
const state =
319-
errorType === OidcDiscoveryError.NotSupported
320-
? AutoDiscoveryAction.IGNORE
321-
: AutoDiscoveryAction.FAIL_ERROR;
313+
errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR;
322314

323315
return {
324316
state,

src/oidc/error.ts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
export enum OidcError {
18+
NotSupported = "OIDC authentication not supported",
19+
Misconfigured = "OIDC is misconfigured",
20+
General = "Something went wrong with OIDC discovery",
21+
OpSupport = "Configured OIDC OP does not support required functions",
22+
DynamicRegistrationNotSupported = "Dynamic registration not supported",
23+
DynamicRegistrationFailed = "Dynamic registration failed",
24+
DynamicRegistrationInvalid = "Dynamic registration invalid response",
25+
}

src/oidc/register.ts

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { IDelegatedAuthConfig } from "../client";
18+
import { OidcError } from "./error";
19+
import { Method } from "../http-api";
20+
import { logger } from "../logger";
21+
import { ValidatedIssuerConfig } from "./validate";
22+
23+
/**
24+
* Client metadata passed to registration endpoint
25+
*/
26+
export type OidcRegistrationClientMetadata = {
27+
clientName: string;
28+
clientUri: string;
29+
redirectUris: string[];
30+
};
31+
32+
/**
33+
* Make the client registration request
34+
* @param registrationEndpoint - URL as returned from issuer ./well-known/openid-configuration
35+
* @param clientMetadata - registration metadata
36+
* @returns resolves to the registered client id when registration is successful
37+
* @throws when registration request fails, or response is invalid
38+
*/
39+
const doRegistration = async (
40+
registrationEndpoint: string,
41+
clientMetadata: OidcRegistrationClientMetadata,
42+
): Promise<string> => {
43+
// https://openid.net/specs/openid-connect-registration-1_0.html
44+
const metadata = {
45+
client_name: clientMetadata.clientName,
46+
client_uri: clientMetadata.clientUri,
47+
response_types: ["code"],
48+
grant_types: ["authorization_code", "refresh_token"],
49+
redirect_uris: clientMetadata.redirectUris,
50+
id_token_signed_response_alg: "RS256",
51+
token_endpoint_auth_method: "none",
52+
application_type: "web",
53+
};
54+
const headers = {
55+
"Accept": "application/json",
56+
"Content-Type": "application/json",
57+
};
58+
59+
try {
60+
const response = await fetch(registrationEndpoint, {
61+
method: Method.Post,
62+
headers,
63+
body: JSON.stringify(metadata),
64+
});
65+
66+
if (response.status >= 400) {
67+
throw new Error(OidcError.DynamicRegistrationFailed);
68+
}
69+
70+
const body = await response.json();
71+
const clientId = body["client_id"];
72+
if (!clientId || typeof clientId !== "string") {
73+
throw new Error(OidcError.DynamicRegistrationInvalid);
74+
}
75+
76+
return clientId;
77+
} catch (error) {
78+
if (Object.values(OidcError).includes((error as Error).message as OidcError)) {
79+
throw error;
80+
} else {
81+
logger.error("Dynamic registration request failed", error);
82+
throw new Error(OidcError.DynamicRegistrationFailed);
83+
}
84+
}
85+
};
86+
87+
/**
88+
* Attempts dynamic registration against the configured registration endpoint
89+
* @param delegatedAuthConfig - Auth config from ValidatedServerConfig
90+
* @param clientName - Client name to register with the OP, eg 'Element'
91+
* @param baseUrl - URL of the home page of the Client, eg 'https://app.element.io/'
92+
* @returns Promise<string> resolved with registered clientId
93+
* @throws when registration is not supported, on failed request or invalid response
94+
*/
95+
export const registerOidcClient = async (
96+
delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig,
97+
clientName: string,
98+
baseUrl: string,
99+
): Promise<string> => {
100+
const clientMetadata = {
101+
clientName,
102+
clientUri: baseUrl,
103+
redirectUris: [baseUrl],
104+
};
105+
if (!delegatedAuthConfig.registrationEndpoint) {
106+
throw new Error(OidcError.DynamicRegistrationNotSupported);
107+
}
108+
const clientId = await doRegistration(delegatedAuthConfig.registrationEndpoint, clientMetadata);
109+
110+
return clientId;
111+
};

0 commit comments

Comments
 (0)