Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft: OIDC native login #660

Open
wants to merge 58 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
a59e4bd
Multi-arch capable Dockerfile
sandhose Feb 2, 2022
5379b83
Build and push multi-arch Docker images in CI
sandhose Feb 2, 2022
24c5879
Make the Docker image configurable at runtime
sandhose Feb 2, 2022
2604e6a
Native OIDC login
sandhose Mar 3, 2022
b9bca9d
Only generate the auth URL and start the login flow on click
sandhose Mar 3, 2022
83684c8
Generate the OIDC redirect URI from the URLRouter
sandhose Mar 3, 2022
2ba8bc3
Simplify OIDC callback navigation handling
sandhose Mar 3, 2022
68daf51
Use platform APIs for text encoding and hashing
sandhose Mar 3, 2022
dd8cd31
Stop the token refresher when disposing the client
sandhose Mar 3, 2022
21cf845
Typo.
sandhose Mar 3, 2022
46e884b
OIDC dynamic client registration
sandhose Apr 25, 2022
4644004
Add client_uri, tos_uri and policy_uri client metadata
sandhose Apr 29, 2022
8da49df
Make hydrogen generate the device scope
sandhose Jul 4, 2022
f976430
Use unstable prefix for MSC2965 issuer discovery
hughns Jul 8, 2022
0a4822c
Rename OIDC login button to Continue
hughns Jul 8, 2022
06a2068
Request urn:matrix:api:* scope for OIDC
hughns Jul 25, 2022
f31f57e
Try to improve error message on no login method available
hughns Jul 25, 2022
786a082
fix: hide OIDC button when not in use
hughns Jul 25, 2022
7463145
Use primary styling for OIDC login button
hughns Jul 25, 2022
a44f13e
Handle case of OIDC Provider not returning supported_grant_types
hughns Jul 25, 2022
36050b1
Handle case of issuer field not ending with /
hughns Jul 25, 2022
f8dca77
Improve error handling for OIDC discovery and registration
hughns Jul 25, 2022
485e8a2
Ask OP to revoke tokens on logout
hughns Jul 25, 2022
1ea9eda
Support statically configured OIDC clients
hughns Jul 29, 2022
35bb265
Use valid length of code_verifier
hughns Jul 29, 2022
462b8b6
Link out to OIDC account management URL if available
hughns Jul 31, 2022
7dc30c4
Use unstable OIDC scope names
hughns Aug 3, 2022
6de66f7
Multi-arch capable Dockerfile
sandhose Feb 2, 2022
9b159a7
Use non-root nginx base in Docker image
sandhose Feb 2, 2022
9fd7f25
Build and push multi-arch Docker images in CI
sandhose Feb 2, 2022
c8bff10
Update the documentation to reference the published docker image
sandhose Feb 2, 2022
49d1547
Make the Docker image configurable at runtime
sandhose Feb 2, 2022
17875e4
Native OIDC login
sandhose Mar 3, 2022
37e9727
Only generate the auth URL and start the login flow on click
sandhose Mar 3, 2022
1ead9bc
Improve error handling for OIDC discovery and registration
hughns Jul 25, 2022
f177a94
Actually make SessionLoadViewModel.logout do something
hughns Jul 31, 2022
a4c16e5
Fix typing and tests
sandhose Aug 1, 2022
896f2b7
Fix the runtime config template to include the default theme
sandhose Aug 1, 2022
9ce9e2d
Add static client for thirdroom
hughns Aug 11, 2022
a2370da
Also build Docker images for the OIDC-login branch
sandhose Aug 22, 2022
7c40c7c
Also publish sha-* tags to GHCR
sandhose Aug 22, 2022
b4ff736
Manual revert of docker related changes
hughns Jan 11, 2023
13a4299
Fix up merge
hughns Jan 18, 2023
9c52fb9
Never attempt to encode OIDC segments
hughns Jan 18, 2023
317d97c
FIx regression bug
hughns Jan 18, 2023
1716a30
Put static OIDC client config into config file
hughns Jan 20, 2023
94352da
Remove some debug logging
hughns Jan 20, 2023
f4b1d99
Reinstate building of OIDC branch docker images
hughns Jan 20, 2023
59b06a0
Fix incorrect reference to OIDC segment type
hughns Jan 20, 2023
2739572
Offer guest mode login if advertised by OIDC Provider
hughns Jan 20, 2023
da86db3
Show OIDC sign in errors more sensibly
hughns Jan 20, 2023
0aafd55
Support sync without filters for guest access
hughns Jan 20, 2023
6580fcf
Fix test cases
hughns Jan 23, 2023
7b7557a
Store id_token and fix up logout implementation
hughns Feb 16, 2023
97b8861
Account management section design changes
hughns Feb 17, 2023
d6dff1d
Add missing param
hughns Feb 17, 2023
6acc0ea
Revert docker changes to those in master
hughns Feb 17, 2023
bde85c9
Remove unused code from rebase
hughns Feb 17, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Expand All @@ -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:
Expand All @@ -38,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
Expand Down
2 changes: 1 addition & 1 deletion src/domain/LogoutViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class LogoutViewModel extends ViewModel<SegmentType, Options> {
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;
Expand Down
16 changes: 13 additions & 3 deletions src/domain/RootViewModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +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").subscribe(() => this._applyNavigation()));
this._applyNavigation(true);
}

