1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-06 12:02:40 +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

@@ -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 {

View File

@@ -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;

View File

@@ -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(),
};
};

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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"],

View File

@@ -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"],
});