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: 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
This commit is contained in:
@ -63,6 +63,7 @@
|
|||||||
"loglevel": "^1.7.1",
|
"loglevel": "^1.7.1",
|
||||||
"matrix-events-sdk": "0.0.1",
|
"matrix-events-sdk": "0.0.1",
|
||||||
"matrix-widget-api": "^1.3.1",
|
"matrix-widget-api": "^1.3.1",
|
||||||
|
"oidc-client-ts": "^2.2.4",
|
||||||
"p-retry": "4",
|
"p-retry": "4",
|
||||||
"sdp-transform": "^2.14.1",
|
"sdp-transform": "^2.14.1",
|
||||||
"unhomoglyph": "^1.0.6",
|
"unhomoglyph": "^1.0.6",
|
||||||
|
53
spec/test-utils/oidc.ts
Normal file
53
spec/test-utils/oidc.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
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 { OidcClientConfig } from "../../src";
|
||||||
|
import { ValidatedIssuerMetadata } from "../../src/oidc/validate";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a valid OidcClientConfig with minimum valid values
|
||||||
|
* @param issuer used as the base for all other urls
|
||||||
|
* @returns OidcClientConfig
|
||||||
|
*/
|
||||||
|
export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClientConfig => {
|
||||||
|
const metadata = mockOpenIdConfiguration(issuer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
issuer,
|
||||||
|
account: issuer + "account",
|
||||||
|
registrationEndpoint: metadata.registration_endpoint,
|
||||||
|
authorizationEndpoint: metadata.authorization_endpoint,
|
||||||
|
tokenEndpoint: metadata.token_endpoint,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Useful for mocking <issuer>/.well-known/openid-configuration
|
||||||
|
* @param issuer used as the base for all other urls
|
||||||
|
* @returns ValidatedIssuerMetadata
|
||||||
|
*/
|
||||||
|
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
|
||||||
|
issuer,
|
||||||
|
revocation_endpoint: issuer + "revoke",
|
||||||
|
token_endpoint: issuer + "token",
|
||||||
|
authorization_endpoint: issuer + "auth",
|
||||||
|
registration_endpoint: issuer + "registration",
|
||||||
|
jwks_uri: issuer + "jwks",
|
||||||
|
response_types_supported: ["code"],
|
||||||
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||||
|
code_challenge_methods_supported: ["S256"],
|
||||||
|
});
|
@ -15,10 +15,17 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fetchMock from "fetch-mock-jest";
|
||||||
import MockHttpBackend from "matrix-mock-request";
|
import MockHttpBackend from "matrix-mock-request";
|
||||||
|
|
||||||
|
import { M_AUTHENTICATION } from "../../src";
|
||||||
import { AutoDiscovery } from "../../src/autodiscovery";
|
import { AutoDiscovery } from "../../src/autodiscovery";
|
||||||
import { OidcError } from "../../src/oidc/error";
|
import { OidcError } from "../../src/oidc/error";
|
||||||
|
import { makeDelegatedAuthConfig } from "../test-utils/oidc";
|
||||||
|
|
||||||
|
// keep to reset the fetch function after using MockHttpBackend
|
||||||
|
// @ts-ignore private property
|
||||||
|
const realAutoDiscoveryFetch: typeof global.fetch = AutoDiscovery.fetchFn;
|
||||||
|
|
||||||
describe("AutoDiscovery", function () {
|
describe("AutoDiscovery", function () {
|
||||||
const getHttpBackend = (): MockHttpBackend => {
|
const getHttpBackend = (): MockHttpBackend => {
|
||||||
@ -27,6 +34,10 @@ describe("AutoDiscovery", function () {
|
|||||||
return httpBackend;
|
return httpBackend;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
AutoDiscovery.setFetchFn(realAutoDiscoveryFetch);
|
||||||
|
});
|
||||||
|
|
||||||
it("should throw an error when no domain is specified", function () {
|
it("should throw an error when no domain is specified", function () {
|
||||||
getHttpBackend();
|
getHttpBackend();
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
@ -855,4 +866,75 @@ describe("AutoDiscovery", function () {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("m.authentication", () => {
|
||||||
|
const homeserverName = "example.org";
|
||||||
|
const homeserverUrl = "https://chat.example.org/";
|
||||||
|
const issuer = "https://auth.org/";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// make these tests independent from fetch mocking above
|
||||||
|
AutoDiscovery.setFetchFn(realAutoDiscoveryFetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.resetBehavior();
|
||||||
|
fetchMock.get(`${homeserverUrl}_matrix/client/versions`, { versions: ["r0.0.1"] });
|
||||||
|
|
||||||
|
fetchMock.get("https://example.org/.well-known/matrix/client", {
|
||||||
|
"m.homeserver": {
|
||||||
|
// Note: we also expect this test to trim the trailing slash
|
||||||
|
base_url: "https://chat.example.org/",
|
||||||
|
},
|
||||||
|
"m.authentication": {
|
||||||
|
issuer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return valid authentication configuration", async () => {
|
||||||
|
const config = makeDelegatedAuthConfig(issuer);
|
||||||
|
|
||||||
|
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
|
||||||
|
fetchMock.get(`${config.metadata.issuer}jwks`, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
keys: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await AutoDiscovery.findClientConfig(homeserverName);
|
||||||
|
|
||||||
|
expect(result[M_AUTHENTICATION.stable!]).toEqual({
|
||||||
|
state: AutoDiscovery.SUCCESS,
|
||||||
|
...config,
|
||||||
|
signingKeys: [],
|
||||||
|
account: undefined,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set state to error for invalid authentication configuration", async () => {
|
||||||
|
const config = makeDelegatedAuthConfig(issuer);
|
||||||
|
// authorization_code is required
|
||||||
|
config.metadata.grant_types_supported = ["openid"];
|
||||||
|
|
||||||
|
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
|
||||||
|
fetchMock.get(`${config.metadata.issuer}jwks`, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
keys: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await AutoDiscovery.findClientConfig(homeserverName);
|
||||||
|
|
||||||
|
expect(result[M_AUTHENTICATION.stable!]).toEqual({
|
||||||
|
state: AutoDiscovery.FAIL_ERROR,
|
||||||
|
error: OidcError.OpSupport,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
@ -25,8 +29,10 @@ import {
|
|||||||
completeAuthorizationCodeGrant,
|
completeAuthorizationCodeGrant,
|
||||||
generateAuthorizationParams,
|
generateAuthorizationParams,
|
||||||
generateAuthorizationUrl,
|
generateAuthorizationUrl,
|
||||||
|
generateOidcAuthorizationUrl,
|
||||||
} from "../../../src/oidc/authorize";
|
} from "../../../src/oidc/authorize";
|
||||||
import { OidcError } from "../../../src/oidc/error";
|
import { OidcError } from "../../../src/oidc/error";
|
||||||
|
import { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "../../test-utils/oidc";
|
||||||
|
|
||||||
jest.mock("jwt-decode");
|
jest.mock("jwt-decode");
|
||||||
|
|
||||||
@ -34,20 +40,16 @@ jest.mock("jwt-decode");
|
|||||||
const realSubtleCrypto = crypto.subtleCrypto;
|
const realSubtleCrypto = crypto.subtleCrypto;
|
||||||
|
|
||||||
describe("oidc authorization", () => {
|
describe("oidc authorization", () => {
|
||||||
const issuer = "https://auth.com/";
|
const delegatedAuthConfig = makeDelegatedAuthConfig();
|
||||||
const authorizationEndpoint = "https://auth.com/authorization";
|
const authorizationEndpoint = delegatedAuthConfig.metadata.authorization_endpoint;
|
||||||
const tokenEndpoint = "https://auth.com/token";
|
const tokenEndpoint = delegatedAuthConfig.metadata.token_endpoint;
|
||||||
const delegatedAuthConfig = {
|
|
||||||
issuer,
|
|
||||||
registrationEndpoint: issuer + "registration",
|
|
||||||
authorizationEndpoint: issuer + "auth",
|
|
||||||
tokenEndpoint,
|
|
||||||
};
|
|
||||||
const clientId = "xyz789";
|
const clientId = "xyz789";
|
||||||
const baseUrl = "https://test.com";
|
const baseUrl = "https://test.com";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
jest.spyOn(logger, "warn");
|
jest.spyOn(logger, "warn");
|
||||||
|
|
||||||
|
fetchMock.get(delegatedAuthConfig.issuer + ".well-known/openid-configuration", mockOpenIdConfiguration());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -97,20 +99,36 @@ describe("oidc authorization", () => {
|
|||||||
"A secure context is required to generate code challenge. Using plain text code challenge",
|
"A secure context is required to generate code challenge. Using plain text code challenge",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateOidcAuthorizationUrl()", () => {
|
||||||
|
it("should generate url with correct parameters", async () => {
|
||||||
|
const nonce = "abc123";
|
||||||
|
|
||||||
|
const metadata = delegatedAuthConfig.metadata;
|
||||||
|
|
||||||
it("uses a s256 code challenge when crypto is available", async () => {
|
|
||||||
jest.spyOn(crypto.subtleCrypto, "digest");
|
|
||||||
const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl });
|
|
||||||
const authUrl = new URL(
|
const authUrl = new URL(
|
||||||
await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams),
|
await generateOidcAuthorizationUrl({
|
||||||
|
metadata,
|
||||||
|
homeserverUrl: baseUrl,
|
||||||
|
clientId,
|
||||||
|
redirectUri: baseUrl,
|
||||||
|
nonce,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const codeChallenge = authUrl.searchParams.get("code_challenge");
|
expect(authUrl.searchParams.get("response_mode")).toEqual("query");
|
||||||
expect(crypto.subtleCrypto.digest).toHaveBeenCalledWith("SHA-256", expect.any(Object));
|
expect(authUrl.searchParams.get("response_type")).toEqual("code");
|
||||||
|
expect(authUrl.searchParams.get("client_id")).toEqual(clientId);
|
||||||
|
expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256");
|
||||||
|
// scope minus the 10char random device id at the end
|
||||||
|
expect(authUrl.searchParams.get("scope")!.slice(0, -10)).toEqual(
|
||||||
|
"openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:",
|
||||||
|
);
|
||||||
|
expect(authUrl.searchParams.get("state")).toBeTruthy();
|
||||||
|
expect(authUrl.searchParams.get("nonce")).toEqual(nonce);
|
||||||
|
|
||||||
// didn't use plain text code challenge
|
expect(authUrl.searchParams.get("code_challenge")).toBeTruthy();
|
||||||
expect(authorizationParams.codeVerifier).not.toEqual(codeChallenge);
|
|
||||||
expect(codeChallenge).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ describe("validateWellKnownAuthentication()", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
it("should throw not supported error when wellKnown has no m.authentication section", () => {
|
it("should throw not supported error when wellKnown has no m.authentication section", () => {
|
||||||
expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcError.NotSupported);
|
expect(() => validateWellKnownAuthentication(undefined)).toThrow(OidcError.NotSupported);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw misconfigured error when authentication issuer is not a string", () => {
|
it("should throw misconfigured error when authentication issuer is not a string", () => {
|
||||||
@ -45,7 +45,9 @@ describe("validateWellKnownAuthentication()", () => {
|
|||||||
issuer: { url: "test.com" },
|
issuer: { url: "test.com" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
|
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||||
|
OidcError.Misconfigured,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw misconfigured error when authentication account is not a string", () => {
|
it("should throw misconfigured error when authentication account is not a string", () => {
|
||||||
@ -56,7 +58,9 @@ describe("validateWellKnownAuthentication()", () => {
|
|||||||
account: { url: "test" },
|
account: { url: "test" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
|
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||||
|
OidcError.Misconfigured,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw misconfigured error when authentication account is false", () => {
|
it("should throw misconfigured error when authentication account is false", () => {
|
||||||
@ -67,7 +71,9 @@ describe("validateWellKnownAuthentication()", () => {
|
|||||||
account: false,
|
account: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
|
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||||
|
OidcError.Misconfigured,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return valid config when wk uses stable m.authentication", () => {
|
it("should return valid config when wk uses stable m.authentication", () => {
|
||||||
@ -78,7 +84,7 @@ describe("validateWellKnownAuthentication()", () => {
|
|||||||
account: "account.com",
|
account: "account.com",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
|
||||||
issuer: "test.com",
|
issuer: "test.com",
|
||||||
account: "account.com",
|
account: "account.com",
|
||||||
});
|
});
|
||||||
@ -91,7 +97,7 @@ describe("validateWellKnownAuthentication()", () => {
|
|||||||
issuer: "test.com",
|
issuer: "test.com",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
|
||||||
issuer: "test.com",
|
issuer: "test.com",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -104,24 +110,10 @@ describe("validateWellKnownAuthentication()", () => {
|
|||||||
somethingElse: "test",
|
somethingElse: "test",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
|
||||||
issuer: "test.com",
|
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", () => {
|
describe("validateOIDCIssuerWellKnown", () => {
|
||||||
@ -129,6 +121,7 @@ describe("validateOIDCIssuerWellKnown", () => {
|
|||||||
authorization_endpoint: "https://test.org/authorize",
|
authorization_endpoint: "https://test.org/authorize",
|
||||||
token_endpoint: "https://authorize.org/token",
|
token_endpoint: "https://authorize.org/token",
|
||||||
registration_endpoint: "https://authorize.org/regsiter",
|
registration_endpoint: "https://authorize.org/regsiter",
|
||||||
|
revocation_endpoint: "https://authorize.org/regsiter",
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
grant_types_supported: ["authorization_code"],
|
grant_types_supported: ["authorization_code"],
|
||||||
code_challenge_methods_supported: ["S256"],
|
code_challenge_methods_supported: ["S256"],
|
||||||
|
@ -15,10 +15,18 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { SigningKey } from "oidc-client-ts";
|
||||||
|
|
||||||
import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } 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 { ValidatedIssuerConfig, validateOIDCIssuerWellKnown, validateWellKnownAuthentication } from "./oidc/validate";
|
import { discoverAndValidateAuthenticationConfig } from "./oidc/discovery";
|
||||||
|
import {
|
||||||
|
ValidatedIssuerConfig,
|
||||||
|
ValidatedIssuerMetadata,
|
||||||
|
validateOIDCIssuerWellKnown,
|
||||||
|
validateWellKnownAuthentication,
|
||||||
|
} from "./oidc/validate";
|
||||||
import { OidcError } from "./oidc/error";
|
import { OidcError } from "./oidc/error";
|
||||||
|
|
||||||
// Dev note: Auto discovery is part of the spec.
|
// Dev note: Auto discovery is part of the spec.
|
||||||
@ -50,12 +58,26 @@ interface AutoDiscoveryState {
|
|||||||
}
|
}
|
||||||
interface WellKnownConfig extends Omit<IWellKnownConfig, "error">, AutoDiscoveryState {}
|
interface WellKnownConfig extends Omit<IWellKnownConfig, "error">, AutoDiscoveryState {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated in favour of OidcClientConfig
|
||||||
|
*/
|
||||||
interface DelegatedAuthConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig, AutoDiscoveryState {}
|
interface DelegatedAuthConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig, AutoDiscoveryState {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
export interface OidcClientConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig {
|
||||||
|
metadata: ValidatedIssuerMetadata;
|
||||||
|
signingKeys?: SigningKey[];
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
/**
|
||||||
|
* @experimental
|
||||||
|
*/
|
||||||
|
"m.authentication"?: (OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -262,7 +284,7 @@ export class AutoDiscovery {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const authConfig = await this.validateDiscoveryAuthenticationConfig(wellknown);
|
const authConfig = await this.discoverAndValidateAuthenticationConfig(wellknown);
|
||||||
clientConfig[M_AUTHENTICATION.stable!] = authConfig;
|
clientConfig[M_AUTHENTICATION.stable!] = authConfig;
|
||||||
|
|
||||||
// Step 8: Give the config to the caller (finally)
|
// Step 8: Give the config to the caller (finally)
|
||||||
@ -271,6 +293,7 @@ export class AutoDiscovery {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate delegated auth configuration
|
* Validate delegated auth configuration
|
||||||
|
* @deprecated use discoverAndValidateAuthenticationConfig
|
||||||
* - m.authentication config is present and valid
|
* - m.authentication config is present and valid
|
||||||
* - delegated auth issuer openid-configuration is reachable
|
* - delegated auth issuer openid-configuration is reachable
|
||||||
* - delegated auth issuer openid-configuration is configured correctly for us
|
* - delegated auth issuer openid-configuration is configured correctly for us
|
||||||
@ -284,7 +307,8 @@ export class AutoDiscovery {
|
|||||||
wellKnown: IClientWellKnown,
|
wellKnown: IClientWellKnown,
|
||||||
): Promise<DelegatedAuthConfig | AutoDiscoveryState> {
|
): Promise<DelegatedAuthConfig | AutoDiscoveryState> {
|
||||||
try {
|
try {
|
||||||
const homeserverAuthenticationConfig = validateWellKnownAuthentication(wellKnown);
|
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
|
||||||
|
const homeserverAuthenticationConfig = validateWellKnownAuthentication(authentication);
|
||||||
|
|
||||||
const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl(
|
const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl(
|
||||||
homeserverAuthenticationConfig.issuer,
|
homeserverAuthenticationConfig.issuer,
|
||||||
@ -319,6 +343,48 @@ export class AutoDiscovery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, validated authentication metadata and optionally signing keys will be returned
|
||||||
|
* 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 discoverAndValidateAuthenticationConfig(
|
||||||
|
wellKnown: IClientWellKnown,
|
||||||
|
): Promise<(OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState> {
|
||||||
|
try {
|
||||||
|
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
|
||||||
|
const result = await discoverAndValidateAuthenticationConfig(authentication);
|
||||||
|
|
||||||
|
// include this for backwards compatibility
|
||||||
|
const validatedIssuerConfig = validateOIDCIssuerWellKnown(result.metadata);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
state: AutoDiscoveryAction.SUCCESS,
|
||||||
|
error: null,
|
||||||
|
...validatedIssuerConfig,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = (error as Error).message as unknown as OidcError;
|
||||||
|
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
|
||||||
|
|
||||||
|
const state =
|
||||||
|
errorType === OidcError.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
|
||||||
|
@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { OidcClient, WebStorageStateStore } from "oidc-client-ts";
|
||||||
|
|
||||||
import { IDelegatedAuthConfig } from "../client";
|
import { IDelegatedAuthConfig } from "../client";
|
||||||
import { Method } from "../http-api";
|
import { Method } from "../http-api";
|
||||||
import { subtleCrypto, TextEncoder } from "../crypto/crypto";
|
import { subtleCrypto, TextEncoder } from "../crypto/crypto";
|
||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
import { randomString } from "../randomstring";
|
import { randomString } from "../randomstring";
|
||||||
import { OidcError } from "./error";
|
import { OidcError } from "./error";
|
||||||
import { validateIdToken, ValidatedIssuerConfig } from "./validate";
|
import { validateIdToken, ValidatedIssuerConfig, ValidatedIssuerMetadata, UserState } from "./validate";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorization parameters which are used in the authentication request of an OIDC auth code flow.
|
* Authorization parameters which are used in the authentication request of an OIDC auth code flow.
|
||||||
@ -35,6 +37,11 @@ export type AuthorizationParams = {
|
|||||||
nonce: string;
|
nonce: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
* Generate the scope used in authorization request with OIDC OP
|
||||||
|
* @returns scope
|
||||||
|
*/
|
||||||
const generateScope = (): string => {
|
const generateScope = (): string => {
|
||||||
const deviceId = randomString(10);
|
const deviceId = randomString(10);
|
||||||
return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
|
return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
|
||||||
@ -74,6 +81,7 @@ export const generateAuthorizationParams = ({ redirectUri }: { redirectUri: stri
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @deprecated use generateOidcAuthorizationUrl
|
||||||
* Generate a URL to attempt authorization with the OP
|
* Generate a URL to attempt authorization with the OP
|
||||||
* See https://openid.net/specs/openid-connect-basic-1_0.html#CodeRequest
|
* See https://openid.net/specs/openid-connect-basic-1_0.html#CodeRequest
|
||||||
* @param authorizationUrl - endpoint to attempt authorization with the OP
|
* @param authorizationUrl - endpoint to attempt authorization with the OP
|
||||||
@ -101,6 +109,49 @@ export const generateAuthorizationUrl = async (
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
* Generate a URL to attempt authorization with the OP
|
||||||
|
* See https://openid.net/specs/openid-connect-basic-1_0.html#CodeRequest
|
||||||
|
* @param oidcClientSettings - oidc configuration
|
||||||
|
* @param homeserverName - used as state
|
||||||
|
* @returns a Promise with the url as a string
|
||||||
|
*/
|
||||||
|
export const generateOidcAuthorizationUrl = async ({
|
||||||
|
metadata,
|
||||||
|
redirectUri,
|
||||||
|
clientId,
|
||||||
|
homeserverUrl,
|
||||||
|
identityServerUrl,
|
||||||
|
nonce,
|
||||||
|
}: {
|
||||||
|
clientId: string;
|
||||||
|
metadata: ValidatedIssuerMetadata;
|
||||||
|
homeserverUrl: string;
|
||||||
|
identityServerUrl?: string;
|
||||||
|
redirectUri: string;
|
||||||
|
nonce: string;
|
||||||
|
}): Promise<string> => {
|
||||||
|
const scope = await generateScope();
|
||||||
|
const oidcClient = new OidcClient({
|
||||||
|
...metadata,
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
authority: metadata.issuer,
|
||||||
|
response_mode: "query",
|
||||||
|
response_type: "code",
|
||||||
|
scope,
|
||||||
|
stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }),
|
||||||
|
});
|
||||||
|
const userState: UserState = { homeserverUrl, nonce, identityServerUrl };
|
||||||
|
const request = await oidcClient.createSigninRequest({
|
||||||
|
state: userState,
|
||||||
|
nonce,
|
||||||
|
});
|
||||||
|
|
||||||
|
return request.url;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The expected response type from the token endpoint during authorization code flow
|
* The expected response type from the token endpoint during authorization code flow
|
||||||
* Normalized to always use capitalized 'Bearer' for token_type
|
* Normalized to always use capitalized 'Bearer' for token_type
|
||||||
|
61
src/oidc/discovery.ts
Normal file
61
src/oidc/discovery.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
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 { MetadataService, OidcClientSettingsStore, SigningKey } from "oidc-client-ts";
|
||||||
|
|
||||||
|
import { IDelegatedAuthConfig } from "../client";
|
||||||
|
import { isValidatedIssuerMetadata, ValidatedIssuerMetadata, validateWellKnownAuthentication } from "./validate";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
* Discover and 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, validated metadata is returned
|
||||||
|
* @param wellKnown - configuration object as returned
|
||||||
|
* by the .well-known auto-discovery endpoint
|
||||||
|
* @returns validated authentication metadata and optionally signing keys
|
||||||
|
* @throws when delegated auth config is invalid or unreachable
|
||||||
|
*/
|
||||||
|
export const discoverAndValidateAuthenticationConfig = async (
|
||||||
|
authenticationConfig?: IDelegatedAuthConfig,
|
||||||
|
): Promise<
|
||||||
|
IDelegatedAuthConfig & {
|
||||||
|
metadata: ValidatedIssuerMetadata;
|
||||||
|
signingKeys?: SigningKey[];
|
||||||
|
}
|
||||||
|
> => {
|
||||||
|
const homeserverAuthenticationConfig = validateWellKnownAuthentication(authenticationConfig);
|
||||||
|
|
||||||
|
// create a temporary settings store so we can use metadata service for discovery
|
||||||
|
const settings = new OidcClientSettingsStore({
|
||||||
|
authority: homeserverAuthenticationConfig.issuer,
|
||||||
|
redirect_uri: "", // Not known yet, this is here to make the type checker happy
|
||||||
|
client_id: "", // Not known yet, this is here to make the type checker happy
|
||||||
|
});
|
||||||
|
const metadataService = new MetadataService(settings);
|
||||||
|
const metadata = await metadataService.getMetadata();
|
||||||
|
const signingKeys = (await metadataService.getSigningKeys()) ?? undefined;
|
||||||
|
|
||||||
|
isValidatedIssuerMetadata(metadata);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...homeserverAuthenticationConfig,
|
||||||
|
metadata,
|
||||||
|
signingKeys,
|
||||||
|
};
|
||||||
|
};
|
@ -15,8 +15,9 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import jwtDecode from "jwt-decode";
|
import jwtDecode from "jwt-decode";
|
||||||
|
import { OidcMetadata } from "oidc-client-ts";
|
||||||
|
|
||||||
import { IClientWellKnown, IDelegatedAuthConfig, M_AUTHENTICATION } from "../client";
|
import { IDelegatedAuthConfig } from "../client";
|
||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
import { OidcError } from "./error";
|
import { OidcError } from "./error";
|
||||||
|
|
||||||
@ -39,9 +40,7 @@ export type ValidatedIssuerConfig = {
|
|||||||
* @returns config - when present and valid
|
* @returns config - when present and valid
|
||||||
* @throws when config is not found or invalid
|
* @throws when config is not found or invalid
|
||||||
*/
|
*/
|
||||||
export const validateWellKnownAuthentication = (wellKnown: IClientWellKnown): IDelegatedAuthConfig => {
|
export const validateWellKnownAuthentication = (authentication?: IDelegatedAuthConfig): IDelegatedAuthConfig => {
|
||||||
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown);
|
|
||||||
|
|
||||||
if (!authentication) {
|
if (!authentication) {
|
||||||
throw new Error(OidcError.NotSupported);
|
throw new Error(OidcError.NotSupported);
|
||||||
}
|
}
|
||||||
@ -101,6 +100,7 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer
|
|||||||
const isInvalid = [
|
const isInvalid = [
|
||||||
requiredStringProperty(wellKnown, "authorization_endpoint"),
|
requiredStringProperty(wellKnown, "authorization_endpoint"),
|
||||||
requiredStringProperty(wellKnown, "token_endpoint"),
|
requiredStringProperty(wellKnown, "token_endpoint"),
|
||||||
|
requiredStringProperty(wellKnown, "revocation_endpoint"),
|
||||||
optionalStringProperty(wellKnown, "registration_endpoint"),
|
optionalStringProperty(wellKnown, "registration_endpoint"),
|
||||||
requiredArrayValue(wellKnown, "response_types_supported", "code"),
|
requiredArrayValue(wellKnown, "response_types_supported", "code"),
|
||||||
requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"),
|
requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"),
|
||||||
@ -119,6 +119,36 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer
|
|||||||
throw new Error(OidcError.OpSupport);
|
throw new Error(OidcError.OpSupport);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata from OIDC authority discovery
|
||||||
|
* With validated properties required in type
|
||||||
|
*/
|
||||||
|
export type ValidatedIssuerMetadata = Partial<OidcMetadata> &
|
||||||
|
Pick<
|
||||||
|
OidcMetadata,
|
||||||
|
| "issuer"
|
||||||
|
| "authorization_endpoint"
|
||||||
|
| "token_endpoint"
|
||||||
|
| "registration_endpoint"
|
||||||
|
| "revocation_endpoint"
|
||||||
|
| "response_types_supported"
|
||||||
|
| "grant_types_supported"
|
||||||
|
| "code_challenge_methods_supported"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps validateOIDCIssuerWellKnown in a type assertion
|
||||||
|
* that asserts expected properties are present
|
||||||
|
* (Typescript assertions cannot be arrow functions)
|
||||||
|
* @param metadata - issuer openid-configuration response
|
||||||
|
* @throws when metadata validation fails
|
||||||
|
*/
|
||||||
|
export function isValidatedIssuerMetadata(
|
||||||
|
metadata: Partial<OidcMetadata>,
|
||||||
|
): asserts metadata is ValidatedIssuerMetadata {
|
||||||
|
validateOIDCIssuerWellKnown(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard JWT claims.
|
* Standard JWT claims.
|
||||||
*
|
*
|
||||||
@ -199,3 +229,19 @@ export const validateIdToken = (idToken: string | undefined, issuer: string, cli
|
|||||||
throw new Error(OidcError.InvalidIdToken);
|
throw new Error(OidcError.InvalidIdToken);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State we ask OidcClient to store when starting oidc authorization flow (in `generateOidcAuthorizationUrl`)
|
||||||
|
* so that we can access it on return from the OP and complete login
|
||||||
|
*/
|
||||||
|
export type UserState = {
|
||||||
|
/**
|
||||||
|
* Remember which server we were trying to login to
|
||||||
|
*/
|
||||||
|
homeserverUrl: string;
|
||||||
|
identityServerUrl?: string;
|
||||||
|
/**
|
||||||
|
* Used to validate id token
|
||||||
|
*/
|
||||||
|
nonce: string;
|
||||||
|
};
|
||||||
|
13
yarn.lock
13
yarn.lock
@ -3115,6 +3115,11 @@ crypto-browserify@^3.0.0:
|
|||||||
randombytes "^2.0.0"
|
randombytes "^2.0.0"
|
||||||
randomfill "^1.0.3"
|
randomfill "^1.0.3"
|
||||||
|
|
||||||
|
crypto-js@^4.1.1:
|
||||||
|
version "4.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.1.1.tgz#9e485bcf03521041bd85844786b83fb7619736cf"
|
||||||
|
integrity sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==
|
||||||
|
|
||||||
cssom@^0.5.0:
|
cssom@^0.5.0:
|
||||||
version "0.5.0"
|
version "0.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36"
|
resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36"
|
||||||
@ -5920,6 +5925,14 @@ object.values@^1.1.6:
|
|||||||
define-properties "^1.1.4"
|
define-properties "^1.1.4"
|
||||||
es-abstract "^1.20.4"
|
es-abstract "^1.20.4"
|
||||||
|
|
||||||
|
oidc-client-ts@^2.2.4:
|
||||||
|
version "2.2.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/oidc-client-ts/-/oidc-client-ts-2.2.4.tgz#7d86b5efe2248f3637a6f3a0ee1af86764aea125"
|
||||||
|
integrity sha512-nOZwIomju+AmXObl5Oq5PjrES/qTt8bLsENJCIydVgi9TEWk7SCkOU6X3RNkY7yfySRM1OJJvDKdREZdmnDT2g==
|
||||||
|
dependencies:
|
||||||
|
crypto-js "^4.1.1"
|
||||||
|
jwt-decode "^3.1.2"
|
||||||
|
|
||||||
once@^1.3.0, once@^1.4.0:
|
once@^1.3.0, once@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
|
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
|
||||||
|
Reference in New Issue
Block a user