Skip to content

Commit b8fa030

Browse files
author
Kerry
authored
OIDC: use oidc-client-ts (#3544)
* use oidc-client-ts during oidc discovery * export new type for auth config * deprecate generateAuthorizationUrl in favour of generateOidcAuthorizationUrl * testing util for oidc configurations * test generateOidcAuthorizationUrl * lint * test discovery * dont pass whole client wellknown to oidc validation funcs * add nonce * use client userState for homeserver
1 parent b606d1e commit b8fa030

10 files changed

+432
-48
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"loglevel": "^1.7.1",
6464
"matrix-events-sdk": "0.0.1",
6565
"matrix-widget-api": "^1.3.1",
66+
"oidc-client-ts": "^2.2.4",
6667
"p-retry": "4",
6768
"sdp-transform": "^2.14.1",
6869
"unhomoglyph": "^1.0.6",

spec/test-utils/oidc.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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 { OidcClientConfig } from "../../src";
18+
import { ValidatedIssuerMetadata } from "../../src/oidc/validate";
19+
20+
/**
21+
* Makes a valid OidcClientConfig with minimum valid values
22+
* @param issuer used as the base for all other urls
23+
* @returns OidcClientConfig
24+
*/
25+
export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClientConfig => {
26+
const metadata = mockOpenIdConfiguration(issuer);
27+
28+
return {
29+
issuer,
30+
account: issuer + "account",
31+
registrationEndpoint: metadata.registration_endpoint,
32+
authorizationEndpoint: metadata.authorization_endpoint,
33+
tokenEndpoint: metadata.token_endpoint,
34+
metadata,
35+
};
36+
};
37+
38+
/**
39+
* Useful for mocking <issuer>/.well-known/openid-configuration
40+
* @param issuer used as the base for all other urls
41+
* @returns ValidatedIssuerMetadata
42+
*/
43+
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
44+
issuer,
45+
revocation_endpoint: issuer + "revoke",
46+
token_endpoint: issuer + "token",
47+
authorization_endpoint: issuer + "auth",
48+
registration_endpoint: issuer + "registration",
49+
jwks_uri: issuer + "jwks",
50+
response_types_supported: ["code"],
51+
grant_types_supported: ["authorization_code", "refresh_token"],
52+
code_challenge_methods_supported: ["S256"],
53+
});

spec/unit/autodiscovery.spec.ts

+82
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,17 @@ See the License for the specific language governing permissions and
1515
limitations under the License.
1616
*/
1717

18+
import fetchMock from "fetch-mock-jest";
1819
import MockHttpBackend from "matrix-mock-request";
1920

21+
import { M_AUTHENTICATION } from "../../src";
2022
import { AutoDiscovery } from "../../src/autodiscovery";
2123
import { OidcError } from "../../src/oidc/error";
24+
import { makeDelegatedAuthConfig } from "../test-utils/oidc";
25+
26+
// keep to reset the fetch function after using MockHttpBackend
27+
// @ts-ignore private property
28+
const realAutoDiscoveryFetch: typeof global.fetch = AutoDiscovery.fetchFn;
2229