Expand All @@ -50,6 +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")?.value;
if (isLogin) {
if (this.activeSection !== "login") {
this._showLogin();
Expand Down Expand Up @@ -85,7 +87,14 @@ export class RootViewModel extends ViewModel {
} else if (loginToken) {
this.urlRouter.normalizeUrl();
if (this.activeSection !== "login") {
this._showLogin(loginToken);
this._showLogin({loginToken});
}
} else if (oidcCallback) {
this.urlRouter.normalizeUrl();
if (this.activeSection !== "login") {
this._showLogin({
oidc: oidcCallback,
});
}
}
else {
Expand Down Expand Up @@ -117,7 +126,7 @@ export class RootViewModel extends ViewModel {
}
}

_showLogin(loginToken) {
_showLogin({loginToken, oidc} = {}) {
this._setSection(() => {
this._loginViewModel = new LoginViewModel(this.childOptions({
defaultHomeserver: this.platform.config["defaultHomeServer"],
Expand All @@ -133,7 +142,8 @@ export class RootViewModel extends ViewModel {
this._pendingClient = client;
this.navigation.push("session", client.sessionId);
},
loginToken
loginToken,
oidc,
}));
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/domain/SessionLoadViewModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
89 changes: 89 additions & 0 deletions src/domain/login/CompleteOIDCLoginViewModel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
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._crypto = options.platform.crypto;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: In view models, there no need to store the properties of the options in member variables as the options are stored in the ViewModel base class. In this case, you can just do this.platform.crypto/encoding/request at any point in a view model.

this._state = state;
this._code = code;
this._attemptLogin = attemptLogin;
this._errorMessage = "";
this.performOIDCLoginCompletion();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally we try (there is some places where we sin though, like LoginViewModel) to not call async methods from the constructor (which can't be async itself), unless there is really no other way and we can be 100% sure the method wont throw.

Usually, we deal with this by adding an async start or init method that is called from the parent view model after creating the child view model.

}

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this comment still relevant?

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`),
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`),
this.platform.settingsStorage.getString(`oidc_${this._state}_account_management_url`),
]);

const oidcApi = new OidcApi({
issuer,
clientId,
request: this._request,
encoding: this._encoding,
crypto: this._crypto,
});
const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri, accountManagementUrl});
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);
}
}
}
79 changes: 75 additions & 4 deletions src/domain/login/LoginViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,39 @@ 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";
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";

import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login";
import { OIDCLoginMethod } from "../../matrix/login/OIDCLoginMethod.js";

