From f1cc22a9203da7f4556c374ee98566e86d89fb4b 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 | 70 ++++++++++++++++--- 6 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index ca65c7c7..5d0da980 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 a06b764f..e70a7487 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 00614951..5f521040 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 d0530d1f..f76f39c6 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, @@ -202,6 +201,7 @@ export class Client { if (loginData.oidc_issuer) { sessionInfo.oidcIssuer = loginData.oidc_issuer; + sessionInfo.oidcClientId = loginData.oidc_client_id; } log.set("id", sessionId); @@ -263,7 +263,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 1e834b64..b25689aa 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 023ba485..319d122f 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"; @@ -54,18 +55,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 +94,35 @@ export class OidcApi { return this._issuer; } - get redirectUri() { - return window.location.origin; + async clientId(): Promise { + return (await this.registration())["client_id"]; } - metadata() { + 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(): Promise { if (!this._metadataPromise) { this._metadataPromise = (async () => { const headers = new Map(); @@ -105,6 +147,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 +169,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 +190,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 +217,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 +249,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();