From a59e4bd3db14d8bfc6f14a374361139b3b5c55da Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:26:02 +0100 Subject: [PATCH 01/58] Multi-arch capable Dockerfile --- Dockerfile | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3a430d6c78..9f6520ab1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,9 @@ -FROM --platform=${BUILDPLATFORM} docker.io/node:alpine as builder +FROM docker.io/node:alpine as builder RUN apk add --no-cache git python3 build-base - -WORKDIR /app - -# Copy package.json and yarn.lock and install dependencies first to speed up subsequent builds -COPY package.json yarn.lock /app/ -RUN yarn install - COPY . /app -RUN yarn build - -# Because we will be running as an unprivileged user, we need to make sure that the config file is writable -# So, we will copy the default config to the /tmp folder that will be writable at runtime -RUN mv -f target/config.json /config.json.bundled \ - && ln -sf /tmp/config.json target/config.json - -FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:alpine - -# Copy the dynamic config script -COPY ./docker/dynamic-config.sh /docker-entrypoint.d/99-dynamic-config.sh -# And the bundled config file -COPY --from=builder /config.json.bundled /config.json.bundled +WORKDIR /app +RUN yarn install \ + && yarn build # Copy the built app from the first build stage COPY --from=builder /app/target /usr/share/nginx/html From 5379b83f14a59091944240ce99793dada42fa906 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:54:01 +0100 Subject: [PATCH 02/58] Build and push multi-arch Docker images in CI --- .github/workflows/docker-publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a70eea6c10..c37e3141c1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -26,6 +26,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Log into registry ${{ env.REGISTRY }} uses: docker/login-action@v2 with: @@ -42,6 +45,7 @@ jobs: - name: Build and push Docker image uses: docker/build-push-action@v3 with: + platforms: linux/amd64,linux/arm64,linux/arm/v7 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From 24c58795fa60432978c2abedb45c5698f7b916a1 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:49:58 +0100 Subject: [PATCH 03/58] Make the Docker image configurable at runtime --- Dockerfile | 6 ++++++ docker/config-template.sh | 7 +++++++ docker/config.json.tmpl | 8 ++++++++ 3 files changed, 21 insertions(+) create mode 100755 docker/config-template.sh create mode 100644 docker/config.json.tmpl diff --git a/Dockerfile b/Dockerfile index 9f6520ab1b..e85fdf75c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,3 +7,9 @@ RUN yarn install \ # Copy the built app from the first build stage COPY --from=builder /app/target /usr/share/nginx/html + +# Values from the default config that can be overridden at runtime +ENV PUSH_APP_ID="io.element.hydrogen.web" \ + PUSH_GATEWAY_URL="https://matrix.org" \ + PUSH_APPLICATION_SERVER_KEY="BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" \ + DEFAULT_HOMESERVER="matrix.org" diff --git a/docker/config-template.sh b/docker/config-template.sh new file mode 100755 index 0000000000..f6cff00c1d --- /dev/null +++ b/docker/config-template.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -eux + +envsubst '$PUSH_APP_ID,$PUSH_GATEWAY_URL,$PUSH_APPLICATION_SERVER_KEY,$DEFAULT_HOMESERVER' \ + < /config.json.tmpl \ + > /tmp/config.json diff --git a/docker/config.json.tmpl b/docker/config.json.tmpl new file mode 100644 index 0000000000..94295c43dd --- /dev/null +++ b/docker/config.json.tmpl @@ -0,0 +1,8 @@ +{ + "push": { + "appId": "$PUSH_APP_ID", + "gatewayUrl": "$PUSH_GATEWAY_URL", + "applicationServerKey": "$PUSH_APPLICATION_SERVER_KEY" + }, + "defaultHomeServer": "$DEFAULT_HOMESERVER" +} From 2604e6a5a9c3e48a646b08b5084c99c72897c1da Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 09:25:58 +0100 Subject: [PATCH 04/58] Native OIDC login --- src/domain/RootViewModel.js | 24 +- .../login/CompleteOIDCLoginViewModel.js | 84 ++++++ src/domain/login/LoginViewModel.ts | 33 ++- src/domain/login/StartOIDCLoginViewModel.js | 55 ++++ src/domain/navigation/index.ts | 53 +++- src/matrix/Client.js | 116 +++++--- src/matrix/login/OIDCLoginMethod.ts | 67 +++++ src/matrix/net/HomeServerApi.ts | 20 +- src/matrix/net/OidcApi.ts | 221 ++++++++++++++ src/matrix/net/TokenRefresher.ts | 125 ++++++++ .../localstorage/SessionInfoStorage.ts | 17 ++ src/matrix/well-known.js | 8 +- src/observable/ObservableValue.ts | 280 ++++++++++++++++++ src/platform/types/types.ts | 1 + src/platform/web/ui/css/login.css | 4 +- src/platform/web/ui/login/LoginView.js | 12 + 16 files changed, 1065 insertions(+), 55 deletions(-) create mode 100644 src/domain/login/CompleteOIDCLoginViewModel.js create mode 100644 src/domain/login/StartOIDCLoginViewModel.js create mode 100644 src/matrix/login/OIDCLoginMethod.ts create mode 100644 src/matrix/net/OidcApi.ts create mode 100644 src/matrix/net/TokenRefresher.ts create mode 100644 src/observable/ObservableValue.ts diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 2896fba612..cb16139b5f 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -41,6 +41,8 @@ export class RootViewModel extends ViewModel { this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("logout").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("oidc-callback").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("oidc-error").subscribe(() => this._applyNavigation())); this._applyNavigation(true); } @@ -50,6 +52,8 @@ export class RootViewModel extends ViewModel { const isForcedLogout = this.navigation.path.get("forced")?.value; const sessionId = this.navigation.path.get("session")?.value; const loginToken = this.navigation.path.get("sso")?.value; + const oidcCallback = this.navigation.path.get("oidc-callback")?.value; + const oidcError = this.navigation.path.get("oidc-error")?.value; if (isLogin) { if (this.activeSection !== "login") { this._showLogin(); @@ -85,7 +89,20 @@ export class RootViewModel extends ViewModel { } else if (loginToken) { this.urlRouter.normalizeUrl(); if (this.activeSection !== "login") { - this._showLogin(loginToken); + this._showLogin({loginToken}); + } + } else if (oidcError) { + this._setSection(() => this._error = new Error(`OIDC error: ${oidcError[1]}`)); + } else if (oidcCallback) { + this._setSection(() => this._error = new Error(`OIDC callback: state=${oidcCallback[0]}, code=${oidcCallback[1]}`)); + this.urlCreator.normalizeUrl(); + if (this.activeSection !== "login") { + this._showLogin({ + oidc: { + state: oidcCallback[0], + code: oidcCallback[1], + } + }); } } else { @@ -117,7 +134,7 @@ export class RootViewModel extends ViewModel { } } - _showLogin(loginToken) { + _showLogin({loginToken, oidc} = {}) { this._setSection(() => { this._loginViewModel = new LoginViewModel(this.childOptions({ defaultHomeserver: this.platform.config["defaultHomeServer"], @@ -133,7 +150,8 @@ export class RootViewModel extends ViewModel { this._pendingClient = client; this.navigation.push("session", client.sessionId); }, - loginToken + loginToken, + oidc, })); }); } diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js new file mode 100644 index 0000000000..fa0b665e8b --- /dev/null +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -0,0 +1,84 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {OidcApi} from "../../matrix/net/OidcApi"; +import {ViewModel} from "../ViewModel"; +import {OIDCLoginMethod} from "../../matrix/login/OIDCLoginMethod"; +import {LoginFailure} from "../../matrix/Client"; + +export class CompleteOIDCLoginViewModel extends ViewModel { + constructor(options) { + super(options); + const { + state, + code, + attemptLogin, + } = options; + this._request = options.platform.request; + this._encoding = options.platform.encoding; + this._state = state; + this._code = code; + this._attemptLogin = attemptLogin; + this._errorMessage = ""; + this.performOIDCLoginCompletion(); + } + + get errorMessage() { return this._errorMessage; } + + _showError(message) { + this._errorMessage = message; + this.emitChange("errorMessage"); + } + + async performOIDCLoginCompletion() { + if (!this._state || !this._code) { + return; + } + const code = this._code; + // TODO: cleanup settings storage + const [startedAt, nonce, codeVerifier, homeserver, issuer] = 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}_homeserver`), + this.platform.settingsStorage.getString(`oidc_${this._state}_issuer`), + ]); + + const oidcApi = new OidcApi({ + issuer, + clientId: "hydrogen-web", + request: this._request, + encoding: this._encoding, + }); + const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt}); + const status = await this._attemptLogin(method); + let error = ""; + switch (status) { + case LoginFailure.Credentials: + error = this.i18n`Your login token is invalid.`; + break; + case LoginFailure.Connection: + error = this.i18n`Can't connect to ${homeserver}.`; + break; + case LoginFailure.Unknown: + error = this.i18n`Something went wrong while checking your login token.`; + break; + } + if (error) { + this._showError(error); + } + } +} diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index f43361d0cb..2911c80856 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -19,6 +19,8 @@ import {Options as BaseOptions, ViewModel} from "../ViewModel"; import {PasswordLoginViewModel} from "./PasswordLoginViewModel"; import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel"; +import {StartOIDCLoginViewModel} from "./StartOIDCLoginViewModel.js"; +import {CompleteOIDCLoginViewModel} from "./CompleteOIDCLoginViewModel.js"; import {LoadStatus} from "../../matrix/Client.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; import {SegmentType} from "../navigation/index"; @@ -39,6 +41,8 @@ export class LoginViewModel extends ViewModel { private _passwordLoginViewModel?: PasswordLoginViewModel; private _startSSOLoginViewModel?: StartSSOLoginViewModel; private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel; + private _startOIDCLoginViewModel?: StartOIDCLoginViewModel; + private _completeOIDCLoginViewModel?: CompleteOIDCLoginViewModel; private _loadViewModel?: SessionLoadViewModel; private _loadViewModelSubscription?: () => void; private _homeserver: string; @@ -52,10 +56,11 @@ export class LoginViewModel extends ViewModel { constructor(options: Readonly) { super(options); - const {ready, defaultHomeserver, loginToken} = options; + const {ready, defaultHomeserver, loginToken, oidc} = options; this._ready = ready; this._loginToken = loginToken; this._client = new Client(this.platform, this.features); + this._oidc = oidc; this._homeserver = defaultHomeserver; this._initViewModels(); } @@ -72,6 +77,9 @@ export class LoginViewModel extends ViewModel { return this._completeSSOLoginViewModel; } + get startOIDCLoginViewModel() { return this._startOIDCLoginViewModel; } + get completeOIDCLoginViewModel() { return this._completeOIDCLoginViewModel; } + get homeserver(): string { return this._homeserver; } @@ -116,6 +124,18 @@ export class LoginViewModel extends ViewModel { }))); this.emitChange("completeSSOLoginViewModel"); } + else if (this._oidc) { + this._hideHomeserver = true; + this._completeOIDCLoginViewModel = this.track(new CompleteOIDCLoginViewModel( + this.childOptions( + { + sessionContainer: this._sessionContainer, + attemptLogin: loginMethod => this.attemptLogin(loginMethod), + state: this._oidc.state, + code: this._oidc.code, + }))); + this.emitChange("completeOIDCLoginViewModel"); + } else { void this.queryHomeserver(); } @@ -142,6 +162,14 @@ export class LoginViewModel extends ViewModel { this.emitChange("errorMessage"); } + async _showOIDCLogin() { + this._startOIDCLoginViewModel = this.track( + new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) + ); + await this._startOIDCLoginViewModel.start(); + this.emitChange("startOIDCLoginViewModel"); + } + private _setBusy(status: boolean): void { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); @@ -263,7 +291,8 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions) { if (this._loginOptions.sso) { this._showSSOLogin(); } if (this._loginOptions.password) { this._showPasswordLogin(); } - if (!this._loginOptions.sso && !this._loginOptions.password) { + if (this._loginOptions.oidc) { await this._showOIDCLogin(); } + if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) { this._showError("This homeserver supports neither SSO nor password based login flows"); } } diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js new file mode 100644 index 0000000000..e742fe1cb5 --- /dev/null +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -0,0 +1,55 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {OidcApi} from "../../matrix/net/OidcApi"; +import {ViewModel} from "../ViewModel"; + +export class StartOIDCLoginViewModel extends ViewModel { + constructor(options) { + super(options); + this._isBusy = true; + this._authorizationEndpoint = null; + this._api = new OidcApi({ + clientId: "hydrogen-web", + issuer: options.loginOptions.oidc.issuer, + request: this.platform.request, + encoding: this.platform.encoding, + }); + this._homeserver = options.loginOptions.homeserver; + } + + get isBusy() { return this._isBusy; } + get authorizationEndpoint() { return this._authorizationEndpoint; } + + async start() { + const p = this._api.generateParams("openid"); + await Promise.all([ + this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()), + this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce), + this.platform.settingsStorage.setString(`oidc_${p.state}_code_verifier`, p.codeVerifier), + this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), + this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._api.issuer), + ]); + + this._authorizationEndpoint = await this._api.authorizationEndpoint(p); + this._isBusy = false; + } + + setBusy(status) { + this._isBusy = status; + this.emitChange("isBusy"); + } +} diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index a2705944f7..54719c05c0 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -49,7 +49,7 @@ function allowsChild(parent: Segment | undefined, child: Segment(navigation: Navigation, defaultSessionId?: string): Segment[] { + const segments: Segment[] = []; + + // Special case for OIDC callback + if (urlPath.includes("state")) { + const params = new URLSearchParams(urlPath); + if (params.has("state")) { + // This is a proper OIDC callback + if (params.has("code")) { + segments.push(new Segment("oidc-callback", [ + params.get("state"), + params.get("code"), + ])); + return segments; + } else if (params.has("error")) { + segments.push(new Segment("oidc-error", [ + params.get("state"), + params.get("error"), + params.get("error_description"), + params.get("error_uri"), + ])); + return segments; + } + } + } + // substring(1) to take of initial / - const parts = urlPath.substring(1).split("/"); + const parts = urlPath.substr(1).split("/"); const iterator = parts[Symbol.iterator](); - const segments: Segment[] = []; - let next; + let next; while (!(next = iterator.next()).done) { const type = next.value; if (type === "rooms") { @@ -220,6 +244,8 @@ export function stringifyPath(path: Path): string { break; case "right-panel": case "sso": + case "oidc-callback": + case "oidc-error": // Do not put these segments in URL continue; default: @@ -508,6 +534,23 @@ export function tests() { assert.equal(newPath?.segments[1].type, "room"); assert.equal(newPath?.segments[1].value, "b"); }, - + "Parse OIDC callback": assert => { + const segments = parseUrlPath("state=tc9CnLU7&code=cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "oidc-callback"); + assert.deepEqual(segments[0].value, ["tc9CnLU7", "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"]); + }, + "Parse OIDC error": assert => { + const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "oidc-error"); + assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", null, null]); + }, + "Parse OIDC error with description": assert => { + const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request&error_description=Unsupported%20response_type%20value"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "oidc-error"); + assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", "Unsupported response_type value", null]); + }, } } diff --git a/src/matrix/Client.js b/src/matrix/Client.js index fabb489b67..1f72f430e2 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -20,6 +20,8 @@ import {lookupHomeserver} from "./well-known.js"; import {AbortableOperation} from "../utils/AbortableOperation"; import {ObservableValue} from "../observable/value"; import {HomeServerApi} from "./net/HomeServerApi"; +import {OidcApi} from "./net/OidcApi"; +import {TokenRefresher} from "./net/TokenRefresher"; import {Reconnector, ConnectionStatus} from "./net/Reconnector"; import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay"; import {MediaRepository} from "./net/MediaRepository"; @@ -125,11 +127,29 @@ export class Client { return result; } - queryLogin(homeserver) { + queryLogin(initialHomeserver) { return new AbortableOperation(async setAbortable => { - homeserver = await lookupHomeserver(homeserver, (url, options) => { + const { homeserver, issuer } = await lookupHomeserver(initialHomeserver, (url, options) => { return setAbortable(this._platform.request(url, options)); }); + if (issuer) { + try { + const oidcApi = new OidcApi({ + issuer, + clientId: "hydrogen-web", + request: this._platform.request, + encoding: this._platform.encoding, + }); + await oidcApi.validate(); + + return { + homeserver, + oidc: { issuer }, + }; + } catch (e) { + console.log(e); + } + } const hsApi = new HomeServerApi({homeserver, request: this._platform.request}); const response = await setAbortable(hsApi.getLoginFlows()).response(); return this._parseLoginOptions(response, homeserver); @@ -179,6 +199,7 @@ export class Client { homeserver: loginMethod.homeserver, accessToken: loginData.access_token, }; + log.set("id", sessionId); } catch (err) { this._error = err; if (err.name === "HomeServerError") { @@ -197,43 +218,28 @@ export class Client { } return; } - await this._createSessionAfterAuth(sessionInfo, inspectAccountSetup, log); - }); - } - - async _createSessionAfterAuth({deviceId, userId, accessToken, homeserver}, inspectAccountSetup, log) { - const id = this.createNewSessionId(); - const lastUsed = this._platform.clock.now(); - const sessionInfo = { - id, - deviceId, - userId, - homeServer: homeserver, // deprecate this over time - homeserver, - accessToken, - lastUsed, - }; - let dehydratedDevice; - if (inspectAccountSetup) { - dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo, log); - if (dehydratedDevice) { - sessionInfo.deviceId = dehydratedDevice.deviceId; + let dehydratedDevice; + if (inspectAccountSetup) { + dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo, log); + if (dehydratedDevice) { + sessionInfo.deviceId = dehydratedDevice.deviceId; + } } - } - await this._platform.sessionInfoStorage.add(sessionInfo); - // loading the session can only lead to - // LoadStatus.Error in case of an error, - // so separate try/catch - try { - await this._loadSessionInfo(sessionInfo, dehydratedDevice, log); - log.set("status", this._status.get()); - } catch (err) { - log.catch(err); - // free olm Account that might be contained - dehydratedDevice?.dispose(); - this._error = err; - this._status.set(LoadStatus.Error); - } + await this._platform.sessionInfoStorage.add(sessionInfo); + // loading the session can only lead to + // LoadStatus.Error in case of an error, + // so separate try/catch + try { + await this._loadSessionInfo(sessionInfo, dehydratedDevice, log); + log.set("status", this._status.get()); + } catch (err) { + log.catch(err); + // free olm Account that might be contained + dehydratedDevice?.dispose(); + this._error = err; + this._status.set(LoadStatus.Error); + } + }); } async _loadSessionInfo(sessionInfo, dehydratedDevice, log) { @@ -246,9 +252,41 @@ export class Client { retryDelay: new ExponentialRetryDelay(clock.createTimeout), createMeasure: clock.createMeasure }); + + let accessToken; + + if (sessionInfo.oidcIssuer) { + const oidcApi = new OidcApi({ + issuer: sessionInfo.oidcIssuer, + clientId: "hydrogen-web", + request: this._platform.request, + encoding: this._platform.encoding, + }); + + // TODO: stop/pause the refresher? + const tokenRefresher = new TokenRefresher({ + oidcApi, + clock: this._platform.clock, + accessToken: sessionInfo.accessToken, + accessTokenExpiresAt: sessionInfo.accessTokenExpiresAt, + refreshToken: sessionInfo.refreshToken, + anticipation: 30 * 1000, + }); + + tokenRefresher.token.subscribe(t => { + this._platform.sessionInfoStorage.updateToken(sessionInfo.id, t.accessToken, t.accessTokenExpiresAt, t.refreshToken); + }); + + await tokenRefresher.start(); + + accessToken = tokenRefresher.accessToken; + } else { + accessToken = new ObservableValue(sessionInfo.accessToken); + } + const hsApi = new HomeServerApi({ homeserver: sessionInfo.homeServer, - accessToken: sessionInfo.accessToken, + accessToken, request: this._platform.request, reconnector: this._reconnector, }); diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts new file mode 100644 index 0000000000..0226877a11 --- /dev/null +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -0,0 +1,67 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ILogItem} from "../../logging/types"; +import {ILoginMethod} from "./LoginMethod"; +import {HomeServerApi} from "../net/HomeServerApi.js"; +import {OidcApi} from "../net/OidcApi"; + +export class OIDCLoginMethod implements ILoginMethod { + private readonly _code: string; + private readonly _codeVerifier: string; + private readonly _nonce: string; + private readonly _oidcApi: OidcApi; + public readonly homeserver: string; + + constructor({ + nonce, + codeVerifier, + code, + homeserver, + oidcApi, + }: { + nonce: string, + code: string, + codeVerifier: string, + homeserver: string, + oidcApi: OidcApi, + }) { + this._oidcApi = oidcApi; + this._code = code; + this._codeVerifier = codeVerifier; + this._nonce = nonce; + this.homeserver = homeserver; + } + + async login(hsApi: HomeServerApi, _deviceName: string, log: ILogItem): Promise> { + const { access_token, refresh_token, expires_in } = await this._oidcApi.completeAuthorizationCodeGrant({ + code: this._code, + codeVerifier: this._codeVerifier, + }); + + // TODO: validate the id_token and the nonce claim + + // Do a "whoami" request to find out the user_id and device_id + const { user_id, device_id } = await hsApi.whoami({ + log, + accessTokenOverride: access_token, + }).response(); + + const oidc_issuer = this._oidcApi.issuer; + + return { oidc_issuer, access_token, refresh_token, expires_in, user_id, device_id }; + } +} diff --git a/src/matrix/net/HomeServerApi.ts b/src/matrix/net/HomeServerApi.ts index c5f9055504..a69ff6f5df 100644 --- a/src/matrix/net/HomeServerApi.ts +++ b/src/matrix/net/HomeServerApi.ts @@ -31,7 +31,7 @@ const DEHYDRATION_PREFIX = "/_matrix/client/unstable/org.matrix.msc2697.v2"; type Options = { homeserver: string; - accessToken: string; + accessToken: BaseObservableValue; request: RequestFunction; reconnector: Reconnector; }; @@ -42,11 +42,12 @@ type BaseRequestOptions = { uploadProgress?: (loadedBytes: number) => void; timeout?: number; prefix?: string; + accessTokenOverride?: string; }; export class HomeServerApi { private readonly _homeserver: string; - private readonly _accessToken: string; + private readonly _accessToken: BaseObservableValue; private readonly _requestFn: RequestFunction; private readonly _reconnector: Reconnector; @@ -63,11 +64,19 @@ export class HomeServerApi { return this._homeserver + prefix + csPath; } - private _baseRequest(method: RequestMethod, url: string, queryParams?: Record, body?: Record, options?: BaseRequestOptions, accessToken?: string): IHomeServerRequest { + private _baseRequest(method: RequestMethod, url: string, queryParams?: Record, body?: Record, options?: BaseRequestOptions, accessTokenSource?: BaseObservableValue): IHomeServerRequest { const queryString = encodeQueryParams(queryParams); url = `${url}?${queryString}`; let encodedBody: EncodedBody["body"]; const headers: Map = new Map(); + + let accessToken: string | null = null; + if (options?.accessTokenOverride) { + accessToken = options.accessTokenOverride; + } else if (accessTokenSource) { + accessToken = accessTokenSource.get(); + } + if (accessToken) { headers.set("Authorization", `Bearer ${accessToken}`); } @@ -287,6 +296,10 @@ export class HomeServerApi { return this._post(`/logout`, {}, {}, options); } + whoami(options?: BaseRequestOptions): IHomeServerRequest { + return this._get(`/account/whoami`, undefined, undefined, options); + } + getDehydratedDevice(options: BaseRequestOptions = {}): IHomeServerRequest { options.prefix = DEHYDRATION_PREFIX; return this._get(`/dehydrated_device`, undefined, undefined, options); @@ -320,6 +333,7 @@ export class HomeServerApi { } import {Request as MockRequest} from "../../mocks/Request.js"; +import {BaseObservableValue} from "../../observable/ObservableValue"; export function tests() { return { diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts new file mode 100644 index 0000000000..3111d65fce --- /dev/null +++ b/src/matrix/net/OidcApi.ts @@ -0,0 +1,221 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the 'License'); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an 'AS IS' BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const WELL_KNOWN = ".well-known/openid-configuration"; + +const RANDOM_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const randomChar = () => RANDOM_CHARSET.charAt(Math.floor(Math.random() * 1e10) % RANDOM_CHARSET.length); +const randomString = (length: number) => + Array.from({ length }, randomChar).join(""); + +type BearerToken = { + token_type: "Bearer", + access_token: string, + refresh_token?: string, + expires_in?: number, +} + +const isValidBearerToken = (t: any): t is BearerToken => + typeof t == "object" && + t["token_type"] === "Bearer" && + typeof t["access_token"] === "string" && + (!("refresh_token" in t) || typeof t["refresh_token"] === "string") && + (!("expires_in" in t) || typeof t["expires_in"] === "number"); + + +type AuthorizationParams = { + state: string, + scope: string, + nonce?: string, + codeVerifier?: string, +}; + +function assert(condition: any, message: string): asserts condition { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +}; + +export class OidcApi { + _issuer: string; + _clientId: string; + _requestFn: any; + _base64: any; + _metadataPromise: Promise; + + constructor({ issuer, clientId, request, encoding }) { + this._issuer = issuer; + this._clientId = clientId; + this._requestFn = request; + this._base64 = encoding.base64; + } + + get metadataUrl() { + return new URL(WELL_KNOWN, this._issuer).toString(); + } + + get issuer() { + return this._issuer; + } + + get redirectUri() { + return window.location.origin; + } + + metadata() { + if (!this._metadataPromise) { + this._metadataPromise = (async () => { + const headers = new Map(); + headers.set("Accept", "application/json"); + const req = this._requestFn(this.metadataUrl, { + method: "GET", + headers, + format: "json", + }); + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to request metadata"); + } + + return res.body; + })(); + } + return this._metadataPromise; + } + + async validate() { + 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(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"); + assert(Array.isArray(m.code_challenge_methods_supported) && m.code_challenge_methods_supported.includes("S256"), "Supports the authorization_code grant type"); + } + + async _generateCodeChallenge( + codeVerifier: string + ): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(codeVerifier); + const digest = await window.crypto.subtle.digest("SHA-256", data); + const base64Digest = this._base64.encode(digest); + return base64Digest.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + } + + async authorizationEndpoint({ + state, + scope, + nonce, + codeVerifier, + }: AuthorizationParams) { + 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", this.redirectUri); + url.searchParams.append("client_id", this._clientId); + url.searchParams.append("state", state); + url.searchParams.append("scope", scope); + if (nonce) { + url.searchParams.append("nonce", nonce); + } + + if (codeVerifier) { + url.searchParams.append("code_challenge_method", "S256"); + url.searchParams.append("code_challenge", await this._generateCodeChallenge(codeVerifier)); + } + + return url.toString(); + } + + async tokenEndpoint() { + const metadata = await this.metadata(); + return metadata["token_endpoint"]; + } + + generateParams(scope: string): AuthorizationParams { + return { + scope, + state: randomString(8), + nonce: randomString(8), + codeVerifier: randomString(32), + }; + } + + async completeAuthorizationCodeGrant({ + codeVerifier, + code, + }: { codeVerifier: string, code: string }): Promise { + const params = new URLSearchParams(); + params.append("grant_type", "authorization_code"); + params.append("client_id", this._clientId); + params.append("code_verifier", codeVerifier); + params.append("redirect_uri", this.redirectUri); + params.append("code", code); + const body = params.toString(); + + const headers = new Map(); + headers.set("Content-Type", "application/x-www-form-urlencoded"); + + const req = this._requestFn(await this.tokenEndpoint(), { + method: "POST", + headers, + format: "json", + body, + }); + + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to exchange authorization code"); + } + + const token = res.body; + assert(isValidBearerToken(token), "Got back a valid bearer token"); + + return token; + } + + async refreshToken({ + refreshToken, + }: { refreshToken: string }): Promise { + const params = new URLSearchParams(); + params.append("grant_type", "refresh_token"); + params.append("client_id", this._clientId); + params.append("refresh_token", refreshToken); + const body = params.toString(); + + const headers = new Map(); + headers.set("Content-Type", "application/x-www-form-urlencoded"); + + const req = this._requestFn(await this.tokenEndpoint(), { + method: "POST", + headers, + format: "json", + body, + }); + + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to use refresh token"); + } + + const token = res.body; + assert(isValidBearerToken(token), "Got back a valid bearer token"); + + return token; + } +} diff --git a/src/matrix/net/TokenRefresher.ts b/src/matrix/net/TokenRefresher.ts new file mode 100644 index 0000000000..489dfb1197 --- /dev/null +++ b/src/matrix/net/TokenRefresher.ts @@ -0,0 +1,125 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the 'License'); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an 'AS IS' BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue"; +import type {Clock, Timeout} from "../../platform/web/dom/Clock"; +import {OidcApi} from "./OidcApi"; + +type Token = { + accessToken: string, + accessTokenExpiresAt: number, + refreshToken: string, +}; + + +export class TokenRefresher { + private _token: ObservableValue; + private _accessToken: BaseObservableValue; + private _anticipation: number; + private _clock: Clock; + private _oidcApi: OidcApi; + private _timeout: Timeout + + constructor({ + oidcApi, + refreshToken, + accessToken, + accessTokenExpiresAt, + anticipation, + clock, + }: { + oidcApi: OidcApi, + refreshToken: string, + accessToken: string, + accessTokenExpiresAt: number, + anticipation: number, + clock: Clock, + }) { + this._token = new ObservableValue({ + accessToken, + accessTokenExpiresAt, + refreshToken, + }); + this._accessToken = this._token.map(t => t.accessToken); + + this._anticipation = anticipation; + this._oidcApi = oidcApi; + this._clock = clock; + } + + async start() { + if (this.needsRenewing) { + await this.renew(); + } + + this._renewingLoop(); + } + + stop() { + // TODO + } + + get needsRenewing() { + const remaining = this._token.get().accessTokenExpiresAt - this._clock.now(); + const anticipated = remaining - this._anticipation; + return anticipated < 0; + } + + async _renewingLoop() { + while (true) { + const remaining = + this._token.get().accessTokenExpiresAt - this._clock.now(); + const anticipated = remaining - this._anticipation; + + if (anticipated > 0) { + this._timeout = this._clock.createTimeout(anticipated); + await this._timeout.elapsed(); + } + + await this.renew(); + } + } + + async renew() { + let refreshToken = this._token.get().refreshToken; + const response = await this._oidcApi + .refreshToken({ + refreshToken, + }); + + if (typeof response.expires_in !== "number") { + throw new Error("Refreshed access token does not expire"); + } + + if (response.refresh_token) { + refreshToken = response.refresh_token; + } + + this._token.set({ + refreshToken, + accessToken: response.access_token, + accessTokenExpiresAt: this._clock.now() + response.expires_in * 1000, + }); + } + + get accessToken(): BaseObservableValue { + return this._accessToken; + } + + get token(): BaseObservableValue { + return this._token; + } +} diff --git a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts index ebe575f65d..80443e8364 100644 --- a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts +++ b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts @@ -21,6 +21,9 @@ interface ISessionInfo { homeserver: string; homeServer: string; // deprecate this over time accessToken: string; + accessTokenExpiresAt?: number; + refreshToken?: string; + oidcIssuer?: string; lastUsed: number; } @@ -28,6 +31,7 @@ interface ISessionInfo { interface ISessionInfoStorage { getAll(): Promise; updateLastUsed(id: string, timestamp: number): Promise; + updateToken(id: string, accessToken: string, accessTokenExpiresAt: number, refreshToken: string): Promise; get(id: string): Promise; add(sessionInfo: ISessionInfo): Promise; delete(sessionId: string): Promise; @@ -62,6 +66,19 @@ export class SessionInfoStorage implements ISessionInfoStorage { } } + async updateToken(id: string, accessToken: string, accessTokenExpiresAt: number, refreshToken: string): Promise { + const sessions = await this.getAll(); + if (sessions) { + const session = sessions.find(session => session.id === id); + if (session) { + session.accessToken = accessToken; + session.accessTokenExpiresAt = accessTokenExpiresAt; + session.refreshToken = refreshToken; + localStorage.setItem(this._name, JSON.stringify(sessions)); + } + } + } + async get(id: string): Promise { const sessions = await this.getAll(); if (sessions) { diff --git a/src/matrix/well-known.js b/src/matrix/well-known.js index 00c91f2759..6e3bedbf7b 100644 --- a/src/matrix/well-known.js +++ b/src/matrix/well-known.js @@ -41,6 +41,7 @@ async function getWellKnownResponse(homeserver, request) { export async function lookupHomeserver(homeserver, request) { homeserver = normalizeHomeserver(homeserver); + let issuer = null; const wellKnownResponse = await getWellKnownResponse(homeserver, request); if (wellKnownResponse && wellKnownResponse.status === 200) { const {body} = wellKnownResponse; @@ -48,6 +49,11 @@ export async function lookupHomeserver(homeserver, request) { if (typeof wellKnownHomeserver === "string") { homeserver = normalizeHomeserver(wellKnownHomeserver); } + + const wellKnownIssuer = body["m.authentication"]?.["issuer"]; + if (typeof wellKnownIssuer === "string") { + issuer = wellKnownIssuer; + } } - return homeserver; + return {homeserver, issuer}; } diff --git a/src/observable/ObservableValue.ts b/src/observable/ObservableValue.ts new file mode 100644 index 0000000000..8b9b3be67a --- /dev/null +++ b/src/observable/ObservableValue.ts @@ -0,0 +1,280 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {AbortError} from "../utils/error"; +import {BaseObservable} from "./BaseObservable"; +import type {SubscriptionHandle} from "./BaseObservable"; + +// like an EventEmitter, but doesn't have an event type +export abstract class BaseObservableValue extends BaseObservable<(value: T) => void> { + emit(argument: T) { + for (const h of this._handlers) { + h(argument); + } + } + + abstract get(): T; + + waitFor(predicate: (value: T) => boolean): IWaitHandle { + if (predicate(this.get())) { + return new ResolvedWaitForHandle(Promise.resolve(this.get())); + } else { + return new WaitForHandle(this, predicate); + } + } + + flatMap(mapper: (value: T) => (BaseObservableValue | undefined)): BaseObservableValue { + return new FlatMapObservableValue(this, mapper); + } + + map(mapper: (value: T) => C): BaseObservableValue { + return new MappedObservableValue(this, mapper); + } +} + +interface IWaitHandle { + promise: Promise; + dispose(): void; +} + +class WaitForHandle implements IWaitHandle { + private _promise: Promise + private _reject: ((reason?: any) => void) | null; + private _subscription: (() => void) | null; + + constructor(observable: BaseObservableValue, predicate: (value: T) => boolean) { + this._promise = new Promise((resolve, reject) => { + this._reject = reject; + this._subscription = observable.subscribe(v => { + if (predicate(v)) { + this._reject = null; + resolve(v); + this.dispose(); + } + }); + }); + } + + get promise(): Promise { + return this._promise; + } + + dispose() { + if (this._subscription) { + this._subscription(); + this._subscription = null; + } + if (this._reject) { + this._reject(new AbortError()); + this._reject = null; + } + } +} + +class ResolvedWaitForHandle implements IWaitHandle { + constructor(public promise: Promise) {} + dispose() {} +} + +export class ObservableValue extends BaseObservableValue { + private _value: T; + + constructor(initialValue: T) { + super(); + this._value = initialValue; + } + + get(): T { + return this._value; + } + + set(value: T): void { + if (value !== this._value) { + this._value = value; + this.emit(this._value); + } + } +} + +export class RetainedObservableValue extends ObservableValue { + private _freeCallback: () => void; + + constructor(initialValue: T, freeCallback: () => void) { + super(initialValue); + this._freeCallback = freeCallback; + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + this._freeCallback(); + } +} + +export class FlatMapObservableValue extends BaseObservableValue { + private sourceSubscription?: SubscriptionHandle; + private targetSubscription?: SubscriptionHandle; + + constructor( + private readonly source: BaseObservableValue

