You've already forked matrix-js-sdk
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:
committed by
GitHub
parent
61375ef38a
commit
c0e30ceca0
@ -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<IHttpOpts & { onlyData: true }>(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: {
|
||||
|
@ -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 <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"],
|
||||
});
|
||||
export { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "../../src/testing.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: (<MatrixError>next.error).errcode,
|
||||
httpStatus: (<MatrixError>next.error).httpStatus,
|
||||
name: (<MatrixError>next.error).errcode,
|
||||
message: "Expected testing error",
|
||||
data: next.error,
|
||||
});
|
||||
return Promise.reject(
|
||||
new MatrixError(
|
||||
{
|
||||
errcode: (<MatrixError>next.error).errcode,
|
||||
name: (<MatrixError>next.error).errcode,
|
||||
message: "Expected testing error",
|
||||
data: next.error,
|
||||
},
|
||||
(<MatrixError>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";
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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: {
|
||||
|
@ -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",
|
||||
});
|
||||
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 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",
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
|
||||
@ -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 Rejects: when the request fails (module:http-api.MatrixError)
|
||||
* @experimental - part of MSC2965
|
||||
* @deprecated in favour of getAuthMetadata
|
||||
*/
|
||||
public async getAuthIssuer(): Promise<{
|
||||
issuer: string;
|
||||
@ -10360,6 +10362,34 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
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 {
|
||||
|
@ -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;
|
||||
|
@ -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<OidcClientConfig> => {
|
||||
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<OidcClientConfig> => {
|
||||
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(),
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -65,12 +65,12 @@ export const registerOidcClient = async (
|
||||
delegatedAuthConfig: OidcClientConfig,
|
||||
clientMetadata: OidcRegistrationClientMetadata,
|
||||
): Promise<string> => {
|
||||
if (!delegatedAuthConfig.registrationEndpoint) {
|
||||
if (!delegatedAuthConfig.registration_endpoint) {
|
||||
throw new Error(OidcError.DynamicRegistrationNotSupported);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
@ -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) {
|
||||
|
@ -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<OidcMetadata> &
|
||||
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> =>
|
||||
!!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`
|
||||
* 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: <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"],
|
||||
};
|
||||
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<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 => {
|
||||
try {
|
||||
return jwtDecode<IdTokenClaims>(token);
|
||||
|
@ -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<ProtocolsPayload>({
|
||||
type: PayloadType.Protocols,
|
||||
protocols: ["device_authorization_grant"],
|
||||
|
@ -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<MatrixEvent["attemptDecryption"]>[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 <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"],
|
||||
});
|
||||
|
Reference in New Issue
Block a user