2330
describe("AutoDiscovery", function () {
2431
const getHttpBackend = (): MockHttpBackend => {
@@ -27,6 +34,10 @@ describe("AutoDiscovery", function () {
2734
return httpBackend;
2835
};
2936

37+
afterAll(() => {
38+
AutoDiscovery.setFetchFn(realAutoDiscoveryFetch);
39+
});
40+
3041
it("should throw an error when no domain is specified", function () {
3142
getHttpBackend();
3243
return Promise.all([
@@ -855,4 +866,75 @@ describe("AutoDiscovery", function () {
855866
}),
856867
]);
857868
});
869+
870+
describe("m.authentication", () => {
871+
const homeserverName = "example.org";
872+
const homeserverUrl = "https://chat.example.org/";
873+
const issuer = "https://auth.org/";
874+
875+
beforeAll(() => {
876+
// make these tests independent from fetch mocking above
877+
AutoDiscovery.setFetchFn(realAutoDiscoveryFetch);
878+
});
879+
880+
beforeEach(() => {
881+
fetchMock.resetBehavior();
882+
fetchMock.get(`${homeserverUrl}_matrix/client/versions`, { versions: ["r0.0.1"] });
883+
884+
fetchMock.get("https://example.org/.well-known/matrix/client", {
885+
"m.homeserver": {
886+
// Note: we also expect this test to trim the trailing slash
887+
base_url: "https://chat.example.org/",
888+
},
889+
"m.authentication": {
890+
issuer,
891+
},
892+
});
893+
});
894+
895+
it("should return valid authentication configuration", async () => {
896+
const config = makeDelegatedAuthConfig(issuer);
897+
898+
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
899+
fetchMock.get(`${config.metadata.issuer}jwks`, {
900+
status: 200,
901+
headers: {
902+
"Content-Type": "application/json",
903+
},
904+
keys: [],
905+
});
906+
907+
const result = await AutoDiscovery.findClientConfig(homeserverName);
908+
909+
expect(result[M_AUTHENTICATION.stable!]).toEqual({
910+
state: AutoDiscovery.SUCCESS,
911+
...config,
912+
signingKeys: [],
913+
account: undefined,
914+
error: null,
915+
});
916+
});
917+
918+
it("should set state to error for invalid authentication configuration", async () => {
919+
const config = makeDelegatedAuthConfig(issuer);
920+
// authorization_code is required
921+
config.metadata.grant_types_supported = ["openid"];
922+
923+
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
924+
fetchMock.get(`${config.metadata.issuer}jwks`, {
925+
status: 200,
926+
headers: {
927+
"Content-Type": "application/json",
928+
},
929+
keys: [],
930+
});
931+
932+
const result = await AutoDiscovery.findClientConfig(homeserverName);
933+
934+
expect(result[M_AUTHENTICATION.stable!]).toEqual({
935+
state: AutoDiscovery.FAIL_ERROR,
936+
error: OidcError.OpSupport,
937+
});
938+
});
939+
});
858940
});

spec/unit/oidc/authorize.spec.ts

+36-18
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
15
/*
26
Copyright 2023 The Matrix.org Foundation C.I.C.
37
@@ -25,29 +29,27 @@ import {
2529
completeAuthorizationCodeGrant,
2630
generateAuthorizationParams,
2731
generateAuthorizationUrl,
32+
generateOidcAuthorizationUrl,
2833
} from "../../../src/oidc/authorize";
2934
import { OidcError } from "../../../src/oidc/error";
35+
import { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "../../test-utils/oidc";
3036

3137
jest.mock("jwt-decode");
3238

3339
// save for resetting mocks
3440
const realSubtleCrypto = crypto.subtleCrypto;
3541

3642
describe("oidc authorization", () => {
37-
const issuer = "https://auth.com/";
38-
const authorizationEndpoint = "https://auth.com/authorization";
39-
const tokenEndpoint = "https://auth.com/token";
40-
const delegatedAuthConfig = {
41-
issuer,
42-
registrationEndpoint: issuer + "registration",
43-
authorizationEndpoint: issuer + "auth",
44-
tokenEndpoint,
45-
};
43+
const delegatedAuthConfig = makeDelegatedAuthConfig();
44+
const authorizationEndpoint = delegatedAuthConfig.metadata.authorization_endpoint;
45+
const tokenEndpoint = delegatedAuthConfig.metadata.token_endpoint;
4646
const clientId = "xyz789";
4747
const baseUrl = "https://test.com";
4848

4949
beforeAll(() => {
5050
jest.spyOn(logger, "warn");
51+
52+
fetchMock.get(delegatedAuthConfig.issuer + ".well-known/openid-configuration", mockOpenIdConfiguration());
5153
});
5254

5355
afterEach(() => {
@@ -97,20 +99,36 @@ describe("oidc authorization", () => {
9799
"A secure context is required to generate code challenge. Using plain text code challenge",
98100
);
99101
});
102+
});
103+
104+
describe("generateOidcAuthorizationUrl()", () => {
105+
it("should generate url with correct parameters", async () => {
106+
const nonce = "abc123";
107+
108+
const metadata = delegatedAuthConfig.metadata;
100109

101-
it("uses a s256 code challenge when crypto is available", async () => {
102-
jest.spyOn(crypto.subtleCrypto, "digest");
103-
const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl });
104110
const authUrl = new URL(
105-
await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams),
111+
await generateOidcAuthorizationUrl({
112+
metadata,
113+
homeserverUrl: baseUrl,
114+
clientId,
115+
redirectUri: baseUrl,
116+
nonce,
117+
}),
106118
);
107119

108-
const codeChallenge = authUrl.searchParams.get("code_challenge");
109-
expect(crypto.subtleCrypto.digest).toHaveBeenCalledWith("SHA-256", expect.any(Object));
120+
expect(authUrl.searchParams.get("response_mode")).toEqual("query");
121+
expect(authUrl.searchParams.get("response_type")).toEqual("code");
122+
expect(authUrl.searchParams.get("client_id")).toEqual(clientId);
123+
expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256");
124+
// scope minus the 10char random device id at the end
125+
expect(authUrl.searchParams.get("scope")!.slice(0, -10)).toEqual(
126+
"openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:",
127+
);
128+
expect(authUrl.searchParams.get("state")).toBeTruthy();
129+
expect(authUrl.searchParams.get("nonce")).toEqual(nonce);
110130

111-
// didn't use plain text code challenge
112-
expect(authorizationParams.codeVerifier).not.toEqual(codeChallenge);
113-
expect(codeChallenge).toBeTruthy();
131+
expect(authUrl.searchParams.get("code_challenge")).toBeTruthy();
114132
});
115133
});
116134

spec/unit/oidc/validate.spec.ts

+14-21
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe("validateWellKnownAuthentication()", () => {
3535
},
3636
};
3737
it("should throw not supported error when wellKnown has no m.authentication section", () => {
38-
expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcError.NotSupported);
38+
expect(() => validateWellKnownAuthentication(undefined)).toThrow(OidcError.NotSupported);
3939
});
4040

4141
it("should throw misconfigured error when authentication issuer is not a string", () => {
@@ -45,7 +45,9 @@ describe("validateWellKnownAuthentication()", () => {
4545
issuer: { url: "test.com" },
4646
},
4747
};
48-
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
48+
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
49+
OidcError.Misconfigured,
50+
);
4951
});
5052

5153
it("should throw misconfigured error when authentication account is not a string", () => {
@@ -56,7 +58,9 @@ describe("validateWellKnownAuthentication()", () => {
5658
account: { url: "test" },
5759
},
5860
};
59-
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
61+
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
62+
OidcError.Misconfigured,
63+
);
6064
});
6165

6266
it("should throw misconfigured error when authentication account is false", () => {
@@ -67,7 +71,9 @@ describe("validateWellKnownAuthentication()", () => {
6771
account: false,
6872
},
6973
};
70-
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
74+
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
75+
OidcError.Misconfigured,
76+
);
7177
});
7278

7379
it("should return valid config when wk uses stable m.authentication", () => {
@@ -78,7 +84,7 @@ describe("validateWellKnownAuthentication()", () => {
7884
account: "account.com",
7985
},
8086
};
81-
expect(validateWellKnownAuthentication(wk)).toEqual({
87+
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
8288
issuer: "test.com",
8389
account: "account.com",
8490
});
@@ -91,7 +97,7 @@ describe("validateWellKnownAuthentication()", () => {
9197
issuer: "test.com",
9298
},
9399
};
94-
expect(validateWellKnownAuthentication(wk)).toEqual({
100+
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
95101
issuer: "test.com",
96102
});
97103
});
@@ -104,22 +110,8 @@ describe("validateWellKnownAuthentication()", () => {
104110
somethingElse: "test",
105111
},
106112
};
107-
expect(validateWellKnownAuthentication(wk)).toEqual({
108-
issuer: "test.com",
109-
});
110-
});
111-
112-
it("should return valid config when wk uses unstable prefix for m.authentication", () => {
113-
const wk = {
114-
...baseWk,
115-
[M_AUTHENTICATION.unstable!]: {
116-
issuer: "test.com",
117-
account: "account.com",
118-
},
119-
};
120-
expect(validateWellKnownAuthentication(wk)).toEqual({
113+
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
121114
issuer: "test.com",
122-
account: "account.com",
123115
});
124116
});
125117
});
@@ -129,6 +121,7 @@ describe("validateOIDCIssuerWellKnown", () => {
129121
authorization_endpoint: "https://test.org/authorize",
130122
token_endpoint: "https://authorize.org/token",
131123
registration_endpoint: "https://authorize.org/regsiter",
124+
revocation_endpoint: "https://authorize.org/regsiter",
132125
response_types_supported: ["code"],
133126
grant_types_supported: ["authorization_code"],
134127
code_challenge_methods_supported: ["S256"],

0 commit comments

Comments
 (0)