, + private readonly mapper: (value: P) => (BaseObservableValue | undefined) + ) { + super(); + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + this.sourceSubscription = this.sourceSubscription!(); + if (this.targetSubscription) { + this.targetSubscription = this.targetSubscription(); + } + } + + onSubscribeFirst() { + super.onSubscribeFirst(); + this.sourceSubscription = this.source.subscribe(() => { + this.updateTargetSubscription(); + this.emit(this.get()); + }); + this.updateTargetSubscription(); + } + + private updateTargetSubscription() { + const sourceValue = this.source.get(); + if (sourceValue) { + const target = this.mapper(sourceValue); + if (target) { + if (!this.targetSubscription) { + this.targetSubscription = target.subscribe(() => this.emit(this.get())); + } + return; + } + } + // if no sourceValue or target + if (this.targetSubscription) { + this.targetSubscription = this.targetSubscription(); + } + } + + get(): C | undefined { + const sourceValue = this.source.get(); + if (!sourceValue) { + return undefined; + } + const mapped = this.mapper(sourceValue); + return mapped?.get(); + } +} + +export class MappedObservableValue extends BaseObservableValue { + private sourceSubscription?: SubscriptionHandle; + + constructor( + private readonly source: BaseObservableValue

, + private readonly mapper: (value: P) => C + ) { + super(); + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + this.sourceSubscription = this.sourceSubscription!(); + } + + onSubscribeFirst() { + super.onSubscribeFirst(); + this.sourceSubscription = this.source.subscribe(() => { + this.emit(this.get()); + }); + } + + get(): C { + const sourceValue = this.source.get(); + return this.mapper(sourceValue); + } +} + +export function tests() { + return { + "set emits an update": assert => { + const a = new ObservableValue(0); + let fired = false; + const subscription = a.subscribe(v => { + fired = true; + assert.strictEqual(v, 5); + }); + a.set(5); + assert(fired); + subscription(); + }, + "set doesn't emit if value hasn't changed": assert => { + const a = new ObservableValue(5); + let fired = false; + const subscription = a.subscribe(() => { + fired = true; + }); + a.set(5); + a.set(5); + assert(!fired); + subscription(); + }, + "waitFor promise resolves on matching update": async assert => { + const a = new ObservableValue(5); + const handle = a.waitFor(v => v === 6); + Promise.resolve().then(() => { + a.set(6); + }); + await handle.promise; + assert.strictEqual(a.get(), 6); + }, + "waitFor promise rejects when disposed": async assert => { + const a = new ObservableValue(0); + const handle = a.waitFor(() => false); + Promise.resolve().then(() => { + handle.dispose(); + }); + await assert.rejects(handle.promise, AbortError); + }, + "flatMap.get": assert => { + const a = new ObservableValue}>(undefined); + const countProxy = a.flatMap(a => a!.count); + assert.strictEqual(countProxy.get(), undefined); + const count = new ObservableValue(0); + a.set({count}); + assert.strictEqual(countProxy.get(), 0); + }, + "flatMap update from source": assert => { + const a = new ObservableValue}>(undefined); + const updates: (number | undefined)[] = []; + a.flatMap(a => a!.count).subscribe(count => { + updates.push(count); + }); + const count = new ObservableValue(0); + a.set({count}); + assert.deepEqual(updates, [0]); + }, + "flatMap update from target": assert => { + const a = new ObservableValue}>(undefined); + const updates: (number | undefined)[] = []; + a.flatMap(a => a!.count).subscribe(count => { + updates.push(count); + }); + const count = new ObservableValue(0); + a.set({count}); + count.set(5); + assert.deepEqual(updates, [0, 5]); + } + } +} diff --git a/src/platform/types/types.ts b/src/platform/types/types.ts index 0e2f536ed1..a2936199ac 100644 --- a/src/platform/types/types.ts +++ b/src/platform/types/types.ts @@ -25,6 +25,7 @@ export interface IRequestOptions { cache?: boolean; method?: string; format?: string; + accessTokenOverride?: string; } export type RequestFunction = (url: string, options: IRequestOptions) => RequestResult; diff --git a/src/platform/web/ui/css/login.css b/src/platform/web/ui/css/login.css index deb16b0205..ae70624257 100644 --- a/src/platform/web/ui/css/login.css +++ b/src/platform/web/ui/css/login.css @@ -68,13 +68,13 @@ limitations under the License. --size: 20px; } -.StartSSOLoginView { +.StartSSOLoginView, .StartOIDCLoginView { display: flex; flex-direction: column; padding: 0 0.4em 0; } -.StartSSOLoginView_button { +.StartSSOLoginView_button, .StartOIDCLoginView_button { flex: 1; margin-top: 12px; } diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index 8800262582..ee8bf169be 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -57,6 +57,7 @@ export class LoginView extends TemplateView { t.mapView(vm => vm.passwordLoginViewModel, vm => vm ? new PasswordLoginView(vm): null), t.if(vm => vm.passwordLoginViewModel && vm.startSSOLoginViewModel, t => t.p({className: "LoginView_separator"}, vm.i18n`or`)), t.mapView(vm => vm.startSSOLoginViewModel, vm => vm ? new StartSSOLoginView(vm) : null), + t.mapView(vm => vm.startOIDCLoginViewModel, vm => vm ? new StartOIDCLoginView(vm) : null), t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), // use t.mapView rather than t.if to create a new view when the view model changes too t.p(hydrogenGithubLink(t)) @@ -76,3 +77,14 @@ class StartSSOLoginView extends TemplateView { ); } } + +class StartOIDCLoginView extends TemplateView { + render(t, vm) { + return t.div({ className: "StartOIDCLoginView" }, + t.a({ + className: "StartOIDCLoginView_button button-action secondary", + href: vm => (vm.isBusy ? "#" : vm.authorizationEndpoint), + }, vm.i18n`Log in via OIDC`) + ); + } +} From b9bca9d58d296186205f80b28e267f5b5868a2d2 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 13:43:16 +0100 Subject: [PATCH 05/58] Only generate the auth URL and start the login flow on click --- src/domain/login/LoginViewModel.ts | 5 ++-- src/domain/login/StartOIDCLoginViewModel.js | 30 ++++++++++++--------- src/platform/web/ui/login/LoginView.js | 4 ++- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 2911c80856..8184d63bc3 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -166,14 +166,15 @@ export class LoginViewModel extends ViewModel { this._startOIDCLoginViewModel = this.track( new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); - await this._startOIDCLoginViewModel.start(); this.emitChange("startOIDCLoginViewModel"); + this._startOIDCLoginViewModel.discover(); } private _setBusy(status: boolean): void { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); + this.startOIDCLoginViewModel?.setBusy(status); this.emitChange("isBusy"); } @@ -291,7 +292,7 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions) { if (this._loginOptions.sso) { this._showSSOLogin(); } if (this._loginOptions.password) { this._showPasswordLogin(); } - if (this._loginOptions.oidc) { await this._showOIDCLogin(); } + if (this._loginOptions.oidc) { this._showOIDCLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) { this._showError("This homeserver supports neither SSO nor password based login flows"); } diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index e742fe1cb5..146d81b166 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -21,35 +21,39 @@ export class StartOIDCLoginViewModel extends ViewModel { constructor(options) { super(options); this._isBusy = true; - this._authorizationEndpoint = null; + this._issuer = options.loginOptions.oidc.issuer; + this._homeserver = options.loginOptions.homeserver; this._api = new OidcApi({ clientId: "hydrogen-web", - issuer: options.loginOptions.oidc.issuer, + issuer: this._issuer, request: this.platform.request, encoding: this.platform.encoding, }); - this._homeserver = options.loginOptions.homeserver; } get isBusy() { return this._isBusy; } - get authorizationEndpoint() { return this._authorizationEndpoint; } - async start() { + setBusy(status) { + this._isBusy = status; + this.emitChange("isBusy"); + } + + async discover() { + // Ask for the metadata once so it gets discovered and cached + await this._api.metadata() + } + + async startOIDCLogin() { const p = this._api.generateParams("openid"); await Promise.all([ this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()), this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce), this.platform.settingsStorage.setString(`oidc_${p.state}_code_verifier`, p.codeVerifier), this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), - this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._api.issuer), + this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._issuer), ]); - this._authorizationEndpoint = await this._api.authorizationEndpoint(p); - this._isBusy = false; - } - - setBusy(status) { - this._isBusy = status; - this.emitChange("isBusy"); + const link = await this._api.authorizationEndpoint(p); + this.platform.openUrl(link); } } diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index ee8bf169be..116c82cd20 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -83,7 +83,9 @@ class StartOIDCLoginView extends TemplateView { return t.div({ className: "StartOIDCLoginView" }, t.a({ className: "StartOIDCLoginView_button button-action secondary", - href: vm => (vm.isBusy ? "#" : vm.authorizationEndpoint), + type: "button", + onClick: () => vm.startOIDCLogin(), + disabled: vm => vm.isBusy }, vm.i18n`Log in via OIDC`) ); } From 83684c830bc4b826a1e658a50e90799ee202e63c Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 15:16:43 +0100 Subject: [PATCH 06/58] Generate the OIDC redirect URI from the URLRouter This also saves the redirectUri during the flow --- src/domain/login/CompleteOIDCLoginViewModel.js | 5 +++-- src/domain/login/StartOIDCLoginViewModel.js | 6 +++++- src/domain/navigation/URLRouter.ts | 4 ++++ src/matrix/login/OIDCLoginMethod.ts | 5 +++++ src/matrix/net/OidcApi.ts | 12 ++++++++---- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index fa0b665e8b..f3a9c441ff 100644 --- a/src/domain/login/CompleteOIDCLoginViewModel.js +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -49,10 +49,11 @@ export class CompleteOIDCLoginViewModel extends ViewModel { } const code = this._code; // TODO: cleanup settings storage - const [startedAt, nonce, codeVerifier, homeserver, issuer] = await Promise.all([ + const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer] = 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`), ]); @@ -63,7 +64,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { request: this._request, encoding: this._encoding, }); - const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt}); + const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri}); const status = await this._attemptLogin(method); let error = ""; switch (status) { diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 146d81b166..89600d58bc 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -44,11 +44,15 @@ export class StartOIDCLoginViewModel extends ViewModel { } async startOIDCLogin() { - const p = this._api.generateParams("openid"); + const p = this._api.generateParams({ + scope: "openid", + redirectUri: this.urlCreator.createOIDCRedirectURL(), + }); await Promise.all([ this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()), this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce), this.platform.settingsStorage.setString(`oidc_${p.state}_code_verifier`, p.codeVerifier), + 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), ]); diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index 2350353063..90cbf7e033 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -152,6 +152,10 @@ export class URLRouter implements IURLRou return window.location.origin; } + createOIDCRedirectURL() { + return window.location.origin; + } + normalizeUrl(): void { // Remove any queryParameters from the URL // Gets rid of the loginToken after SSO diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts index 0226877a11..1e834b648a 100644 --- a/src/matrix/login/OIDCLoginMethod.ts +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -23,6 +23,7 @@ export class OIDCLoginMethod implements ILoginMethod { private readonly _code: string; private readonly _codeVerifier: string; private readonly _nonce: string; + private readonly _redirectUri: string; private readonly _oidcApi: OidcApi; public readonly homeserver: string; @@ -31,18 +32,21 @@ export class OIDCLoginMethod implements ILoginMethod { codeVerifier, code, homeserver, + redirectUri, oidcApi, }: { nonce: string, code: string, codeVerifier: string, homeserver: string, + redirectUri: string, oidcApi: OidcApi, }) { this._oidcApi = oidcApi; this._code = code; this._codeVerifier = codeVerifier; this._nonce = nonce; + this._redirectUri = redirectUri; this.homeserver = homeserver; } @@ -50,6 +54,7 @@ export class OIDCLoginMethod implements ILoginMethod { const { access_token, refresh_token, expires_in } = await this._oidcApi.completeAuthorizationCodeGrant({ code: this._code, codeVerifier: this._codeVerifier, + redirectUri: this._redirectUri, }); // TODO: validate the id_token and the nonce claim diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 3111d65fce..3dfe4cdd16 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -39,6 +39,7 @@ const isValidBearerToken = (t: any): t is BearerToken => type AuthorizationParams = { state: string, scope: string, + redirectUri: string, nonce?: string, codeVerifier?: string, }; @@ -118,6 +119,7 @@ export class OidcApi { async authorizationEndpoint({ state, + redirectUri, scope, nonce, codeVerifier, @@ -126,7 +128,7 @@ export class OidcApi { const url = new URL(metadata["authorization_endpoint"]); url.searchParams.append("response_mode", "fragment"); url.searchParams.append("response_type", "code"); - url.searchParams.append("redirect_uri", this.redirectUri); + url.searchParams.append("redirect_uri", redirectUri); url.searchParams.append("client_id", this._clientId); url.searchParams.append("state", state); url.searchParams.append("scope", scope); @@ -147,9 +149,10 @@ export class OidcApi { return metadata["token_endpoint"]; } - generateParams(scope: string): AuthorizationParams { + generateParams({ scope, redirectUri }: { scope: string, redirectUri: string }): AuthorizationParams { return { scope, + redirectUri, state: randomString(8), nonce: randomString(8), codeVerifier: randomString(32), @@ -159,12 +162,13 @@ export class OidcApi { async completeAuthorizationCodeGrant({ codeVerifier, code, - }: { codeVerifier: string, code: string }): Promise { + redirectUri, + }: { 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("code_verifier", codeVerifier); - params.append("redirect_uri", this.redirectUri); + params.append("redirect_uri", redirectUri); params.append("code", code); const body = params.toString(); From 2ba8bc3c4c58dd04910a0d8e6eeb55f44e80f3c1 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 15:30:28 +0100 Subject: [PATCH 07/58] Simplify OIDC callback navigation handling --- src/domain/RootViewModel.js | 26 +++++++++++--------------- src/domain/navigation/index.ts | 34 +++++++++++++++++----------------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index cb16139b5f..9edba2e0de 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -41,8 +41,7 @@ export class RootViewModel extends ViewModel { this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("logout").subscribe(() => this._applyNavigation())); - this.track(this.navigation.observe("oidc-callback").subscribe(() => this._applyNavigation())); - this.track(this.navigation.observe("oidc-error").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("oidc").subscribe(() => this._applyNavigation())); this._applyNavigation(true); } @@ -52,8 +51,7 @@ export class RootViewModel extends ViewModel { const isForcedLogout = this.navigation.path.get("forced")?.value; const sessionId = this.navigation.path.get("session")?.value; const loginToken = this.navigation.path.get("sso")?.value; - const oidcCallback = this.navigation.path.get("oidc-callback")?.value; - const oidcError = this.navigation.path.get("oidc-error")?.value; + const oidcCallback = this.navigation.path.get("oidc")?.value; if (isLogin) { if (this.activeSection !== "login") { this._showLogin(); @@ -91,18 +89,16 @@ export class RootViewModel extends ViewModel { if (this.activeSection !== "login") { this._showLogin({loginToken}); } - } else if (oidcError) { - this._setSection(() => this._error = new Error(`OIDC error: ${oidcError[1]}`)); } else if (oidcCallback) { - this._setSection(() => this._error = new Error(`OIDC callback: state=${oidcCallback[0]}, code=${oidcCallback[1]}`)); - this.urlCreator.normalizeUrl(); - if (this.activeSection !== "login") { - this._showLogin({ - oidc: { - state: oidcCallback[0], - code: oidcCallback[1], - } - }); + if (oidcCallback.error) { + this._setSection(() => this._error = new Error(`OIDC error: ${oidcCallback.error}`)); + } else { + this.urlCreator.normalizeUrl(); + if (this.activeSection !== "login") { + this._showLogin({ + oidc: oidcCallback, + }); + } } } else { diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 54719c05c0..d16fdd0672 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -49,7 +49,7 @@ function allowsChild(parent: Segment | undefined, child: Segment, if (params.has("state")) { // This is a proper OIDC callback if (params.has("code")) { - segments.push(new Segment("oidc-callback", [ - params.get("state"), - params.get("code"), - ])); + segments.push(new Segment("oidc", { + state: params.get("state"), + code: params.get("code"), + })); return segments; } else if (params.has("error")) { - segments.push(new Segment("oidc-error", [ - params.get("state"), - params.get("error"), - params.get("error_description"), - params.get("error_uri"), - ])); + segments.push(new Segment("oidc", { + state: params.get("state"), + error: params.get("error"), + errorDescription: params.get("error_description"), + errorUri: params.get("error_uri"), + })); return segments; } } @@ -537,20 +537,20 @@ export function tests() { "Parse OIDC callback": assert => { const segments = parseUrlPath("state=tc9CnLU7&code=cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"); assert.equal(segments.length, 1); - assert.equal(segments[0].type, "oidc-callback"); - assert.deepEqual(segments[0].value, ["tc9CnLU7", "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"]); + assert.equal(segments[0].type, "oidc"); + assert.deepEqual(segments[0].value, {state: "tc9CnLU7", code: "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"}); }, "Parse OIDC error": assert => { const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request"); assert.equal(segments.length, 1); - assert.equal(segments[0].type, "oidc-error"); - assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", null, null]); + assert.equal(segments[0].type, "oidc"); + assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorUri: null, errorDescription: null}); }, "Parse OIDC error with description": assert => { const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request&error_description=Unsupported%20response_type%20value"); assert.equal(segments.length, 1); - assert.equal(segments[0].type, "oidc-error"); - assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", "Unsupported response_type value", null]); + assert.equal(segments[0].type, "oidc"); + assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorDescription: "Unsupported response_type value", errorUri: null}); }, } } From 68daf516ac158ec627228261ef0df4d7da0409d1 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 15:41:40 +0100 Subject: [PATCH 08/58] Use platform APIs for text encoding and hashing --- src/domain/login/CompleteOIDCLoginViewModel.js | 2 ++ src/domain/login/StartOIDCLoginViewModel.js | 1 + src/matrix/Client.js | 2 ++ src/matrix/net/OidcApi.ts | 15 ++++++++------- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index f3a9c441ff..ca65c7c7ec 100644 --- a/src/domain/login/CompleteOIDCLoginViewModel.js +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -29,6 +29,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { } = options; this._request = options.platform.request; this._encoding = options.platform.encoding; + this._crypto = options.platform.crypto; this._state = state; this._code = code; this._attemptLogin = attemptLogin; @@ -63,6 +64,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { clientId: "hydrogen-web", request: this._request, encoding: this._encoding, + crypto: this._crypto, }); const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri}); const status = await this._attemptLogin(method); diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 89600d58bc..a06b764f13 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -28,6 +28,7 @@ export class StartOIDCLoginViewModel extends ViewModel { issuer: this._issuer, request: this.platform.request, encoding: this.platform.encoding, + crypto: this.platform.crypto, }); } diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 1f72f430e2..51cfa6715f 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -139,6 +139,7 @@ export class Client { clientId: "hydrogen-web", request: this._platform.request, encoding: this._platform.encoding, + crypto: this._platform.crypto, }); await oidcApi.validate(); @@ -261,6 +262,7 @@ export class Client { clientId: "hydrogen-web", request: this._platform.request, encoding: this._platform.encoding, + crypto: this._platform.crypto, }); // TODO: stop/pause the refresher? diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 3dfe4cdd16..f7c08dcae5 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -54,14 +54,16 @@ export class OidcApi { _issuer: string; _clientId: string; _requestFn: any; - _base64: any; + _encoding: any; + _crypto: any; _metadataPromise: Promise; - constructor({ issuer, clientId, request, encoding }) { + constructor({ issuer, clientId, request, encoding, crypto }) { this._issuer = issuer; this._clientId = clientId; this._requestFn = request; - this._base64 = encoding.base64; + this._encoding = encoding; + this._crypto = crypto; } get metadataUrl() { @@ -110,10 +112,9 @@ export class OidcApi { async _generateCodeChallenge( codeVerifier: string ): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(codeVerifier); - const digest = await window.crypto.subtle.digest("SHA-256", data); - const base64Digest = this._base64.encode(digest); + const data = this._encoding.utf8.encode(codeVerifier); + const digest = await this._crypto.digest("SHA-256", data); + const base64Digest = this._encoding.base64.encode(digest); return base64Digest.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } From dd8cd315e5349702391f51e8b429ecd5ba174846 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 16:27:43 +0100 Subject: [PATCH 09/58] Stop the token refresher when disposing the client --- src/matrix/Client.js | 13 ++++++++----- src/matrix/net/OidcApi.ts | 4 +++- src/matrix/net/TokenRefresher.ts | 16 +++++++++++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 51cfa6715f..6814501ca7 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -265,8 +265,7 @@ export class Client { crypto: this._platform.crypto, }); - // TODO: stop/pause the refresher? - const tokenRefresher = new TokenRefresher({ + this._tokenRefresher = new TokenRefresher({ oidcApi, clock: this._platform.clock, accessToken: sessionInfo.accessToken, @@ -275,13 +274,13 @@ export class Client { anticipation: 30 * 1000, }); - tokenRefresher.token.subscribe(t => { + this._tokenRefresher.token.subscribe(t => { this._platform.sessionInfoStorage.updateToken(sessionInfo.id, t.accessToken, t.accessTokenExpiresAt, t.refreshToken); }); - await tokenRefresher.start(); + await this._tokenRefresher.start(); - accessToken = tokenRefresher.accessToken; + accessToken = this._tokenRefresher.accessToken; } else { accessToken = new ObservableValue(sessionInfo.accessToken); } @@ -505,6 +504,10 @@ export class Client { this._sync.stop(); this._sync = null; } + if (this._tokenRefresher) { + this._tokenRefresher.stop(); + this._tokenRefresher = null; + } if (this._session) { this._session.dispose(); this._session = null; diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index f7c08dcae5..023ba48531 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type {RequestFunction} from "../../platform/types/types"; + const WELL_KNOWN = ".well-known/openid-configuration"; const RANDOM_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; @@ -53,7 +55,7 @@ function assert(condition: any, message: string): asserts condition { export class OidcApi { _issuer: string; _clientId: string; - _requestFn: any; + _requestFn: RequestFunction; _encoding: any; _crypto: any; _metadataPromise: Promise; diff --git a/src/matrix/net/TokenRefresher.ts b/src/matrix/net/TokenRefresher.ts index 489dfb1197..2010cebe05 100644 --- a/src/matrix/net/TokenRefresher.ts +++ b/src/matrix/net/TokenRefresher.ts @@ -32,6 +32,7 @@ export class TokenRefresher { private _clock: Clock; private _oidcApi: OidcApi; private _timeout: Timeout + private _running: boolean; constructor({ oidcApi, @@ -65,11 +66,15 @@ export class TokenRefresher { await this.renew(); } + this._running = true; this._renewingLoop(); } stop() { - // TODO + this._running = false; + if (this._timeout) { + this._timeout.dispose(); + } } get needsRenewing() { @@ -79,14 +84,19 @@ export class TokenRefresher { } async _renewingLoop() { - while (true) { + while (this._running) { const remaining = this._token.get().accessTokenExpiresAt - this._clock.now(); const anticipated = remaining - this._anticipation; if (anticipated > 0) { this._timeout = this._clock.createTimeout(anticipated); - await this._timeout.elapsed(); + try { + await this._timeout.elapsed(); + } catch { + // The timeout will throw when aborted, so stop the loop if it is the case + return; + } } await this.renew(); From 21cf8455068737daa92882f0d9b89310d4760ea8 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 16:38:41 +0100 Subject: [PATCH 10/58] Typo. --- src/domain/login/LoginViewModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 8184d63bc3..2a3a65caf6 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -174,7 +174,7 @@ export class LoginViewModel extends ViewModel { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); - this.startOIDCLoginViewModel?.setBusy(status); + this._startOIDCLoginViewModel?.setBusy(status); this.emitChange("isBusy"); } From 46e884b589018360b2a8932a02f9a93929eee436 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 25 Apr 2022 09:31:00 +0200 Subject: [PATCH 11/58] OIDC dynamic client registration --- .../login/CompleteOIDCLoginViewModel.js | 5 +- src/domain/login/StartOIDCLoginViewModel.js | 5 +- src/domain/navigation/URLRouter.ts | 6 +- src/matrix/Client.js | 17 ++++- src/matrix/login/OIDCLoginMethod.ts | 3 +- src/matrix/net/OidcApi.ts | 70 ++++++++++++++++--- 6 files changed, 88 insertions(+), 18 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.ts b/src/domain/navigation/URLRouter.ts index 90cbf7e033..05daa2f1cf 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -152,10 +152,14 @@ export class URLRouter implements IURLRou return window.location.origin; } - createOIDCRedirectURL() { + createOIDCRedirectURL(): string { return window.location.origin; } + absoluteUrlForAsset(asset: string): string { + return (new URL('/assets/' + asset, window.location.origin)).toString(); + } + normalizeUrl(): void { // 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 6814501ca7..197b962e9b 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -136,7 +136,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, @@ -200,6 +199,20 @@ export class Client { homeserver: loginMethod.homeserver, accessToken: loginData.access_token, }; + + if (loginData.refresh_token) { + sessionInfo.refreshToken = loginData.refresh_token; + } + + if (loginData.expires_in) { + sessionInfo.accessTokenExpiresAt = clock.now() + loginData.expires_in * 1000; + } + + if (loginData.oidc_issuer) { + sessionInfo.oidcIssuer = loginData.oidc_issuer; + sessionInfo.oidcClientId = loginData.oidc_client_id; + } + log.set("id", sessionId); } catch (err) { this._error = err; @@ -259,7 +272,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..319d122f26 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"]; + } + + 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 +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(); From 46440044cb098899d49af9be5c42a70183936266 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 29 Apr 2022 16:30:24 +0200 Subject: [PATCH 12/58] Add client_uri, tos_uri and policy_uri client metadata --- src/domain/navigation/URLRouter.ts | 4 ++++ src/matrix/net/OidcApi.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index 05daa2f1cf..1fccb476be 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -156,6 +156,10 @@ export class URLRouter implements IURLRou return window.location.origin; } + absoluteAppUrl(): string { + return window.location.origin; + } + absoluteUrlForAsset(asset: string): string { return (new URL('/assets/' + asset, window.location.origin)).toString(); } diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 319d122f26..57168622c1 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -78,6 +78,9 @@ export class OidcApi { return { client_name: "Hydrogen Web", logo_uri: this._urlCreator.absoluteUrlForAsset("icon.png"), + client_uri: this._urlCreator.absoluteAppUrl(), + tos_uri: "https://element.io/terms-of-service", + policy_uri: "https://element.io/privacy", response_types: ["code"], grant_types: ["authorization_code", "refresh_token"], redirect_uris: [this._urlCreator.createOIDCRedirectURL()], From 8da49df0b6a948d055b6c50417fb3a645af27807 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 4 Jul 2022 18:44:31 +0200 Subject: [PATCH 13/58] Make hydrogen generate the device scope --- src/domain/login/StartOIDCLoginViewModel.js | 5 +++-- src/matrix/net/OidcApi.ts | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index e70a748762..d6424f7423 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -42,12 +42,13 @@ 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(); + await this._api.registration(); } async startOIDCLogin() { + const deviceScope = this._api.generateDeviceScope(); const p = this._api.generateParams({ - scope: "openid", + scope: `openid ${deviceScope}`, redirectUri: this.urlCreator.createOIDCRedirectURL(), }); const clientId = await this._api.clientId(); diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 57168622c1..b8d459b3d5 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -203,6 +203,11 @@ export class OidcApi { return metadata["registration_endpoint"]; } + generateDeviceScope(): String { + const deviceId = randomString(10); + return `urn:matrix:device:${deviceId}`; + } + generateParams({ scope, redirectUri }: { scope: string, redirectUri: string }): AuthorizationParams { return { scope, From f976430393ad5425d01223ed8d1027e002aacf9a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 8 Jul 2022 15:35:55 +0100 Subject: [PATCH 14/58] Use unstable prefix for MSC2965 issuer discovery --- src/matrix/well-known.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/well-known.js b/src/matrix/well-known.js index 6e3bedbf7b..10e78f2cb5 100644 --- a/src/matrix/well-known.js +++ b/src/matrix/well-known.js @@ -50,7 +50,7 @@ export async function lookupHomeserver(homeserver, request) { homeserver = normalizeHomeserver(wellKnownHomeserver); } - const wellKnownIssuer = body["m.authentication"]?.["issuer"]; + const wellKnownIssuer = body["org.matrix.msc2965.authentication"]?.["issuer"]; if (typeof wellKnownIssuer === "string") { issuer = wellKnownIssuer; } From 0a4822c9453732f8092c300bb237ee2903968287 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 8 Jul 2022 15:50:49 +0100 Subject: [PATCH 15/58] Rename OIDC login button to Continue --- src/platform/web/ui/login/LoginView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index 116c82cd20..e178318338 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -86,7 +86,7 @@ class StartOIDCLoginView extends TemplateView { type: "button", onClick: () => vm.startOIDCLogin(), disabled: vm => vm.isBusy - }, vm.i18n`Log in via OIDC`) + }, vm.i18n`Continue`) ); } } From 06a2068df91a50fc29ade7eb973bc31f0a50b1f0 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 08:58:04 +0100 Subject: [PATCH 16/58] Request urn:matrix:api:* scope for OIDC --- src/domain/login/StartOIDCLoginViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index d6424f7423..4189e58129 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -48,7 +48,7 @@ export class StartOIDCLoginViewModel extends ViewModel { async startOIDCLogin() { const deviceScope = this._api.generateDeviceScope(); const p = this._api.generateParams({ - scope: `openid ${deviceScope}`, + scope: `openid urn:matrix:api:* ${deviceScope}`, redirectUri: this.urlCreator.createOIDCRedirectURL(), }); const clientId = await this._api.clientId(); From f31f57ecbcee1e18aadbae437f0d8652e80a9db3 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:00:16 +0100 Subject: [PATCH 17/58] Try to improve error message on no login method available --- src/domain/login/LoginViewModel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 2a3a65caf6..678a483057 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -294,8 +294,8 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions.password) { this._showPasswordLogin(); } if (this._loginOptions.oidc) { this._showOIDCLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) { - this._showError("This homeserver supports neither SSO nor password based login flows"); - } + this._showError("This homeserver supports neither SSO nor password based login flows or has a usable OIDC Provider"); + } } else { this._showError(`Could not query login methods supported by ${this.homeserver}`); From 786a08269094f8b08abb1471de5d606b41b3cc28 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:00:30 +0100 Subject: [PATCH 18/58] fix: hide OIDC button when not in use --- src/domain/login/LoginViewModel.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 678a483057..eb2549e26e 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -228,6 +228,7 @@ export class LoginViewModel extends ViewModel { this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel); this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); + this._startOIDCLoginViewModel = this.disposeTracked(this._startOIDCLoginViewModel); this.emitChange("disposeViewModels"); } From 7463145feb34f4d074ad1776af6b69a6d937749d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:06:30 +0100 Subject: [PATCH 19/58] Use primary styling for OIDC login button --- src/platform/web/ui/login/LoginView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index e178318338..b44de2e462 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -82,7 +82,7 @@ class StartOIDCLoginView extends TemplateView { render(t, vm) { return t.div({ className: "StartOIDCLoginView" }, t.a({ - className: "StartOIDCLoginView_button button-action secondary", + className: "StartOIDCLoginView_button button-action primary", type: "button", onClick: () => vm.startOIDCLogin(), disabled: vm => vm.isBusy From a44f13e610096cd679d47f18e8eab060396c2c7e Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:06:49 +0100 Subject: [PATCH 20/58] Handle case of OIDC Provider not returning supported_grant_types --- src/matrix/net/OidcApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index b8d459b3d5..832c94e7cb 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -153,7 +153,7 @@ export class OidcApi { 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"); + assert(typeof m.authorization_endpoint === "string" || (Array.isArray(m.grant_types_supported) && m.grant_types_supported.includes("authorization_code")), "Supports the authorization_code grant type"); assert(Array.isArray(m.code_challenge_methods_supported) && m.code_challenge_methods_supported.includes("S256"), "Supports the authorization_code grant type"); } From 36050b1c698b77f9000dc93923d10956fd3fbaff Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:12:48 +0100 Subject: [PATCH 21/58] Handle case of issuer field not ending with / --- src/matrix/net/OidcApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 832c94e7cb..1d0db4629f 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -90,7 +90,7 @@ export class OidcApi { } get metadataUrl() { - return new URL(WELL_KNOWN, this._issuer).toString(); + return new URL(WELL_KNOWN, `${this._issuer}${this._issuer.endsWith('/') ? '' : '/'}`).toString(); } get issuer() { From f8dca77b9278b6f5298c8c319418e1b6c280cbf1 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:22:06 +0100 Subject: [PATCH 22/58] Improve error handling for OIDC discovery and registration --- src/domain/login/LoginViewModel.ts | 7 ++++++- src/domain/login/StartOIDCLoginViewModel.js | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index eb2549e26e..f250a9ad2d 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -167,7 +167,12 @@ export class LoginViewModel extends ViewModel { new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); this.emitChange("startOIDCLoginViewModel"); - this._startOIDCLoginViewModel.discover(); + try { + await this._startOIDCLoginViewModel.discover(); + } catch (err) { + this._showError(err.message); + this._disposeViewModels(); + } } private _setBusy(status: boolean): void { diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 4189e58129..70980e32be 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -41,8 +41,18 @@ 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.registration(); + try { + await this._api.metadata() + } catch (err) { + this.logger.log("Failed to discover OIDC metadata: " + err); + throw new Error("Failed to discover OIDC metadata: " + err.message ); + } + try { + await this._api.registration(); + } catch (err) { + this.logger.log("Failed to register OIDC client: " + err); + throw new Error("Failed to register OIDC client: " + err.message ); + } } async startOIDCLogin() { From 485e8a2419f8ff41b15da66d9d636a37ed5883e3 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 15:34:35 +0100 Subject: [PATCH 23/58] Ask OP to revoke tokens on logout --- src/matrix/Client.js | 11 +++++++++++ src/matrix/net/OidcApi.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 197b962e9b..2bb1e2318b 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -490,6 +490,17 @@ export class Client { request: this._platform.request }); await hsApi.logout({log}).response(); + const oidcApi = new OidcApi({ + issuer: sessionInfo.oidcIssuer, + clientId: sessionInfo.oidcClientId, + request: this._platform.request, + encoding: this._platform.encoding, + crypto: this._platform.crypto, + }); + await oidcApi.revokeToken({ token: sessionInfo.accessToken, type: "access" }); + if (sessionInfo.refreshToken) { + await oidcApi.revokeToken({ token: sessionInfo.refreshToken, type: "refresh" }); + } } catch (err) {} await this.deleteSession(log); }); diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 1d0db4629f..5a8019528a 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -203,6 +203,11 @@ export class OidcApi { return metadata["registration_endpoint"]; } + async revocationEndpoint(): Promise { + const metadata = await this.metadata(); + return metadata["revocation_endpoint"]; + } + generateDeviceScope(): String { const deviceId = randomString(10); return `urn:matrix:device:${deviceId}`; @@ -281,4 +286,35 @@ export class OidcApi { return token; } + + async revokeToken({ + token, + type, + }: { token: string, type: "refresh" | "access" }): Promise { + const revocationEndpoint = await this.revocationEndpoint(); + if (!revocationEndpoint) { + return; + } + + const params = new URLSearchParams(); + params.append("token_type", type); + params.append("token", token); + params.append("client_id", await this.clientId()); + const body = params.toString(); + + const headers = new Map(); + headers.set("Content-Type", "application/x-www-form-urlencoded"); + + const req = this._requestFn(revocationEndpoint, { + method: "POST", + headers, + format: "json", + body, + }); + + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to revoke token"); + } + } } From 1ea9eda51f3c092d82e2ad44bce09759ec9c3c7c Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 29 Jul 2022 08:59:05 +0100 Subject: [PATCH 24/58] Support statically configured OIDC clients --- src/matrix/net/OidcApi.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 5a8019528a..bdc9352abe 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -53,6 +53,22 @@ function assert(condition: any, message: string): asserts condition { } }; +type IssuerUri = string; +interface ClientConfig { + client_id: string; + client_secret?: string; +} + +// These are statically configured OIDC client IDs for particular issuers: +const clientIds: Record = { + "https://dev-6525741.okta.com/": { + client_id: "0oa5x44w64wpNsxi45d7", + }, + "https://keycloak-oidc.lab.element.dev/realms/master/": { + client_id: "hydrogen-oidc-playground" + }, +}; + export class OidcApi { _issuer: string; _requestFn: RequestFunction; @@ -104,6 +120,13 @@ export class OidcApi { registration(): Promise { if (!this._registrationPromise) { this._registrationPromise = (async () => { + // use static client if available + const authority = `${this.issuer}${this.issuer.endsWith('/') ? '' : '/'}`; + + if (clientIds[authority]) { + return clientIds[authority]; + } + const headers = new Map(); headers.set("Accept", "application/json"); headers.set("Content-Type", "application/json"); From 35bb265fececddc31357deb5d3142bdb011430ee Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 29 Jul 2022 09:44:24 +0100 Subject: [PATCH 25/58] Use valid length of code_verifier --- src/matrix/net/OidcApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index bdc9352abe..103aae82b3 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -242,7 +242,7 @@ export class OidcApi { redirectUri, state: randomString(8), nonce: randomString(8), - codeVerifier: randomString(32), + codeVerifier: randomString(64), // https://tools.ietf.org/html/rfc7636#section-4.1 length needs to be 43-128 characters }; } From 462b8b6a7b070ecbbe80e78fce75a60561be664b Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Sun, 31 Jul 2022 17:17:00 +0100 Subject: [PATCH 26/58] Link out to OIDC account management URL if available --- src/domain/login/CompleteOIDCLoginViewModel.js | 5 +++-- src/domain/login/StartOIDCLoginViewModel.js | 2 ++ src/domain/session/settings/SettingsViewModel.js | 8 ++++++++ src/matrix/Client.js | 5 +++-- src/matrix/login/OIDCLoginMethod.ts | 6 +++++- src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts | 1 + src/matrix/well-known.js | 8 +++++++- src/platform/web/ui/session/settings/SettingsView.js | 7 +++++++ 8 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index 5d0da98021..a544939aab 100644 --- a/src/domain/login/CompleteOIDCLoginViewModel.js +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -50,7 +50,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { } const code = this._code; // TODO: cleanup settings storage - const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer, clientId] = await Promise.all([ + const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer, clientId, accountManagementUrl] = 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`), @@ -58,6 +58,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { 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`), + this.platform.settingsStorage.getString(`oidc_${this._state}_account_management_url`), ]); const oidcApi = new OidcApi({ @@ -67,7 +68,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { encoding: this._encoding, crypto: this._crypto, }); - const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri}); + const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri, accountManagementUrl}); const status = await this._attemptLogin(method); let error = ""; switch (status) { diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 70980e32be..07cae075b6 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -22,6 +22,7 @@ export class StartOIDCLoginViewModel extends ViewModel { super(options); this._isBusy = true; this._issuer = options.loginOptions.oidc.issuer; + this._accountManagementUrl = options.loginOptions.oidc.account; this._homeserver = options.loginOptions.homeserver; this._api = new OidcApi({ issuer: this._issuer, @@ -70,6 +71,7 @@ export class StartOIDCLoginViewModel extends ViewModel { 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), + this.platform.settingsStorage.setString(`oidc_${p.state}_account_management_url`, this._accountManagementUrl), ]); const link = await this._api.authorizationEndpoint(p); diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index f8420a5346..a9351e05a6 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -55,6 +55,7 @@ export class SettingsViewModel extends ViewModel { this._activeTheme = undefined; this._logsFeedbackMessage = undefined; this._featuresViewModel = new FeaturesViewModel(this.childOptions()); + this._accountManagementUrl = null; } get _session() { @@ -84,9 +85,16 @@ export class SettingsViewModel extends ViewModel { if (!import.meta.env.DEV) { this._activeTheme = await this.platform.themeLoader.getActiveTheme(); } + const {accountManagementUrl} = await this.platform.sessionInfoStorage.get(this._client._sessionId); + this._accountManagementUrl = accountManagementUrl; this.emitChange(""); } + + get accountManagementUrl() { + return this._accountManagementUrl; + } + get closeUrl() { return this._closeUrl; } diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 2bb1e2318b..6c9aff4ce1 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -129,7 +129,7 @@ export class Client { queryLogin(initialHomeserver) { return new AbortableOperation(async setAbortable => { - const { homeserver, issuer } = await lookupHomeserver(initialHomeserver, (url, options) => { + const { homeserver, issuer, account } = await lookupHomeserver(initialHomeserver, (url, options) => { return setAbortable(this._platform.request(url, options)); }); if (issuer) { @@ -144,7 +144,7 @@ export class Client { return { homeserver, - oidc: { issuer }, + oidc: { issuer, account }, }; } catch (e) { console.log(e); @@ -211,6 +211,7 @@ export class Client { if (loginData.oidc_issuer) { sessionInfo.oidcIssuer = loginData.oidc_issuer; sessionInfo.oidcClientId = loginData.oidc_client_id; + sessionInfo.accountManagementUrl = loginData.oidc_account_management_url; } log.set("id", sessionId); diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts index b25689aade..e0e3f58ff7 100644 --- a/src/matrix/login/OIDCLoginMethod.ts +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -25,6 +25,7 @@ export class OIDCLoginMethod implements ILoginMethod { private readonly _nonce: string; private readonly _redirectUri: string; private readonly _oidcApi: OidcApi; + private readonly _accountManagementUrl?: string; public readonly homeserver: string; constructor({ @@ -34,6 +35,7 @@ export class OIDCLoginMethod implements ILoginMethod { homeserver, redirectUri, oidcApi, + accountManagementUrl, }: { nonce: string, code: string, @@ -41,6 +43,7 @@ export class OIDCLoginMethod implements ILoginMethod { homeserver: string, redirectUri: string, oidcApi: OidcApi, + accountManagementUrl?: string, }) { this._oidcApi = oidcApi; this._code = code; @@ -48,6 +51,7 @@ export class OIDCLoginMethod implements ILoginMethod { this._nonce = nonce; this._redirectUri = redirectUri; this.homeserver = homeserver; + this._accountManagementUrl = accountManagementUrl; } async login(hsApi: HomeServerApi, _deviceName: string, log: ILogItem): Promise> { @@ -68,6 +72,6 @@ export class OIDCLoginMethod implements ILoginMethod { const oidc_issuer = this._oidcApi.issuer; const oidc_client_id = await this._oidcApi.clientId(); - return { oidc_issuer, oidc_client_id, 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, oidc_account_management_url: this._accountManagementUrl }; } } diff --git a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts index 80443e8364..000879e8be 100644 --- a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts +++ b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts @@ -24,6 +24,7 @@ interface ISessionInfo { accessTokenExpiresAt?: number; refreshToken?: string; oidcIssuer?: string; + accountManagementUrl?: string; lastUsed: number; } diff --git a/src/matrix/well-known.js b/src/matrix/well-known.js index 10e78f2cb5..9a858f2b15 100644 --- a/src/matrix/well-known.js +++ b/src/matrix/well-known.js @@ -42,6 +42,7 @@ async function getWellKnownResponse(homeserver, request) { export async function lookupHomeserver(homeserver, request) { homeserver = normalizeHomeserver(homeserver); let issuer = null; + let account = null; const wellKnownResponse = await getWellKnownResponse(homeserver, request); if (wellKnownResponse && wellKnownResponse.status === 200) { const {body} = wellKnownResponse; @@ -54,6 +55,11 @@ export async function lookupHomeserver(homeserver, request) { if (typeof wellKnownIssuer === "string") { issuer = wellKnownIssuer; } + + const wellKnownAccount = body["org.matrix.msc2965.authentication"]?.["account"]; + if (typeof wellKnownAccount === "string") { + account = wellKnownAccount; + } } - return {homeserver, issuer}; + return {homeserver, issuer, account}; } diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index aea1108af0..11164643f0 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -48,6 +48,13 @@ export class SettingsView extends TemplateView { disabled: vm => vm.isLoggingOut }, vm.i18n`Log out`)), ); + + settingNodes.push( + t.if(vm => vm.accountManagementUrl, t => { + return t.p([vm.i18n`You can manage your account `, t.a({href: vm.accountManagementUrl, target: "_blank"}, vm.i18n`here`), "."]); + }), + ); + settingNodes.push( t.h3("Key backup & security"), t.view(new KeyBackupSettingsView(vm.keyBackupViewModel)) From 7dc30c4c759602f20598311be207ee17719a8bc7 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 3 Aug 2022 17:18:30 +0100 Subject: [PATCH 27/58] Use unstable OIDC scope names --- src/domain/login/StartOIDCLoginViewModel.js | 2 +- src/matrix/net/OidcApi.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 07cae075b6..b6a171fa57 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -59,7 +59,7 @@ export class StartOIDCLoginViewModel extends ViewModel { async startOIDCLogin() { const deviceScope = this._api.generateDeviceScope(); const p = this._api.generateParams({ - scope: `openid urn:matrix:api:* ${deviceScope}`, + scope: `openid urn:matrix:org.matrix.msc2967.client:api:* ${deviceScope}`, redirectUri: this.urlCreator.createOIDCRedirectURL(), }); const clientId = await this._api.clientId(); diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 103aae82b3..cb57c4abbd 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -233,7 +233,7 @@ export class OidcApi { generateDeviceScope(): String { const deviceId = randomString(10); - return `urn:matrix:device:${deviceId}`; + return `urn:matrix:org.matrix.msc2967.client:device:${deviceId}`; } generateParams({ scope, redirectUri }: { scope: string, redirectUri: string }): AuthorizationParams { From 6de66f722610dd995c3709a45835f2ac401fde61 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:26:02 +0100 Subject: [PATCH 28/58] Multi-arch capable Dockerfile --- Dockerfile | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index e85fdf75c7..948edd72fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,16 @@ -FROM docker.io/node:alpine as builder +FROM --platform=${BUILDPLATFORM} docker.io/library/node:16.13-alpine3.15 as builder RUN apk add --no-cache git python3 build-base -COPY . /app WORKDIR /app -RUN yarn install \ - && yarn build -# Copy the built app from the first build stage +# Install the dependencies first +COPY yarn.lock package.json ./ +RUN yarn install + +# Copy the rest and build the app +COPY . . +RUN yarn build + +FROM --platform=${TARGETPLATFORM} docker.io/library/nginx:alpine COPY --from=builder /app/target /usr/share/nginx/html # Values from the default config that can be overridden at runtime From 9b159a76c319fca5717f4b218b00bdbac52ea7ba Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:33:10 +0100 Subject: [PATCH 29/58] Use non-root nginx base in Docker image --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 948edd72fe..a8c7a26032 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ RUN yarn install COPY . . RUN yarn build -FROM --platform=${TARGETPLATFORM} docker.io/library/nginx:alpine +FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:1.21-alpine COPY --from=builder /app/target /usr/share/nginx/html # Values from the default config that can be overridden at runtime From 9fd7f25e8ac37a80ab57e628c139b6642383ef55 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:54:01 +0100 Subject: [PATCH 30/58] Build and push multi-arch Docker images in CI --- .github/workflows/docker-publish.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c37e3141c1..dca01b88d7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -29,6 +29,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Log into registry ${{ env.REGISTRY }} uses: docker/login-action@v2 with: From c8bff1059c46ca5e57014e9176e535ae0712ecb7 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 16:02:53 +0100 Subject: [PATCH 31/58] Update the documentation to reference the published docker image --- doc/docker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/docker.md b/doc/docker.md index 41632909c1..e240da9286 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -41,7 +41,7 @@ export DOCKER_BUILDKIT=1 docker build -t hydrogen . ``` -Or, pull the docker image from GitHub Container Registry: +Or, pull the Docker image from the GitHub Container Registry: ``` docker pull ghcr.io/vector-im/hydrogen-web From 49d1547e78cc7658447161ff5299b221bcca1945 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:49:58 +0100 Subject: [PATCH 32/58] Make the Docker image configurable at runtime --- Dockerfile | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Dockerfile b/Dockerfile index a8c7a26032..d4faba7e2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,17 @@ RUN yarn install COPY . . RUN yarn build +# Remove the default config, replace it with a symlink to somewhere that will be updated at runtime +RUN rm -f target/config.json \ + && ln -sf /tmp/config.json target/config.json + FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:1.21-alpine + +# Copy the config template as well as the templating script +COPY ./docker/config.json.tmpl /config.json.tmpl +COPY ./docker/config-template.sh /docker-entrypoint.d/99-config-template.sh + +# Copy the built app from the first build stage COPY --from=builder /app/target /usr/share/nginx/html # Values from the default config that can be overridden at runtime From 17875e42df69fa61dcab9fe78bd28e4597e9907e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 09:25:58 +0100 Subject: [PATCH 33/58] Native OIDC login --- src/domain/login/LoginViewModel.ts | 52 ++++++++++++++++++++---------- src/domain/navigation/index.ts | 6 ++-- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index f250a9ad2d..afecd9fafb 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -15,6 +15,7 @@ limitations under the License. */ import {Client} from "../../matrix/Client.js"; +import {OidcApi} from "../../matrix/net/OidcApi.js"; import {Options as BaseOptions, ViewModel} from "../ViewModel"; import {PasswordLoginViewModel} from "./PasswordLoginViewModel"; import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel"; @@ -26,10 +27,12 @@ import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; import {SegmentType} from "../navigation/index"; import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login"; +import { OIDCLoginMethod } from "../../matrix/login/OIDCLoginMethod.js"; type Options = { defaultHomeserver: string; ready: ReadyFn; + oidc?: { state: string, code: string }; loginToken?: string; } & BaseOptions; @@ -37,6 +40,7 @@ export class LoginViewModel extends ViewModel { private _ready: ReadyFn; private _loginToken?: string; private _client: Client; + private _oidc?: { state: string, code: string }; private _loginOptions?: LoginOptions; private _passwordLoginViewModel?: PasswordLoginViewModel; private _startSSOLoginViewModel?: StartSSOLoginViewModel; @@ -77,8 +81,13 @@ export class LoginViewModel extends ViewModel { return this._completeSSOLoginViewModel; } - get startOIDCLoginViewModel() { return this._startOIDCLoginViewModel; } - get completeOIDCLoginViewModel() { return this._completeOIDCLoginViewModel; } + get startOIDCLoginViewModel(): StartOIDCLoginViewModel { + return this._startOIDCLoginViewModel; + } + + get completeOIDCLoginViewModel(): CompleteOIDCLoginViewModel { + return this._completeOIDCLoginViewModel; + } get homeserver(): string { return this._homeserver; @@ -129,8 +138,8 @@ export class LoginViewModel extends ViewModel { this._completeOIDCLoginViewModel = this.track(new CompleteOIDCLoginViewModel( this.childOptions( { - sessionContainer: this._sessionContainer, - attemptLogin: loginMethod => this.attemptLogin(loginMethod), + client: this._client, + attemptLogin: (loginMethod: OIDCLoginMethod) => this.attemptLogin(loginMethod), state: this._oidc.state, code: this._oidc.code, }))); @@ -157,24 +166,32 @@ export class LoginViewModel extends ViewModel { this.emitChange("startSSOLoginViewModel"); } - private _showError(message: string): void { - this._errorMessage = message; - this.emitChange("errorMessage"); - } - - async _showOIDCLogin() { + private async _showOIDCLogin(): Promise { this._startOIDCLoginViewModel = this.track( new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); + await this._startOIDCLoginViewModel.start(); this.emitChange("startOIDCLoginViewModel"); - try { - await this._startOIDCLoginViewModel.discover(); - } catch (err) { - this._showError(err.message); - this._disposeViewModels(); - } } + private _showError(message: string): void { + this._errorMessage = message; + this.emitChange("errorMessage"); + } + + // async _showOIDCLogin() { + // this._startOIDCLoginViewModel = this.track( + // new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) + // ); + // this.emitChange("startOIDCLoginViewModel"); + // try { + // await this._startOIDCLoginViewModel.discover(); + // } catch (err) { + // this._showError(err.message); + // this._disposeViewModels(); + // } + // } + private _setBusy(status: boolean): void { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); @@ -298,7 +315,7 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions) { if (this._loginOptions.sso) { this._showSSOLogin(); } if (this._loginOptions.password) { this._showPasswordLogin(); } - if (this._loginOptions.oidc) { this._showOIDCLogin(); } + if (this._loginOptions.oidc) { await this._showOIDCLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) { this._showError("This homeserver supports neither SSO nor password based login flows or has a usable OIDC Provider"); } @@ -325,5 +342,6 @@ export type LoginOptions = { homeserver: string; password?: (username: string, password: string) => PasswordLoginMethod; sso?: SSOLoginHelper; + oidc?: { issuer: string }; token?: (loginToken: string) => TokenLoginMethod; }; diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index d16fdd0672..58cc7fcbe5 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -34,6 +34,8 @@ export type SegmentType = { "details": true; "members": true; "member": string; + "oidc-callback": (string | null)[]; + "oidc-error": (string | null)[]; }; export function createNavigation(): Navigation { @@ -153,9 +155,9 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path, } // substring(1) to take of initial / - const parts = urlPath.substr(1).split("/"); + const parts = urlPath.substring(1).split("/"); const iterator = parts[Symbol.iterator](); - let next; + let next; while (!(next = iterator.next()).done) { const type = next.value; if (type === "rooms") { From 37e9727b188a40a8bded6ee620a0488bfe9fc501 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 13:43:16 +0100 Subject: [PATCH 34/58] Only generate the auth URL and start the login flow on click --- src/domain/login/LoginViewModel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index afecd9fafb..88711602fe 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -170,8 +170,8 @@ export class LoginViewModel extends ViewModel { this._startOIDCLoginViewModel = this.track( new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); - await this._startOIDCLoginViewModel.start(); this.emitChange("startOIDCLoginViewModel"); + this._startOIDCLoginViewModel.discover(); } private _showError(message: string): void { @@ -315,7 +315,7 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions) { if (this._loginOptions.sso) { this._showSSOLogin(); } if (this._loginOptions.password) { this._showPasswordLogin(); } - if (this._loginOptions.oidc) { await this._showOIDCLogin(); } + if (this._loginOptions.oidc) { this._showOIDCLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) { this._showError("This homeserver supports neither SSO nor password based login flows or has a usable OIDC Provider"); } From 1ead9bcddc0b7bf5517947011c5bc2a8646d1d9c Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:22:06 +0100 Subject: [PATCH 35/58] Improve error handling for OIDC discovery and registration --- src/domain/login/LoginViewModel.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 88711602fe..f89d29be9a 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -171,7 +171,12 @@ export class LoginViewModel extends ViewModel { new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); this.emitChange("startOIDCLoginViewModel"); - this._startOIDCLoginViewModel.discover(); + try { + await this._startOIDCLoginViewModel.discover(); + } catch (err) { + this._showError(err.message); + this._disposeViewModels(); + } } private _showError(message: string): void { From f177a94d4ddc30113a18cbaa85f80daf10484139 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Sun, 31 Jul 2022 10:06:01 +0100 Subject: [PATCH 36/58] Actually make SessionLoadViewModel.logout do something --- src/domain/SessionLoadViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index 6a63145f4a..bae04191f9 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -154,7 +154,7 @@ export class SessionLoadViewModel extends ViewModel { } async logout() { - await this._client.startLogout(this.navigation.path.get("session").value); + await this._client.startLogout(this.navigation.path.get("session")?.value); this.navigation.push("session", true); } From a4c16e5db32f99df1acb579615f1d3a4bc176876 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 1 Aug 2022 16:23:20 +0200 Subject: [PATCH 37/58] Fix typing and tests --- src/domain/navigation/URLRouter.ts | 3 +++ src/domain/navigation/index.ts | 38 ++++++++++++++++++++---------- src/matrix/net/OidcApi.ts | 7 +++--- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index 1fccb476be..872c9f7584 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -32,6 +32,9 @@ export interface IURLRouter { urlForPath(path: Path): string; openRoomActionUrl(roomId: string): string; createSSOCallbackURL(): string; + createOIDCRedirectURL(): string; + absoluteAppUrl(): string; + absoluteUrlForAsset(asset: string): string; normalizeUrl(): void; } diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 58cc7fcbe5..0cdbe805ed 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -34,8 +34,16 @@ export type SegmentType = { "details": true; "members": true; "member": string; - "oidc-callback": (string | null)[]; - "oidc-error": (string | null)[]; + "oidc": { + state: string, + } & + ({ + code: string, + } | { + error: string, + errorDescription: string | null, + errorUri: string | null , + }); }; export function createNavigation(): Navigation { @@ -134,18 +142,21 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path, // Special case for OIDC callback if (urlPath.includes("state")) { const params = new URLSearchParams(urlPath); - if (params.has("state")) { + const state = params.get("state"); + const code = params.get("code"); + const error = params.get("error"); + if (state) { // This is a proper OIDC callback - if (params.has("code")) { + if (code) { segments.push(new Segment("oidc", { - state: params.get("state"), - code: params.get("code"), + state, + code, })); return segments; - } else if (params.has("error")) { + } else if (error) { segments.push(new Segment("oidc", { - state: params.get("state"), - error: params.get("error"), + state, + error, errorDescription: params.get("error_description"), errorUri: params.get("error_uri"), })); @@ -537,19 +548,22 @@ export function tests() { assert.equal(newPath?.segments[1].value, "b"); }, "Parse OIDC callback": assert => { - const segments = parseUrlPath("state=tc9CnLU7&code=cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"); + const path = createEmptyPath(); + const segments = parseUrlPath("state=tc9CnLU7&code=cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "oidc"); assert.deepEqual(segments[0].value, {state: "tc9CnLU7", code: "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"}); }, "Parse OIDC error": assert => { - const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request"); + const path = createEmptyPath(); + const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "oidc"); assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorUri: null, errorDescription: null}); }, "Parse OIDC error with description": assert => { - const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request&error_description=Unsupported%20response_type%20value"); + const path = createEmptyPath(); + const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request&error_description=Unsupported%20response_type%20value", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "oidc"); assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorDescription: "Unsupported response_type value", errorUri: null}); diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index cb57c4abbd..ca2e273993 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -15,7 +15,8 @@ limitations under the License. */ import type {RequestFunction} from "../../platform/types/types"; -import type {URLRouter} from "../../domain/navigation/URLRouter.js"; +import type {IURLRouter} from "../../domain/navigation/URLRouter.js"; +import type {SegmentType} from "../../domain/navigation"; const WELL_KNOWN = ".well-known/openid-configuration"; @@ -69,12 +70,12 @@ const clientIds: Record = { }, }; -export class OidcApi { +export class OidcApi { _issuer: string; _requestFn: RequestFunction; _encoding: any; _crypto: any; - _urlCreator: URLRouter; + _urlCreator: IURLRouter; _metadataPromise: Promise; _registrationPromise: Promise; From 896f2b7e67abd415bedffaee498aca622a5c5e43 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 1 Aug 2022 17:01:25 +0200 Subject: [PATCH 38/58] Fix the runtime config template to include the default theme --- docker/config.json.tmpl | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docker/config.json.tmpl b/docker/config.json.tmpl index 94295c43dd..48ecef0161 100644 --- a/docker/config.json.tmpl +++ b/docker/config.json.tmpl @@ -4,5 +4,13 @@ "gatewayUrl": "$PUSH_GATEWAY_URL", "applicationServerKey": "$PUSH_APPLICATION_SERVER_KEY" }, - "defaultHomeServer": "$DEFAULT_HOMESERVER" + "defaultHomeServer": "$DEFAULT_HOMESERVER", + "bugReportEndpointUrl": "https://element.io/bugreports/submit", + "themeManifests": [ + "assets/theme-element.json" + ], + "defaultTheme": { + "light": "element-light", + "dark": "element-dark" + } } From 9ce9e2d1922212772fb62d740f9f07e054ec6731 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 11 Aug 2022 17:48:35 +0100 Subject: [PATCH 39/58] Add static client for thirdroom --- src/matrix/net/OidcApi.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index ca2e273993..8b43bac719 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -68,6 +68,9 @@ const clientIds: Record = { "https://keycloak-oidc.lab.element.dev/realms/master/": { client_id: "hydrogen-oidc-playground" }, + "https://id.thirdroom.io/realms/thirdroom/": { + client_id: "hydrogen-oidc-playground" + }, }; export class OidcApi { From a2370da1497e371d26f908a569e3ae5068a2e8e8 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 22 Aug 2022 15:35:46 +0200 Subject: [PATCH 40/58] Also build Docker images for the OIDC-login branch --- .github/workflows/docker-publish.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index dca01b88d7..674060441d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,9 @@ name: Container Image on: push: - branches: [ master ] + branches: + - master + - sandhose/oidc-login tags: [ 'v*' ] pull_request: branches: [ master ] From 7c40c7cf138cdb25c7744618f5399bc929253e31 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 22 Aug 2022 15:41:45 +0200 Subject: [PATCH 41/58] Also publish sha-* tags to GHCR --- .github/workflows/docker-publish.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 674060441d..08832129c1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -46,6 +46,12 @@ jobs: uses: docker/metadata-action@v4 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha - name: Build and push Docker image uses: docker/build-push-action@v3 From b4ff736c9735a157fb9641ad9e5c2e0645d9b569 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 11 Jan 2023 19:38:44 +0000 Subject: [PATCH 42/58] Manual revert of docker related changes --- .github/workflows/docker-publish.yml | 14 +------------ Dockerfile | 31 +++++----------------------- doc/docker.md | 4 +--- docker/config-template.sh | 7 ------- docker/config.json.tmpl | 16 -------------- 5 files changed, 7 insertions(+), 65 deletions(-) delete mode 100755 docker/config-template.sh delete mode 100644 docker/config.json.tmpl diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 08832129c1..950623723d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,9 +2,7 @@ name: Container Image on: push: - branches: - - master - - sandhose/oidc-login + branches: [ master ] tags: [ 'v*' ] pull_request: branches: [ master ] @@ -31,9 +29,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: Log into registry ${{ env.REGISTRY }} uses: docker/login-action@v2 with: @@ -46,17 +41,10 @@ jobs: uses: docker/metadata-action@v4 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha - name: Build and push Docker image uses: docker/build-push-action@v3 with: - platforms: linux/amd64,linux/arm64,linux/arm/v7 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index d4faba7e2a..f9e323130e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,9 @@ -FROM --platform=${BUILDPLATFORM} docker.io/library/node:16.13-alpine3.15 as builder +FROM docker.io/node:alpine as builder RUN apk add --no-cache git python3 build-base +COPY . /app WORKDIR /app +RUN yarn install \ + && yarn build -# Install the dependencies first -COPY yarn.lock package.json ./ -RUN yarn install - -# Copy the rest and build the app -COPY . . -RUN yarn build - -# Remove the default config, replace it with a symlink to somewhere that will be updated at runtime -RUN rm -f target/config.json \ - && ln -sf /tmp/config.json target/config.json - -FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:1.21-alpine - -# Copy the config template as well as the templating script -COPY ./docker/config.json.tmpl /config.json.tmpl -COPY ./docker/config-template.sh /docker-entrypoint.d/99-config-template.sh - -# Copy the built app from the first build stage +FROM docker.io/nginx:alpine COPY --from=builder /app/target /usr/share/nginx/html - -# Values from the default config that can be overridden at runtime -ENV PUSH_APP_ID="io.element.hydrogen.web" \ - PUSH_GATEWAY_URL="https://matrix.org" \ - PUSH_APPLICATION_SERVER_KEY="BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" \ - DEFAULT_HOMESERVER="matrix.org" diff --git a/doc/docker.md b/doc/docker.md index e240da9286..6030d11244 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -35,9 +35,7 @@ To stop the container, simply hit `ctrl+c`. In this repository, create a Docker image: -```sh -# Enable BuildKit https://docs.docker.com/develop/develop-images/build_enhancements/ -export DOCKER_BUILDKIT=1 +``` docker build -t hydrogen . ``` diff --git a/docker/config-template.sh b/docker/config-template.sh deleted file mode 100755 index f6cff00c1d..0000000000 --- a/docker/config-template.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -set -eux - -envsubst '$PUSH_APP_ID,$PUSH_GATEWAY_URL,$PUSH_APPLICATION_SERVER_KEY,$DEFAULT_HOMESERVER' \ - < /config.json.tmpl \ - > /tmp/config.json diff --git a/docker/config.json.tmpl b/docker/config.json.tmpl deleted file mode 100644 index 48ecef0161..0000000000 --- a/docker/config.json.tmpl +++ /dev/null @@ -1,16 +0,0 @@ -{ - "push": { - "appId": "$PUSH_APP_ID", - "gatewayUrl": "$PUSH_GATEWAY_URL", - "applicationServerKey": "$PUSH_APPLICATION_SERVER_KEY" - }, - "defaultHomeServer": "$DEFAULT_HOMESERVER", - "bugReportEndpointUrl": "https://element.io/bugreports/submit", - "themeManifests": [ - "assets/theme-element.json" - ], - "defaultTheme": { - "light": "element-light", - "dark": "element-dark" - } -} From 13a4299698c8de979cc2aa42e2d52c143df2d826 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 18 Jan 2023 18:21:21 +0000 Subject: [PATCH 43/58] Fix up merge --- src/domain/RootViewModel.js | 2 +- src/domain/login/StartOIDCLoginViewModel.js | 4 +- src/matrix/Client.js | 69 ++++++++++++++------- src/matrix/net/OidcApi.ts | 12 ++-- 4 files changed, 54 insertions(+), 33 deletions(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 9edba2e0de..aabc70beb3 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -93,7 +93,7 @@ export class RootViewModel extends ViewModel { if (oidcCallback.error) { this._setSection(() => this._error = new Error(`OIDC error: ${oidcCallback.error}`)); } else { - this.urlCreator.normalizeUrl(); + this.urlRouter.normalizeUrl(); if (this.activeSection !== "login") { this._showLogin({ oidc: oidcCallback, diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index b6a171fa57..4dc47efd3e 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -29,7 +29,7 @@ export class StartOIDCLoginViewModel extends ViewModel { request: this.platform.request, encoding: this.platform.encoding, crypto: this.platform.crypto, - urlCreator: this.urlCreator, + urlRouter: this.urlRouter, }); } @@ -60,7 +60,7 @@ export class StartOIDCLoginViewModel extends ViewModel { const deviceScope = this._api.generateDeviceScope(); const p = this._api.generateParams({ scope: `openid urn:matrix:org.matrix.msc2967.client:api:* ${deviceScope}`, - redirectUri: this.urlCreator.createOIDCRedirectURL(), + redirectUri: this.urlRouter.createOIDCRedirectURL(), }); const clientId = await this._api.clientId(); await Promise.all([ diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 6c9aff4ce1..9ad5694c3c 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -205,7 +205,7 @@ export class Client { } if (loginData.expires_in) { - sessionInfo.accessTokenExpiresAt = clock.now() + loginData.expires_in * 1000; + sessionInfo.expiresIn = loginData.expires_in; } if (loginData.oidc_issuer) { @@ -213,8 +213,6 @@ export class Client { sessionInfo.oidcClientId = loginData.oidc_client_id; sessionInfo.accountManagementUrl = loginData.oidc_account_management_url; } - - log.set("id", sessionId); } catch (err) { this._error = err; if (err.name === "HomeServerError") { @@ -233,30 +231,53 @@ export class Client { } return; } - let dehydratedDevice; - if (inspectAccountSetup) { - dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo, log); - if (dehydratedDevice) { - sessionInfo.deviceId = dehydratedDevice.deviceId; - } - } - await this._platform.sessionInfoStorage.add(sessionInfo); - // loading the session can only lead to - // LoadStatus.Error in case of an error, - // so separate try/catch - try { - await this._loadSessionInfo(sessionInfo, dehydratedDevice, log); - log.set("status", this._status.get()); - } catch (err) { - log.catch(err); - // free olm Account that might be contained - dehydratedDevice?.dispose(); - this._error = err; - this._status.set(LoadStatus.Error); - } + await this._createSessionAfterAuth(sessionInfo, inspectAccountSetup, log); }); } + async _createSessionAfterAuth({deviceId, userId, accessToken, refreshToken, homeserver, expiresIn, oidcIssuer, oidcClientId, accountManagementUrl}, inspectAccountSetup, log) { + const id = this.createNewSessionId(); + const lastUsed = this._platform.clock.now(); + const sessionInfo = { + id, + deviceId, + userId, + homeServer: homeserver, // deprecate this over time + homeserver, + accessToken, + lastUsed, + refreshToken, + oidcIssuer, + oidcClientId, + accountManagementUrl, + }; + if (expiresIn) { + sessionInfo.accessTokenExpiresAt = lastUsed + expiresIn * 1000; + } + let dehydratedDevice; + if (inspectAccountSetup) { + dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo, log); + if (dehydratedDevice) { + sessionInfo.deviceId = dehydratedDevice.deviceId; + } + } + log.set("id", id); + await this._platform.sessionInfoStorage.add(sessionInfo); + // loading the session can only lead to + // LoadStatus.Error in case of an error, + // so separate try/catch + try { + await this._loadSessionInfo(sessionInfo, dehydratedDevice, log); + log.set("status", this._status.get()); + } catch (err) { + log.catch(err); + // free olm Account that might be contained + dehydratedDevice?.dispose(); + this._error = err; + this._status.set(LoadStatus.Error); + } + } + async _loadSessionInfo(sessionInfo, dehydratedDevice, log) { log.set("appVersion", this._platform.version); const clock = this._platform.clock; diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 8b43bac719..f34e543fd8 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -78,16 +78,16 @@ export class OidcApi { _requestFn: RequestFunction; _encoding: any; _crypto: any; - _urlCreator: IURLRouter; + _urlRouter: IURLRouter; _metadataPromise: Promise; _registrationPromise: Promise; - constructor({ issuer, request, encoding, crypto, urlCreator, clientId }) { + constructor({ issuer, request, encoding, crypto, urlRouter, clientId }) { this._issuer = issuer; this._requestFn = request; this._encoding = encoding; this._crypto = crypto; - this._urlCreator = urlCreator; + this._urlRouter = urlRouter; if (clientId) { this._registrationPromise = Promise.resolve({ client_id: clientId }); @@ -97,13 +97,13 @@ export class OidcApi { get clientMetadata() { return { client_name: "Hydrogen Web", - logo_uri: this._urlCreator.absoluteUrlForAsset("icon.png"), - client_uri: this._urlCreator.absoluteAppUrl(), + logo_uri: this._urlRouter.absoluteUrlForAsset("icon.png"), + client_uri: this._urlRouter.absoluteAppUrl(), tos_uri: "https://element.io/terms-of-service", policy_uri: "https://element.io/privacy", response_types: ["code"], grant_types: ["authorization_code", "refresh_token"], - redirect_uris: [this._urlCreator.createOIDCRedirectURL()], + redirect_uris: [this._urlRouter.createOIDCRedirectURL()], id_token_signed_response_alg: "RS256", token_endpoint_auth_method: "none", }; From 9c52fb9e3682ef3b1e8846a43dbd057e73f37bdb Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 18 Jan 2023 18:33:17 +0000 Subject: [PATCH 44/58] Never attempt to encode OIDC segments --- src/domain/navigation/index.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 0cdbe805ed..7928ead80a 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -239,6 +239,10 @@ export function stringifyPath(path: Path): string { let urlPath = ""; let prevSegment: Segment | undefined; for (const segment of path.segments) { + if (segment.type === "oidc-callback" || segment.type === "oidc-error") { + // Do not put these segments in URL + continue; + } const encodedSegmentValue = encodeSegmentValue(segment.value); switch (segment.type) { case "rooms": @@ -257,10 +261,6 @@ export function stringifyPath(path: Path): string { break; case "right-panel": case "sso": - case "oidc-callback": - case "oidc-error": - // Do not put these segments in URL - continue; default: urlPath += `/${segment.type}`; if (encodedSegmentValue) { @@ -272,7 +272,8 @@ export function stringifyPath(path: Path): string { return urlPath; } -function encodeSegmentValue(value: SegmentType[keyof SegmentType]): string { +// We exclude the OIDC segment types as they are never encoded +function encodeSegmentValue(value: Exclude): string { if (value === true) { // Nothing to encode for boolean return ""; From 317d97cd332dc83d15677d5646ae9bf19e6815fb Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 18 Jan 2023 18:41:07 +0000 Subject: [PATCH 45/58] FIx regression bug --- src/domain/navigation/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 7928ead80a..f401fa43af 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -261,6 +261,8 @@ export function stringifyPath(path: Path): string { break; case "right-panel": case "sso": + // Do not put these segments in URL + continue; default: urlPath += `/${segment.type}`; if (encodedSegmentValue) { From 1716a308753c2e0186148623d4c774e1004ec34c Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 20 Jan 2023 11:34:13 +0000 Subject: [PATCH 46/58] Put static OIDC client config into config file --- src/domain/login/StartOIDCLoginViewModel.js | 1 + src/matrix/Client.js | 1 + src/matrix/net/OidcApi.ts | 34 +++++++++------------ src/platform/types/config.ts | 10 ++++++ src/platform/web/assets/config.json | 13 +++++++- 5 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 4dc47efd3e..a88bb89c2f 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -30,6 +30,7 @@ export class StartOIDCLoginViewModel extends ViewModel { encoding: this.platform.encoding, crypto: this.platform.crypto, urlRouter: this.urlRouter, + staticClients: this.platform.config["staticOidcClients"], }); } diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 9ad5694c3c..59fb5903af 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -139,6 +139,7 @@ export class Client { request: this._platform.request, encoding: this._platform.encoding, crypto: this._platform.crypto, + staticClients: this._platform.config["staticOidcClients"], }); await oidcApi.validate(); diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index f34e543fd8..3d7a9a9067 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -15,7 +15,7 @@ limitations under the License. */ import type {RequestFunction} from "../../platform/types/types"; -import type {IURLRouter} from "../../domain/navigation/URLRouter.js"; +import type {IURLRouter} from "../../domain/navigation/URLRouter"; import type {SegmentType} from "../../domain/navigation"; const WELL_KNOWN = ".well-known/openid-configuration"; @@ -54,40 +54,34 @@ function assert(condition: any, message: string): asserts condition { } }; -type IssuerUri = string; -interface ClientConfig { +export type IssuerUri = string; + +export interface OidcClientConfig { client_id: string; - client_secret?: string; } -// These are statically configured OIDC client IDs for particular issuers: -const clientIds: Record = { - "https://dev-6525741.okta.com/": { - client_id: "0oa5x44w64wpNsxi45d7", - }, - "https://keycloak-oidc.lab.element.dev/realms/master/": { - client_id: "hydrogen-oidc-playground" - }, - "https://id.thirdroom.io/realms/thirdroom/": { - client_id: "hydrogen-oidc-playground" - }, -}; +export type StaticOidcClientsConfig = Record; export class OidcApi { - _issuer: string; + _issuer: IssuerUri; _requestFn: RequestFunction; _encoding: any; _crypto: any; _urlRouter: IURLRouter; _metadataPromise: Promise; _registrationPromise: Promise; + _staticClients: StaticOidcClientsConfig; - constructor({ issuer, request, encoding, crypto, urlRouter, clientId }) { + constructor({ issuer, request, encoding, crypto, urlRouter, clientId, staticClients = {} }: { issuer: IssuerUri, request: RequestFunction, encoding: any, crypto: any, urlRouter: IURLRouter, clientId?: string, staticClients?: StaticOidcClientsConfig}) { this._issuer = issuer; this._requestFn = request; this._encoding = encoding; this._crypto = crypto; this._urlRouter = urlRouter; + this._staticClients = staticClients; + + console.log(staticClients); + console.log(clientId); if (clientId) { this._registrationPromise = Promise.resolve({ client_id: clientId }); @@ -127,8 +121,8 @@ export class OidcApi { // use static client if available const authority = `${this.issuer}${this.issuer.endsWith('/') ? '' : '/'}`; - if (clientIds[authority]) { - return clientIds[authority]; + if (this._staticClients[authority]) { + return this._staticClients[authority]; } const headers = new Map(); diff --git a/src/platform/types/config.ts b/src/platform/types/config.ts index 8a5eabf217..a9c8c94deb 100644 --- a/src/platform/types/config.ts +++ b/src/platform/types/config.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { StaticOidcClientsConfig } from "../../matrix/net/OidcApi"; + export type Config = { /** * The default homeserver used by Hydrogen; auto filled in the login UI. @@ -61,4 +63,12 @@ export type Config = { // See pushkey in above link applicationServerKey: string; }; + + /** + * Configuration for OIDC issuers where a static client_id has been issued for the app. + * Otherwise dynamic client registration is attempted. + * The issuer URL must have a trailing `/`. + * OPTIONAL + */ + staticOidcClients?: StaticOidcClientsConfig; }; diff --git a/src/platform/web/assets/config.json b/src/platform/web/assets/config.json index fd46fcbc35..7767bcca47 100644 --- a/src/platform/web/assets/config.json +++ b/src/platform/web/assets/config.json @@ -5,5 +5,16 @@ "applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" }, "defaultHomeServer": "matrix.org", - "bugReportEndpointUrl": "https://element.io/bugreports/submit" + "bugReportEndpointUrl": "https://element.io/bugreports/submit", + "staticOidcClients": { + "https://dev-6525741.okta.com/": { + "client_id": "0oa5x44w64wpNsxi45d7" + }, + "https://keycloak-oidc.lab.element.dev/realms/master/": { + "client_id": "hydrogen-oidc-playground" + }, + "https://id.thirdroom.io/realms/thirdroom/": { + "client_id": "hydrogen-oidc-playground" + } + } } From 94352da86bda3c2ff2c4b3c1c1c01f8bd9bd8520 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 20 Jan 2023 11:45:50 +0000 Subject: [PATCH 47/58] Remove some debug logging --- src/matrix/net/OidcApi.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 3d7a9a9067..2f664ef0bf 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -80,9 +80,6 @@ export class OidcApi { this._urlRouter = urlRouter; this._staticClients = staticClients; - console.log(staticClients); - console.log(clientId); - if (clientId) { this._registrationPromise = Promise.resolve({ client_id: clientId }); } From f4b1d99ea1337f291b199cc72f8ff020c4d3a49f Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 20 Jan 2023 11:49:07 +0000 Subject: [PATCH 48/58] Reinstate building of OIDC branch docker images --- .github/workflows/docker-publish.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 950623723d..ed4cdd3836 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Container Image on: push: - branches: [ master ] + branches: [ master, sandhose/oidc-login ] # TODO: remove sandhose/oidc-login before merging tags: [ 'v*' ] pull_request: branches: [ master ] @@ -41,6 +41,12 @@ jobs: uses: docker/metadata-action@v4 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | # Override tags so that we can use the SHA versions + type=schedule + type=ref,event=branch + type=ref,event=tag + type=ref,event=pr + type=sha - name: Build and push Docker image uses: docker/build-push-action@v3 From 59b06a0773d0e9790f714741772659d3d1a206de Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 20 Jan 2023 14:15:12 +0000 Subject: [PATCH 49/58] Fix incorrect reference to OIDC segment type --- src/domain/navigation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index f401fa43af..72c551a080 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -239,7 +239,7 @@ export function stringifyPath(path: Path): string { let urlPath = ""; let prevSegment: Segment | undefined; for (const segment of path.segments) { - if (segment.type === "oidc-callback" || segment.type === "oidc-error") { + if (segment.type === "oidc") { // Do not put these segments in URL continue; } From 2739572403f36d6016ae9172175adf440f037371 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 20 Jan 2023 14:43:46 +0000 Subject: [PATCH 50/58] Offer guest mode login if advertised by OIDC Provider --- src/domain/login/LoginViewModel.ts | 25 +++++++++++++++++++-- src/domain/login/StartOIDCLoginViewModel.js | 3 ++- src/matrix/Client.js | 4 +++- src/matrix/net/OidcApi.ts | 5 +++++ src/platform/web/ui/css/login.css | 4 ++-- src/platform/web/ui/login/LoginView.js | 15 +++++++++++++ 6 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index f89d29be9a..2eaa83fe52 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -46,6 +46,7 @@ export class LoginViewModel extends ViewModel { private _startSSOLoginViewModel?: StartSSOLoginViewModel; private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel; private _startOIDCLoginViewModel?: StartOIDCLoginViewModel; + private _startOIDCGuestLoginViewModel?: StartOIDCLoginViewModel; private _completeOIDCLoginViewModel?: CompleteOIDCLoginViewModel; private _loadViewModel?: SessionLoadViewModel; private _loadViewModelSubscription?: () => void; @@ -85,6 +86,10 @@ export class LoginViewModel extends ViewModel { return this._startOIDCLoginViewModel; } + get startOIDCGuestLoginViewModel(): StartOIDCLoginViewModel { + return this._startOIDCGuestLoginViewModel; + } + get completeOIDCLoginViewModel(): CompleteOIDCLoginViewModel { return this._completeOIDCLoginViewModel; } @@ -168,7 +173,7 @@ export class LoginViewModel extends ViewModel { private async _showOIDCLogin(): Promise { this._startOIDCLoginViewModel = this.track( - new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) + new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions, asGuest: false})) ); this.emitChange("startOIDCLoginViewModel"); try { @@ -179,6 +184,19 @@ export class LoginViewModel extends ViewModel { } } + private async _showOIDCGuestLogin(): Promise { + this._startOIDCGuestLoginViewModel = this.track( + new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions, asGuest: true})) + ); + this.emitChange("startOIDCGuestLoginViewModel"); + try { + await this._startOIDCLoginViewModel.discover(); + } catch (err) { + this._showError(err.message); + this._disposeViewModels(); + } + } + private _showError(message: string): void { this._errorMessage = message; this.emitChange("errorMessage"); @@ -202,6 +220,7 @@ export class LoginViewModel extends ViewModel { this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); this._startOIDCLoginViewModel?.setBusy(status); + this._startOIDCGuestLoginViewModel?.setBusy(status); this.emitChange("isBusy"); } @@ -256,6 +275,7 @@ export class LoginViewModel extends ViewModel { this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); this._startOIDCLoginViewModel = this.disposeTracked(this._startOIDCLoginViewModel); + this._startOIDCGuestLoginViewModel = this.disposeTracked(this._startOIDCGuestLoginViewModel); this.emitChange("disposeViewModels"); } @@ -321,6 +341,7 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions.sso) { this._showSSOLogin(); } if (this._loginOptions.password) { this._showPasswordLogin(); } if (this._loginOptions.oidc) { this._showOIDCLogin(); } + if (this._loginOptions.oidc?.guestAvailable) { this._showOIDCGuestLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) { this._showError("This homeserver supports neither SSO nor password based login flows or has a usable OIDC Provider"); } @@ -347,6 +368,6 @@ export type LoginOptions = { homeserver: string; password?: (username: string, password: string) => PasswordLoginMethod; sso?: SSOLoginHelper; - oidc?: { issuer: string }; + oidc?: { issuer: string, guestAvailable: boolean }; token?: (loginToken: string) => TokenLoginMethod; }; diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index a88bb89c2f..a985888fae 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -32,6 +32,7 @@ export class StartOIDCLoginViewModel extends ViewModel { urlRouter: this.urlRouter, staticClients: this.platform.config["staticOidcClients"], }); + this._asGuest = options.asGuest; } get isBusy() { return this._isBusy; } @@ -60,7 +61,7 @@ export class StartOIDCLoginViewModel extends ViewModel { async startOIDCLogin() { const deviceScope = this._api.generateDeviceScope(); const p = this._api.generateParams({ - scope: `openid urn:matrix:org.matrix.msc2967.client:api:* ${deviceScope}`, + scope: `openid urn:matrix:org.matrix.msc2967.client:api:${this._asGuest ? 'guest' : '*'} ${deviceScope}`, redirectUri: this.urlRouter.createOIDCRedirectURL(), }); const clientId = await this._api.clientId(); diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 59fb5903af..9ab5a73b15 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -143,9 +143,11 @@ export class Client { }); await oidcApi.validate(); + const guestAvailable = await oidcApi.isGuestAvailable(); + return { homeserver, - oidc: { issuer, account }, + oidc: { issuer, account, guestAvailable }, }; } catch (e) { console.log(e); diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 2f664ef0bf..08219a2236 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -226,6 +226,11 @@ export class OidcApi { return metadata["revocation_endpoint"]; } + async isGuestAvailable(): Promise { + const metadata = await this.metadata(); + return metadata["scopes_supported"]?.includes("urn:matrix:org.matrix.msc2967.client:api:guest"); + } + generateDeviceScope(): String { const deviceId = randomString(10); return `urn:matrix:org.matrix.msc2967.client:device:${deviceId}`; diff --git a/src/platform/web/ui/css/login.css b/src/platform/web/ui/css/login.css index ae70624257..6d96098645 100644 --- a/src/platform/web/ui/css/login.css +++ b/src/platform/web/ui/css/login.css @@ -68,13 +68,13 @@ limitations under the License. --size: 20px; } -.StartSSOLoginView, .StartOIDCLoginView { +.StartSSOLoginView, .StartOIDCLoginView, .StartOIDCGuestLoginView { display: flex; flex-direction: column; padding: 0 0.4em 0; } -.StartSSOLoginView_button, .StartOIDCLoginView_button { +.StartSSOLoginView_button, .StartOIDCLoginView_button, .StartOIDCGuestLoginView_button { flex: 1; margin-top: 12px; } diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index b44de2e462..5a3542d8d3 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -58,6 +58,8 @@ export class LoginView extends TemplateView { t.if(vm => vm.passwordLoginViewModel && vm.startSSOLoginViewModel, t => t.p({className: "LoginView_separator"}, vm.i18n`or`)), t.mapView(vm => vm.startSSOLoginViewModel, vm => vm ? new StartSSOLoginView(vm) : null), t.mapView(vm => vm.startOIDCLoginViewModel, vm => vm ? new StartOIDCLoginView(vm) : null), + t.if(vm => vm.startOIDCLoginViewModel && vm.startOIDCGuestLoginViewModel, t => t.p({className: "LoginView_separator"}, vm.i18n`or`)), + t.mapView(vm => vm.startOIDCGuestLoginViewModel, vm => vm ? new StartOIDCGuestLoginView(vm) : null), t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), // use t.mapView rather than t.if to create a new view when the view model changes too t.p(hydrogenGithubLink(t)) @@ -90,3 +92,16 @@ class StartOIDCLoginView extends TemplateView { ); } } + +class StartOIDCGuestLoginView extends TemplateView { + render(t, vm) { + return t.div({ className: "StartOIDCGuestLoginView" }, + t.a({ + className: "StartOIDCGuestLoginView_button button-action primary", + type: "button", + onClick: () => vm.startOIDCLogin(), + disabled: vm => vm.isBusy + }, vm.i18n`Continue as Guest`) + ); + } +} \ No newline at end of file From da86db3f3f8a5b84402f12f58d90dfee27438763 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 20 Jan 2023 15:15:28 +0000 Subject: [PATCH 51/58] Show OIDC sign in errors more sensibly --- src/domain/RootViewModel.js | 14 +++++--------- src/domain/login/LoginViewModel.ts | 10 +++++++--- src/domain/navigation/index.ts | 4 ++++ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index aabc70beb3..291a3b01a6 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -90,15 +90,11 @@ export class RootViewModel extends ViewModel { this._showLogin({loginToken}); } } else if (oidcCallback) { - if (oidcCallback.error) { - this._setSection(() => this._error = new Error(`OIDC error: ${oidcCallback.error}`)); - } else { - this.urlRouter.normalizeUrl(); - if (this.activeSection !== "login") { - this._showLogin({ - oidc: oidcCallback, - }); - } + this.urlRouter.normalizeUrl(); + if (this.activeSection !== "login") { + this._showLogin({ + oidc: oidcCallback, + }); } } else { diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 2eaa83fe52..9cf9d97c66 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -32,7 +32,7 @@ import { OIDCLoginMethod } from "../../matrix/login/OIDCLoginMethod.js"; type Options = { defaultHomeserver: string; ready: ReadyFn; - oidc?: { state: string, code: string }; + oidc?: SegmentType["oidc"]; loginToken?: string; } & BaseOptions; @@ -40,7 +40,7 @@ export class LoginViewModel extends ViewModel { private _ready: ReadyFn; private _loginToken?: string; private _client: Client; - private _oidc?: { state: string, code: string }; + private _oidc?: SegmentType["oidc"]; private _loginOptions?: LoginOptions; private _passwordLoginViewModel?: PasswordLoginViewModel; private _startSSOLoginViewModel?: StartSSOLoginViewModel; @@ -138,7 +138,7 @@ export class LoginViewModel extends ViewModel { }))); this.emitChange("completeSSOLoginViewModel"); } - else if (this._oidc) { + else if (this._oidc?.success === true) { this._hideHomeserver = true; this._completeOIDCLoginViewModel = this.track(new CompleteOIDCLoginViewModel( this.childOptions( @@ -150,6 +150,10 @@ export class LoginViewModel extends ViewModel { }))); this.emitChange("completeOIDCLoginViewModel"); } + else if (this._oidc?.success === false) { + this._hideHomeserver = false; + this._showError(`Sign in failed: ${this._oidc.errorDescription ?? this._oidc.error} `); + } else { void this.queryHomeserver(); } diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 72c551a080..a6b28a47be 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -38,8 +38,10 @@ export type SegmentType = { state: string, } & ({ + success: true, code: string, } | { + success: false, error: string, errorDescription: string | null, errorUri: string | null , @@ -149,6 +151,7 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path, // This is a proper OIDC callback if (code) { segments.push(new Segment("oidc", { + success: true, state, code, })); @@ -156,6 +159,7 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path, } else if (error) { segments.push(new Segment("oidc", { state, + success: false, error, errorDescription: params.get("error_description"), errorUri: params.get("error_uri"), From 0aafd556982de7dec91ce658c89a2bb61942e6d7 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 20 Jan 2023 17:42:42 +0000 Subject: [PATCH 52/58] Support sync without filters for guest access --- src/matrix/Sync.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index d335336d29..4b590454cc 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -176,11 +176,17 @@ export class Sync { async _syncRequest(syncToken, timeout, log) { let {syncFilterId} = this._session; if (typeof syncFilterId !== "string") { - this._currentRequest = this._hsApi.createFilter(this._session.user.id, {room: {state: {lazy_load_members: true}}}, {log}); - syncFilterId = (await this._currentRequest.response()).filter_id; + try { + this._currentRequest = this._hsApi.createFilter(this._session.user.id, {room: {state: {lazy_load_members: true}}}, {log}); + syncFilterId = (await this._currentRequest.response()).filter_id; + } catch (err) { + // if the server doesn't support filters, we'll just have to do without + log.log('Sync filters aren\'t available, falling back to no filter'); + syncFilterId = "filteringNotAvailable"; + } } const totalRequestTimeout = timeout + (80 * 1000); // same as riot-web, don't get stuck on wedged long requests - this._currentRequest = this._hsApi.sync(syncToken, syncFilterId, timeout, {timeout: totalRequestTimeout, log}); + this._currentRequest = this._hsApi.sync(syncToken, syncFilterId === "filteringNotAvailable" ? undefined : syncFilterId, timeout, {timeout: totalRequestTimeout, log}); const response = await this._currentRequest.response(); const isInitialSync = !syncToken; From 6580fcf5bd4072072f58c5486800276bb031b24b Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 23 Jan 2023 10:36:50 +0000 Subject: [PATCH 53/58] Fix test cases --- src/domain/navigation/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index a6b28a47be..554a51cca1 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -559,21 +559,21 @@ export function tests() { const segments = parseUrlPath("state=tc9CnLU7&code=cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "oidc"); - assert.deepEqual(segments[0].value, {state: "tc9CnLU7", code: "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"}); + assert.deepEqual(segments[0].value, {state: "tc9CnLU7", code: "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx", success: true}); }, "Parse OIDC error": assert => { const path = createEmptyPath(); const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "oidc"); - assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorUri: null, errorDescription: null}); + assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorUri: null, errorDescription: null, success: false}); }, "Parse OIDC error with description": assert => { const path = createEmptyPath(); const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request&error_description=Unsupported%20response_type%20value", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "oidc"); - assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorDescription: "Unsupported response_type value", errorUri: null}); + assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorDescription: "Unsupported response_type value", errorUri: null, success: false}); }, } } From 7b7557ab82c4353dc53896eb42834150f0d61ea9 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 16 Feb 2023 18:25:21 +0000 Subject: [PATCH 54/58] Store id_token and fix up logout implementation --- src/domain/LogoutViewModel.ts | 2 +- src/domain/navigation/URLRouter.ts | 5 ++ src/matrix/Client.js | 60 +++++++++++++------ src/matrix/login/OIDCLoginMethod.ts | 4 +- src/matrix/net/OidcApi.ts | 49 ++++++++++++++- .../localstorage/SessionInfoStorage.ts | 1 + 6 files changed, 98 insertions(+), 23 deletions(-) diff --git a/src/domain/LogoutViewModel.ts b/src/domain/LogoutViewModel.ts index 49933f2130..f3b6d1f2b2 100644 --- a/src/domain/LogoutViewModel.ts +++ b/src/domain/LogoutViewModel.ts @@ -52,7 +52,7 @@ export class LogoutViewModel extends ViewModel { this.emitChange("busy"); try { const client = new Client(this.platform); - await client.startLogout(this._sessionId); + await client.startLogout(this._sessionId, this.urlRouter); this.navigation.push("session", true); } catch (err) { this._error = err; diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index 872c9f7584..ae3e004190 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -33,6 +33,7 @@ export interface IURLRouter { openRoomActionUrl(roomId: string): string; createSSOCallbackURL(): string; createOIDCRedirectURL(): string; + createOIDCPostLogoutRedirectURL(): string; absoluteAppUrl(): string; absoluteUrlForAsset(asset: string): string; normalizeUrl(): void; @@ -159,6 +160,10 @@ export class URLRouter implements IURLRou return window.location.origin; } + createOIDCPostLogoutRedirectURL(): string { + return window.location.origin; + } + absoluteAppUrl(): string { return window.location.origin; } diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 9ab5a73b15..8ca0acbc07 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -211,6 +211,10 @@ export class Client { sessionInfo.expiresIn = loginData.expires_in; } + if (loginData.id_token) { + sessionInfo.idToken = loginData.id_token; + } + if (loginData.oidc_issuer) { sessionInfo.oidcIssuer = loginData.oidc_issuer; sessionInfo.oidcClientId = loginData.oidc_client_id; @@ -238,7 +242,7 @@ export class Client { }); } - async _createSessionAfterAuth({deviceId, userId, accessToken, refreshToken, homeserver, expiresIn, oidcIssuer, oidcClientId, accountManagementUrl}, inspectAccountSetup, log) { + async _createSessionAfterAuth({deviceId, userId, accessToken, refreshToken, homeserver, expiresIn, idToken, oidcIssuer, oidcClientId, accountManagementUrl}, inspectAccountSetup, log) { const id = this.createNewSessionId(); const lastUsed = this._platform.clock.now(); const sessionInfo = { @@ -253,6 +257,7 @@ export class Client { oidcIssuer, oidcClientId, accountManagementUrl, + idToken, }; if (expiresIn) { sessionInfo.accessTokenExpiresAt = lastUsed + expiresIn * 1000; @@ -500,7 +505,7 @@ export class Client { return !this._reconnector; } - startLogout(sessionId) { + startLogout(sessionId, urlRouter) { return this._platform.logger.run("logout", async log => { this._sessionId = sessionId; log.set("id", this._sessionId); @@ -508,26 +513,43 @@ export class Client { if (!sessionInfo) { throw new Error(`Could not find session for id ${this._sessionId}`); } + let endSessionRedirectEndpoint; try { - const hsApi = new HomeServerApi({ - homeserver: sessionInfo.homeServer, - accessToken: sessionInfo.accessToken, - request: this._platform.request - }); - await hsApi.logout({log}).response(); - const oidcApi = new OidcApi({ - issuer: sessionInfo.oidcIssuer, - clientId: sessionInfo.oidcClientId, - request: this._platform.request, - encoding: this._platform.encoding, - crypto: this._platform.crypto, - }); - await oidcApi.revokeToken({ token: sessionInfo.accessToken, type: "access" }); - if (sessionInfo.refreshToken) { - await oidcApi.revokeToken({ token: sessionInfo.refreshToken, type: "refresh" }); + if (sessionInfo.oidcClientId) { + // OIDC logout + const oidcApi = new OidcApi({ + issuer: sessionInfo.oidcIssuer, + clientId: sessionInfo.oidcClientId, + request: this._platform.request, + encoding: this._platform.encoding, + crypto: this._platform.crypto, + urlRouter, + }); + await oidcApi.revokeToken({ token: sessionInfo.accessToken, type: "access" }); + if (sessionInfo.refreshToken) { + await oidcApi.revokeToken({ token: sessionInfo.refreshToken, type: "refresh" }); + } + endSessionRedirectEndpoint = await oidcApi.endSessionEndpoint({ + idTokenHint: sessionInfo.idToken, + logoutHint: sessionInfo.userId, + }) + } else { + // regular logout + const hsApi = new HomeServerApi({ + homeserver: sessionInfo.homeServer, + accessToken: sessionInfo.accessToken, + request: this._platform.request + }); + await hsApi.logout({log}).response(); } - } catch (err) {} + } catch (err) { + console.error(err); + } await this.deleteSession(log); + // OIDC might have given us a redirect URI to go to do tell the OP we are signing out + if (endSessionRedirectEndpoint) { + this._platform.openUrl(endSessionRedirectEndpoint); + } }); } diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts index e0e3f58ff7..2533737bf3 100644 --- a/src/matrix/login/OIDCLoginMethod.ts +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -55,7 +55,7 @@ export class OIDCLoginMethod implements ILoginMethod { } async login(hsApi: HomeServerApi, _deviceName: string, log: ILogItem): Promise> { - const { access_token, refresh_token, expires_in } = await this._oidcApi.completeAuthorizationCodeGrant({ + const { access_token, refresh_token, expires_in, id_token } = await this._oidcApi.completeAuthorizationCodeGrant({ code: this._code, codeVerifier: this._codeVerifier, redirectUri: this._redirectUri, @@ -72,6 +72,6 @@ export class OIDCLoginMethod implements ILoginMethod { const oidc_issuer = this._oidcApi.issuer; const oidc_client_id = await this._oidcApi.clientId(); - return { oidc_issuer, oidc_client_id, access_token, refresh_token, expires_in, user_id, device_id, oidc_account_management_url: this._accountManagementUrl }; + return { oidc_issuer, oidc_client_id, access_token, refresh_token, expires_in, id_token, user_id, device_id, oidc_account_management_url: this._accountManagementUrl }; } } diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 08219a2236..f066f91830 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -30,6 +30,7 @@ type BearerToken = { access_token: string, refresh_token?: string, expires_in?: number, + id_token?: string, } const isValidBearerToken = (t: any): t is BearerToken => @@ -48,6 +49,28 @@ type AuthorizationParams = { codeVerifier?: string, }; +/** + * @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html + */ +type LogoutParams = { + /** + * Maps to the `id_token_hint` parameter. + */ + idTokenHint?: string, + /** + * Maps to the `state` parameter. + */ + state?: string, + /** + * Maps to the `post_logout_redirect_uri` parameter. + */ + redirectUri?: string, + /** + * Maps to the `logout_hint` parameter. + */ + logoutHint?: string, +}; + function assert(condition: any, message: string): asserts condition { if (!condition) { throw new Error(`Assertion failed: ${message}`); @@ -97,6 +120,7 @@ export class OidcApi { redirect_uris: [this._urlRouter.createOIDCRedirectURL()], id_token_signed_response_alg: "RS256", token_endpoint_auth_method: "none", + post_logout_redirect_uris: [this._urlRouter.createOIDCPostLogoutRedirectURL()], }; } @@ -226,6 +250,30 @@ export class OidcApi { return metadata["revocation_endpoint"]; } + async endSessionEndpoint({idTokenHint, logoutHint, redirectUri, state}: LogoutParams): Promise { + const metadata = await this.metadata(); + const endpoint = metadata["end_session_endpoint"]; + if (!endpoint) { + return undefined; + } + if (!redirectUri) { + redirectUri = this._urlRouter.createOIDCPostLogoutRedirectURL(); + } + const url = new URL(endpoint); + url.searchParams.append("client_id", await this.clientId()); + url.searchParams.append("post_logout_redirect_uri", redirectUri); + if (idTokenHint) { + url.searchParams.append("id_token_hint", idTokenHint); + } + if (logoutHint) { + url.searchParams.append("logout_hint", logoutHint); + } + if (state) { + url.searchParams.append("state", state); + } + return url.href; + } + async isGuestAvailable(): Promise { const metadata = await this.metadata(); return metadata["scopes_supported"]?.includes("urn:matrix:org.matrix.msc2967.client:api:guest"); @@ -331,7 +379,6 @@ export class OidcApi { const req = this._requestFn(revocationEndpoint, { method: "POST", headers, - format: "json", body, }); diff --git a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts index 000879e8be..2a51383f04 100644 --- a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts +++ b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts @@ -26,6 +26,7 @@ interface ISessionInfo { oidcIssuer?: string; accountManagementUrl?: string; lastUsed: number; + idToken?: string; } // todo: this should probably be in platform/types? From 97b88612668ac24c4079c1326add637a46ea129b Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 17 Feb 2023 09:31:43 +0000 Subject: [PATCH 55/58] Account management section design changes Design matches https://github.com/matrix-org/matrix-react-sdk/pull/8681 --- src/platform/web/ui/session/settings/SettingsView.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 11164643f0..8f5b73ca95 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -51,7 +51,12 @@ export class SettingsView extends TemplateView { settingNodes.push( t.if(vm => vm.accountManagementUrl, t => { - return t.p([vm.i18n`You can manage your account `, t.a({href: vm.accountManagementUrl, target: "_blank"}, vm.i18n`here`), "."]); + const url = new URL(vm.accountManagementUrl); + return t.div([ + t.h3("Account"), + t.p([vm.i18n`Your account details are managed separately at `, t.code(url.hostname), "."]), + t.button({ onClick: () => window.open(vm.accountManagementUrl, '_blank') }, vm.i18n`Manage account`), + ]); }), ); From d6dff1dd9fd49e3e85a7202383036a993250833c Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 17 Feb 2023 09:48:22 +0000 Subject: [PATCH 56/58] Add missing param --- src/domain/SessionLoadViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index bae04191f9..7c885bf442 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -154,7 +154,7 @@ export class SessionLoadViewModel extends ViewModel { } async logout() { - await this._client.startLogout(this.navigation.path.get("session")?.value); + await this._client.startLogout(this.navigation.path.get("session")?.value, this.urlRouter); this.navigation.push("session", true); } From 6acc0ea3a5fc56dac01fa44005a473bc4451e914 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 17 Feb 2023 11:28:05 +0000 Subject: [PATCH 57/58] Revert docker changes to those in master --- Dockerfile | 27 ++++++++++++++++++++++----- doc/docker.md | 6 ++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index f9e323130e..3a430d6c78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,26 @@ -FROM docker.io/node:alpine as builder +FROM --platform=${BUILDPLATFORM} docker.io/node:alpine as builder RUN apk add --no-cache git python3 build-base -COPY . /app + WORKDIR /app -RUN yarn install \ - && yarn build -FROM docker.io/nginx:alpine +# Copy package.json and yarn.lock and install dependencies first to speed up subsequent builds +COPY package.json yarn.lock /app/ +RUN yarn install + +COPY . /app +RUN yarn build + +# Because we will be running as an unprivileged user, we need to make sure that the config file is writable +# So, we will copy the default config to the /tmp folder that will be writable at runtime +RUN mv -f target/config.json /config.json.bundled \ + && ln -sf /tmp/config.json target/config.json + +FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:alpine + +# Copy the dynamic config script +COPY ./docker/dynamic-config.sh /docker-entrypoint.d/99-dynamic-config.sh +# And the bundled config file +COPY --from=builder /config.json.bundled /config.json.bundled + +# Copy the built app from the first build stage COPY --from=builder /app/target /usr/share/nginx/html diff --git a/doc/docker.md b/doc/docker.md index 6030d11244..41632909c1 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -35,11 +35,13 @@ To stop the container, simply hit `ctrl+c`. In this repository, create a Docker image: -``` +```sh +# Enable BuildKit https://docs.docker.com/develop/develop-images/build_enhancements/ +export DOCKER_BUILDKIT=1 docker build -t hydrogen . ``` -Or, pull the Docker image from the GitHub Container Registry: +Or, pull the docker image from GitHub Container Registry: ``` docker pull ghcr.io/vector-im/hydrogen-web From bde85c9cee1637406d43e1f9b372833d313789db Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 17 Feb 2023 11:48:23 +0000 Subject: [PATCH 58/58] Remove unused code from rebase --- src/domain/login/LoginViewModel.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 9cf9d97c66..3515f50230 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -206,19 +206,6 @@ export class LoginViewModel extends ViewModel { this.emitChange("errorMessage"); } - // async _showOIDCLogin() { - // this._startOIDCLoginViewModel = this.track( - // new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) - // ); - // this.emitChange("startOIDCLoginViewModel"); - // try { - // await this._startOIDCLoginViewModel.discover(); - // } catch (err) { - // this._showError(err.message); - // this._disposeViewModels(); - // } - // } - private _setBusy(status: boolean): void { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status);