1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-31 15:24:23 +03:00

Update MSC2965 OIDC Discovery implementation (#4064)

This commit is contained in:
Michael Telatynski
2024-02-23 16:43:11 +00:00
committed by GitHub
parent be3913e8a5
commit a26fc46ed4
14 changed files with 77 additions and 420 deletions

View File

@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { OidcClientConfig } from "../../src"; import { OidcClientConfig, ValidatedIssuerMetadata } from "../../src";
import { ValidatedIssuerMetadata } from "../../src/oidc/validate";
/** /**
* Makes a valid OidcClientConfig with minimum valid values * Makes a valid OidcClientConfig with minimum valid values
@ -26,8 +25,7 @@ export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClien
const metadata = mockOpenIdConfiguration(issuer); const metadata = mockOpenIdConfiguration(issuer);
return { return {
issuer, accountManagementEndpoint: issuer + "account",
account: issuer + "account",
registrationEndpoint: metadata.registration_endpoint, registrationEndpoint: metadata.registration_endpoint,
authorizationEndpoint: metadata.authorization_endpoint, authorizationEndpoint: metadata.authorization_endpoint,
tokenEndpoint: metadata.token_endpoint, tokenEndpoint: metadata.token_endpoint,

View File

@ -15,13 +15,10 @@ 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 { AutoDiscoveryAction, M_AUTHENTICATION } from "../../src"; import { AutoDiscoveryAction } from "../../src";
import { AutoDiscovery } from "../../src/autodiscovery"; import { AutoDiscovery } from "../../src/autodiscovery";
import { OidcError } from "../../src/oidc/error";
import { makeDelegatedAuthConfig } from "../test-utils/oidc";
// keep to reset the fetch function after using MockHttpBackend // keep to reset the fetch function after using MockHttpBackend
// @ts-ignore private property // @ts-ignore private property
@ -409,10 +406,6 @@ describe("AutoDiscovery", function () {
error: null, error: null,
base_url: null, base_url: null,
}, },
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
}; };
expect(conf).toEqual(expected); expect(conf).toEqual(expected);
@ -450,10 +443,6 @@ describe("AutoDiscovery", function () {
error: null, error: null,
base_url: null, base_url: null,
}, },
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
}; };
expect(conf).toEqual(expected); expect(conf).toEqual(expected);
@ -476,9 +465,6 @@ describe("AutoDiscovery", function () {
// Note: we also expect this test to trim the trailing slash // Note: we also expect this test to trim the trailing slash
base_url: "https://chat.example.org/", base_url: "https://chat.example.org/",
}, },
"m.authentication": {
invalid: true,
},
}); });
return Promise.all([ return Promise.all([
httpBackend.flushAllExpected(), httpBackend.flushAllExpected(),
@ -494,10 +480,6 @@ describe("AutoDiscovery", function () {
error: null, error: null,
base_url: null, base_url: null,
}, },
"m.authentication": {
state: "FAIL_ERROR",
error: OidcError.Misconfigured,
},
}; };
expect(conf).toEqual(expected); expect(conf).toEqual(expected);
@ -728,10 +710,6 @@ describe("AutoDiscovery", function () {
error: null, error: null,
base_url: "https://identity.example.org", base_url: "https://identity.example.org",
}, },
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
}; };
expect(conf).toEqual(expected); expect(conf).toEqual(expected);
@ -784,10 +762,6 @@ describe("AutoDiscovery", function () {
"org.example.custom.property": { "org.example.custom.property": {
cupcakes: "yes", cupcakes: "yes",
}, },
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
}; };
expect(conf).toEqual(expected); expect(conf).toEqual(expected);
@ -897,75 +871,4 @@ 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: ["v1.5"] });
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,
});
});
});
}); });

View File

@ -46,8 +46,8 @@ const realSubtleCrypto = crypto.subtleCrypto;
describe("oidc authorization", () => { describe("oidc authorization", () => {
const delegatedAuthConfig = makeDelegatedAuthConfig(); const delegatedAuthConfig = makeDelegatedAuthConfig();
const authorizationEndpoint = delegatedAuthConfig.metadata.authorization_endpoint; const authorizationEndpoint = delegatedAuthConfig.authorizationEndpoint;
const tokenEndpoint = delegatedAuthConfig.metadata.token_endpoint; const tokenEndpoint = delegatedAuthConfig.tokenEndpoint;
const clientId = "xyz789"; const clientId = "xyz789";
const baseUrl = "https://test.com"; const baseUrl = "https://test.com";
@ -58,7 +58,10 @@ describe("oidc authorization", () => {
jest.spyOn(logger, "warn"); jest.spyOn(logger, "warn");
jest.setSystemTime(now); jest.setSystemTime(now);
fetchMock.get(delegatedAuthConfig.issuer + ".well-known/openid-configuration", mockOpenIdConfiguration()); fetchMock.get(
delegatedAuthConfig.metadata.issuer + ".well-known/openid-configuration",
mockOpenIdConfiguration(),
);
Object.defineProperty(window, "crypto", { Object.defineProperty(window, "crypto", {
value: { value: {

View File

@ -18,10 +18,10 @@ import fetchMockJest from "fetch-mock-jest";
import { OidcError } from "../../../src/oidc/error"; import { OidcError } from "../../../src/oidc/error";
import { OidcRegistrationClientMetadata, registerOidcClient } from "../../../src/oidc/register"; import { OidcRegistrationClientMetadata, registerOidcClient } from "../../../src/oidc/register";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
describe("registerOidcClient()", () => { describe("registerOidcClient()", () => {
const issuer = "https://auth.com/"; const issuer = "https://auth.com/";
const registrationEndpoint = "https://auth.com/register";
const clientName = "Element"; const clientName = "Element";
const baseUrl = "https://just.testing"; const baseUrl = "https://just.testing";
const metadata: OidcRegistrationClientMetadata = { const metadata: OidcRegistrationClientMetadata = {
@ -35,25 +35,20 @@ describe("registerOidcClient()", () => {
}; };
const dynamicClientId = "xyz789"; const dynamicClientId = "xyz789";
const delegatedAuthConfig = { const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
issuer,
registrationEndpoint,
authorizationEndpoint: issuer + "auth",
tokenEndpoint: issuer + "token",
};
beforeEach(() => { beforeEach(() => {
fetchMockJest.mockClear(); fetchMockJest.mockClear();
fetchMockJest.resetBehavior(); fetchMockJest.resetBehavior();
}); });
it("should make correct request to register client", async () => { it("should make correct request to register client", async () => {
fetchMockJest.post(registrationEndpoint, { fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
status: 200, status: 200,
body: JSON.stringify({ client_id: dynamicClientId }), body: JSON.stringify({ client_id: dynamicClientId }),
}); });
expect(await registerOidcClient(delegatedAuthConfig, metadata)).toEqual(dynamicClientId); expect(await registerOidcClient(delegatedAuthConfig, metadata)).toEqual(dynamicClientId);
expect(fetchMockJest).toHaveBeenCalledWith( expect(fetchMockJest).toHaveBeenCalledWith(
registrationEndpoint, delegatedAuthConfig.registrationEndpoint!,
expect.objectContaining({ expect.objectContaining({
headers: { headers: {
"Accept": "application/json", "Accept": "application/json",
@ -77,7 +72,7 @@ describe("registerOidcClient()", () => {
}); });
it("should throw when registration request fails", async () => { it("should throw when registration request fails", async () => {
fetchMockJest.post(registrationEndpoint, { fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
status: 500, status: 500,
}); });
await expect(() => registerOidcClient(delegatedAuthConfig, metadata)).rejects.toThrow( await expect(() => registerOidcClient(delegatedAuthConfig, metadata)).rejects.toThrow(
@ -86,7 +81,7 @@ describe("registerOidcClient()", () => {
}); });
it("should throw when registration response is invalid", async () => { it("should throw when registration response is invalid", async () => {
fetchMockJest.post(registrationEndpoint, { fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
status: 200, status: 200,
// no clientId in response // no clientId in response
body: "{}", body: "{}",

View File

@ -64,7 +64,7 @@ describe("OidcTokenRefresher", () => {
keys: [], keys: [],
}); });
fetchMock.post(config.metadata.token_endpoint, { fetchMock.post(config.tokenEndpoint, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -88,7 +88,7 @@ describe("OidcTokenRefresher", () => {
}, },
{ overwriteRoutes: true }, { overwriteRoutes: true },
); );
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims); const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.oidcClientReady).rejects.toThrow(); await expect(refresher.oidcClientReady).rejects.toThrow();
expect(logger.error).toHaveBeenCalledWith( expect(logger.error).toHaveBeenCalledWith(
"Failed to initialise OIDC client.", "Failed to initialise OIDC client.",
@ -98,7 +98,7 @@ describe("OidcTokenRefresher", () => {
}); });
it("initialises oidc client", async () => { it("initialises oidc client", async () => {
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims); const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady; await refresher.oidcClientReady;
// @ts-ignore peek at private property to see we initialised the client correctly // @ts-ignore peek at private property to see we initialised the client correctly
@ -114,19 +114,19 @@ describe("OidcTokenRefresher", () => {
describe("doRefreshAccessToken()", () => { describe("doRefreshAccessToken()", () => {
it("should throw when oidcClient has not been initialised", async () => { it("should throw when oidcClient has not been initialised", async () => {
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims); const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow( await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow(
"Cannot get new token before OIDC client is initialised.", "Cannot get new token before OIDC client is initialised.",
); );
}); });
it("should refresh the tokens", async () => { it("should refresh the tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims); const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady; await refresher.oidcClientReady;
const result = await refresher.doRefreshAccessToken("refresh-token"); const result = await refresher.doRefreshAccessToken("refresh-token");
expect(fetchMock).toHaveFetched(config.metadata.token_endpoint, { expect(fetchMock).toHaveFetched(config.tokenEndpoint, {
method: "POST", method: "POST",
}); });
@ -137,7 +137,7 @@ describe("OidcTokenRefresher", () => {
}); });
it("should persist the new tokens", async () => { it("should persist the new tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims); const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady; await refresher.oidcClientReady;
// spy on our stub // spy on our stub
jest.spyOn(refresher, "persistTokens"); jest.spyOn(refresher, "persistTokens");
@ -153,7 +153,7 @@ describe("OidcTokenRefresher", () => {
it("should only have one inflight refresh request at once", async () => { it("should only have one inflight refresh request at once", async () => {
fetchMock fetchMock
.postOnce( .postOnce(
config.metadata.token_endpoint, config.tokenEndpoint,
{ {
status: 200, status: 200,
headers: { headers: {
@ -164,7 +164,7 @@ describe("OidcTokenRefresher", () => {
{ overwriteRoutes: true }, { overwriteRoutes: true },
) )
.postOnce( .postOnce(
config.metadata.token_endpoint, config.tokenEndpoint,
{ {
status: 200, status: 200,
headers: { headers: {
@ -175,7 +175,7 @@ describe("OidcTokenRefresher", () => {
{ overwriteRoutes: false }, { overwriteRoutes: false },
); );
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims); const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady; await refresher.oidcClientReady;
// reset call counts // reset call counts
fetchMock.resetHistory(); fetchMock.resetHistory();
@ -188,7 +188,7 @@ describe("OidcTokenRefresher", () => {
const result2 = await first; const result2 = await first;
// only one call to token endpoint // only one call to token endpoint
expect(fetchMock).toHaveFetchedTimes(1, config.metadata.token_endpoint); expect(fetchMock).toHaveFetchedTimes(1, config.tokenEndpoint);
expect(result1).toEqual({ expect(result1).toEqual({
accessToken: "first-new-access-token", accessToken: "first-new-access-token",
refreshToken: "first-new-refresh-token", refreshToken: "first-new-refresh-token",
@ -208,7 +208,7 @@ describe("OidcTokenRefresher", () => {
it("should log and rethrow when token refresh fails", async () => { it("should log and rethrow when token refresh fails", async () => {
fetchMock.post( fetchMock.post(
config.metadata.token_endpoint, config.tokenEndpoint,
{ {
status: 503, status: 503,
headers: { headers: {
@ -218,7 +218,7 @@ describe("OidcTokenRefresher", () => {
{ overwriteRoutes: true }, { overwriteRoutes: true },
); );
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims); const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady; await refresher.oidcClientReady;
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow(); await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
@ -228,7 +228,7 @@ describe("OidcTokenRefresher", () => {
// make sure inflight request is cleared after a failure // make sure inflight request is cleared after a failure
fetchMock fetchMock
.postOnce( .postOnce(
config.metadata.token_endpoint, config.tokenEndpoint,
{ {
status: 503, status: 503,
headers: { headers: {
@ -238,7 +238,7 @@ describe("OidcTokenRefresher", () => {
{ overwriteRoutes: true }, { overwriteRoutes: true },
) )
.postOnce( .postOnce(
config.metadata.token_endpoint, config.tokenEndpoint,
{ {
status: 200, status: 200,
headers: { headers: {
@ -249,7 +249,7 @@ describe("OidcTokenRefresher", () => {
{ overwriteRoutes: false }, { overwriteRoutes: false },
); );
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims); const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady; await refresher.oidcClientReady;
// reset call counts // reset call counts
fetchMock.resetHistory(); fetchMock.resetHistory();

View File

@ -17,105 +17,12 @@ limitations under the License.
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { M_AUTHENTICATION } from "../../../src";
import { logger } from "../../../src/logger"; import { logger } from "../../../src/logger";
import { import { validateIdToken, validateOIDCIssuerWellKnown } from "../../../src/oidc/validate";
validateIdToken,
validateOIDCIssuerWellKnown,
validateWellKnownAuthentication,
} from "../../../src/oidc/validate";
import { OidcError } from "../../../src/oidc/error"; import { OidcError } from "../../../src/oidc/error";
jest.mock("jwt-decode"); jest.mock("jwt-decode");
describe("validateWellKnownAuthentication()", () => {
const baseWk = {
"m.homeserver": {
base_url: "https://hs.org",
},
};
it("should throw not supported error when wellKnown has no m.authentication section", () => {
expect(() => validateWellKnownAuthentication(undefined)).toThrow(OidcError.NotSupported);
});
it("should throw misconfigured error when authentication issuer is not a string", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: { url: "test.com" },
},
};
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
OidcError.Misconfigured,
);
});
it("should throw misconfigured error when authentication account is not a string", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
account: { url: "test" },
},
};
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
OidcError.Misconfigured,
);
});
it("should throw misconfigured error when authentication account is false", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
account: false,
},
};
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
OidcError.Misconfigured,
);
});
it("should return valid config when wk uses stable m.authentication", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
account: "account.com",
},
};
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
issuer: "test.com",
account: "account.com",
});
});
it("should return valid config when m.authentication account is missing", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
},
};
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
issuer: "test.com",
});
});
it("should remove unexpected properties", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
somethingElse: "test",
},
};
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
issuer: "test.com",
});
});
});
describe("validateOIDCIssuerWellKnown", () => { describe("validateOIDCIssuerWellKnown", () => {
const validWk: any = { const validWk: any = {
authorization_endpoint: "https://test.org/authorize", authorization_endpoint: "https://test.org/authorize",

View File

@ -15,19 +15,9 @@ 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, IServerVersions } 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 { discoverAndValidateAuthenticationConfig } from "./oidc/discovery";
import {
ValidatedIssuerConfig,
ValidatedIssuerMetadata,
validateOIDCIssuerWellKnown,
validateWellKnownAuthentication,
} from "./oidc/validate";
import { OidcError } from "./oidc/error";
import { SUPPORTED_MATRIX_VERSIONS } from "./version-support"; import { SUPPORTED_MATRIX_VERSIONS } from "./version-support";
// Dev note: Auto discovery is part of the spec. // Dev note: Auto discovery is part of the spec.
@ -65,26 +55,9 @@ 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 {}
/**
* @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;
/**
* @experimental
*/
"m.authentication"?: (OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState;
} }
/** /**
@ -318,107 +291,10 @@ export class AutoDiscovery {
} }
}); });
const authConfig = await this.discoverAndValidateAuthenticationConfig(wellknown);
clientConfig[M_AUTHENTICATION.stable!] = authConfig;
// Step 8: Give the config to the caller (finally) // Step 8: Give the config to the caller (finally)
return Promise.resolve(clientConfig); return Promise.resolve(clientConfig);
} }
/**
* Validate delegated auth configuration
* @deprecated use discoverAndValidateAuthenticationConfig
* - 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, DelegatedAuthConfig will be returned with endpoints used for delegated auth
* 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 validateDiscoveryAuthenticationConfig(
wellKnown: IClientWellKnown,
): Promise<DelegatedAuthConfig | AutoDiscoveryState> {
try {
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
const homeserverAuthenticationConfig = validateWellKnownAuthentication(authentication);
const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl(
homeserverAuthenticationConfig.issuer,
)}/.well-known/openid-configuration`;
const issuerWellKnown = await this.fetchWellKnownObject<unknown>(issuerOpenIdConfigUrl);
if (issuerWellKnown.action !== AutoDiscoveryAction.SUCCESS) {
logger.error("Failed to fetch issuer openid configuration");
throw new Error(OidcError.General);
}
const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown.raw);
const delegatedAuthConfig: DelegatedAuthConfig = {
state: AutoDiscoveryAction.SUCCESS,
error: null,
...homeserverAuthenticationConfig,
...validatedIssuerConfig,
};
return delegatedAuthConfig;
} 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,
};
}
}
/**
* 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

View File

@ -625,13 +625,10 @@ export interface IServerVersions {
unstable_features: Record<string, boolean>; unstable_features: Record<string, boolean>;
} }
export const M_AUTHENTICATION = new UnstableValue("m.authentication", "org.matrix.msc2965.authentication");
export interface IClientWellKnown { export interface IClientWellKnown {
[key: string]: any; [key: string]: any;
"m.homeserver"?: IWellKnownConfig; "m.homeserver"?: IWellKnownConfig;
"m.identity_server"?: IWellKnownConfig; "m.identity_server"?: IWellKnownConfig;
[M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965
} }
export interface IWellKnownConfig<T = IClientWellKnown> { export interface IWellKnownConfig<T = IClientWellKnown> {
@ -645,14 +642,6 @@ export interface IWellKnownConfig<T = IClientWellKnown> {
server_name?: string; server_name?: string;
} }
export interface IDelegatedAuthConfig {
// MSC2965
/** The OIDC Provider/issuer the client should use */
issuer: string;
/** The optional URL of the web UI where the user can manage their account */
account?: string;
}
interface IKeyBackupPath { interface IKeyBackupPath {
path: string; path: string;
queryData?: { queryData?: {

View File

@ -16,7 +16,6 @@ limitations under the License.
import { IdTokenClaims, Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts"; import { IdTokenClaims, Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts";
import { IDelegatedAuthConfig } from "../client";
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";
@ -209,7 +208,7 @@ export const completeAuthorizationCodeGrant = async (
code: string, code: string,
state: string, state: string,
): Promise<{ ): Promise<{
oidcClientSettings: IDelegatedAuthConfig & { clientId: string }; oidcClientSettings: { clientId: string; issuer: string };
tokenResponse: BearerTokenResponse; tokenResponse: BearerTokenResponse;
homeserverUrl: string; homeserverUrl: string;
idTokenClaims: IdTokenClaims; idTokenClaims: IdTokenClaims;

View File

@ -14,36 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { MetadataService, OidcClientSettingsStore, SigningKey } from "oidc-client-ts"; import { MetadataService, OidcClientSettingsStore } from "oidc-client-ts";
import { IDelegatedAuthConfig } from "../client"; import { isValidatedIssuerMetadata, validateOIDCIssuerWellKnown } from "./validate";
import { isValidatedIssuerMetadata, ValidatedIssuerMetadata, validateWellKnownAuthentication } from "./validate"; import { Method, timeoutSignal } from "../http-api";
import { OidcClientConfig } from "./index";
/** /**
* @experimental * @experimental
* Discover and validate delegated auth configuration * 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 reachable
* - delegated auth issuer openid-configuration is configured correctly for us * - delegated auth issuer openid-configuration is configured correctly for us
* Fetches https://oidc-issuer.example.com/.well-known/openid-configuration and other files linked therein.
* When successful, validated metadata is returned * When successful, validated metadata is returned
* @param wellKnown - configuration object as returned * @param issuer - the OIDC issuer as returned by the /auth_issuer API
* by the .well-known auto-discovery endpoint
* @returns validated authentication metadata and optionally signing keys * @returns validated authentication metadata and optionally signing keys
* @throws when delegated auth config is invalid or unreachable * @throws when delegated auth config is invalid or unreachable
*/ */
export const discoverAndValidateAuthenticationConfig = async ( export const discoverAndValidateOIDCIssuerWellKnown = async (issuer: string): Promise<OidcClientConfig> => {
authenticationConfig?: IDelegatedAuthConfig, const issuerOpenIdConfigUrl = new URL(".well-known/openid-configuration", issuer);
): Promise< const issuerWellKnownResponse = await fetch(issuerOpenIdConfigUrl, {
IDelegatedAuthConfig & { method: Method.Get,
metadata: ValidatedIssuerMetadata; signal: timeoutSignal(5000),
signingKeys?: SigningKey[]; });
} const issuerWellKnown = await issuerWellKnownResponse.json();
> => { const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown);
const homeserverAuthenticationConfig = validateWellKnownAuthentication(authenticationConfig);
// create a temporary settings store so we can use metadata service for discovery // create a temporary settings store, so we can use metadata service for discovery
const settings = new OidcClientSettingsStore({ const settings = new OidcClientSettingsStore({
authority: homeserverAuthenticationConfig.issuer, authority: issuer,
redirect_uri: "", // Not known yet, this is here to make the type checker happy 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 client_id: "", // Not known yet, this is here to make the type checker happy
}); });
@ -54,7 +53,7 @@ export const discoverAndValidateAuthenticationConfig = async (
isValidatedIssuerMetadata(metadata); isValidatedIssuerMetadata(metadata);
return { return {
...homeserverAuthenticationConfig, ...validatedIssuerConfig,
metadata, metadata,
signingKeys, signingKeys,
}; };

View File

@ -14,9 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import type { SigningKey } from "oidc-client-ts";
import { ValidatedIssuerConfig, ValidatedIssuerMetadata } from "./validate";
export * from "./authorize"; export * from "./authorize";
export * from "./discovery"; export * from "./discovery";
export * from "./error"; export * from "./error";
export * from "./register"; export * from "./register";
export * from "./tokenRefresher"; export * from "./tokenRefresher";
export * from "./validate"; export * from "./validate";
/**
* Validated config for native OIDC authentication, as returned by {@link discoverAndValidateOIDCIssuerWellKnown}.
* Contains metadata and signing keys from the issuer's well-known (https://oidc-issuer.example.com/.well-known/openid-configuration).
*/
export interface OidcClientConfig extends ValidatedIssuerConfig {
metadata: ValidatedIssuerMetadata;
signingKeys?: SigningKey[];
}

View File

@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { IDelegatedAuthConfig } from "../client"; import { OidcClientConfig } from ".";
import { OidcError } from "./error"; import { OidcError } from "./error";
import { Method } from "../http-api"; import { Method } from "../http-api";
import { logger } from "../logger"; import { logger } from "../logger";
import { ValidatedIssuerConfig } from "./validate";
import { NonEmptyArray } from "../@types/common"; import { NonEmptyArray } from "../@types/common";
/** /**
@ -112,13 +111,13 @@ const doRegistration = async (
/** /**
* Attempts dynamic registration against the configured registration endpoint * Attempts dynamic registration against the configured registration endpoint
* @param delegatedAuthConfig - Auth config from ValidatedServerConfig * @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
* @param clientMetadata - The metadata for the client which to register * @param clientMetadata - The metadata for the client which to register
* @returns Promise<string> resolved with registered clientId * @returns Promise<string> resolved with registered clientId
* @throws when registration is not supported, on failed request or invalid response * @throws when registration is not supported, on failed request or invalid response
*/ */
export const registerOidcClient = async ( export const registerOidcClient = async (
delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig, delegatedAuthConfig: OidcClientConfig,
clientMetadata: OidcRegistrationClientMetadata, clientMetadata: OidcRegistrationClientMetadata,
): Promise<string> => { ): Promise<string> => {
if (!delegatedAuthConfig.registrationEndpoint) { if (!delegatedAuthConfig.registrationEndpoint) {

View File

@ -17,9 +17,8 @@ limitations under the License.
import { IdTokenClaims, OidcClient, WebStorageStateStore } from "oidc-client-ts"; import { IdTokenClaims, OidcClient, WebStorageStateStore } from "oidc-client-ts";
import { AccessTokens } from "../http-api"; import { AccessTokens } from "../http-api";
import { IDelegatedAuthConfig } from "../client";
import { generateScope } from "./authorize"; import { generateScope } from "./authorize";
import { discoverAndValidateAuthenticationConfig } from "./discovery"; import { discoverAndValidateOIDCIssuerWellKnown } from "./discovery";
import { logger } from "../logger"; import { logger } from "../logger";
/** /**
@ -42,9 +41,9 @@ export class OidcTokenRefresher {
public constructor( public constructor(
/** /**
* Delegated auth config as found in matrix client .well-known * The OIDC issuer as returned by the /auth_issuer API
*/ */
authConfig: IDelegatedAuthConfig, issuer: string,
/** /**
* id of this client as registered with the OP * id of this client as registered with the OP
*/ */
@ -63,17 +62,17 @@ export class OidcTokenRefresher {
*/ */
private readonly idTokenClaims: IdTokenClaims, private readonly idTokenClaims: IdTokenClaims,
) { ) {
this.oidcClientReady = this.initialiseOidcClient(authConfig, clientId, deviceId, redirectUri); this.oidcClientReady = this.initialiseOidcClient(issuer, clientId, deviceId, redirectUri);
} }
private async initialiseOidcClient( private async initialiseOidcClient(
authConfig: IDelegatedAuthConfig, issuer: string,
clientId: string, clientId: string,
deviceId: string, deviceId: string,
redirectUri: string, redirectUri: string,
): Promise<void> { ): Promise<void> {
try { try {
const config = await discoverAndValidateAuthenticationConfig(authConfig); const config = await discoverAndValidateOIDCIssuerWellKnown(issuer);
const scope = generateScope(deviceId); const scope = generateScope(deviceId);

View File

@ -17,7 +17,6 @@ limitations under the License.
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { OidcMetadata, SigninResponse } from "oidc-client-ts"; import { OidcMetadata, SigninResponse } from "oidc-client-ts";
import { IDelegatedAuthConfig } from "../client";
import { logger } from "../logger"; import { logger } from "../logger";
import { OidcError } from "./error"; import { OidcError } from "./error";
@ -35,31 +34,6 @@ export type ValidatedIssuerConfig = {
accountManagementActionsSupported?: string[]; accountManagementActionsSupported?: string[];
}; };
/**
* Validates MSC2965 m.authentication config
* Returns valid configuration
* @param wellKnown - client well known as returned from ./well-known/client/matrix
* @returns config - when present and valid
* @throws when config is not found or invalid
*/
export const validateWellKnownAuthentication = (authentication?: IDelegatedAuthConfig): IDelegatedAuthConfig => {
if (!authentication) {
throw new Error(OidcError.NotSupported);
}
if (
typeof authentication.issuer === "string" &&
(!authentication.hasOwnProperty("account") || typeof authentication.account === "string")
) {
return {
issuer: authentication.issuer,
account: authentication.account,
};
}
throw new Error(OidcError.Misconfigured);
};
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value); !!value && typeof value === "object" && !Array.isArray(value);
const requiredStringProperty = (wellKnown: Record<string, unknown>, key: string): boolean => { const requiredStringProperty = (wellKnown: Record<string, unknown>, key: string): boolean => {
@ -150,7 +124,11 @@ export type ValidatedIssuerMetadata = Partial<OidcMetadata> &
| "response_types_supported" | "response_types_supported"
| "grant_types_supported" | "grant_types_supported"
| "code_challenge_methods_supported" | "code_challenge_methods_supported"
>; > & {
// MSC2965 extensions to the OIDC spec
account_management_uri?: string;
account_management_actions_supported?: string[];
};
/** /**
* Wraps validateOIDCIssuerWellKnown in a type assertion * Wraps validateOIDCIssuerWellKnown in a type assertion