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

Switch OIDC primarily to new /auth_metadata API (#4626)

This commit is contained in:
Michael Telatynski
2025-01-22 13:48:27 +00:00
committed by GitHub
parent 61375ef38a
commit c0e30ceca0
16 changed files with 267 additions and 193 deletions

View File

@ -36,7 +36,7 @@ import {
MatrixError, MatrixError,
MatrixHttpApi, MatrixHttpApi,
} from "../../../src"; } from "../../../src";
import { mockOpenIdConfiguration } from "../../test-utils/oidc"; import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient { function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient {
const baseUrl = "https://example.com"; const baseUrl = "https://example.com";
@ -57,7 +57,7 @@ function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled
getDomain: () => "example.com", getDomain: () => "example.com",
getDevice: jest.fn(), getDevice: jest.fn(),
getCrypto: jest.fn(() => crypto), 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; } as unknown as MatrixClient;
client.http = new MatrixHttpApi<IHttpOpts & { onlyData: true }>(client, { client.http = new MatrixHttpApi<IHttpOpts & { onlyData: true }>(client, {
baseUrl: client.baseUrl, baseUrl: client.baseUrl,
@ -69,10 +69,6 @@ function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled
describe("MSC4108SignInWithQR", () => { describe("MSC4108SignInWithQR", () => {
beforeEach(() => { beforeEach(() => {
fetchMock.get(
"https://issuer/.well-known/openid-configuration",
mockOpenIdConfiguration("https://issuer/", [DEVICE_CODE_SCOPE]),
);
fetchMock.get("https://issuer/jwks", { fetchMock.get("https://issuer/jwks", {
status: 200, status: 200,
headers: { headers: {

View File

@ -14,42 +14,4 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { OidcClientConfig, ValidatedIssuerMetadata } from "../../src"; export { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "../../src/testing.ts";
/**
* 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 <issuer>/.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"],
});

View File

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import { Mocked, mocked } from "jest-mock"; import { Mocked, mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import { logger } from "../../src/logger"; import { logger } from "../../src/logger";
import { ClientEvent, IMatrixClientCreateOpts, ITurnServerResponse, MatrixClient, Store } from "../../src/client"; 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 { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
import { KnownMembership } from "../../src/@types/membership"; import { KnownMembership } from "../../src/@types/membership";
import { RoomMessageEventContent } from "../../src/@types/events"; import { RoomMessageEventContent } from "../../src/@types/events";
import { mockOpenIdConfiguration } from "../test-utils/oidc.ts";
jest.useFakeTimers(); jest.useFakeTimers();
@ -265,13 +267,17 @@ describe("MatrixClient", function () {
if (next.error) { if (next.error) {
// eslint-disable-next-line // eslint-disable-next-line
return Promise.reject({ return Promise.reject(
errcode: (<MatrixError>next.error).errcode, new MatrixError(
httpStatus: (<MatrixError>next.error).httpStatus, {
name: (<MatrixError>next.error).errcode, errcode: (<MatrixError>next.error).errcode,
message: "Expected testing error", name: (<MatrixError>next.error).errcode,
data: next.error, message: "Expected testing error",
}); data: next.error,
},
(<MatrixError>next.error).httpStatus,
),
);
} }
return Promise.resolve(next.data); 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", () => { describe("identityHashedLookup", () => {
it("should return hashed lookup results", async () => { it("should return hashed lookup results", async () => {
const ID_ACCESS_TOKEN = "hello_id_server_please_let_me_make_a_request"; const ID_ACCESS_TOKEN = "hello_id_server_please_let_me_make_a_request";

View File

@ -40,8 +40,8 @@ jest.mock("jwt-decode");
describe("oidc authorization", () => { describe("oidc authorization", () => {
const delegatedAuthConfig = makeDelegatedAuthConfig(); const delegatedAuthConfig = makeDelegatedAuthConfig();
const authorizationEndpoint = delegatedAuthConfig.authorizationEndpoint; const authorizationEndpoint = delegatedAuthConfig.authorization_endpoint;
const tokenEndpoint = delegatedAuthConfig.tokenEndpoint; const tokenEndpoint = delegatedAuthConfig.token_endpoint;
const clientId = "xyz789"; const clientId = "xyz789";
const baseUrl = "https://test.com"; const baseUrl = "https://test.com";
@ -52,10 +52,7 @@ describe("oidc authorization", () => {
jest.spyOn(logger, "warn"); jest.spyOn(logger, "warn");
jest.setSystemTime(now); jest.setSystemTime(now);
fetchMock.get( fetchMock.get(delegatedAuthConfig.issuer + ".well-known/openid-configuration", mockOpenIdConfiguration());
delegatedAuthConfig.metadata.issuer + ".well-known/openid-configuration",
mockOpenIdConfiguration(),
);
globalThis.TextEncoder = TextEncoder; globalThis.TextEncoder = TextEncoder;
}); });
@ -127,11 +124,9 @@ describe("oidc authorization", () => {
it("should generate url with correct parameters", async () => { it("should generate url with correct parameters", async () => {
const nonce = "abc123"; const nonce = "abc123";
const metadata = delegatedAuthConfig.metadata;
const authUrl = new URL( const authUrl = new URL(
await generateOidcAuthorizationUrl({ await generateOidcAuthorizationUrl({
metadata, metadata: delegatedAuthConfig,
homeserverUrl: baseUrl, homeserverUrl: baseUrl,
clientId, clientId,
redirectUri: baseUrl, redirectUri: baseUrl,
@ -156,11 +151,9 @@ describe("oidc authorization", () => {
it("should generate url with create prompt", async () => { it("should generate url with create prompt", async () => {
const nonce = "abc123"; const nonce = "abc123";
const metadata = delegatedAuthConfig.metadata;
const authUrl = new URL( const authUrl = new URL(
await generateOidcAuthorizationUrl({ await generateOidcAuthorizationUrl({
metadata, metadata: delegatedAuthConfig,
homeserverUrl: baseUrl, homeserverUrl: baseUrl,
clientId, clientId,
redirectUri: baseUrl, redirectUri: baseUrl,

View File

@ -42,13 +42,13 @@ describe("registerOidcClient()", () => {
}); });
it("should make correct request to register client", async () => { it("should make correct request to register client", async () => {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, { fetchMockJest.post(delegatedAuthConfig.registration_endpoint!, {
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(
delegatedAuthConfig.registrationEndpoint!, delegatedAuthConfig.registration_endpoint,
expect.objectContaining({ expect.objectContaining({
headers: { headers: {
"Accept": "application/json", "Accept": "application/json",
@ -72,7 +72,7 @@ describe("registerOidcClient()", () => {
}); });
it("should throw when registration request fails", async () => { it("should throw when registration request fails", async () => {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, { fetchMockJest.post(delegatedAuthConfig.registration_endpoint!, {
status: 500, status: 500,
}); });
await expect(() => registerOidcClient(delegatedAuthConfig, metadata)).rejects.toThrow( await expect(() => registerOidcClient(delegatedAuthConfig, metadata)).rejects.toThrow(
@ -81,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(delegatedAuthConfig.registrationEndpoint!, { fetchMockJest.post(delegatedAuthConfig.registration_endpoint!, {
status: 200, status: 200,
// no clientId in response // no clientId in response
body: "{}", body: "{}",
@ -96,7 +96,7 @@ describe("registerOidcClient()", () => {
registerOidcClient( registerOidcClient(
{ {
...delegatedAuthConfig, ...delegatedAuthConfig,
registrationEndpoint: undefined, registration_endpoint: undefined,
}, },
metadata, metadata,
), ),
@ -108,10 +108,7 @@ describe("registerOidcClient()", () => {
registerOidcClient( registerOidcClient(
{ {
...delegatedAuthConfig, ...delegatedAuthConfig,
metadata: { grant_types_supported: [delegatedAuthConfig.grant_types_supported[0]],
...delegatedAuthConfig.metadata,
grant_types_supported: [delegatedAuthConfig.metadata.grant_types_supported[0]],
},
}, },
metadata, metadata,
), ),

View File

@ -55,8 +55,8 @@ describe("OidcTokenRefresher", () => {
}); });
beforeEach(() => { beforeEach(() => {
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata); fetchMock.get(`${config.issuer}.well-known/openid-configuration`, config);
fetchMock.get(`${config.metadata.issuer}jwks`, { fetchMock.get(`${config.issuer}jwks`, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -64,7 +64,7 @@ describe("OidcTokenRefresher", () => {
keys: [], keys: [],
}); });
fetchMock.post(config.tokenEndpoint, { fetchMock.post(config.token_endpoint, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -81,7 +81,7 @@ describe("OidcTokenRefresher", () => {
it("throws when oidc client cannot be initialised", async () => { it("throws when oidc client cannot be initialised", async () => {
jest.spyOn(logger, "error"); jest.spyOn(logger, "error");
fetchMock.get( fetchMock.get(
`${config.metadata.issuer}.well-known/openid-configuration`, `${config.issuer}.well-known/openid-configuration`,
{ {
ok: false, ok: false,
status: 404, status: 404,
@ -126,7 +126,7 @@ describe("OidcTokenRefresher", () => {
const result = await refresher.doRefreshAccessToken("refresh-token"); const result = await refresher.doRefreshAccessToken("refresh-token");
expect(fetchMock).toHaveFetched(config.tokenEndpoint, { expect(fetchMock).toHaveFetched(config.token_endpoint, {
method: "POST", method: "POST",
}); });
@ -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.tokenEndpoint, config.token_endpoint,
{ {
status: 200, status: 200,
headers: { headers: {
@ -164,7 +164,7 @@ describe("OidcTokenRefresher", () => {
{ overwriteRoutes: true }, { overwriteRoutes: true },
) )
.postOnce( .postOnce(
config.tokenEndpoint, config.token_endpoint,
{ {
status: 200, status: 200,
headers: { headers: {
@ -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.tokenEndpoint); expect(fetchMock).toHaveFetchedTimes(1, config.token_endpoint);
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.tokenEndpoint, config.token_endpoint,
{ {
status: 503, status: 503,
headers: { headers: {
@ -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.tokenEndpoint, config.token_endpoint,
{ {
status: 503, status: 503,
headers: { headers: {
@ -238,7 +238,7 @@ describe("OidcTokenRefresher", () => {
{ overwriteRoutes: true }, { overwriteRoutes: true },
) )
.postOnce( .postOnce(
config.tokenEndpoint, config.token_endpoint,
{ {
status: 200, status: 200,
headers: { headers: {

View File

@ -18,17 +18,18 @@ import { mocked } from "jest-mock";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { logger } from "../../../src/logger"; 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"; import { OidcError } from "../../../src/oidc/error";
jest.mock("jwt-decode"); jest.mock("jwt-decode");
describe("validateOIDCIssuerWellKnown", () => { describe("validateOIDCIssuerWellKnown", () => {
const validWk: any = { const validWk: ValidatedAuthMetadata = {
issuer: "https://test.org",
authorization_endpoint: "https://test.org/authorize", authorization_endpoint: "https://test.org/authorize",
token_endpoint: "https://authorize.org/token", token_endpoint: "https://authorize.org/token",
registration_endpoint: "https://authorize.org/regsiter", registration_endpoint: "https://authorize.org/register",
revocation_endpoint: "https://authorize.org/regsiter", revocation_endpoint: "https://authorize.org/revoke",
response_types_supported: ["code"], response_types_supported: ["code"],
grant_types_supported: ["authorization_code"], grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"], code_challenge_methods_supported: ["S256"],
@ -44,14 +45,14 @@ describe("validateOIDCIssuerWellKnown", () => {
it("should throw OP support error when wellKnown is not an object", () => { it("should throw OP support error when wellKnown is not an object", () => {
expect(() => { expect(() => {
validateOIDCIssuerWellKnown([]); validateAuthMetadata([]);
}).toThrow(OidcError.OpSupport); }).toThrow(OidcError.OpSupport);
expect(logger.error).toHaveBeenCalledWith("Issuer configuration not found or malformed"); expect(logger.error).toHaveBeenCalledWith("Issuer configuration not found or malformed");
}); });
it("should log all errors before throwing", () => { it("should log all errors before throwing", () => {
expect(() => { expect(() => {
validateOIDCIssuerWellKnown({ validateAuthMetadata({
...validWk, ...validWk,
authorization_endpoint: undefined, authorization_endpoint: undefined,
response_types_supported: [], response_types_supported: [],
@ -62,24 +63,31 @@ describe("validateOIDCIssuerWellKnown", () => {
}); });
it("should return validated issuer config", () => { it("should return validated issuer config", () => {
expect(validateOIDCIssuerWellKnown(validWk)).toEqual({ expect(validateAuthMetadata(validWk)).toEqual(
authorizationEndpoint: validWk.authorization_endpoint, expect.objectContaining({
tokenEndpoint: validWk.token_endpoint, issuer: validWk.issuer,
registrationEndpoint: validWk.registration_endpoint, authorization_endpoint: validWk.authorization_endpoint,
accountManagementActionsSupported: ["org.matrix.cross_signing_reset"], token_endpoint: validWk.token_endpoint,
accountManagementEndpoint: "https://authorize.org/account", 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 registrationendpoint", () => { it("should return validated issuer config without registration_endpoint", () => {
const wk = { ...validWk }; const { registration_endpoint: _, ...wk } = validWk;
delete wk.registration_endpoint; expect(validateAuthMetadata(wk)).toEqual({
expect(validateOIDCIssuerWellKnown(wk)).toEqual({ issuer: validWk.issuer,
authorizationEndpoint: validWk.authorization_endpoint, authorization_endpoint: validWk.authorization_endpoint,
tokenEndpoint: validWk.token_endpoint, token_endpoint: validWk.token_endpoint,
registrationEndpoint: undefined, revocation_endpoint: validWk.revocation_endpoint,
accountManagementActionsSupported: ["org.matrix.cross_signing_reset"], registration_endpoint: undefined,
accountManagementEndpoint: "https://authorize.org/account", 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, ...validWk,
[key]: value, [key]: value,
}; };
expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcError.OpSupport); expect(() => validateAuthMetadata(wk)).toThrow(OidcError.OpSupport);
}); });
}); });

View File

@ -247,6 +247,7 @@ import { ImageInfo } from "./@types/media.ts";
import { Capabilities, ServerCapabilities } from "./serverCapabilities.ts"; import { Capabilities, ServerCapabilities } from "./serverCapabilities.ts";
import { sha256 } from "./digest.ts"; import { sha256 } from "./digest.ts";
import { keyFromAuthData } from "./common-crypto/key-passphrase.ts"; import { keyFromAuthData } from "./common-crypto/key-passphrase.ts";
import { discoverAndValidateOIDCIssuerWellKnown, OidcClientConfig, validateAuthMetadataAndKeys } from "./oidc/index.ts";
export type Store = IStore; export type Store = IStore;
@ -10352,6 +10353,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Resolves: A promise of an object containing the OIDC issuer if configured * @returns Resolves: A promise of an object containing the OIDC issuer if configured
* @returns Rejects: when the request fails (module:http-api.MatrixError) * @returns Rejects: when the request fails (module:http-api.MatrixError)
* @experimental - part of MSC2965 * @experimental - part of MSC2965
* @deprecated in favour of getAuthMetadata
*/ */
public async getAuthIssuer(): Promise<{ public async getAuthIssuer(): Promise<{
issuer: string; issuer: string;
@ -10360,6 +10362,34 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
prefix: ClientPrefix.Unstable + "/org.matrix.msc2965", prefix: ClientPrefix.Unstable + "/org.matrix.msc2965",
}); });
} }
/**
* Discover and validate delegated auth configuration
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
* Fetches /auth_metadata falling back to legacy implementation using /auth_issuer followed by
* https://oidc-issuer.example.com/.well-known/openid-configuration and other files linked therein.
* When successful, validated metadata is returned
* @returns validated authentication metadata and optionally signing keys
* @throws when delegated auth config is invalid or unreachable
* @experimental - part of MSC2965
*/
public async getAuthMetadata(): Promise<OidcClientConfig> {
let authMetadata: unknown | undefined;
try {
authMetadata = await this.http.request<unknown>(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 { function getUnstableDelayQueryOpts(delayOpts: SendDelayedEventRequestOpts): QueryDict {

View File

@ -23,7 +23,7 @@ import {
BearerTokenResponse, BearerTokenResponse,
UserState, UserState,
validateBearerTokenResponse, validateBearerTokenResponse,
ValidatedIssuerMetadata, ValidatedAuthMetadata,
validateIdToken, validateIdToken,
validateStoredUserState, validateStoredUserState,
} from "./validate.ts"; } from "./validate.ts";
@ -138,7 +138,7 @@ export const generateOidcAuthorizationUrl = async ({
urlState, urlState,
}: { }: {
clientId: string; clientId: string;
metadata: ValidatedIssuerMetadata; metadata: ValidatedAuthMetadata;
homeserverUrl: string; homeserverUrl: string;
identityServerUrl?: string; identityServerUrl?: string;
redirectUri: string; redirectUri: string;

View File

@ -16,7 +16,7 @@ limitations under the License.
import { MetadataService, OidcClientSettingsStore } from "oidc-client-ts"; 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 { Method, timeoutSignal } from "../http-api/index.ts";
import { OidcClientConfig } from "./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 * @param issuer - the OIDC issuer as returned by the /auth_issuer API
* @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
* @deprecated in favour of {@link MatrixClient#getAuthMetadata}
*/ */
export const discoverAndValidateOIDCIssuerWellKnown = async (issuer: string): Promise<OidcClientConfig> => { export const discoverAndValidateOIDCIssuerWellKnown = async (issuer: string): Promise<OidcClientConfig> => {
const issuerOpenIdConfigUrl = new URL(".well-known/openid-configuration", issuer); const issuerOpenIdConfigUrl = new URL(".well-known/openid-configuration", issuer);
@ -38,23 +39,29 @@ export const discoverAndValidateOIDCIssuerWellKnown = async (issuer: string): Pr
signal: timeoutSignal(5000), signal: timeoutSignal(5000),
}); });
const issuerWellKnown = await issuerWellKnownResponse.json(); 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<OidcClientConfig> => {
const validatedIssuerConfig = validateAuthMetadata(authMetadata);
// 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: issuer, authority: validatedIssuerConfig.issuer,
metadata: validatedIssuerConfig,
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
}); });
const metadataService = new MetadataService(settings); const metadataService = new MetadataService(settings);
const metadata = await metadataService.getMetadata();
const signingKeys = (await metadataService.getSigningKeys()) ?? undefined;
isValidatedIssuerMetadata(metadata);
return { return {
...validatedIssuerConfig, ...validatedIssuerConfig,
metadata, signingKeys: await metadataService.getSigningKeys(),
signingKeys,
}; };
}; };

View File

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import type { SigningKey } from "oidc-client-ts"; import type { SigningKey } from "oidc-client-ts";
import { ValidatedIssuerConfig, ValidatedIssuerMetadata } from "./validate.ts"; import { ValidatedAuthMetadata } from "./validate.ts";
export * from "./authorize.ts"; export * from "./authorize.ts";
export * from "./discovery.ts"; export * from "./discovery.ts";
@ -28,7 +28,6 @@ export * from "./validate.ts";
* Validated config for native OIDC authentication, as returned by {@link discoverAndValidateOIDCIssuerWellKnown}. * 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). * 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 { export interface OidcClientConfig extends ValidatedAuthMetadata {
metadata: ValidatedIssuerMetadata; signingKeys: SigningKey[] | null;
signingKeys?: SigningKey[];
} }

View File

@ -65,12 +65,12 @@ export const registerOidcClient = async (
delegatedAuthConfig: OidcClientConfig, delegatedAuthConfig: OidcClientConfig,
clientMetadata: OidcRegistrationClientMetadata, clientMetadata: OidcRegistrationClientMetadata,
): Promise<string> => { ): Promise<string> => {
if (!delegatedAuthConfig.registrationEndpoint) { if (!delegatedAuthConfig.registration_endpoint) {
throw new Error(OidcError.DynamicRegistrationNotSupported); throw new Error(OidcError.DynamicRegistrationNotSupported);
} }
const grantTypes: NonEmptyArray<string> = ["authorization_code", "refresh_token"]; const grantTypes: NonEmptyArray<string> = ["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); throw new Error(OidcError.DynamicRegistrationNotSupported);
} }
@ -95,7 +95,7 @@ export const registerOidcClient = async (
}; };
try { try {
const response = await fetch(delegatedAuthConfig.registrationEndpoint, { const response = await fetch(delegatedAuthConfig.registration_endpoint, {
method: Method.Post, method: Method.Post,
headers, headers,
body: JSON.stringify(metadata), body: JSON.stringify(metadata),

View File

@ -77,11 +77,12 @@ export class OidcTokenRefresher {
const scope = generateScope(deviceId); const scope = generateScope(deviceId);
this.oidcClient = new OidcClient({ this.oidcClient = new OidcClient({
...config.metadata, metadata: config,
signingKeys: config.signingKeys ?? undefined,
client_id: clientId, client_id: clientId,
scope, scope,
redirect_uri: redirectUri, redirect_uri: redirectUri,
authority: config.metadata.issuer, authority: config.issuer,
stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }), stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }),
}); });
} catch (error) { } catch (error) {

View File

@ -20,13 +20,28 @@ import { IdTokenClaims, OidcMetadata, SigninResponse } from "oidc-client-ts";
import { logger } from "../logger.ts"; import { logger } from "../logger.ts";
import { OidcError } from "./error.ts"; import { OidcError } from "./error.ts";
export type ValidatedIssuerConfig = { /**
authorizationEndpoint: string; * Metadata from OIDC authority discovery
tokenEndpoint: string; * With validated properties required in type
registrationEndpoint?: string; */
accountManagementEndpoint?: string; export type ValidatedAuthMetadata = Partial<OidcMetadata> &
accountManagementActionsSupported?: string[]; 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<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);
@ -67,78 +82,39 @@ const requiredArrayValue = (wellKnown: Record<string, unknown>, key: string, val
* Validates issuer `.well-known/openid-configuration` * Validates issuer `.well-known/openid-configuration`
* As defined in RFC5785 https://openid.net/specs/openid-connect-discovery-1_0.html * As defined in RFC5785 https://openid.net/specs/openid-connect-discovery-1_0.html
* validates that OP is compatible with Element's OIDC flow * validates that OP is compatible with Element's OIDC flow
* @param wellKnown - json object * @param authMetadata - json object
* @returns valid issuer config * @returns valid issuer config
* @throws Error - when issuer config is not found or is invalid * @throws Error - when issuer config is not found or is invalid
*/ */
export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuerConfig => { export const validateAuthMetadata = (authMetadata: unknown): ValidatedAuthMetadata => {
if (!isRecord(wellKnown)) { if (!isRecord(authMetadata)) {
logger.error("Issuer configuration not found or malformed"); logger.error("Issuer configuration not found or malformed");
throw new Error(OidcError.OpSupport); throw new Error(OidcError.OpSupport);
} }
const isInvalid = [ const isInvalid = [
requiredStringProperty(wellKnown, "authorization_endpoint"), requiredStringProperty(authMetadata, "issuer"),
requiredStringProperty(wellKnown, "token_endpoint"), requiredStringProperty(authMetadata, "authorization_endpoint"),
requiredStringProperty(wellKnown, "revocation_endpoint"), requiredStringProperty(authMetadata, "token_endpoint"),
optionalStringProperty(wellKnown, "registration_endpoint"), requiredStringProperty(authMetadata, "revocation_endpoint"),
optionalStringProperty(wellKnown, "account_management_uri"), optionalStringProperty(authMetadata, "registration_endpoint"),
optionalStringProperty(wellKnown, "device_authorization_endpoint"), optionalStringProperty(authMetadata, "account_management_uri"),
optionalStringArrayProperty(wellKnown, "account_management_actions_supported"), optionalStringProperty(authMetadata, "device_authorization_endpoint"),
requiredArrayValue(wellKnown, "response_types_supported", "code"), optionalStringArrayProperty(authMetadata, "account_management_actions_supported"),
requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"), requiredArrayValue(authMetadata, "response_types_supported", "code"),
requiredArrayValue(wellKnown, "code_challenge_methods_supported", "S256"), requiredArrayValue(authMetadata, "grant_types_supported", "authorization_code"),
requiredArrayValue(authMetadata, "code_challenge_methods_supported", "S256"),
optionalStringArrayProperty(authMetadata, "prompt_values_supported"),
].some((isValid) => !isValid); ].some((isValid) => !isValid);
if (!isInvalid) { if (!isInvalid) {
return { return authMetadata as ValidatedAuthMetadata;
authorizationEndpoint: <string>wellKnown["authorization_endpoint"],
tokenEndpoint: <string>wellKnown["token_endpoint"],
registrationEndpoint: <string>wellKnown["registration_endpoint"],
accountManagementEndpoint: <string>wellKnown["account_management_uri"],
accountManagementActionsSupported: <string[]>wellKnown["account_management_actions_supported"],
};
} }
logger.error("Issuer configuration not valid"); logger.error("Issuer configuration not valid");
throw new Error(OidcError.OpSupport); throw new Error(OidcError.OpSupport);
}; };
/**
* Metadata from OIDC authority discovery
* With validated properties required in type
*/
export type ValidatedIssuerMetadata = Partial<OidcMetadata> &
Pick<
OidcMetadata,
| "issuer"
| "authorization_endpoint"
| "token_endpoint"
| "registration_endpoint"
| "revocation_endpoint"
| "response_types_supported"
| "grant_types_supported"
| "code_challenge_methods_supported"
| "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<OidcMetadata>,
): asserts metadata is ValidatedIssuerMetadata {
validateOIDCIssuerWellKnown(metadata);
}
export const decodeIdToken = (token: string): IdTokenClaims => { export const decodeIdToken = (token: string): IdTokenClaims => {
try { try {
return jwtDecode<IdTokenClaims>(token); return jwtDecode<IdTokenClaims>(token);

View File

@ -27,7 +27,7 @@ import { logger } from "../logger.ts";
import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel.ts"; import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel.ts";
import { MatrixError } from "../http-api/index.ts"; import { MatrixError } from "../http-api/index.ts";
import { sleep } from "../utils.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"; import { CryptoApi } from "../crypto-api/index.ts";
/** /**
@ -189,13 +189,12 @@ export class MSC4108SignInWithQR {
// MSC4108-Flow: NewScanned -send protocols message // MSC4108-Flow: NewScanned -send protocols message
let oidcClientConfig: OidcClientConfig | undefined; let oidcClientConfig: OidcClientConfig | undefined;
try { try {
const { issuer } = await this.client!.getAuthIssuer(); oidcClientConfig = await this.client!.getAuthMetadata();
oidcClientConfig = await discoverAndValidateOIDCIssuerWellKnown(issuer);
} catch (e) { } catch (e) {
logger.error("Failed to discover OIDC metadata", 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<ProtocolsPayload>({ await this.send<ProtocolsPayload>({
type: PayloadType.Protocols, type: PayloadType.Protocols,
protocols: ["device_authorization_grant"], protocols: ["device_authorization_grant"],

View File

@ -27,6 +27,7 @@ import { RoomMember } from "./models/room-member.ts";
import { EventType } from "./@types/event.ts"; import { EventType } from "./@types/event.ts";
import { DecryptionFailureCode } from "./crypto-api/index.ts"; import { DecryptionFailureCode } from "./crypto-api/index.ts";
import { DecryptionError, EventDecryptionResult } from "./common-crypto/CryptoBackend.ts"; import { DecryptionError, EventDecryptionResult } from "./common-crypto/CryptoBackend.ts";
import { OidcClientConfig, ValidatedAuthMetadata } from "./oidc/index.ts";
/** /**
* Create a {@link MatrixEvent}. * Create a {@link MatrixEvent}.
@ -188,3 +189,45 @@ export async function decryptExistingEvent(
} as Parameters<MatrixEvent["attemptDecryption"]>[0]; } as Parameters<MatrixEvent["attemptDecryption"]>[0];
await mxEvent.attemptDecryption(mockCrypto); 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 <issuer>/.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"],
});