Skip to content

Commit

Permalink
Native OIDC login
Browse files Browse the repository at this point in the history
  • Loading branch information
sandhose committed Mar 3, 2022
1 parent c574cdd commit 50129e8
Show file tree
Hide file tree
Showing 16 changed files with 810 additions and 22 deletions.
24 changes: 21 additions & 3 deletions src/domain/RootViewModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export class RootViewModel extends ViewModel {
this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("sso").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);
}

Expand All @@ -46,6 +48,8 @@ export class RootViewModel extends ViewModel {
const logoutSessionId = this.navigation.path.get("logout")?.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();
Expand Down Expand Up @@ -77,7 +81,20 @@ export class RootViewModel extends ViewModel {
} else if (loginToken) {
this.urlCreator.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 {
Expand Down Expand Up @@ -109,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 @@ -125,7 +142,8 @@ export class RootViewModel extends ViewModel {
this._pendingClient = client;
this.navigation.push("session", client.sessionId);
},
loginToken
loginToken,
oidc,
}));
});
}
Expand Down
84 changes: 84 additions & 0 deletions src/domain/login/CompleteOIDCLoginViewModel.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
36 changes: 32 additions & 4 deletions src/domain/login/LoginViewModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,25 @@ import {ViewModel} from "../ViewModel";
import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js";
import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js";
import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js";
import {StartOIDCLoginViewModel} from "./StartOIDCLoginViewModel.js";
import {CompleteOIDCLoginViewModel} from "./CompleteOIDCLoginViewModel.js";
import {LoadStatus} from "../../matrix/Client.js";
import {SessionLoadViewModel} from "../SessionLoadViewModel.js";

export class LoginViewModel extends ViewModel {
constructor(options) {
super(options);
const {ready, defaultHomeserver, loginToken} = options;
const {ready, defaultHomeserver, loginToken, oidc} = options;
this._ready = ready;
this._loginToken = loginToken;
this._oidc = oidc;
this._client = new Client(this.platform);
this._loginOptions = null;
this._passwordLoginViewModel = null;
this._startSSOLoginViewModel = null;
this._completeSSOLoginViewModel = null;
this._startOIDCLoginViewModel = null;
this._completeOIDCLoginViewModel = null;
this._loadViewModel = null;
this._loadViewModelSubscription = null;
this._homeserver = defaultHomeserver;
Expand All @@ -47,12 +52,14 @@ export class LoginViewModel extends ViewModel {

get passwordLoginViewModel() { return this._passwordLoginViewModel; }
get startSSOLoginViewModel() { return this._startSSOLoginViewModel; }
get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; }
get completeSSOLoginViewModel() { return this._completeSSOLoginViewModel; }
get startOIDCLoginViewModel() { return this._startOIDCLoginViewModel; }
get completeOIDCLoginViewModel() { return this._completeOIDCLoginViewModel; }
get homeserver() { return this._homeserver; }
get resolvedHomeserver() { return this._loginOptions?.homeserver; }
get errorMessage() { return this._errorMessage; }
get showHomeserver() { return !this._hideHomeserver; }
get loadViewModel() {return this._loadViewModel; }
get loadViewModel() { return this._loadViewModel; }
get isBusy() { return this._isBusy; }
get isFetchingLoginOptions() { return !!this._abortQueryOperation; }

Expand All @@ -72,6 +79,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 {
await this.queryHomeserver();
}
Expand All @@ -93,6 +112,14 @@ export class LoginViewModel extends ViewModel {
this.emitChange("startSSOLoginViewModel");
}

async _showOIDCLogin() {
this._startOIDCLoginViewModel = this.track(
new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions}))
);
await this._startOIDCLoginViewModel.start();
this.emitChange("startOIDCLoginViewModel");
}

_showError(message) {
this._errorMessage = message;
this.emitChange("errorMessage");
Expand Down Expand Up @@ -219,7 +246,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");
}
}
Expand Down
55 changes: 55 additions & 0 deletions src/domain/login/StartOIDCLoginViewModel.js
Original file line number Diff line number Diff line change
@@ -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");
}
}
53 changes: 48 additions & 5 deletions src/domain/navigation/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function allowsChild(parent, child) {
switch (parent?.type) {
case undefined:
// allowed root segments
return type === "login" || type === "session" || type === "sso" || type === "logout";
return type === "login" || type === "session" || type === "sso" || type === "logout" || type === "oidc-callback" || type === "oidc-error";
case "session":
return type === "room" || type === "rooms" || type === "settings" || type === "create-room";
case "rooms":
Expand All @@ -39,7 +39,7 @@ function allowsChild(parent, child) {
case "room":
return type === "lightbox" || type === "right-panel";
case "right-panel":
return type === "details"|| type === "members" || type === "member";
return type === "details" || type === "members" || type === "member";
default:
return false;
}
Expand Down Expand Up @@ -105,11 +105,35 @@ export function addPanelIfNeeded(navigation, path) {
}

export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
const segments = [];

// 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;
}
}
}

// substr(1) to take of initial /
const parts = urlPath.substr(1).split("/");
const iterator = parts[Symbol.iterator]();
const segments = [];
let next;
let next;
while (!(next = iterator.next()).done) {
const type = next.value;
if (type === "rooms") {
Expand Down Expand Up @@ -191,6 +215,8 @@ export function stringifyPath(path) {
break;
case "right-panel":
case "sso":
case "oidc-callback":
case "oidc-error":
// Do not put these segments in URL
continue;
default:
Expand Down Expand Up @@ -454,6 +480,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]);
},
}
}
Loading

0 comments on commit 50129e8

Please sign in to comment.