type Options = {
defaultHomeserver: string;
ready: ReadyFn;
oidc?: SegmentType["oidc"];
loginToken?: string;
} & BaseOptions;

export class LoginViewModel extends ViewModel<SegmentType, Options> {
private _ready: ReadyFn;
private _loginToken?: string;
private _client: Client;
private _oidc?: SegmentType["oidc"];
private _loginOptions?: LoginOptions;
private _passwordLoginViewModel?: PasswordLoginViewModel;
private _startSSOLoginViewModel?: StartSSOLoginViewModel;
private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel;
private _startOIDCLoginViewModel?: StartOIDCLoginViewModel;
private _startOIDCGuestLoginViewModel?: StartOIDCLoginViewModel;
private _completeOIDCLoginViewModel?: CompleteOIDCLoginViewModel;
private _loadViewModel?: SessionLoadViewModel;
private _loadViewModelSubscription?: () => void;
private _homeserver: string;
Expand All @@ -52,10 +61,11 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {

constructor(options: Readonly<Options>) {
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();
}
Expand All @@ -72,6 +82,18 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
return this._completeSSOLoginViewModel;
}

get startOIDCLoginViewModel(): StartOIDCLoginViewModel {
return this._startOIDCLoginViewModel;
}

get startOIDCGuestLoginViewModel(): StartOIDCLoginViewModel {
return this._startOIDCGuestLoginViewModel;
}

get completeOIDCLoginViewModel(): CompleteOIDCLoginViewModel {
return this._completeOIDCLoginViewModel;
}

get homeserver(): string {
return this._homeserver;
}
Expand Down Expand Up @@ -116,6 +138,22 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
})));
this.emitChange("completeSSOLoginViewModel");
}
else if (this._oidc?.success === true) {
this._hideHomeserver = true;
this._completeOIDCLoginViewModel = this.track(new CompleteOIDCLoginViewModel(
this.childOptions(
{
client: this._client,
attemptLogin: (loginMethod: OIDCLoginMethod) => this.attemptLogin(loginMethod),
state: this._oidc.state,
code: this._oidc.code,
})));
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();
}
Expand All @@ -137,6 +175,32 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
this.emitChange("startSSOLoginViewModel");
}

private async _showOIDCLogin(): Promise<void> {
this._startOIDCLoginViewModel = this.track(
new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions, asGuest: false}))
);
this.emitChange("startOIDCLoginViewModel");
try {
await this._startOIDCLoginViewModel.discover();
} catch (err) {
this._showError(err.message);
this._disposeViewModels();
}
}

private async _showOIDCGuestLogin(): Promise<void> {
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");
Expand All @@ -146,6 +210,8 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
this._isBusy = status;
this._passwordLoginViewModel?.setBusy(status);
this._startSSOLoginViewModel?.setBusy(status);
this._startOIDCLoginViewModel?.setBusy(status);
this._startOIDCGuestLoginViewModel?.setBusy(status);
this.emitChange("isBusy");
}

Expand Down Expand Up @@ -199,6 +265,8 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
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._startOIDCGuestLoginViewModel = this.disposeTracked(this._startOIDCGuestLoginViewModel);
this.emitChange("disposeViewModels");
}

Expand Down Expand Up @@ -263,9 +331,11 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
if (this._loginOptions) {
if (this._loginOptions.sso) { this._showSSOLogin(); }
if (this._loginOptions.password) { this._showPasswordLogin(); }
if (!this._loginOptions.sso && !this._loginOptions.password) {
this._showError("This homeserver supports neither SSO nor password based login flows");
}
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");
}
}
else {
this._showError(`Could not query login methods supported by ${this.homeserver}`);
Expand All @@ -289,5 +359,6 @@ export type LoginOptions = {
homeserver: string;
password?: (username: string, password: string) => PasswordLoginMethod;
sso?: SSOLoginHelper;
oidc?: { issuer: string, guestAvailable: boolean };
token?: (loginToken: string) => TokenLoginMethod;
};
Loading