Update MSC2965 OIDC Discovery implementation (#12245)

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
Michael Telatynski 2024-02-23 16:43:14 +00:00 committed by GitHub
parent 729eca49e4
commit 7b1e8e3d2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 350 additions and 300 deletions

View file

@ -795,7 +795,7 @@ async function createOidcTokenRefresher(credentials: IMatrixClientCreds): Promis
throw new Error("Expected deviceId in user credentials.");
}
const tokenRefresher = new TokenRefresher(
{ issuer: tokenIssuer },
tokenIssuer,
clientId,
redirectUri,
deviceId,

View file

@ -80,9 +80,6 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
this.setState({ otherHomeserver: ev.target.value });
};
// TODO: Do we want to support .well-known lookups here?
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org"
private validate = withValidation<this, { error?: string }>({
deriveData: async ({ value }): Promise<{ error?: string }> => {
let hsUrl = (value ?? "").trim(); // trim to account for random whitespace
@ -91,7 +88,10 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
if (!hsUrl.includes("://")) {
try {
const discoveryResult = await AutoDiscovery.findClientConfig(hsUrl);
this.validatedConf = AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(hsUrl, discoveryResult);
this.validatedConf = await AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(
hsUrl,
discoveryResult,
);
return {}; // we have a validated config, we don't need to try the other paths
} catch (e) {
logger.error(`Attempted ${hsUrl} as a server_name but it failed`, e);

View file

@ -14,18 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IDelegatedAuthConfig, MatrixClient, M_AUTHENTICATION } from "matrix-js-sdk/src/matrix";
import { discoverAndValidateAuthenticationConfig } from "matrix-js-sdk/src/oidc/discovery";
import { MatrixClient, discoverAndValidateOIDCIssuerWellKnown } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { OidcClient } from "oidc-client-ts";
import { getStoredOidcTokenIssuer, getStoredOidcClientId } from "../../utils/oidc/persistOidcSettings";
import { getDelegatedAuthAccountUrl } from "../../utils/oidc/getDelegatedAuthAccountUrl";
import PlatformPeg from "../../PlatformPeg";
/**
* @experimental
* Stores information about configured OIDC provider
*
* In OIDC Native mode the client is registered with OIDC directly and maintains an OIDC token.
*
* In OIDC Aware mode, the client is aware that the Server is using OIDC, but is using the standard Matrix APIs for most things.
* (Notable exceptions are account management, where a link to the account management endpoint will be provided instead.)
*
* Otherwise, the store is not operating. Auth is then in Legacy mode and everything uses normal Matrix APIs.
*/
export class OidcClientStore {
private oidcClient?: OidcClient;
@ -47,8 +52,16 @@ export class OidcClientStore {
if (this.authenticatedIssuer) {
await this.getOidcClient();
} else {
const wellKnown = await this.matrixClient.waitForClientWellKnown();
this._accountManagementEndpoint = getDelegatedAuthAccountUrl(wellKnown);
// We are not in OIDC Native mode, as we have no locally stored issuer. Check if the server delegates auth to OIDC.
try {
const authIssuer = await this.matrixClient.getAuthIssuer();
const { accountManagementEndpoint, metadata } = await discoverAndValidateOIDCIssuerWellKnown(
authIssuer.issuer,
);
this._accountManagementEndpoint = accountManagementEndpoint ?? metadata.issuer;
} catch (e) {
console.log("Auth issuer not found", e);
}
}
}
@ -127,23 +140,18 @@ export class OidcClientStore {
* @returns promise that resolves when initialising OidcClient succeeds or fails
*/
private async initOidcClient(): Promise<void> {
const wellKnown = await this.matrixClient.waitForClientWellKnown();
if (!wellKnown && !this.authenticatedIssuer) {
if (!this.authenticatedIssuer) {
logger.error("Cannot initialise OIDC client without issuer.");
return;
}
const delegatedAuthConfig =
(wellKnown && M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown)) ?? undefined;
try {
const clientId = getStoredOidcClientId();
const { account, metadata, signingKeys } = await discoverAndValidateAuthenticationConfig(
// if HS has valid delegated auth config in .well-known, use it
// otherwise fallback to the known issuer
delegatedAuthConfig ?? { issuer: this.authenticatedIssuer! },
const { accountManagementEndpoint, metadata, signingKeys } = await discoverAndValidateOIDCIssuerWellKnown(
this.authenticatedIssuer,
);
// if no account endpoint is configured default to the issuer
this._accountManagementEndpoint = account ?? metadata.issuer;
this._accountManagementEndpoint = accountManagementEndpoint ?? metadata.issuer;
this.oidcClient = new OidcClient({
...metadata,
authority: metadata.issuer,

View file

@ -19,9 +19,11 @@ import {
AutoDiscovery,
AutoDiscoveryError,
ClientConfig,
OidcClientConfig,
M_AUTHENTICATION,
discoverAndValidateOIDCIssuerWellKnown,
IClientWellKnown,
MatrixClient,
MatrixError,
OidcClientConfig,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
@ -217,12 +219,12 @@ export default class AutoDiscoveryUtils {
* @param {boolean} isSynthetic If true, then the discoveryResult was synthesised locally.
* @returns {Promise<ValidatedServerConfig>} Resolves to the validated configuration.
*/
public static buildValidatedConfigFromDiscovery(
public static async buildValidatedConfigFromDiscovery(
serverName?: string,
discoveryResult?: ClientConfig,
syntaxOnly = false,
isSynthetic = false,
): ValidatedServerConfig {
): Promise<ValidatedServerConfig> {
if (!discoveryResult?.["m.homeserver"]) {
// This shouldn't happen without major misconfiguration, so we'll log a bit of information
// in the log so we can find this bit of code but otherwise tell the user "it broke".
@ -293,26 +295,20 @@ export default class AutoDiscoveryUtils {
throw new UserFriendlyError("auth|autodiscovery_unexpected_error_hs");
}
// This isn't inherently auto-discovery but used to be in an earlier incarnation of the MSC,
// and shuttling the data together makes a lot of sense
let delegatedAuthentication: OidcClientConfig | undefined;
if (discoveryResult[M_AUTHENTICATION.stable!]?.state === AutoDiscovery.SUCCESS) {
const {
authorizationEndpoint,
registrationEndpoint,
tokenEndpoint,
account,
issuer,
metadata,
signingKeys,
} = discoveryResult[M_AUTHENTICATION.stable!] as OidcClientConfig;
delegatedAuthentication = Object.freeze({
authorizationEndpoint,
registrationEndpoint,
tokenEndpoint,
account,
issuer,
metadata,
signingKeys,
});
let delegatedAuthenticationError: Error | undefined;
try {
const tempClient = new MatrixClient({ baseUrl: preferredHomeserverUrl });
const { issuer } = await tempClient.getAuthIssuer();
delegatedAuthentication = await discoverAndValidateOIDCIssuerWellKnown(issuer);
} catch (e) {
if (e instanceof MatrixError && e.httpStatus === 404 && e.errcode === "M_UNRECOGNIZED") {
// 404 M_UNRECOGNIZED means the server does not support OIDC
} else {
delegatedAuthenticationError = e as Error;
}
}
return {
@ -321,7 +317,7 @@ export default class AutoDiscoveryUtils {
hsNameIsDifferent: url.hostname !== preferredHomeserverName,
isUrl: preferredIdentityUrl,
isDefault: false,
warning: hsResult.error,
warning: hsResult.error ?? delegatedAuthenticationError ?? null,
isNameResolvable: !isSynthetic,
delegatedAuthentication,
} as ValidatedServerConfig;

View file

@ -14,10 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { OidcClientConfig, IDelegatedAuthConfig } from "matrix-js-sdk/src/matrix";
import { ValidatedIssuerConfig } from "matrix-js-sdk/src/oidc/validate";
export type ValidatedDelegatedAuthConfig = IDelegatedAuthConfig & ValidatedIssuerConfig;
import { OidcClientConfig } from "matrix-js-sdk/src/matrix";
export interface ValidatedServerConfig {
hsUrl: string;
@ -34,9 +31,9 @@ export interface ValidatedServerConfig {
/**
* Config related to delegated authentication
* Included when delegated auth is configured and valid, otherwise undefined
* From homeserver .well-known m.authentication, and issuer's .well-known/openid-configuration
* Used for OIDC native flow authentication
* Included when delegated auth is configured and valid, otherwise undefined.
* From issuer's .well-known/openid-configuration.
* Used for OIDC native flow authentication.
*/
delegatedAuthentication?: OidcClientConfig;
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IDelegatedAuthConfig, OidcTokenRefresher, AccessTokens } from "matrix-js-sdk/src/matrix";
import { OidcTokenRefresher, AccessTokens } from "matrix-js-sdk/src/matrix";
import { IdTokenClaims } from "oidc-client-ts";
import PlatformPeg from "../../PlatformPeg";
@ -28,14 +28,14 @@ export class TokenRefresher extends OidcTokenRefresher {
private readonly deviceId!: string;
public constructor(
authConfig: IDelegatedAuthConfig,
issuer: string,
clientId: string,
redirectUri: string,
deviceId: string,
idTokenClaims: IdTokenClaims,
private readonly userId: string,
) {
super(authConfig, clientId, deviceId, redirectUri, idTokenClaims);
super(issuer, clientId, deviceId, redirectUri, idTokenClaims);
this.deviceId = deviceId;
}

View file

@ -1,27 +0,0 @@
/*
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 "matrix-js-sdk/src/matrix";
/**
* Get the delegated auth account management url if configured
* @param clientWellKnown from MatrixClient.getClientWellKnown
* @returns the account management url, or undefined
*/
export const getDelegatedAuthAccountUrl = (clientWellKnown: IClientWellKnown | undefined): string | undefined => {
const delegatedAuthConfig = M_AUTHENTICATION.findIn<IDelegatedAuthConfig | undefined>(clientWellKnown);
return delegatedAuthConfig?.account;
};

View file

@ -15,10 +15,9 @@ limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { registerOidcClient } from "matrix-js-sdk/src/oidc/register";
import { registerOidcClient, OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { IConfigOptions } from "../../IConfigOptions";
import { ValidatedDelegatedAuthConfig } from "../ValidatedServerConfig";
import PlatformPeg from "../../PlatformPeg";
/**
@ -46,12 +45,12 @@ const getStaticOidcClientId = (
* @throws if no clientId is found
*/
export const getOidcClientId = async (
delegatedAuthConfig: ValidatedDelegatedAuthConfig,
delegatedAuthConfig: OidcClientConfig,
staticOidcClients?: IConfigOptions["oidc_static_clients"],
): Promise<string> => {
const staticClientId = getStaticOidcClientId(delegatedAuthConfig.issuer, staticOidcClients);
const staticClientId = getStaticOidcClientId(delegatedAuthConfig.metadata.issuer, staticOidcClients);
if (staticClientId) {
logger.debug(`Using static clientId for issuer ${delegatedAuthConfig.issuer}`);
logger.debug(`Using static clientId for issuer ${delegatedAuthConfig.metadata.issuer}`);
return staticClientId;
}
return await registerOidcClient(delegatedAuthConfig, await PlatformPeg.get()!.getOidcClientMetadata());

View file

@ -683,10 +683,10 @@ describe("Lifecycle", () => {
beforeAll(() => {
fetchMock.get(
`${delegatedAuthConfig.issuer}.well-known/openid-configuration`,
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
delegatedAuthConfig.metadata,
);
fetchMock.get(`${delegatedAuthConfig.issuer}jwks`, {
fetchMock.get(`${delegatedAuthConfig.metadata.issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
@ -696,12 +696,6 @@ describe("Lifecycle", () => {
});
beforeEach(() => {
// mock oidc config for oidc client initialisation
mockClient.waitForClientWellKnown.mockResolvedValue({
"m.authentication": {
issuer: issuer,
},
});
initSessionStorageMock();
// set values in session storage as they would be after a successful oidc authentication
persistOidcAuthenticatedSettings(clientId, issuer, idTokenClaims);
@ -711,7 +705,9 @@ describe("Lifecycle", () => {
await setLoggedIn(credentials);
// didn't try to initialise token refresher
expect(fetchMock).not.toHaveFetched(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`);
expect(fetchMock).not.toHaveFetched(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
);
});
it("should not try to create a token refresher without a deviceId", async () => {
@ -722,7 +718,9 @@ describe("Lifecycle", () => {
});
// didn't try to initialise token refresher
expect(fetchMock).not.toHaveFetched(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`);
expect(fetchMock).not.toHaveFetched(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
);
});
it("should not try to create a token refresher without an issuer in session storage", async () => {
@ -738,7 +736,9 @@ describe("Lifecycle", () => {
});
// didn't try to initialise token refresher
expect(fetchMock).not.toHaveFetched(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`);
expect(fetchMock).not.toHaveFetched(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
);
});
it("should create a client with a tokenRefreshFunction", async () => {

View file

@ -211,6 +211,11 @@ describe("<MatrixChat />", () => {
unstable_features: {},
versions: SERVER_SUPPORTED_MATRIX_VERSIONS,
});
fetchMock.catch({
status: 404,
body: '{"errcode": "M_UNRECOGNIZED", "error": "Unrecognized request"}',
headers: { "content-type": "application/json" },
});
jest.spyOn(StorageManager, "idbLoad").mockReset();
jest.spyOn(StorageManager, "idbSave").mockResolvedValue(undefined);

View file

@ -37,7 +37,6 @@ jest.mock("matrix-js-sdk/src/matrix", () => ({
...jest.requireActual("matrix-js-sdk/src/matrix"),
createClient: jest.fn(),
}));
jest.useFakeTimers();
/** The matrix versions our mock server claims to support */
const SERVER_SUPPORTED_MATRIX_VERSIONS = ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"];
@ -160,11 +159,17 @@ describe("Registration", function () {
// mock a statically registered client to avoid dynamic registration
SdkConfig.put({
oidc_static_clients: {
[authConfig.issuer]: {
[authConfig.metadata.issuer]: {
client_id: clientId,
},
},
});
fetchMock.get(`${defaultHsUrl}/_matrix/client/unstable/org.matrix.msc2965/auth_issuer`, {
issuer: authConfig.metadata.issuer,
});
fetchMock.get("https://auth.org/.well-known/openid-configuration", authConfig.metadata);
fetchMock.get(authConfig.metadata.jwks_uri!, { keys: [] });
});
describe("when oidc native flow is not enabled in settings", () => {
@ -192,14 +197,14 @@ describe("Registration", function () {
// no form
expect(container.querySelector("form")).toBeFalsy();
expect(screen.getByText("Continue")).toBeTruthy();
expect(await screen.findByText("Continue")).toBeTruthy();
});
it("should start OIDC login flow as registration on button click", async () => {
getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(screen.getByText("Continue"));
fireEvent.click(await screen.findByText("Continue"));
expect(startOidcLogin).toHaveBeenCalledWith(
authConfig,

View file

@ -64,6 +64,11 @@ describe("<ServerPickerDialog />", () => {
});
fetchMock.resetHistory();
fetchMock.catch({
status: 404,
body: '{"errcode": "M_UNRECOGNIZED", "error": "Unrecognized request"}',
headers: { "content-type": "application/json" },
});
});
it("should render dialog", () => {

View file

@ -17,17 +17,17 @@ limitations under the License.
import fetchMock from "fetch-mock-jest";
import { mocked } from "jest-mock";
import { OidcClient } from "oidc-client-ts";
import { M_AUTHENTICATION } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { discoverAndValidateAuthenticationConfig } from "matrix-js-sdk/src/oidc/discovery";
import { discoverAndValidateOIDCIssuerWellKnown } from "matrix-js-sdk/src/matrix";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { OidcClientStore } from "../../../src/stores/oidc/OidcClientStore";
import { flushPromises, getMockClientWithEventEmitter, mockPlatformPeg } from "../../test-utils";
import { mockOpenIdConfiguration } from "../../test-utils/oidc";
jest.mock("matrix-js-sdk/src/oidc/discovery", () => ({
discoverAndValidateAuthenticationConfig: jest.fn(),
jest.mock("matrix-js-sdk/src/matrix", () => ({
...jest.requireActual("matrix-js-sdk/src/matrix"),
discoverAndValidateOIDCIssuerWellKnown: jest.fn(),
}));
describe("OidcClientStore", () => {
@ -36,7 +36,7 @@ describe("OidcClientStore", () => {
const account = metadata.issuer + "account";
const mockClient = getMockClientWithEventEmitter({
waitForClientWellKnown: jest.fn().mockResolvedValue({}),
getAuthIssuer: jest.fn(),
});
beforeEach(() => {
@ -44,20 +44,16 @@ describe("OidcClientStore", () => {
localStorage.setItem("mx_oidc_client_id", clientId);
localStorage.setItem("mx_oidc_token_issuer", metadata.issuer);
mocked(discoverAndValidateAuthenticationConfig).mockClear().mockResolvedValue({
mocked(discoverAndValidateOIDCIssuerWellKnown).mockClear().mockResolvedValue({
metadata,
account,
issuer: metadata.issuer,
});
mockClient.waitForClientWellKnown.mockResolvedValue({
[M_AUTHENTICATION.stable!]: {
issuer: metadata.issuer,
account,
},
accountManagementEndpoint: account,
authorizationEndpoint: "authorization-endpoint",
tokenEndpoint: "token-endpoint",
});
jest.spyOn(logger, "error").mockClear();
fetchMock.get(`${metadata.issuer}.well-known/openid-configuration`, metadata);
fetchMock.get(`${metadata.issuer}jwks`, { keys: [] });
mockPlatformPeg();
});
@ -78,7 +74,6 @@ describe("OidcClientStore", () => {
describe("initialising oidcClient", () => {
it("should initialise oidc client from constructor", () => {
mockClient.waitForClientWellKnown.mockResolvedValue(undefined as any);
const store = new OidcClientStore(mockClient);
// started initialising
@ -87,7 +82,6 @@ describe("OidcClientStore", () => {
});
it("should fallback to stored issuer when no client well known is available", async () => {
mockClient.waitForClientWellKnown.mockResolvedValue(undefined as any);
const store = new OidcClientStore(mockClient);
// successfully created oidc client
@ -109,10 +103,10 @@ describe("OidcClientStore", () => {
});
it("should log and return when discovery and validation fails", async () => {
mocked(discoverAndValidateAuthenticationConfig).mockRejectedValue(new Error(OidcError.OpSupport));
mocked(discoverAndValidateOIDCIssuerWellKnown).mockRejectedValue(new Error(OidcError.OpSupport));
const store = new OidcClientStore(mockClient);
await flushPromises();
await store.readyPromise;
expect(logger.error).toHaveBeenCalledWith(
"Failed to initialise OidcClientStore",
@ -143,15 +137,15 @@ describe("OidcClientStore", () => {
});
it("should set account management endpoint to issuer when not configured", async () => {
mocked(discoverAndValidateAuthenticationConfig).mockClear().mockResolvedValue({
mocked(discoverAndValidateOIDCIssuerWellKnown).mockClear().mockResolvedValue({
metadata,
account: undefined,
issuer: metadata.issuer,
accountManagementEndpoint: undefined,
authorizationEndpoint: "authorization-endpoint",
tokenEndpoint: "token-endpoint",
});
const store = new OidcClientStore(mockClient);
// @ts-ignore private property
await store.getOidcClient();
await store.readyPromise;
expect(store.accountManagementEndpoint).toEqual(metadata.issuer);
});
@ -175,7 +169,7 @@ describe("OidcClientStore", () => {
// only called once for multiple calls to getOidcClient
// before and after initialisation is complete
expect(discoverAndValidateAuthenticationConfig).toHaveBeenCalledTimes(1);
expect(discoverAndValidateOIDCIssuerWellKnown).toHaveBeenCalledTimes(1);
});
});
@ -199,7 +193,6 @@ describe("OidcClientStore", () => {
it("should throw when oidcClient could not be initialised", async () => {
// make oidcClient initialisation fail
mockClient.waitForClientWellKnown.mockResolvedValue(undefined as any);
localStorage.removeItem("mx_oidc_token_issuer");
const store = new OidcClientStore(mockClient);
@ -245,4 +238,17 @@ describe("OidcClientStore", () => {
expect(OidcClient.prototype.revokeToken).toHaveBeenCalledWith(accessToken, "access_token");
});
});
describe("OIDC Aware", () => {
beforeEach(() => {
localStorage.clear();
});
it("should resolve account management endpoint", async () => {
mockClient.getAuthIssuer.mockResolvedValue({ issuer: metadata.issuer });
const store = new OidcClientStore(mockClient);
await store.readyPromise;
expect(store.accountManagementEndpoint).toBe(account);
});
});
});

View file

@ -26,8 +26,7 @@ export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClien
const metadata = mockOpenIdConfiguration(issuer);
return {
issuer,
account: issuer + "account",
accountManagementEndpoint: issuer + "account",
registrationEndpoint: metadata.registration_endpoint,
authorizationEndpoint: metadata.authorization_endpoint,
tokenEndpoint: metadata.token_endpoint,
@ -50,4 +49,5 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
account_management_uri: issuer + "account",
});

View file

@ -14,12 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { AutoDiscovery, AutoDiscoveryAction, ClientConfig, M_AUTHENTICATION } from "matrix-js-sdk/src/matrix";
import { AutoDiscovery, AutoDiscoveryAction, ClientConfig } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import fetchMock from "fetch-mock-jest";
import AutoDiscoveryUtils from "../../src/utils/AutoDiscoveryUtils";
import { mockOpenIdConfiguration } from "../test-utils/oidc";
describe("AutoDiscoveryUtils", () => {
beforeEach(() => {
fetchMock.catch({
status: 404,
body: '{"errcode": "M_UNRECOGNIZED", "error": "Unrecognized request"}',
headers: { "content-type": "application/json" },
});
});
describe("buildValidatedConfigFromDiscovery()", () => {
const serverName = "my-server";
@ -56,24 +66,24 @@ describe("AutoDiscoveryUtils", () => {
isUrl: "identity.com",
};
it("throws an error when discovery result is falsy", () => {
expect(() => AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, undefined as any)).toThrow(
"Unexpected error resolving homeserver configuration",
);
it("throws an error when discovery result is falsy", async () => {
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, undefined as any),
).rejects.toThrow("Unexpected error resolving homeserver configuration");
expect(logger.error).toHaveBeenCalled();
});
it("throws an error when discovery result does not include homeserver config", () => {
it("throws an error when discovery result does not include homeserver config", async () => {
const discoveryResult = {
...validIsConfig,
} as unknown as ClientConfig;
expect(() => AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult)).toThrow(
"Unexpected error resolving homeserver configuration",
);
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Unexpected error resolving homeserver configuration");
expect(logger.error).toHaveBeenCalled();
});
it("throws an error when identity server config has fail error and recognised error string", () => {
it("throws an error when identity server config has fail error and recognised error string", async () => {
const discoveryResult = {
...validHsConfig,
"m.identity_server": {
@ -81,13 +91,13 @@ describe("AutoDiscoveryUtils", () => {
error: "GenericFailure",
},
};
expect(() => AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult)).toThrow(
"Unexpected error resolving identity server configuration",
);
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Unexpected error resolving identity server configuration");
expect(logger.error).toHaveBeenCalled();
});
it("throws an error when homeserver config has fail error and recognised error string", () => {
it("throws an error when homeserver config has fail error and recognised error string", async () => {
const discoveryResult = {
...validIsConfig,
"m.homeserver": {
@ -95,25 +105,25 @@ describe("AutoDiscoveryUtils", () => {
error: AutoDiscovery.ERROR_INVALID_HOMESERVER,
},
};
expect(() => AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult)).toThrow(
"Homeserver URL does not appear to be a valid Matrix homeserver",
);
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Homeserver URL does not appear to be a valid Matrix homeserver");
expect(logger.error).toHaveBeenCalled();
});
it("throws an error with fallback message identity server config has fail error", () => {
it("throws an error with fallback message identity server config has fail error", async () => {
const discoveryResult = {
...validHsConfig,
"m.identity_server": {
state: AutoDiscoveryAction.FAIL_ERROR,
},
};
expect(() => AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult)).toThrow(
"Unexpected error resolving identity server configuration",
);
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Unexpected error resolving identity server configuration");
});
it("throws an error when error is ERROR_INVALID_HOMESERVER", () => {
it("throws an error when error is ERROR_INVALID_HOMESERVER", async () => {
const discoveryResult = {
...validIsConfig,
"m.homeserver": {
@ -121,12 +131,12 @@ describe("AutoDiscoveryUtils", () => {
error: AutoDiscovery.ERROR_INVALID_HOMESERVER,
},
};
expect(() => AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult)).toThrow(
"Homeserver URL does not appear to be a valid Matrix homeserver",
);
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Homeserver URL does not appear to be a valid Matrix homeserver");
});
it("throws an error when homeserver base_url is falsy", () => {
it("throws an error when homeserver base_url is falsy", async () => {
const discoveryResult = {
...validIsConfig,
"m.homeserver": {
@ -134,13 +144,13 @@ describe("AutoDiscoveryUtils", () => {
base_url: "",
},
};
expect(() => AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult)).toThrow(
"Unexpected error resolving homeserver configuration",
);
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Unexpected error resolving homeserver configuration");
expect(logger.error).toHaveBeenCalledWith("No homeserver URL configured");
});
it("throws an error when homeserver base_url is not a valid URL", () => {
it("throws an error when homeserver base_url is not a valid URL", async () => {
const discoveryResult = {
...validIsConfig,
"m.homeserver": {
@ -148,24 +158,25 @@ describe("AutoDiscoveryUtils", () => {
base_url: "banana",
},
};
expect(() => AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult)).toThrow(
"Invalid URL: banana",
);
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Invalid URL: banana");
});
it("uses hs url hostname when serverName is falsy in args and config", () => {
it("uses hs url hostname when serverName is falsy in args and config", async () => {
const discoveryResult = {
...validIsConfig,
...validHsConfig,
};
expect(AutoDiscoveryUtils.buildValidatedConfigFromDiscovery("", discoveryResult)).toEqual({
await expect(AutoDiscoveryUtils.buildValidatedConfigFromDiscovery("", discoveryResult)).resolves.toEqual({
...expectedValidatedConfig,
hsNameIsDifferent: false,
hsName: "matrix.org",
warning: null,
});
});
it("uses serverName from props", () => {
it("uses serverName from props", async () => {
const discoveryResult = {
...validIsConfig,
"m.homeserver": {
@ -174,16 +185,17 @@ describe("AutoDiscoveryUtils", () => {
},
};
const syntaxOnly = true;
expect(
await expect(
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult, syntaxOnly),
).toEqual({
).resolves.toEqual({
...expectedValidatedConfig,
hsNameIsDifferent: true,
hsName: serverName,
warning: null,
});
});
it("ignores liveliness error when checking syntax only", () => {
it("ignores liveliness error when checking syntax only", async () => {
const discoveryResult = {
...validIsConfig,
"m.homeserver": {
@ -193,60 +205,15 @@ describe("AutoDiscoveryUtils", () => {
},
};
const syntaxOnly = true;
expect(
await expect(
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult, syntaxOnly),
).toEqual({
).resolves.toEqual({
...expectedValidatedConfig,
warning: "Homeserver URL does not appear to be a valid Matrix homeserver",
});
});
it("ignores delegated auth config when discovery was not successful", () => {
const discoveryResult = {
...validIsConfig,
...validHsConfig,
[M_AUTHENTICATION.stable!]: {
state: AutoDiscoveryAction.FAIL_ERROR,
error: "",
},
};
const syntaxOnly = true;
expect(
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult, syntaxOnly),
).toEqual({
...expectedValidatedConfig,
delegatedAuthentication: undefined,
warning: undefined,
});
});
it("sets delegated auth config when discovery was successful", () => {
const authConfig = {
issuer: "https://test.com/",
authorizationEndpoint: "https://test.com/auth",
registrationEndpoint: "https://test.com/registration",
tokenEndpoint: "https://test.com/token",
};
const discoveryResult: ClientConfig = {
...validIsConfig,
...validHsConfig,
[M_AUTHENTICATION.stable!]: {
state: AutoDiscoveryAction.SUCCESS,
error: null,
...authConfig,
},
};
const syntaxOnly = true;
expect(
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult, syntaxOnly),
).toEqual({
...expectedValidatedConfig,
delegatedAuthentication: authConfig,
warning: undefined,
});
});
it("handles homeserver too old error", () => {
it("handles homeserver too old error", async () => {
const discoveryResult: ClientConfig = {
...validIsConfig,
"m.homeserver": {
@ -256,12 +223,165 @@ describe("AutoDiscoveryUtils", () => {
},
};
const syntaxOnly = true;
expect(() =>
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult, syntaxOnly),
).toThrow(
).rejects.toThrow(
"Your homeserver is too old and does not support the minimum API version required. Please contact your server owner, or upgrade your server.",
);
});
it("should validate delegated oidc auth", async () => {
const issuer = "https://auth.matrix.org/";
fetchMock.get(
`${validHsConfig["m.homeserver"].base_url}/_matrix/client/unstable/org.matrix.msc2965/auth_issuer`,
{
issuer,
},
);
fetchMock.get(`${issuer}.well-known/openid-configuration`, {
...mockOpenIdConfiguration(issuer),
"scopes_supported": ["openid", "email"],
"response_modes_supported": ["form_post", "query", "fragment"],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"none",
],
"token_endpoint_auth_signing_alg_values_supported": [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES256K",
],
"revocation_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"none",
],
"revocation_endpoint_auth_signing_alg_values_supported": [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES256K",
],
"introspection_endpoint": `${issuer}oauth2/introspect`,
"introspection_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"none",
],
"introspection_endpoint_auth_signing_alg_values_supported": [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES256K",
],
"userinfo_endpoint": `${issuer}oauth2/userinfo`,
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": [
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"PS256",
"PS384",
"PS512",
"ES256K",
],
"userinfo_signing_alg_values_supported": [
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"PS256",
"PS384",
"PS512",
"ES256K",
],
"display_values_supported": ["page"],
"claim_types_supported": ["normal"],
"claims_supported": ["iss", "sub", "aud", "iat", "exp", "nonce", "auth_time", "at_hash", "c_hash"],
"claims_parameter_supported": false,
"request_parameter_supported": false,
"request_uri_parameter_supported": false,
"prompt_values_supported": ["none", "login", "create"],
"device_authorization_endpoint": `${issuer}oauth2/device`,
"org.matrix.matrix-authentication-service.graphql_endpoint": `${issuer}graphql`,
"account_management_uri": `${issuer}account/`,
"account_management_actions_supported": [
"org.matrix.profile",
"org.matrix.sessions_list",
"org.matrix.session_view",
"org.matrix.session_end",
"org.matrix.cross_signing_reset",
],
});
fetchMock.get(`${issuer}jwks`, {
keys: [],
});
const discoveryResult = {
...validIsConfig,
...validHsConfig,
};
await expect(
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).resolves.toEqual({
...expectedValidatedConfig,
hsNameIsDifferent: true,
hsName: serverName,
delegatedAuthentication: expect.objectContaining({
accountManagementActionsSupported: [
"org.matrix.profile",
"org.matrix.sessions_list",
"org.matrix.session_view",
"org.matrix.session_end",
"org.matrix.cross_signing_reset",
],
accountManagementEndpoint: "https://auth.matrix.org/account/",
authorizationEndpoint: "https://auth.matrix.org/auth",
metadata: expect.objectContaining({
issuer,
}),
registrationEndpoint: "https://auth.matrix.org/registration",
signingKeys: [],
tokenEndpoint: "https://auth.matrix.org/token",
}),
warning: null,
});
});
});
describe("authComponentStateForError", () => {

View file

@ -46,8 +46,8 @@ describe("TokenRefresher", () => {
};
beforeEach(() => {
fetchMock.get(`${authConfig.issuer}.well-known/openid-configuration`, authConfig.metadata);
fetchMock.get(`${authConfig.issuer}jwks`, {
fetchMock.get(`${issuer}.well-known/openid-configuration`, authConfig.metadata);
fetchMock.get(`${issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
@ -68,7 +68,7 @@ describe("TokenRefresher", () => {
const getPickleKey = jest.fn().mockResolvedValue(pickleKey);
mockPlatformPeg({ getPickleKey });
const refresher = new TokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims, userId);
const refresher = new TokenRefresher(issuer, clientId, redirectUri, deviceId, idTokenClaims, userId);
await refresher.oidcClientReady;
@ -83,7 +83,7 @@ describe("TokenRefresher", () => {
const getPickleKey = jest.fn().mockResolvedValue(null);
mockPlatformPeg({ getPickleKey });
const refresher = new TokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims, userId);
const refresher = new TokenRefresher(issuer, clientId, redirectUri, deviceId, idTokenClaims, userId);
await refresher.oidcClientReady;

View file

@ -69,7 +69,10 @@ describe("OIDC authorization", () => {
});
beforeAll(() => {
fetchMock.get(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`, delegatedAuthConfig.metadata);
fetchMock.get(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
delegatedAuthConfig.metadata,
);
});
afterAll(() => {

View file

@ -1,61 +0,0 @@
/*
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 "matrix-js-sdk/src/matrix";
import { getDelegatedAuthAccountUrl } from "../../../src/utils/oidc/getDelegatedAuthAccountUrl";
describe("getDelegatedAuthAccountUrl()", () => {
it("should return undefined when wk is undefined", () => {
expect(getDelegatedAuthAccountUrl(undefined)).toBeUndefined();
});
it("should return undefined when wk has no authentication config", () => {
expect(getDelegatedAuthAccountUrl({})).toBeUndefined();
});
it("should return undefined when wk authentication config has no configured account url", () => {
expect(
getDelegatedAuthAccountUrl({
[M_AUTHENTICATION.stable!]: {
issuer: "issuer.org",
},
}),
).toBeUndefined();
});
it("should return the account url for authentication config using the unstable prefix", () => {
expect(
getDelegatedAuthAccountUrl({
[M_AUTHENTICATION.unstable!]: {
issuer: "issuer.org",
account: "issuer.org/account",
},
}),
).toEqual("issuer.org/account");
});
it("should return the account url for authentication config using the stable prefix", () => {
expect(
getDelegatedAuthAccountUrl({
[M_AUTHENTICATION.stable!]: {
issuer: "issuer.org",
account: "issuer.org/account",
},
}),
).toEqual("issuer.org/account");
});
});

View file

@ -16,15 +16,15 @@ limitations under the License.
import fetchMockJest from "fetch-mock-jest";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { getOidcClientId } from "../../../src/utils/oidc/registerClient";
import { ValidatedDelegatedAuthConfig } from "../../../src/utils/ValidatedServerConfig";
import { mockPlatformPeg } from "../../test-utils";
import PlatformPeg from "../../../src/PlatformPeg";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
describe("getOidcClientId()", () => {
const issuer = "https://auth.com/";
const registrationEndpoint = "https://auth.com/register";
const clientName = "Element";
const baseUrl = "https://just.testing";
const dynamicClientId = "xyz789";
@ -33,12 +33,7 @@ describe("getOidcClientId()", () => {
client_id: "abc123",
},
};
const delegatedAuthConfig = {
issuer,
registrationEndpoint,
authorizationEndpoint: issuer + "auth",
tokenEndpoint: issuer + "token",
};
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
beforeEach(() => {
fetchMockJest.mockClear();
@ -63,11 +58,10 @@ describe("getOidcClientId()", () => {
});
it("should throw when no static clientId is configured and no registration endpoint", async () => {
const authConfigWithoutRegistration: ValidatedDelegatedAuthConfig = {
...delegatedAuthConfig,
issuer: "https://issuerWithoutStaticClientId.org/",
registrationEndpoint: undefined,
};
const authConfigWithoutRegistration: OidcClientConfig = makeDelegatedAuthConfig(
"https://issuerWithoutStaticClientId.org/",
);
authConfigWithoutRegistration.registrationEndpoint = undefined;
await expect(getOidcClientId(authConfigWithoutRegistration, staticOidcClients)).rejects.toThrow(
OidcError.DynamicRegistrationNotSupported,
);
@ -76,7 +70,7 @@ describe("getOidcClientId()", () => {
});
it("should handle when staticOidcClients object is falsy", async () => {
const authConfigWithoutRegistration: ValidatedDelegatedAuthConfig = {
const authConfigWithoutRegistration: OidcClientConfig = {
...delegatedAuthConfig,
registrationEndpoint: undefined,
};
@ -88,14 +82,14 @@ describe("getOidcClientId()", () => {
});
it("should make correct request to register client", async () => {
fetchMockJest.post(registrationEndpoint, {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
status: 200,
body: JSON.stringify({ client_id: dynamicClientId }),
});
expect(await getOidcClientId(delegatedAuthConfig)).toEqual(dynamicClientId);
// didn't try to register
expect(fetchMockJest).toHaveBeenCalledWith(
registrationEndpoint,
delegatedAuthConfig.registrationEndpoint!,
expect.objectContaining({
headers: {
"Accept": "application/json",
@ -120,14 +114,14 @@ describe("getOidcClientId()", () => {
});
it("should throw when registration request fails", async () => {
fetchMockJest.post(registrationEndpoint, {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
status: 500,
});
await expect(getOidcClientId(delegatedAuthConfig)).rejects.toThrow(OidcError.DynamicRegistrationFailed);
});
it("should throw when registration response is invalid", async () => {
fetchMockJest.post(registrationEndpoint, {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
status: 200,
// no clientId in response
body: "{}",