diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index 82818e229a3..d3bb0a8c6ff 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -36,7 +36,7 @@ import { MatrixError, MatrixHttpApi, } from "../../../src"; -import { mockOpenIdConfiguration } from "../../test-utils/oidc"; +import { makeDelegatedAuthConfig } from "../../test-utils/oidc"; function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient { const baseUrl = "https://example.com"; @@ -57,7 +57,7 @@ function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled getDomain: () => "example.com", getDevice: jest.fn(), getCrypto: jest.fn(() => crypto), - getAuthIssuer: jest.fn().mockResolvedValue({ issuer: "https://issuer/" }), + getAuthMetadata: jest.fn().mockResolvedValue(makeDelegatedAuthConfig("https://issuer/", [DEVICE_CODE_SCOPE])), } as unknown as MatrixClient; client.http = new MatrixHttpApi(client, { baseUrl: client.baseUrl, @@ -69,10 +69,6 @@ function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled describe("MSC4108SignInWithQR", () => { beforeEach(() => { - fetchMock.get( - "https://issuer/.well-known/openid-configuration", - mockOpenIdConfiguration("https://issuer/", [DEVICE_CODE_SCOPE]), - ); fetchMock.get("https://issuer/jwks", { status: 200, headers: { diff --git a/spec/test-utils/oidc.ts b/spec/test-utils/oidc.ts index 8f2965c9a2a..6b15b4f488e 100644 --- a/spec/test-utils/oidc.ts +++ b/spec/test-utils/oidc.ts @@ -14,42 +14,4 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { OidcClientConfig, ValidatedIssuerMetadata } from "../../src"; - -/** - * Makes a valid OidcClientConfig with minimum valid values - * @param issuer used as the base for all other urls - * @returns OidcClientConfig - */ -export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClientConfig => { - const metadata = mockOpenIdConfiguration(issuer); - - return { - accountManagementEndpoint: issuer + "account", - registrationEndpoint: metadata.registration_endpoint, - authorizationEndpoint: metadata.authorization_endpoint, - tokenEndpoint: metadata.token_endpoint, - metadata, - }; -}; - -/** - * Useful for mocking /.well-known/openid-configuration - * @param issuer used as the base for all other urls - * @returns ValidatedIssuerMetadata - */ -export const mockOpenIdConfiguration = ( - issuer = "https://auth.org/", - additionalGrantTypes: string[] = [], -): ValidatedIssuerMetadata => ({ - issuer, - revocation_endpoint: issuer + "revoke", - token_endpoint: issuer + "token", - authorization_endpoint: issuer + "auth", - registration_endpoint: issuer + "registration", - device_authorization_endpoint: issuer + "device", - jwks_uri: issuer + "jwks", - response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token", ...additionalGrantTypes], - code_challenge_methods_supported: ["S256"], -}); +export { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "../../src/testing.ts"; diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 8e55a0c65e5..fa261c9ce69 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { Mocked, mocked } from "jest-mock"; +import fetchMock from "fetch-mock-jest"; import { logger } from "../../src/logger"; import { ClientEvent, IMatrixClientCreateOpts, ITurnServerResponse, MatrixClient, Store } from "../../src/client"; @@ -76,6 +77,7 @@ import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorageImpl } from ". import { CryptoBackend } from "../../src/common-crypto/CryptoBackend"; import { KnownMembership } from "../../src/@types/membership"; import { RoomMessageEventContent } from "../../src/@types/events"; +import { mockOpenIdConfiguration } from "../test-utils/oidc.ts"; jest.useFakeTimers(); @@ -265,13 +267,17 @@ describe("MatrixClient", function () { if (next.error) { // eslint-disable-next-line - return Promise.reject({ - errcode: (next.error).errcode, - httpStatus: (next.error).httpStatus, - name: (next.error).errcode, - message: "Expected testing error", - data: next.error, - }); + return Promise.reject( + new MatrixError( + { + errcode: (next.error).errcode, + name: (next.error).errcode, + message: "Expected testing error", + data: next.error, + }, + (next.error).httpStatus, + ), + ); } return Promise.resolve(next.data); } @@ -3489,6 +3495,63 @@ describe("MatrixClient", function () { }); }); + describe("getAuthMetadata", () => { + beforeEach(() => { + fetchMock.mockReset(); + // This request is made by oidc-client-ts so is not intercepted by httpLookups + fetchMock.get("https://auth.org/jwks", { + status: 200, + headers: { + "Content-Type": "application/json", + }, + keys: [], + }); + }); + + it("should use unstable prefix", async () => { + const metadata = mockOpenIdConfiguration(); + httpLookups = [ + { + method: "GET", + path: `/auth_metadata`, + data: metadata, + prefix: "/_matrix/client/unstable/org.matrix.msc2965", + }, + ]; + + await expect(client.getAuthMetadata()).resolves.toEqual({ + ...metadata, + signingKeys: [], + }); + expect(httpLookups.length).toEqual(0); + }); + + it("should fall back to auth_issuer + openid-configuration", async () => { + const metadata = mockOpenIdConfiguration(); + httpLookups = [ + { + method: "GET", + path: `/auth_metadata`, + error: new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404), + prefix: "/_matrix/client/unstable/org.matrix.msc2965", + }, + { + method: "GET", + path: `/auth_issuer`, + data: { issuer: metadata.issuer }, + prefix: "/_matrix/client/unstable/org.matrix.msc2965", + }, + ]; + fetchMock.get("https://auth.org/.well-known/openid-configuration", metadata); + + await expect(client.getAuthMetadata()).resolves.toEqual({ + ...metadata, + signingKeys: [], + }); + expect(httpLookups.length).toEqual(0); + }); + }); + describe("identityHashedLookup", () => { it("should return hashed lookup results", async () => { const ID_ACCESS_TOKEN = "hello_id_server_please_let_me_make_a_request"; diff --git a/spec/unit/oidc/authorize.spec.ts b/spec/unit/oidc/authorize.spec.ts index 9fcf899d537..2c3657bea42 100644 --- a/spec/unit/oidc/authorize.spec.ts +++ b/spec/unit/oidc/authorize.spec.ts @@ -40,8 +40,8 @@ jest.mock("jwt-decode"); describe("oidc authorization", () => { const delegatedAuthConfig = makeDelegatedAuthConfig(); - const authorizationEndpoint = delegatedAuthConfig.authorizationEndpoint; - const tokenEndpoint = delegatedAuthConfig.tokenEndpoint; + const authorizationEndpoint = delegatedAuthConfig.authorization_endpoint; + const tokenEndpoint = delegatedAuthConfig.token_endpoint; const clientId = "xyz789"; const baseUrl = "https://test.com"; @@ -52,10 +52,7 @@ describe("oidc authorization", () => { jest.spyOn(logger, "warn"); jest.setSystemTime(now); - fetchMock.get( - delegatedAuthConfig.metadata.issuer + ".well-known/openid-configuration", - mockOpenIdConfiguration(), - ); + fetchMock.get(delegatedAuthConfig.issuer + ".well-known/openid-configuration", mockOpenIdConfiguration()); globalThis.TextEncoder = TextEncoder; }); @@ -127,11 +124,9 @@ describe("oidc authorization", () => { it("should generate url with correct parameters", async () => { const nonce = "abc123"; - const metadata = delegatedAuthConfig.metadata; - const authUrl = new URL( await generateOidcAuthorizationUrl({ - metadata, + metadata: delegatedAuthConfig, homeserverUrl: baseUrl, clientId, redirectUri: baseUrl, @@ -156,11 +151,9 @@ describe("oidc authorization", () => { it("should generate url with create prompt", async () => { const nonce = "abc123"; - const metadata = delegatedAuthConfig.metadata; - const authUrl = new URL( await generateOidcAuthorizationUrl({ - metadata, + metadata: delegatedAuthConfig, homeserverUrl: baseUrl, clientId, redirectUri: baseUrl, diff --git a/spec/unit/oidc/register.spec.ts b/spec/unit/oidc/register.spec.ts index f0a37e33366..0f02b3fd28c 100644 --- a/spec/unit/oidc/register.spec.ts +++ b/spec/unit/oidc/register.spec.ts @@ -42,13 +42,13 @@ describe("registerOidcClient()", () => { }); it("should make correct request to register client", async () => { - fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, { + fetchMockJest.post(delegatedAuthConfig.registration_endpoint!, { status: 200, body: JSON.stringify({ client_id: dynamicClientId }), }); expect(await registerOidcClient(delegatedAuthConfig, metadata)).toEqual(dynamicClientId); expect(fetchMockJest).toHaveBeenCalledWith( - delegatedAuthConfig.registrationEndpoint!, + delegatedAuthConfig.registration_endpoint, expect.objectContaining({ headers: { "Accept": "application/json", @@ -72,7 +72,7 @@ describe("registerOidcClient()", () => { }); it("should throw when registration request fails", async () => { - fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, { + fetchMockJest.post(delegatedAuthConfig.registration_endpoint!, { status: 500, }); await expect(() => registerOidcClient(delegatedAuthConfig, metadata)).rejects.toThrow( @@ -81,7 +81,7 @@ describe("registerOidcClient()", () => { }); it("should throw when registration response is invalid", async () => { - fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, { + fetchMockJest.post(delegatedAuthConfig.registration_endpoint!, { status: 200, // no clientId in response body: "{}", @@ -96,7 +96,7 @@ describe("registerOidcClient()", () => { registerOidcClient( { ...delegatedAuthConfig, - registrationEndpoint: undefined, + registration_endpoint: undefined, }, metadata, ), @@ -108,10 +108,7 @@ describe("registerOidcClient()", () => { registerOidcClient( { ...delegatedAuthConfig, - metadata: { - ...delegatedAuthConfig.metadata, - grant_types_supported: [delegatedAuthConfig.metadata.grant_types_supported[0]], - }, + grant_types_supported: [delegatedAuthConfig.grant_types_supported[0]], }, metadata, ), diff --git a/spec/unit/oidc/tokenRefresher.spec.ts b/spec/unit/oidc/tokenRefresher.spec.ts index e291142b868..43a236afb8d 100644 --- a/spec/unit/oidc/tokenRefresher.spec.ts +++ b/spec/unit/oidc/tokenRefresher.spec.ts @@ -55,8 +55,8 @@ describe("OidcTokenRefresher", () => { }); beforeEach(() => { - fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata); - fetchMock.get(`${config.metadata.issuer}jwks`, { + fetchMock.get(`${config.issuer}.well-known/openid-configuration`, config); + fetchMock.get(`${config.issuer}jwks`, { status: 200, headers: { "Content-Type": "application/json", @@ -64,7 +64,7 @@ describe("OidcTokenRefresher", () => { keys: [], }); - fetchMock.post(config.tokenEndpoint, { + fetchMock.post(config.token_endpoint, { status: 200, headers: { "Content-Type": "application/json", @@ -81,7 +81,7 @@ describe("OidcTokenRefresher", () => { it("throws when oidc client cannot be initialised", async () => { jest.spyOn(logger, "error"); fetchMock.get( - `${config.metadata.issuer}.well-known/openid-configuration`, + `${config.issuer}.well-known/openid-configuration`, { ok: false, status: 404, @@ -126,7 +126,7 @@ describe("OidcTokenRefresher", () => { const result = await refresher.doRefreshAccessToken("refresh-token"); - expect(fetchMock).toHaveFetched(config.tokenEndpoint, { + expect(fetchMock).toHaveFetched(config.token_endpoint, { method: "POST", }); @@ -153,7 +153,7 @@ describe("OidcTokenRefresher", () => { it("should only have one inflight refresh request at once", async () => { fetchMock .postOnce( - config.tokenEndpoint, + config.token_endpoint, { status: 200, headers: { @@ -164,7 +164,7 @@ describe("OidcTokenRefresher", () => { { overwriteRoutes: true }, ) .postOnce( - config.tokenEndpoint, + config.token_endpoint, { status: 200, headers: { @@ -188,7 +188,7 @@ describe("OidcTokenRefresher", () => { const result2 = await first; // only one call to token endpoint - expect(fetchMock).toHaveFetchedTimes(1, config.tokenEndpoint); + expect(fetchMock).toHaveFetchedTimes(1, config.token_endpoint); 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.tokenEndpoint, + config.token_endpoint, { status: 503, headers: { @@ -228,7 +228,7 @@ describe("OidcTokenRefresher", () => { // make sure inflight request is cleared after a failure fetchMock .postOnce( - config.tokenEndpoint, + config.token_endpoint, { status: 503, headers: { @@ -238,7 +238,7 @@ describe("OidcTokenRefresher", () => { { overwriteRoutes: true }, ) .postOnce( - config.tokenEndpoint, + config.token_endpoint, { status: 200, headers: { diff --git a/spec/unit/oidc/validate.spec.ts b/spec/unit/oidc/validate.spec.ts index bfb40a15b5f..4802e22da7f 100644 --- a/spec/unit/oidc/validate.spec.ts +++ b/spec/unit/oidc/validate.spec.ts @@ -18,17 +18,18 @@ import { mocked } from "jest-mock"; import { jwtDecode } from "jwt-decode"; import { logger } from "../../../src/logger"; -import { validateIdToken, validateOIDCIssuerWellKnown } from "../../../src/oidc/validate"; +import { ValidatedAuthMetadata, validateIdToken, validateAuthMetadata } from "../../../src/oidc/validate"; import { OidcError } from "../../../src/oidc/error"; jest.mock("jwt-decode"); describe("validateOIDCIssuerWellKnown", () => { - const validWk: any = { + const validWk: ValidatedAuthMetadata = { + issuer: "https://test.org", authorization_endpoint: "https://test.org/authorize", token_endpoint: "https://authorize.org/token", - registration_endpoint: "https://authorize.org/regsiter", - revocation_endpoint: "https://authorize.org/regsiter", + registration_endpoint: "https://authorize.org/register", + revocation_endpoint: "https://authorize.org/revoke", response_types_supported: ["code"], grant_types_supported: ["authorization_code"], code_challenge_methods_supported: ["S256"], @@ -44,14 +45,14 @@ describe("validateOIDCIssuerWellKnown", () => { it("should throw OP support error when wellKnown is not an object", () => { expect(() => { - validateOIDCIssuerWellKnown([]); + validateAuthMetadata([]); }).toThrow(OidcError.OpSupport); expect(logger.error).toHaveBeenCalledWith("Issuer configuration not found or malformed"); }); it("should log all errors before throwing", () => { expect(() => { - validateOIDCIssuerWellKnown({ + validateAuthMetadata({ ...validWk, authorization_endpoint: undefined, response_types_supported: [], @@ -62,24 +63,31 @@ describe("validateOIDCIssuerWellKnown", () => { }); it("should return validated issuer config", () => { - expect(validateOIDCIssuerWellKnown(validWk)).toEqual({ - authorizationEndpoint: validWk.authorization_endpoint, - tokenEndpoint: validWk.token_endpoint, - registrationEndpoint: validWk.registration_endpoint, - accountManagementActionsSupported: ["org.matrix.cross_signing_reset"], - accountManagementEndpoint: "https://authorize.org/account", - }); - }); - - it("should return validated issuer config without registrationendpoint", () => { - const wk = { ...validWk }; - delete wk.registration_endpoint; - expect(validateOIDCIssuerWellKnown(wk)).toEqual({ - authorizationEndpoint: validWk.authorization_endpoint, - tokenEndpoint: validWk.token_endpoint, - registrationEndpoint: undefined, - accountManagementActionsSupported: ["org.matrix.cross_signing_reset"], - accountManagementEndpoint: "https://authorize.org/account", + expect(validateAuthMetadata(validWk)).toEqual( + expect.objectContaining({ + issuer: validWk.issuer, + authorization_endpoint: validWk.authorization_endpoint, + token_endpoint: validWk.token_endpoint, + registration_endpoint: validWk.registration_endpoint, + account_management_actions_supported: ["org.matrix.cross_signing_reset"], + account_management_uri: "https://authorize.org/account", + }), + ); + }); + + it("should return validated issuer config without registration_endpoint", () => { + const { registration_endpoint: _, ...wk } = validWk; + expect(validateAuthMetadata(wk)).toEqual({ + issuer: validWk.issuer, + authorization_endpoint: validWk.authorization_endpoint, + token_endpoint: validWk.token_endpoint, + revocation_endpoint: validWk.revocation_endpoint, + registration_endpoint: undefined, + account_management_actions_supported: ["org.matrix.cross_signing_reset"], + account_management_uri: "https://authorize.org/account", + code_challenge_methods_supported: ["S256"], + grant_types_supported: ["authorization_code"], + response_types_supported: ["code"], }); }); @@ -106,7 +114,7 @@ describe("validateOIDCIssuerWellKnown", () => { ...validWk, [key]: value, }; - expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcError.OpSupport); + expect(() => validateAuthMetadata(wk)).toThrow(OidcError.OpSupport); }); }); diff --git a/src/client.ts b/src/client.ts index 0e49b16091c..e8675b2ed0b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -247,6 +247,7 @@ import { ImageInfo } from "./@types/media.ts"; import { Capabilities, ServerCapabilities } from "./serverCapabilities.ts"; import { sha256 } from "./digest.ts"; import { keyFromAuthData } from "./common-crypto/key-passphrase.ts"; +import { discoverAndValidateOIDCIssuerWellKnown, OidcClientConfig, validateAuthMetadataAndKeys } from "./oidc/index.ts"; export type Store = IStore; @@ -10328,6 +10329,7 @@ export class MatrixClient extends TypedEventEmitter { + let authMetadata: unknown | undefined; + try { + authMetadata = await this.http.request(Method.Get, "/auth_metadata", undefined, undefined, { + prefix: ClientPrefix.Unstable + "/org.matrix.msc2965", + }); + } catch (e) { + if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") { + const { issuer } = await this.getAuthIssuer(); + return discoverAndValidateOIDCIssuerWellKnown(issuer); + } + throw e; + } + + return validateAuthMetadataAndKeys(authMetadata); + } } function getUnstableDelayQueryOpts(delayOpts: SendDelayedEventRequestOpts): QueryDict { diff --git a/src/oidc/authorize.ts b/src/oidc/authorize.ts index 7e3692dfb94..bf7bfd8475e 100644 --- a/src/oidc/authorize.ts +++ b/src/oidc/authorize.ts @@ -23,7 +23,7 @@ import { BearerTokenResponse, UserState, validateBearerTokenResponse, - ValidatedIssuerMetadata, + ValidatedAuthMetadata, validateIdToken, validateStoredUserState, } from "./validate.ts"; @@ -138,7 +138,7 @@ export const generateOidcAuthorizationUrl = async ({ urlState, }: { clientId: string; - metadata: ValidatedIssuerMetadata; + metadata: ValidatedAuthMetadata; homeserverUrl: string; identityServerUrl?: string; redirectUri: string; diff --git a/src/oidc/discovery.ts b/src/oidc/discovery.ts index 656bb30a5c8..842990dba63 100644 --- a/src/oidc/discovery.ts +++ b/src/oidc/discovery.ts @@ -16,7 +16,7 @@ limitations under the License. import { MetadataService, OidcClientSettingsStore } from "oidc-client-ts"; -import { isValidatedIssuerMetadata, validateOIDCIssuerWellKnown } from "./validate.ts"; +import { validateAuthMetadata } from "./validate.ts"; import { Method, timeoutSignal } from "../http-api/index.ts"; import { OidcClientConfig } from "./index.ts"; @@ -30,6 +30,7 @@ import { OidcClientConfig } from "./index.ts"; * @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 + * @deprecated in favour of {@link MatrixClient#getAuthMetadata} */ export const discoverAndValidateOIDCIssuerWellKnown = async (issuer: string): Promise => { const issuerOpenIdConfigUrl = new URL(".well-known/openid-configuration", issuer); @@ -38,23 +39,29 @@ export const discoverAndValidateOIDCIssuerWellKnown = async (issuer: string): Pr signal: timeoutSignal(5000), }); const issuerWellKnown = await issuerWellKnownResponse.json(); - const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown); + return validateAuthMetadataAndKeys(issuerWellKnown); +}; + +/** + * @experimental + * Validate the authentication metadata and fetch the signing keys from the jwks_uri in the metadata + * @param authMetadata - the authentication metadata to validate + * @returns validated authentication metadata and signing keys + */ +export const validateAuthMetadataAndKeys = async (authMetadata: unknown): Promise => { + const validatedIssuerConfig = validateAuthMetadata(authMetadata); // create a temporary settings store, so we can use metadata service for discovery const settings = new OidcClientSettingsStore({ - authority: issuer, + authority: validatedIssuerConfig.issuer, + metadata: validatedIssuerConfig, redirect_uri: "", // Not known yet, this is here to make the type checker happy client_id: "", // Not known yet, this is here to make the type checker happy }); const metadataService = new MetadataService(settings); - const metadata = await metadataService.getMetadata(); - const signingKeys = (await metadataService.getSigningKeys()) ?? undefined; - - isValidatedIssuerMetadata(metadata); return { ...validatedIssuerConfig, - metadata, - signingKeys, + signingKeys: await metadataService.getSigningKeys(), }; }; diff --git a/src/oidc/index.ts b/src/oidc/index.ts index 519b311a617..bc4f37118be 100644 --- a/src/oidc/index.ts +++ b/src/oidc/index.ts @@ -15,7 +15,7 @@ limitations under the License. */ import type { SigningKey } from "oidc-client-ts"; -import { ValidatedIssuerConfig, ValidatedIssuerMetadata } from "./validate.ts"; +import { ValidatedAuthMetadata } from "./validate.ts"; export * from "./authorize.ts"; export * from "./discovery.ts"; @@ -28,7 +28,6 @@ export * from "./validate.ts"; * 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[]; +export interface OidcClientConfig extends ValidatedAuthMetadata { + signingKeys: SigningKey[] | null; } diff --git a/src/oidc/register.ts b/src/oidc/register.ts index 161644a84a9..036cbb71404 100644 --- a/src/oidc/register.ts +++ b/src/oidc/register.ts @@ -65,12 +65,12 @@ export const registerOidcClient = async ( delegatedAuthConfig: OidcClientConfig, clientMetadata: OidcRegistrationClientMetadata, ): Promise => { - if (!delegatedAuthConfig.registrationEndpoint) { + if (!delegatedAuthConfig.registration_endpoint) { throw new Error(OidcError.DynamicRegistrationNotSupported); } const grantTypes: NonEmptyArray = ["authorization_code", "refresh_token"]; - if (grantTypes.some((scope) => !delegatedAuthConfig.metadata.grant_types_supported.includes(scope))) { + if (grantTypes.some((scope) => !delegatedAuthConfig.grant_types_supported.includes(scope))) { throw new Error(OidcError.DynamicRegistrationNotSupported); } @@ -95,7 +95,7 @@ export const registerOidcClient = async ( }; try { - const response = await fetch(delegatedAuthConfig.registrationEndpoint, { + const response = await fetch(delegatedAuthConfig.registration_endpoint, { method: Method.Post, headers, body: JSON.stringify(metadata), diff --git a/src/oidc/tokenRefresher.ts b/src/oidc/tokenRefresher.ts index b75de5c2ce3..d7e863c2a9a 100644 --- a/src/oidc/tokenRefresher.ts +++ b/src/oidc/tokenRefresher.ts @@ -77,11 +77,12 @@ export class OidcTokenRefresher { const scope = generateScope(deviceId); this.oidcClient = new OidcClient({ - ...config.metadata, + metadata: config, + signingKeys: config.signingKeys ?? undefined, client_id: clientId, scope, redirect_uri: redirectUri, - authority: config.metadata.issuer, + authority: config.issuer, stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }), }); } catch (error) { diff --git a/src/oidc/validate.ts b/src/oidc/validate.ts index ce62e90eb6c..a6f921314ef 100644 --- a/src/oidc/validate.ts +++ b/src/oidc/validate.ts @@ -20,13 +20,28 @@ import { IdTokenClaims, OidcMetadata, SigninResponse } from "oidc-client-ts"; import { logger } from "../logger.ts"; import { OidcError } from "./error.ts"; -export type ValidatedIssuerConfig = { - authorizationEndpoint: string; - tokenEndpoint: string; - registrationEndpoint?: string; - accountManagementEndpoint?: string; - accountManagementActionsSupported?: string[]; -}; +/** + * Metadata from OIDC authority discovery + * With validated properties required in type + */ +export type ValidatedAuthMetadata = Partial & + Pick< + OidcMetadata, + | "issuer" + | "authorization_endpoint" + | "token_endpoint" + | "revocation_endpoint" + | "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[]; + // The OidcMetadata type from oidc-client-ts does not include `prompt_values_supported` + // even though it is part of the OIDC spec + prompt_values_supported?: string[]; + }; const isRecord = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); @@ -67,78 +82,39 @@ const requiredArrayValue = (wellKnown: Record, key: string, val * Validates issuer `.well-known/openid-configuration` * As defined in RFC5785 https://openid.net/specs/openid-connect-discovery-1_0.html * validates that OP is compatible with Element's OIDC flow - * @param wellKnown - json object + * @param authMetadata - json object * @returns valid issuer config * @throws Error - when issuer config is not found or is invalid */ -export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuerConfig => { - if (!isRecord(wellKnown)) { +export const validateAuthMetadata = (authMetadata: unknown): ValidatedAuthMetadata => { + if (!isRecord(authMetadata)) { logger.error("Issuer configuration not found or malformed"); throw new Error(OidcError.OpSupport); } const isInvalid = [ - requiredStringProperty(wellKnown, "authorization_endpoint"), - requiredStringProperty(wellKnown, "token_endpoint"), - requiredStringProperty(wellKnown, "revocation_endpoint"), - optionalStringProperty(wellKnown, "registration_endpoint"), - optionalStringProperty(wellKnown, "account_management_uri"), - optionalStringProperty(wellKnown, "device_authorization_endpoint"), - optionalStringArrayProperty(wellKnown, "account_management_actions_supported"), - requiredArrayValue(wellKnown, "response_types_supported", "code"), - requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"), - requiredArrayValue(wellKnown, "code_challenge_methods_supported", "S256"), + requiredStringProperty(authMetadata, "issuer"), + requiredStringProperty(authMetadata, "authorization_endpoint"), + requiredStringProperty(authMetadata, "token_endpoint"), + requiredStringProperty(authMetadata, "revocation_endpoint"), + optionalStringProperty(authMetadata, "registration_endpoint"), + optionalStringProperty(authMetadata, "account_management_uri"), + optionalStringProperty(authMetadata, "device_authorization_endpoint"), + optionalStringArrayProperty(authMetadata, "account_management_actions_supported"), + requiredArrayValue(authMetadata, "response_types_supported", "code"), + requiredArrayValue(authMetadata, "grant_types_supported", "authorization_code"), + requiredArrayValue(authMetadata, "code_challenge_methods_supported", "S256"), + optionalStringArrayProperty(authMetadata, "prompt_values_supported"), ].some((isValid) => !isValid); if (!isInvalid) { - return { - authorizationEndpoint: wellKnown["authorization_endpoint"], - tokenEndpoint: wellKnown["token_endpoint"], - registrationEndpoint: wellKnown["registration_endpoint"], - accountManagementEndpoint: wellKnown["account_management_uri"], - accountManagementActionsSupported: wellKnown["account_management_actions_supported"], - }; + return authMetadata as ValidatedAuthMetadata; } logger.error("Issuer configuration not valid"); throw new Error(OidcError.OpSupport); }; -/** - * Metadata from OIDC authority discovery - * With validated properties required in type - */ -export type ValidatedIssuerMetadata = Partial & - Pick< - OidcMetadata, - | "issuer" - | "authorization_endpoint" - | "token_endpoint" - | "registration_endpoint" - | "revocation_endpoint" - | "response_types_supported" - | "grant_types_supported" - | "code_challenge_methods_supported" - | "device_authorization_endpoint" - > & { - // MSC2965 extensions to the OIDC spec - account_management_uri?: string; - account_management_actions_supported?: string[]; - }; - -/** - * Wraps validateOIDCIssuerWellKnown in a type assertion - * that asserts expected properties are present - * (Typescript assertions cannot be arrow functions) - * @param metadata - issuer openid-configuration response - * @throws when metadata validation fails - */ -export function isValidatedIssuerMetadata( - metadata: Partial, -): asserts metadata is ValidatedIssuerMetadata { - validateOIDCIssuerWellKnown(metadata); -} - export const decodeIdToken = (token: string): IdTokenClaims => { try { return jwtDecode(token); diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index a22b3149e5a..849b8623537 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -27,7 +27,7 @@ import { logger } from "../logger.ts"; import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel.ts"; import { MatrixError } from "../http-api/index.ts"; import { sleep } from "../utils.ts"; -import { DEVICE_CODE_SCOPE, discoverAndValidateOIDCIssuerWellKnown, OidcClientConfig } from "../oidc/index.ts"; +import { DEVICE_CODE_SCOPE, OidcClientConfig } from "../oidc/index.ts"; import { CryptoApi } from "../crypto-api/index.ts"; /** @@ -189,13 +189,12 @@ export class MSC4108SignInWithQR { // MSC4108-Flow: NewScanned -send protocols message let oidcClientConfig: OidcClientConfig | undefined; try { - const { issuer } = await this.client!.getAuthIssuer(); - oidcClientConfig = await discoverAndValidateOIDCIssuerWellKnown(issuer); + oidcClientConfig = await this.client!.getAuthMetadata(); } catch (e) { logger.error("Failed to discover OIDC metadata", e); } - if (oidcClientConfig?.metadata.grant_types_supported.includes(DEVICE_CODE_SCOPE)) { + if (oidcClientConfig?.grant_types_supported.includes(DEVICE_CODE_SCOPE)) { await this.send({ type: PayloadType.Protocols, protocols: ["device_authorization_grant"], diff --git a/src/testing.ts b/src/testing.ts index eab7e019de3..b6d68cd886e 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -27,6 +27,7 @@ import { RoomMember } from "./models/room-member.ts"; import { EventType } from "./@types/event.ts"; import { DecryptionFailureCode } from "./crypto-api/index.ts"; import { DecryptionError, EventDecryptionResult } from "./common-crypto/CryptoBackend.ts"; +import { OidcClientConfig, ValidatedAuthMetadata } from "./oidc/index.ts"; /** * Create a {@link MatrixEvent}. @@ -188,3 +189,45 @@ export async function decryptExistingEvent( } as Parameters[0]; await mxEvent.attemptDecryption(mockCrypto); } + +/** + * Makes a valid OidcClientConfig with minimum valid values + * @param issuer used as the base for all other urls + * @param additionalGrantTypes to add to the default grant types + * @returns OidcClientConfig + * @experimental + */ +export const makeDelegatedAuthConfig = ( + issuer = "https://auth.org/", + additionalGrantTypes: string[] = [], +): OidcClientConfig => { + const metadata = mockOpenIdConfiguration(issuer, additionalGrantTypes); + + return { + ...metadata, + signingKeys: null, + }; +}; + +/** + * Useful for mocking /.well-known/openid-configuration + * @param issuer used as the base for all other urls + * @param additionalGrantTypes to add to the default grant types + * @returns ValidatedAuthMetadata + * @experimental + */ +export const mockOpenIdConfiguration = ( + issuer = "https://auth.org/", + additionalGrantTypes: string[] = [], +): ValidatedAuthMetadata => ({ + issuer, + revocation_endpoint: issuer + "revoke", + token_endpoint: issuer + "token", + authorization_endpoint: issuer + "auth", + registration_endpoint: issuer + "registration", + device_authorization_endpoint: issuer + "device", + jwks_uri: issuer + "jwks", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token", ...additionalGrantTypes], + code_challenge_methods_supported: ["S256"], +});