From a26fc46ed4eef7264826f10ef15150ce894adabb Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 23 Feb 2024 16:43:11 +0000 Subject: [PATCH] Update MSC2965 OIDC Discovery implementation (#4064) --- spec/test-utils/oidc.ts | 6 +- spec/unit/autodiscovery.spec.ts | 99 +------------------- spec/unit/oidc/authorize.spec.ts | 9 +- spec/unit/oidc/register.spec.ts | 17 ++-- spec/unit/oidc/tokenRefresher.spec.ts | 32 +++---- spec/unit/oidc/validate.spec.ts | 95 +------------------ src/autodiscovery.ts | 126 +------------------------- src/client.ts | 11 --- src/oidc/authorize.ts | 3 +- src/oidc/discovery.ts | 35 ++++--- src/oidc/index.ts | 12 +++ src/oidc/register.ts | 7 +- src/oidc/tokenRefresher.ts | 13 ++- src/oidc/validate.ts | 32 +------ 14 files changed, 77 insertions(+), 420 deletions(-) diff --git a/spec/test-utils/oidc.ts b/spec/test-utils/oidc.ts index 0bd97442d..7b2adc226 100644 --- a/spec/test-utils/oidc.ts +++ b/spec/test-utils/oidc.ts @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { OidcClientConfig } from "../../src"; -import { ValidatedIssuerMetadata } from "../../src/oidc/validate"; +import { OidcClientConfig, ValidatedIssuerMetadata } from "../../src"; /** * Makes a valid OidcClientConfig with minimum valid values @@ -26,8 +25,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, diff --git a/spec/unit/autodiscovery.spec.ts b/spec/unit/autodiscovery.spec.ts index b4025218e..ceed8be1f 100644 --- a/spec/unit/autodiscovery.spec.ts +++ b/spec/unit/autodiscovery.spec.ts @@ -15,13 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import fetchMock from "fetch-mock-jest"; import MockHttpBackend from "matrix-mock-request"; -import { AutoDiscoveryAction, M_AUTHENTICATION } from "../../src"; +import { AutoDiscoveryAction } from "../../src"; 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 // @ts-ignore private property @@ -409,10 +406,6 @@ describe("AutoDiscovery", function () { error: null, base_url: null, }, - "m.authentication": { - state: "IGNORE", - error: OidcError.NotSupported, - }, }; expect(conf).toEqual(expected); @@ -450,10 +443,6 @@ describe("AutoDiscovery", function () { error: null, base_url: null, }, - "m.authentication": { - state: "IGNORE", - error: OidcError.NotSupported, - }, }; expect(conf).toEqual(expected); @@ -476,9 +465,6 @@ describe("AutoDiscovery", function () { // Note: we also expect this test to trim the trailing slash base_url: "https://chat.example.org/", }, - "m.authentication": { - invalid: true, - }, }); return Promise.all([ httpBackend.flushAllExpected(), @@ -494,10 +480,6 @@ describe("AutoDiscovery", function () { error: null, base_url: null, }, - "m.authentication": { - state: "FAIL_ERROR", - error: OidcError.Misconfigured, - }, }; expect(conf).toEqual(expected); @@ -728,10 +710,6 @@ describe("AutoDiscovery", function () { error: null, base_url: "https://identity.example.org", }, - "m.authentication": { - state: "IGNORE", - error: OidcError.NotSupported, - }, }; expect(conf).toEqual(expected); @@ -784,10 +762,6 @@ describe("AutoDiscovery", function () { "org.example.custom.property": { cupcakes: "yes", }, - "m.authentication": { - state: "IGNORE", - error: OidcError.NotSupported, - }, }; 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, - }); - }); - }); }); diff --git a/spec/unit/oidc/authorize.spec.ts b/spec/unit/oidc/authorize.spec.ts index f6264f5d1..e9cf7589a 100644 --- a/spec/unit/oidc/authorize.spec.ts +++ b/spec/unit/oidc/authorize.spec.ts @@ -46,8 +46,8 @@ const realSubtleCrypto = crypto.subtleCrypto; describe("oidc authorization", () => { const delegatedAuthConfig = makeDelegatedAuthConfig(); - const authorizationEndpoint = delegatedAuthConfig.metadata.authorization_endpoint; - const tokenEndpoint = delegatedAuthConfig.metadata.token_endpoint; + const authorizationEndpoint = delegatedAuthConfig.authorizationEndpoint; + const tokenEndpoint = delegatedAuthConfig.tokenEndpoint; const clientId = "xyz789"; const baseUrl = "https://test.com"; @@ -58,7 +58,10 @@ describe("oidc authorization", () => { jest.spyOn(logger, "warn"); 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", { value: { diff --git a/spec/unit/oidc/register.spec.ts b/spec/unit/oidc/register.spec.ts index f0e257841..372f7d677 100644 --- a/spec/unit/oidc/register.spec.ts +++ b/spec/unit/oidc/register.spec.ts @@ -18,10 +18,10 @@ import fetchMockJest from "fetch-mock-jest"; import { OidcError } from "../../../src/oidc/error"; import { OidcRegistrationClientMetadata, registerOidcClient } from "../../../src/oidc/register"; +import { makeDelegatedAuthConfig } from "../../test-utils/oidc"; describe("registerOidcClient()", () => { const issuer = "https://auth.com/"; - const registrationEndpoint = "https://auth.com/register"; const clientName = "Element"; const baseUrl = "https://just.testing"; const metadata: OidcRegistrationClientMetadata = { @@ -35,25 +35,20 @@ describe("registerOidcClient()", () => { }; const dynamicClientId = "xyz789"; - const delegatedAuthConfig = { - issuer, - registrationEndpoint, - authorizationEndpoint: issuer + "auth", - tokenEndpoint: issuer + "token", - }; + const delegatedAuthConfig = makeDelegatedAuthConfig(issuer); beforeEach(() => { fetchMockJest.mockClear(); fetchMockJest.resetBehavior(); }); 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 registerOidcClient(delegatedAuthConfig, metadata)).toEqual(dynamicClientId); expect(fetchMockJest).toHaveBeenCalledWith( - registrationEndpoint, + delegatedAuthConfig.registrationEndpoint!, expect.objectContaining({ headers: { "Accept": "application/json", @@ -77,7 +72,7 @@ describe("registerOidcClient()", () => { }); it("should throw when registration request fails", async () => { - fetchMockJest.post(registrationEndpoint, { + fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, { status: 500, }); await expect(() => registerOidcClient(delegatedAuthConfig, metadata)).rejects.toThrow( @@ -86,7 +81,7 @@ describe("registerOidcClient()", () => { }); it("should throw when registration response is invalid", async () => { - fetchMockJest.post(registrationEndpoint, { + fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, { status: 200, // no clientId in response body: "{}", diff --git a/spec/unit/oidc/tokenRefresher.spec.ts b/spec/unit/oidc/tokenRefresher.spec.ts index 803a63d9a..e291142b8 100644 --- a/spec/unit/oidc/tokenRefresher.spec.ts +++ b/spec/unit/oidc/tokenRefresher.spec.ts @@ -64,7 +64,7 @@ describe("OidcTokenRefresher", () => { keys: [], }); - fetchMock.post(config.metadata.token_endpoint, { + fetchMock.post(config.tokenEndpoint, { status: 200, headers: { "Content-Type": "application/json", @@ -88,7 +88,7 @@ describe("OidcTokenRefresher", () => { }, { 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(); expect(logger.error).toHaveBeenCalledWith( "Failed to initialise OIDC client.", @@ -98,7 +98,7 @@ describe("OidcTokenRefresher", () => { }); 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; // @ts-ignore peek at private property to see we initialised the client correctly @@ -114,19 +114,19 @@ describe("OidcTokenRefresher", () => { describe("doRefreshAccessToken()", () => { 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( "Cannot get new token before OIDC client is initialised.", ); }); 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; const result = await refresher.doRefreshAccessToken("refresh-token"); - expect(fetchMock).toHaveFetched(config.metadata.token_endpoint, { + expect(fetchMock).toHaveFetched(config.tokenEndpoint, { method: "POST", }); @@ -137,7 +137,7 @@ describe("OidcTokenRefresher", () => { }); 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; // spy on our stub jest.spyOn(refresher, "persistTokens"); @@ -153,7 +153,7 @@ describe("OidcTokenRefresher", () => { it("should only have one inflight refresh request at once", async () => { fetchMock .postOnce( - config.metadata.token_endpoint, + config.tokenEndpoint, { status: 200, headers: { @@ -164,7 +164,7 @@ describe("OidcTokenRefresher", () => { { overwriteRoutes: true }, ) .postOnce( - config.metadata.token_endpoint, + config.tokenEndpoint, { status: 200, headers: { @@ -175,7 +175,7 @@ describe("OidcTokenRefresher", () => { { overwriteRoutes: false }, ); - const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims); + const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims); await refresher.oidcClientReady; // reset call counts fetchMock.resetHistory(); @@ -188,7 +188,7 @@ describe("OidcTokenRefresher", () => { const result2 = await first; // only one call to token endpoint - expect(fetchMock).toHaveFetchedTimes(1, config.metadata.token_endpoint); + expect(fetchMock).toHaveFetchedTimes(1, config.tokenEndpoint); expect(result1).toEqual({ accessToken: "first-new-access-token", refreshToken: "first-new-refresh-token", @@ -208,7 +208,7 @@ describe("OidcTokenRefresher", () => { it("should log and rethrow when token refresh fails", async () => { fetchMock.post( - config.metadata.token_endpoint, + config.tokenEndpoint, { status: 503, headers: { @@ -218,7 +218,7 @@ describe("OidcTokenRefresher", () => { { 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 expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow(); @@ -228,7 +228,7 @@ describe("OidcTokenRefresher", () => { // make sure inflight request is cleared after a failure fetchMock .postOnce( - config.metadata.token_endpoint, + config.tokenEndpoint, { status: 503, headers: { @@ -238,7 +238,7 @@ describe("OidcTokenRefresher", () => { { overwriteRoutes: true }, ) .postOnce( - config.metadata.token_endpoint, + config.tokenEndpoint, { status: 200, headers: { @@ -249,7 +249,7 @@ describe("OidcTokenRefresher", () => { { overwriteRoutes: false }, ); - const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims); + const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims); await refresher.oidcClientReady; // reset call counts fetchMock.resetHistory(); diff --git a/spec/unit/oidc/validate.spec.ts b/spec/unit/oidc/validate.spec.ts index a3fde6ee4..c9207e28f 100644 --- a/spec/unit/oidc/validate.spec.ts +++ b/spec/unit/oidc/validate.spec.ts @@ -17,105 +17,12 @@ limitations under the License. import { mocked } from "jest-mock"; import { jwtDecode } from "jwt-decode"; -import { M_AUTHENTICATION } from "../../../src"; import { logger } from "../../../src/logger"; -import { - validateIdToken, - validateOIDCIssuerWellKnown, - validateWellKnownAuthentication, -} from "../../../src/oidc/validate"; +import { validateIdToken, validateOIDCIssuerWellKnown } from "../../../src/oidc/validate"; import { OidcError } from "../../../src/oidc/error"; 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", () => { const validWk: any = { authorization_endpoint: "https://test.org/authorize", diff --git a/src/autodiscovery.ts b/src/autodiscovery.ts index 9bf6cdafa..29dac54bd 100644 --- a/src/autodiscovery.ts +++ b/src/autodiscovery.ts @@ -15,19 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { SigningKey } from "oidc-client-ts"; - -import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } from "./client"; +import { IClientWellKnown, IWellKnownConfig, IServerVersions } from "./client"; import { logger } from "./logger"; 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"; // Dev note: Auto discovery is part of the spec. @@ -65,26 +55,9 @@ interface AutoDiscoveryState { } interface WellKnownConfig extends Omit, 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 { "m.homeserver": 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) 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 { - try { - const authentication = M_AUTHENTICATION.findIn(wellKnown) || undefined; - const homeserverAuthenticationConfig = validateWellKnownAuthentication(authentication); - - const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl( - homeserverAuthenticationConfig.issuer, - )}/.well-known/openid-configuration`; - const issuerWellKnown = await this.fetchWellKnownObject(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(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 * prior to logging in. Such information includes the homeserver URL diff --git a/src/client.ts b/src/client.ts index 15fc1a994..3b489ad52 100644 --- a/src/client.ts +++ b/src/client.ts @@ -625,13 +625,10 @@ export interface IServerVersions { unstable_features: Record; } -export const M_AUTHENTICATION = new UnstableValue("m.authentication", "org.matrix.msc2965.authentication"); - export interface IClientWellKnown { [key: string]: any; "m.homeserver"?: IWellKnownConfig; "m.identity_server"?: IWellKnownConfig; - [M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965 } export interface IWellKnownConfig { @@ -645,14 +642,6 @@ export interface IWellKnownConfig { 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 { path: string; queryData?: { diff --git a/src/oidc/authorize.ts b/src/oidc/authorize.ts index c0b844436..35b93dc48 100644 --- a/src/oidc/authorize.ts +++ b/src/oidc/authorize.ts @@ -16,7 +16,6 @@ limitations under the License. import { IdTokenClaims, Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts"; -import { IDelegatedAuthConfig } from "../client"; import { subtleCrypto, TextEncoder } from "../crypto/crypto"; import { logger } from "../logger"; import { randomString } from "../randomstring"; @@ -209,7 +208,7 @@ export const completeAuthorizationCodeGrant = async ( code: string, state: string, ): Promise<{ - oidcClientSettings: IDelegatedAuthConfig & { clientId: string }; + oidcClientSettings: { clientId: string; issuer: string }; tokenResponse: BearerTokenResponse; homeserverUrl: string; idTokenClaims: IdTokenClaims; diff --git a/src/oidc/discovery.ts b/src/oidc/discovery.ts index 76aaeea80..7199c8715 100644 --- a/src/oidc/discovery.ts +++ b/src/oidc/discovery.ts @@ -14,36 +14,35 @@ See the License for the specific language governing permissions and 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, ValidatedIssuerMetadata, validateWellKnownAuthentication } from "./validate"; +import { isValidatedIssuerMetadata, validateOIDCIssuerWellKnown } from "./validate"; +import { Method, timeoutSignal } from "../http-api"; +import { OidcClientConfig } from "./index"; /** * @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 + * Fetches https://oidc-issuer.example.com/.well-known/openid-configuration and other files linked therein. * When successful, validated metadata is returned - * @param wellKnown - configuration object as returned - * by the .well-known auto-discovery endpoint + * @param issuer - the OIDC issuer as returned by the /auth_issuer API * @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); +export const discoverAndValidateOIDCIssuerWellKnown = async (issuer: string): Promise => { + const issuerOpenIdConfigUrl = new URL(".well-known/openid-configuration", issuer); + const issuerWellKnownResponse = await fetch(issuerOpenIdConfigUrl, { + method: Method.Get, + signal: timeoutSignal(5000), + }); + const issuerWellKnown = await issuerWellKnownResponse.json(); + const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown); - // 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({ - authority: homeserverAuthenticationConfig.issuer, + authority: 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 }); @@ -54,7 +53,7 @@ export const discoverAndValidateAuthenticationConfig = async ( isValidatedIssuerMetadata(metadata); return { - ...homeserverAuthenticationConfig, + ...validatedIssuerConfig, metadata, signingKeys, }; diff --git a/src/oidc/index.ts b/src/oidc/index.ts index 7c15d2ce9..7fc31836f 100644 --- a/src/oidc/index.ts +++ b/src/oidc/index.ts @@ -14,9 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { SigningKey } from "oidc-client-ts"; +import { ValidatedIssuerConfig, ValidatedIssuerMetadata } from "./validate"; + export * from "./authorize"; export * from "./discovery"; export * from "./error"; export * from "./register"; export * from "./tokenRefresher"; 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[]; +} diff --git a/src/oidc/register.ts b/src/oidc/register.ts index 65add4935..6e4948f50 100644 --- a/src/oidc/register.ts +++ b/src/oidc/register.ts @@ -14,11 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IDelegatedAuthConfig } from "../client"; +import { OidcClientConfig } from "."; import { OidcError } from "./error"; import { Method } from "../http-api"; import { logger } from "../logger"; -import { ValidatedIssuerConfig } from "./validate"; import { NonEmptyArray } from "../@types/common"; /** @@ -112,13 +111,13 @@ const doRegistration = async ( /** * 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 * @returns Promise resolved with registered clientId * @throws when registration is not supported, on failed request or invalid response */ export const registerOidcClient = async ( - delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig, + delegatedAuthConfig: OidcClientConfig, clientMetadata: OidcRegistrationClientMetadata, ): Promise => { if (!delegatedAuthConfig.registrationEndpoint) { diff --git a/src/oidc/tokenRefresher.ts b/src/oidc/tokenRefresher.ts index 10c9bc48e..79ef94422 100644 --- a/src/oidc/tokenRefresher.ts +++ b/src/oidc/tokenRefresher.ts @@ -17,9 +17,8 @@ limitations under the License. import { IdTokenClaims, OidcClient, WebStorageStateStore } from "oidc-client-ts"; import { AccessTokens } from "../http-api"; -import { IDelegatedAuthConfig } from "../client"; import { generateScope } from "./authorize"; -import { discoverAndValidateAuthenticationConfig } from "./discovery"; +import { discoverAndValidateOIDCIssuerWellKnown } from "./discovery"; import { logger } from "../logger"; /** @@ -42,9 +41,9 @@ export class OidcTokenRefresher { 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 */ @@ -63,17 +62,17 @@ export class OidcTokenRefresher { */ private readonly idTokenClaims: IdTokenClaims, ) { - this.oidcClientReady = this.initialiseOidcClient(authConfig, clientId, deviceId, redirectUri); + this.oidcClientReady = this.initialiseOidcClient(issuer, clientId, deviceId, redirectUri); } private async initialiseOidcClient( - authConfig: IDelegatedAuthConfig, + issuer: string, clientId: string, deviceId: string, redirectUri: string, ): Promise { try { - const config = await discoverAndValidateAuthenticationConfig(authConfig); + const config = await discoverAndValidateOIDCIssuerWellKnown(issuer); const scope = generateScope(deviceId); diff --git a/src/oidc/validate.ts b/src/oidc/validate.ts index c806ca807..38d730ba4 100644 --- a/src/oidc/validate.ts +++ b/src/oidc/validate.ts @@ -17,7 +17,6 @@ limitations under the License. import { jwtDecode } from "jwt-decode"; import { OidcMetadata, SigninResponse } from "oidc-client-ts"; -import { IDelegatedAuthConfig } from "../client"; import { logger } from "../logger"; import { OidcError } from "./error"; @@ -35,31 +34,6 @@ export type ValidatedIssuerConfig = { 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 => !!value && typeof value === "object" && !Array.isArray(value); const requiredStringProperty = (wellKnown: Record, key: string): boolean => { @@ -150,7 +124,11 @@ export type ValidatedIssuerMetadata = Partial & | "response_types_supported" | "grant_types_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