From de8c561c0cb267594cd3dff94f0bf3c8ab5b47b5 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 25 Apr 2022 09:31:00 +0200 Subject: [PATCH] OIDC dynamic client registration --- .../login/CompleteOIDCLoginViewModel.js | 5 +- src/domain/login/StartOIDCLoginViewModel.js | 5 +- src/domain/navigation/URLRouter.js | 4 ++ src/matrix/Client.js | 4 +- src/matrix/login/OIDCLoginMethod.ts | 3 +- src/matrix/net/OidcApi.ts | 71 ++++++++++++++++--- 6 files changed, 75 insertions(+), 17 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index ca65c7c7ec..5d0da98021 100644 --- a/src/domain/login/CompleteOIDCLoginViewModel.js +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -50,18 +50,19 @@ export class CompleteOIDCLoginViewModel extends ViewModel { } const code = this._code; // TODO: cleanup settings storage - const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer] = await Promise.all([ + const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer, clientId] = await Promise.all([ this.platform.settingsStorage.getInt(`oidc_${this._state}_started_at`), this.platform.settingsStorage.getString(`oidc_${this._state}_nonce`), this.platform.settingsStorage.getString(`oidc_${this._state}_code_verifier`), this.platform.settingsStorage.getString(`oidc_${this._state}_redirect_uri`), this.platform.settingsStorage.getString(`oidc_${this._state}_homeserver`), this.platform.settingsStorage.getString(`oidc_${this._state}_issuer`), + this.platform.settingsStorage.getString(`oidc_${this._state}_client_id`), ]); const oidcApi = new OidcApi({ issuer, - clientId: "hydrogen-web", + clientId, request: this._request, encoding: this._encoding, crypto: this._crypto, diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index a06b764f13..e70a748762 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -24,11 +24,11 @@ export class StartOIDCLoginViewModel extends ViewModel { this._issuer = options.loginOptions.oidc.issuer; this._homeserver = options.loginOptions.homeserver; this._api = new OidcApi({ - clientId: "hydrogen-web", issuer: this._issuer, request: this.platform.request, encoding: this.platform.encoding, crypto: this.platform.crypto, + urlCreator: this.urlCreator, }); } @@ -42,6 +42,7 @@ export class StartOIDCLoginViewModel extends ViewModel { async discover() { // Ask for the metadata once so it gets discovered and cached await this._api.metadata() + await this._api.ensureRegistered(); } async startOIDCLogin() { @@ -49,6 +50,7 @@ export class StartOIDCLoginViewModel extends ViewModel { scope: "openid", redirectUri: this.urlCreator.createOIDCRedirectURL(), }); + const clientId = await this._api.clientId(); await Promise.all([ this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()), this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce), @@ -56,6 +58,7 @@ export class StartOIDCLoginViewModel extends ViewModel { this.platform.settingsStorage.setString(`oidc_${p.state}_redirect_uri`, p.redirectUri), this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._issuer), + this.platform.settingsStorage.setString(`oidc_${p.state}_client_id`, clientId), ]); const link = await this._api.authorizationEndpoint(p); diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 00614951e7..5f52104037 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -129,6 +129,10 @@ export class URLRouter { return window.location.origin; } + absoluteUrlForAsset(asset) { + return (new URL('/assets/' + asset, window.location.origin)).toString(); + } + normalizeUrl() { // Remove any queryParameters from the URL // Gets rid of the loginToken after SSO diff --git a/src/matrix/Client.js b/src/matrix/Client.js index f8571ed092..c1f729207b 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -132,7 +132,6 @@ export class Client { try { const oidcApi = new OidcApi({ issuer, - clientId: "hydrogen-web", request: this._platform.request, encoding: this._platform.encoding, crypto: this._platform.crypto, @@ -201,6 +200,7 @@ export class Client { if (loginData.oidc_issuer) { sessionInfo.oidcIssuer = loginData.oidc_issuer; + sessionInfo.oidcClientId = loginData.oidc_client_id; } log.set("id", sessionId); @@ -262,7 +262,7 @@ export class Client { if (sessionInfo.oidcIssuer) { const oidcApi = new OidcApi({ issuer: sessionInfo.oidcIssuer, - clientId: "hydrogen-web", + clientId: sessionInfo.oidcClientId, request: this._platform.request, encoding: this._platform.encoding, crypto: this._platform.crypto, diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts index 1e834b648a..b25689aade 100644 --- a/src/matrix/login/OIDCLoginMethod.ts +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -66,7 +66,8 @@ export class OIDCLoginMethod implements ILoginMethod { }).response(); const oidc_issuer = this._oidcApi.issuer; + const oidc_client_id = await this._oidcApi.clientId(); - return { oidc_issuer, access_token, refresh_token, expires_in, user_id, device_id }; + return { oidc_issuer, oidc_client_id, access_token, refresh_token, expires_in, user_id, device_id }; } } diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 023ba48531..451d15eb62 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -15,6 +15,7 @@ limitations under the License. */ import type {RequestFunction} from "../../platform/types/types"; +import type {URLRouter} from "../../domain/navigation/URLRouter.js"; const WELL_KNOWN = ".well-known/openid-configuration"; @@ -41,6 +42,7 @@ const isValidBearerToken = (t: any): t is BearerToken => type AuthorizationParams = { state: string, scope: string, + clientId: string, redirectUri: string, nonce?: string, codeVerifier?: string, @@ -54,18 +56,35 @@ function assert(condition: any, message: string): asserts condition { export class OidcApi { _issuer: string; - _clientId: string; _requestFn: RequestFunction; _encoding: any; _crypto: any; + _urlCreator: URLRouter; _metadataPromise: Promise; + _registrationPromise: Promise; - constructor({ issuer, clientId, request, encoding, crypto }) { + constructor({ issuer, request, encoding, crypto, urlCreator, clientId }) { this._issuer = issuer; - this._clientId = clientId; this._requestFn = request; this._encoding = encoding; this._crypto = crypto; + this._urlCreator = urlCreator; + + if (clientId) { + this._registrationPromise = Promise.resolve({ client_id: clientId }); + } + } + + get clientMetadata() { + return { + client_name: "Hydrogen Web", + logo_uri: this._urlCreator.absoluteUrlForAsset("icon.png"), + response_types: ["code"], + grant_types: ["authorization_code", "refresh_token"], + redirect_uris: [this._urlCreator.createOIDCRedirectURL()], + id_token_signed_response_alg: "RS256", + token_endpoint_auth_method: "none", + }; } get metadataUrl() { @@ -76,11 +95,35 @@ export class OidcApi { return this._issuer; } - get redirectUri() { - return window.location.origin; + async clientId(): Promise { + return (await this.registration())["client_id"]; + } + + registration(): Promise { + if (!this._registrationPromise) { + this._registrationPromise = (async () => { + const headers = new Map(); + headers.set("Accept", "application/json"); + headers.set("Content-Type", "application/json"); + const req = this._requestFn(await this.registrationEndpoint(), { + method: "POST", + headers, + format: "json", + body: JSON.stringify(this.clientMetadata), + }); + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to register client"); + } + + return res.body; + })(); + } + + return this._registrationPromise; } - metadata() { + metadata(): Promise { if (!this._metadataPromise) { this._metadataPromise = (async () => { const headers = new Map(); @@ -105,6 +148,7 @@ export class OidcApi { const m = await this.metadata(); assert(typeof m.authorization_endpoint === "string", "Has an authorization endpoint"); assert(typeof m.token_endpoint === "string", "Has a token endpoint"); + assert(typeof m.registration_endpoint === "string", "Has a registration endpoint"); assert(Array.isArray(m.response_types_supported) && m.response_types_supported.includes("code"), "Supports the code response type"); assert(Array.isArray(m.response_modes_supported) && m.response_modes_supported.includes("fragment"), "Supports the fragment response mode"); assert(Array.isArray(m.grant_types_supported) && m.grant_types_supported.includes("authorization_code"), "Supports the authorization_code grant type"); @@ -126,13 +170,13 @@ export class OidcApi { scope, nonce, codeVerifier, - }: AuthorizationParams) { + }: AuthorizationParams): Promise { const metadata = await this.metadata(); const url = new URL(metadata["authorization_endpoint"]); url.searchParams.append("response_mode", "fragment"); url.searchParams.append("response_type", "code"); url.searchParams.append("redirect_uri", redirectUri); - url.searchParams.append("client_id", this._clientId); + url.searchParams.append("client_id", await this.clientId()); url.searchParams.append("state", state); url.searchParams.append("scope", scope); if (nonce) { @@ -147,11 +191,16 @@ export class OidcApi { return url.toString(); } - async tokenEndpoint() { + async tokenEndpoint(): Promise { const metadata = await this.metadata(); return metadata["token_endpoint"]; } + async registrationEndpoint(): Promise { + const metadata = await this.metadata(); + return metadata["registration_endpoint"]; + } + generateParams({ scope, redirectUri }: { scope: string, redirectUri: string }): AuthorizationParams { return { scope, @@ -169,7 +218,7 @@ export class OidcApi { }: { codeVerifier: string, code: string, redirectUri: string }): Promise { const params = new URLSearchParams(); params.append("grant_type", "authorization_code"); - params.append("client_id", this._clientId); + params.append("client_id", await this.clientId()); params.append("code_verifier", codeVerifier); params.append("redirect_uri", redirectUri); params.append("code", code); @@ -201,7 +250,7 @@ export class OidcApi { }: { refreshToken: string }): Promise { const params = new URLSearchParams(); params.append("grant_type", "refresh_token"); - params.append("client_id", this._clientId); + params.append("client_id", await this.clientId()); params.append("refresh_token", refreshToken); const body = params.toString();