Improve client metadata used for OIDC dynamic registration (#12257)

pull/28217/head
Michael Telatynski 2024-02-16 14:43:58 +00:00 committed by GitHub
parent e8ce9cb360
commit cd8679c172
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 80 additions and 41 deletions

View File

@ -16,6 +16,8 @@ limitations under the License.
import { JSXElementConstructor } from "react"; import { JSXElementConstructor } from "react";
export type { NonEmptyArray } from "matrix-js-sdk/src/matrix";
// Based on https://stackoverflow.com/a/53229857/3532235 // Based on https://stackoverflow.com/a/53229857/3532235
export type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }; export type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
export type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U; export type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
@ -38,8 +40,6 @@ export type KeysStartingWith<Input extends object, Str extends string> = {
[P in keyof Input]: P extends `${Str}${infer _X}` ? P : never; // we don't use _X [P in keyof Input]: P extends `${Str}${infer _X}` ? P : never; // we don't use _X
}[keyof Input]; }[keyof Input];
export type NonEmptyArray<T> = [T, ...T[]];
export type Defaultize<P, D> = P extends any export type Defaultize<P, D> = P extends any
? string extends keyof P ? string extends keyof P
? P ? P

View File

@ -17,7 +17,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { MatrixClient, MatrixEvent, Room, SSOAction, encodeUnpaddedBase64 } from "matrix-js-sdk/src/matrix"; import {
MatrixClient,
MatrixEvent,
Room,
SSOAction,
encodeUnpaddedBase64,
OidcRegistrationClientMetadata,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import dis from "./dispatcher/dispatcher"; import dis from "./dispatcher/dispatcher";
@ -30,6 +37,7 @@ import { MatrixClientPeg } from "./MatrixClientPeg";
import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager"; import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager";
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { IConfigOptions } from "./IConfigOptions"; import { IConfigOptions } from "./IConfigOptions";
import SdkConfig from "./SdkConfig";
export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url"; export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url"; export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
@ -426,7 +434,7 @@ export default abstract class BasePlatform {
/** /**
* Delete a previously stored pickle key from storage. * Delete a previously stored pickle key from storage.
* @param {string} userId the user ID for the user that the pickle key is for. * @param {string} userId the user ID for the user that the pickle key is for.
* @param {string} userId the device ID that the pickle key is for. * @param {string} deviceId the device ID that the pickle key is for.
*/ */
public async destroyPickleKey(userId: string, deviceId: string): Promise<void> { public async destroyPickleKey(userId: string, deviceId: string): Promise<void> {
try { try {
@ -443,4 +451,31 @@ export default abstract class BasePlatform {
window.sessionStorage.clear(); window.sessionStorage.clear();
window.localStorage.clear(); window.localStorage.clear();
} }
/**
* Base URL to use when generating external links for this client, for platforms e.g. Desktop this will be a different instance
*/
public get baseUrl(): string {
return window.location.origin + window.location.pathname;
}
/**
* Metadata to use for dynamic OIDC client registrations
*/
public async getOidcClientMetadata(): Promise<OidcRegistrationClientMetadata> {
const config = SdkConfig.get();
return {
clientName: config.brand,
clientUri: this.baseUrl,
redirectUris: [this.getSSOCallbackUrl().href],
logoUri: new URL("vector-icons/1024.png", this.baseUrl).href,
applicationType: "web",
// XXX: We break the spec by not consistently supplying these required fields
// contacts: [],
// @ts-ignore
tosUri: config.terms_and_conditions_links?.[0]?.url,
// @ts-ignore
policyUri: config.privacy_policy_url,
};
}
} }

View File

@ -120,7 +120,6 @@ export default class Login {
try { try {
const oidcFlow = await tryInitOidcNativeFlow( const oidcFlow = await tryInitOidcNativeFlow(
this.delegatedAuthentication, this.delegatedAuthentication,
SdkConfig.get().brand,
SdkConfig.get().oidc_static_clients, SdkConfig.get().oidc_static_clients,
isRegistration, isRegistration,
); );
@ -223,7 +222,6 @@ export interface OidcNativeFlow extends ILoginFlow {
* results. * results.
* *
* @param delegatedAuthConfig Auth config from ValidatedServerConfig * @param delegatedAuthConfig Auth config from ValidatedServerConfig
* @param clientName Client name to register with the OP, eg 'Element', used during client registration with OP
* @param staticOidcClientIds static client config from config.json, used during client registration with OP * @param staticOidcClientIds static client config from config.json, used during client registration with OP
* @param isRegistration true when we are attempting registration * @param isRegistration true when we are attempting registration
* @returns Promise<OidcNativeFlow> when oidc native authentication flow is supported and correctly configured * @returns Promise<OidcNativeFlow> when oidc native authentication flow is supported and correctly configured
@ -231,15 +229,14 @@ export interface OidcNativeFlow extends ILoginFlow {
*/ */
const tryInitOidcNativeFlow = async ( const tryInitOidcNativeFlow = async (
delegatedAuthConfig: OidcClientConfig, delegatedAuthConfig: OidcClientConfig,
brand: string, staticOidcClientIds?: IConfigOptions["oidc_static_clients"],
oidcStaticClients?: IConfigOptions["oidc_static_clients"],
isRegistration?: boolean, isRegistration?: boolean,
): Promise<OidcNativeFlow> => { ): Promise<OidcNativeFlow> => {
// if registration is not supported, bail before attempting to get the clientId // if registration is not supported, bail before attempting to get the clientId
if (isRegistration && !isUserRegistrationSupported(delegatedAuthConfig)) { if (isRegistration && !isUserRegistrationSupported(delegatedAuthConfig)) {
throw new Error("Registration is not supported by OP"); throw new Error("Registration is not supported by OP");
} }
const clientId = await getOidcClientId(delegatedAuthConfig, brand, window.location.origin, oidcStaticClients); const clientId = await getOidcClientId(delegatedAuthConfig, staticOidcClientIds);
const flow = { const flow = {
type: "oidcNativeFlow", type: "oidcNativeFlow",

View File

@ -19,6 +19,7 @@ import { registerOidcClient } from "matrix-js-sdk/src/oidc/register";
import { IConfigOptions } from "../../IConfigOptions"; import { IConfigOptions } from "../../IConfigOptions";
import { ValidatedDelegatedAuthConfig } from "../ValidatedServerConfig"; import { ValidatedDelegatedAuthConfig } from "../ValidatedServerConfig";
import PlatformPeg from "../../PlatformPeg";
/** /**
* Get the statically configured clientId for the issuer * Get the statically configured clientId for the issuer
@ -40,16 +41,12 @@ const getStaticOidcClientId = (
* Checks statically configured clientIds first * Checks statically configured clientIds first
* Then attempts dynamic registration with the OP * Then attempts dynamic registration with the OP
* @param delegatedAuthConfig Auth config from ValidatedServerConfig * @param delegatedAuthConfig Auth config from ValidatedServerConfig
* @param clientName Client name to register with the OP, eg 'Element'
* @param baseUrl URL of the home page of the Client, eg 'https://app.element.io/'
* @param staticOidcClients static client config from config.json * @param staticOidcClients static client config from config.json
* @returns Promise<string> resolves with clientId * @returns Promise<string> resolves with clientId
* @throws if no clientId is found * @throws if no clientId is found
*/ */
export const getOidcClientId = async ( export const getOidcClientId = async (
delegatedAuthConfig: ValidatedDelegatedAuthConfig, delegatedAuthConfig: ValidatedDelegatedAuthConfig,
clientName: string,
baseUrl: string,
staticOidcClients?: IConfigOptions["oidc_static_clients"], staticOidcClients?: IConfigOptions["oidc_static_clients"],
): Promise<string> => { ): Promise<string> => {
const staticClientId = getStaticOidcClientId(delegatedAuthConfig.issuer, staticOidcClients); const staticClientId = getStaticOidcClientId(delegatedAuthConfig.issuer, staticOidcClients);
@ -57,5 +54,5 @@ export const getOidcClientId = async (
logger.debug(`Using static clientId for issuer ${delegatedAuthConfig.issuer}`); logger.debug(`Using static clientId for issuer ${delegatedAuthConfig.issuer}`);
return staticClientId; return staticClientId;
} }
return await registerOidcClient(delegatedAuthConfig, clientName, baseUrl); return await registerOidcClient(delegatedAuthConfig, await PlatformPeg.get()!.getOidcClientMetadata());
}; };

View File

@ -415,12 +415,7 @@ describe("Login", function () {
// tried to register // tried to register
expect(fetchMock).toHaveBeenCalledWith(delegatedAuth.registrationEndpoint, expect.any(Object)); expect(fetchMock).toHaveBeenCalledWith(delegatedAuth.registrationEndpoint, expect.any(Object));
// called with values from config // called with values from config
expect(registerClientUtils.getOidcClientId).toHaveBeenCalledWith( expect(registerClientUtils.getOidcClientId).toHaveBeenCalledWith(delegatedAuth, oidcStaticClientsConfig);
delegatedAuth,
"test-brand",
"http://localhost",
oidcStaticClientsConfig,
);
}); });
it("should fallback to normal login when client registration fails", async () => { it("should fallback to normal login when client registration fails", async () => {

View File

@ -22,7 +22,7 @@ import PlatformPeg from "../../src/PlatformPeg";
// doesn't implement abstract // doesn't implement abstract
// @ts-ignore // @ts-ignore
class MockPlatform extends BasePlatform { class MockPlatform extends BasePlatform {
constructor(platformMocks: Partial<Record<MethodLikeKeys<BasePlatform>, unknown>>) { constructor(platformMocks: Partial<Record<keyof BasePlatform, unknown>>) {
super(); super();
Object.assign(this, platformMocks); Object.assign(this, platformMocks);
} }

View File

@ -19,6 +19,8 @@ import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { getOidcClientId } from "../../../src/utils/oidc/registerClient"; import { getOidcClientId } from "../../../src/utils/oidc/registerClient";
import { ValidatedDelegatedAuthConfig } from "../../../src/utils/ValidatedServerConfig"; import { ValidatedDelegatedAuthConfig } from "../../../src/utils/ValidatedServerConfig";
import { mockPlatformPeg } from "../../test-utils";
import PlatformPeg from "../../../src/PlatformPeg";
describe("getOidcClientId()", () => { describe("getOidcClientId()", () => {
const issuer = "https://auth.com/"; const issuer = "https://auth.com/";
@ -41,10 +43,21 @@ describe("getOidcClientId()", () => {
beforeEach(() => { beforeEach(() => {
fetchMockJest.mockClear(); fetchMockJest.mockClear();
fetchMockJest.resetBehavior(); fetchMockJest.resetBehavior();
mockPlatformPeg();
Object.defineProperty(PlatformPeg.get(), "baseUrl", {
get(): string {
return baseUrl;
},
});
Object.defineProperty(PlatformPeg.get(), "getSSOCallbackUrl", {
value: () => ({
href: baseUrl,
}),
});
}); });
it("should return static clientId when configured", async () => { it("should return static clientId when configured", async () => {
expect(await getOidcClientId(delegatedAuthConfig, clientName, baseUrl, staticOidcClients)).toEqual("abc123"); expect(await getOidcClientId(delegatedAuthConfig, staticOidcClients)).toEqual("abc123");
// didn't try to register // didn't try to register
expect(fetchMockJest).toHaveFetchedTimes(0); expect(fetchMockJest).toHaveFetchedTimes(0);
}); });
@ -55,9 +68,9 @@ describe("getOidcClientId()", () => {
issuer: "https://issuerWithoutStaticClientId.org/", issuer: "https://issuerWithoutStaticClientId.org/",
registrationEndpoint: undefined, registrationEndpoint: undefined,
}; };
await expect( await expect(getOidcClientId(authConfigWithoutRegistration, staticOidcClients)).rejects.toThrow(
getOidcClientId(authConfigWithoutRegistration, clientName, baseUrl, staticOidcClients), OidcError.DynamicRegistrationNotSupported,
).rejects.toThrow(OidcError.DynamicRegistrationNotSupported); );
// didn't try to register // didn't try to register
expect(fetchMockJest).toHaveFetchedTimes(0); expect(fetchMockJest).toHaveFetchedTimes(0);
}); });
@ -67,7 +80,7 @@ describe("getOidcClientId()", () => {
...delegatedAuthConfig, ...delegatedAuthConfig,
registrationEndpoint: undefined, registrationEndpoint: undefined,
}; };
await expect(getOidcClientId(authConfigWithoutRegistration, clientName, baseUrl)).rejects.toThrow( await expect(getOidcClientId(authConfigWithoutRegistration)).rejects.toThrow(
OidcError.DynamicRegistrationNotSupported, OidcError.DynamicRegistrationNotSupported,
); );
// didn't try to register // didn't try to register
@ -79,15 +92,20 @@ describe("getOidcClientId()", () => {
status: 200, status: 200,
body: JSON.stringify({ client_id: dynamicClientId }), body: JSON.stringify({ client_id: dynamicClientId }),
}); });
expect(await getOidcClientId(delegatedAuthConfig, clientName, baseUrl)).toEqual(dynamicClientId); expect(await getOidcClientId(delegatedAuthConfig)).toEqual(dynamicClientId);
// didn't try to register // didn't try to register
expect(fetchMockJest).toHaveBeenCalledWith(registrationEndpoint, { expect(fetchMockJest).toHaveBeenCalledWith(
headers: { registrationEndpoint,
"Accept": "application/json", expect.objectContaining({
"Content-Type": "application/json", headers: {
}, "Accept": "application/json",
method: "POST", "Content-Type": "application/json",
body: JSON.stringify({ },
method: "POST",
}),
);
expect(JSON.parse(fetchMockJest.mock.calls[0][1]!.body as string)).toEqual(
expect.objectContaining({
client_name: clientName, client_name: clientName,
client_uri: baseUrl, client_uri: baseUrl,
response_types: ["code"], response_types: ["code"],
@ -96,17 +114,16 @@ describe("getOidcClientId()", () => {
id_token_signed_response_alg: "RS256", id_token_signed_response_alg: "RS256",
token_endpoint_auth_method: "none", token_endpoint_auth_method: "none",
application_type: "web", application_type: "web",
logo_uri: `${baseUrl}/vector-icons/1024.png`,
}), }),
}); );
}); });
it("should throw when registration request fails", async () => { it("should throw when registration request fails", async () => {
fetchMockJest.post(registrationEndpoint, { fetchMockJest.post(registrationEndpoint, {
status: 500, status: 500,
}); });
await expect(getOidcClientId(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow( await expect(getOidcClientId(delegatedAuthConfig)).rejects.toThrow(OidcError.DynamicRegistrationFailed);
OidcError.DynamicRegistrationFailed,
);
}); });
it("should throw when registration response is invalid", async () => { it("should throw when registration response is invalid", async () => {
@ -115,8 +132,6 @@ describe("getOidcClientId()", () => {
// no clientId in response // no clientId in response
body: "{}", body: "{}",
}); });
await expect(getOidcClientId(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow( await expect(getOidcClientId(delegatedAuthConfig)).rejects.toThrow(OidcError.DynamicRegistrationInvalid);
OidcError.DynamicRegistrationInvalid,
);
}); });
}); });