Merge pull request #453 from MidhunSureshR/sso-login

[SSO] - [PR 4] - SSO/Token login functionality
This commit is contained in:
Bruno Windels 2021-08-23 11:38:48 +00:00 committed by GitHub
commit 3b693c5b02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 808 additions and 182 deletions

View File

@ -1,84 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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 {ViewModel} from "./ViewModel.js";
import {SessionLoadViewModel} from "./SessionLoadViewModel.js";
export class LoginViewModel extends ViewModel {
constructor(options) {
super(options);
const {ready, defaultHomeServer, createSessionContainer} = options;
this._createSessionContainer = createSessionContainer;
this._ready = ready;
this._defaultHomeServer = defaultHomeServer;
this._sessionContainer = null;
this._loadViewModel = null;
this._loadViewModelSubscription = null;
}
get defaultHomeServer() { return this._defaultHomeServer; }
get loadViewModel() {return this._loadViewModel; }
get isBusy() {
if (!this._loadViewModel) {
return false;
} else {
return this._loadViewModel.loading;
}
}
async login(username, password, homeserver) {
this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription);
if (this._loadViewModel) {
this._loadViewModel = this.disposeTracked(this._loadViewModel);
}
this._loadViewModel = this.track(new SessionLoadViewModel(this.childOptions({
createAndStartSessionContainer: () => {
this._sessionContainer = this._createSessionContainer();
this._sessionContainer.startWithLogin(homeserver, username, password);
return this._sessionContainer;
},
ready: sessionContainer => {
// make sure we don't delete the session in dispose when navigating away
this._sessionContainer = null;
this._ready(sessionContainer);
},
homeserver,
})));
this._loadViewModel.start();
this.emitChange("loadViewModel");
this._loadViewModelSubscription = this.track(this._loadViewModel.disposableOn("change", () => {
if (!this._loadViewModel.loading) {
this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription);
}
this.emitChange("isBusy");
}));
}
get cancelUrl() {
return this.urlCreator.urlForSegment("session");
}
dispose() {
super.dispose();
if (this._sessionContainer) {
// if we move away before we're done with initial sync
// delete the session
this._sessionContainer.deleteSession();
}
}
}

View File

@ -16,7 +16,7 @@ limitations under the License.
import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionViewModel} from "./session/SessionViewModel.js";
import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js";
import {LoginViewModel} from "./LoginViewModel.js"; import {LoginViewModel} from "./login/LoginViewModel.js";
import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js";
import {ViewModel} from "./ViewModel.js"; import {ViewModel} from "./ViewModel.js";
@ -35,12 +35,14 @@ export class RootViewModel extends ViewModel {
async load() { async load() {
this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation()));
this._applyNavigation(true); this._applyNavigation(true);
} }
async _applyNavigation(shouldRestoreLastUrl) { async _applyNavigation(shouldRestoreLastUrl) {
const isLogin = this.navigation.observe("login").get(); const isLogin = this.navigation.path.get("login")
const sessionId = this.navigation.observe("session").get(); const sessionId = this.navigation.path.get("session")?.value;
const loginToken = this.navigation.path.get("sso")?.value;
if (isLogin) { if (isLogin) {
if (this.activeSection !== "login") { if (this.activeSection !== "login") {
this._showLogin(); this._showLogin();
@ -65,7 +67,13 @@ export class RootViewModel extends ViewModel {
this._showSessionLoader(sessionId); this._showSessionLoader(sessionId);
} }
} }
} else { } else if (loginToken) {
this.urlCreator.normalizeUrl();
if (this.activeSection !== "login") {
this._showLogin(loginToken);
}
}
else {
try { try {
if (!(shouldRestoreLastUrl && this.urlCreator.tryRestoreLastUrl())) { if (!(shouldRestoreLastUrl && this.urlCreator.tryRestoreLastUrl())) {
const sessionInfos = await this.platform.sessionInfoStorage.getAll(); const sessionInfos = await this.platform.sessionInfoStorage.getAll();
@ -94,7 +102,7 @@ export class RootViewModel extends ViewModel {
} }
} }
_showLogin() { _showLogin(loginToken) {
this._setSection(() => { this._setSection(() => {
this._loginViewModel = new LoginViewModel(this.childOptions({ this._loginViewModel = new LoginViewModel(this.childOptions({
defaultHomeServer: this.platform.config["defaultHomeServer"], defaultHomeServer: this.platform.config["defaultHomeServer"],
@ -111,6 +119,7 @@ export class RootViewModel extends ViewModel {
this._pendingSessionContainer = sessionContainer; this._pendingSessionContainer = sessionContainer;
this.navigation.push("session", sessionContainer.sessionId); this.navigation.push("session", sessionContainer.sessionId);
}, },
loginToken
})); }));
}); });
} }
@ -123,13 +132,11 @@ export class RootViewModel extends ViewModel {
} }
_showSessionLoader(sessionId) { _showSessionLoader(sessionId) {
this._setSection(() => {
this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({
createAndStartSessionContainer: () => {
const sessionContainer = this._createSessionContainer(); const sessionContainer = this._createSessionContainer();
sessionContainer.startWithExistingSession(sessionId); sessionContainer.startWithExistingSession(sessionId);
return sessionContainer; this._setSection(() => {
}, this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({
sessionContainer,
ready: sessionContainer => this._showSession(sessionContainer) ready: sessionContainer => this._showSession(sessionContainer)
})); }));
this._sessionLoadViewModel.start(); this._sessionLoadViewModel.start();

View File

@ -14,15 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {LoadStatus, LoginFailure} from "../matrix/SessionContainer.js"; import {LoadStatus} from "../matrix/SessionContainer.js";
import {SyncStatus} from "../matrix/Sync.js"; import {SyncStatus} from "../matrix/Sync.js";
import {ViewModel} from "./ViewModel.js"; import {ViewModel} from "./ViewModel.js";
export class SessionLoadViewModel extends ViewModel { export class SessionLoadViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {createAndStartSessionContainer, ready, homeserver, deleteSessionOnCancel} = options; const {sessionContainer, ready, homeserver, deleteSessionOnCancel} = options;
this._createAndStartSessionContainer = createAndStartSessionContainer; this._sessionContainer = sessionContainer;
this._ready = ready; this._ready = ready;
this._homeserver = homeserver; this._homeserver = homeserver;
this._deleteSessionOnCancel = deleteSessionOnCancel; this._deleteSessionOnCancel = deleteSessionOnCancel;
@ -38,7 +38,6 @@ export class SessionLoadViewModel extends ViewModel {
try { try {
this._loading = true; this._loading = true;
this.emitChange("loading"); this.emitChange("loading");
this._sessionContainer = this._createAndStartSessionContainer();
this._waitHandle = this._sessionContainer.loadStatus.waitFor(s => { this._waitHandle = this._sessionContainer.loadStatus.waitFor(s => {
this.emitChange("loadLabel"); this.emitChange("loadLabel");
// wait for initial sync, but not catchup sync // wait for initial sync, but not catchup sync
@ -109,22 +108,9 @@ export class SessionLoadViewModel extends ViewModel {
return `Something went wrong: ${error && error.message}.`; return `Something went wrong: ${error && error.message}.`;
} }
// Statuses related to login are handled by respective login view models
if (sc) { if (sc) {
switch (sc.loadStatus.get()) { switch (sc.loadStatus.get()) {
case LoadStatus.NotLoading:
return `Preparing…`;
case LoadStatus.Login:
return `Checking your login and password…`;
case LoadStatus.LoginFailed:
switch (sc.loginFailure) {
case LoginFailure.LoginFailure:
return `Your username and/or password don't seem to be correct.`;
case LoginFailure.Connection:
return `Can't connect to ${this._homeserver}.`;
case LoginFailure.Unknown:
return `Something went wrong while checking your login and password.`;
}
break;
case LoadStatus.SessionSetup: case LoadStatus.SessionSetup:
return `Setting up your encryption keys…`; return `Setting up your encryption keys…`;
case LoadStatus.Loading: case LoadStatus.Loading:

View File

@ -0,0 +1,76 @@
/*
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 {ViewModel} from "../ViewModel.js";
import {LoginFailure} from "../../matrix/SessionContainer.js";
export class CompleteSSOLoginViewModel extends ViewModel {
constructor(options) {
super(options);
const {
loginToken,
sessionContainer,
attemptLogin,
} = options;
this._loginToken = loginToken;
this._sessionContainer = sessionContainer;
this._attemptLogin = attemptLogin;
this._errorMessage = "";
this.performSSOLoginCompletion();
}
get errorMessage() { return this._errorMessage; }
_showError(message) {
this._errorMessage = message;
this.emitChange("errorMessage");
}
async performSSOLoginCompletion() {
if (!this._loginToken) {
return;
}
const homeserver = await this.platform.settingsStorage.getString("sso_ongoing_login_homeserver");
let loginOptions;
try {
loginOptions = await this._sessionContainer.queryLogin(homeserver);
}
catch (err) {
this._showError(err.message);
return;
}
if (!loginOptions.token) {
this.navigation.push("session");
return;
}
const status = await this._attemptLogin(loginOptions.token(this._loginToken));
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);
}
}
}

View File

@ -0,0 +1,194 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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 {ViewModel} from "../ViewModel.js";
import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js";
import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js";
import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js";
import {LoadStatus} from "../../matrix/SessionContainer.js";
import {SessionLoadViewModel} from "../SessionLoadViewModel.js";
export class LoginViewModel extends ViewModel {
constructor(options) {
super(options);
const {ready, defaultHomeServer, createSessionContainer, loginToken} = options;
this._createSessionContainer = createSessionContainer;
this._ready = ready;
this._loginToken = loginToken;
this._sessionContainer = this._createSessionContainer();
this._loginOptions = null;
this._passwordLoginViewModel = null;
this._startSSOLoginViewModel = null;
this._completeSSOLoginViewModel = null;
this._loadViewModel = null;
this._loadViewModelSubscription = null;
this._homeserver = defaultHomeServer;
this._errorMessage = "";
this._hideHomeserver = false;
this._isBusy = false;
this._isFetchingLoginOptions = false;
this._createViewModels(this._homeserver);
}
get passwordLoginViewModel() { return this._passwordLoginViewModel; }
get startSSOLoginViewModel() { return this._startSSOLoginViewModel; }
get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; }
get homeserver() { return this._homeserver; }
get errorMessage() { return this._errorMessage; }
get showHomeserver() { return !this._hideHomeserver; }
get loadViewModel() {return this._loadViewModel; }
get isBusy() { return this._isBusy; }
get isFetchingLoginOptions() { return this._isFetchingLoginOptions; }
goBack() {
this.navigation.push("session");
}
async _createViewModels(homeserver) {
if (this._loginToken) {
this._hideHomeserver = true;
this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel(
this.childOptions(
{
sessionContainer: this._sessionContainer,
attemptLogin: loginMethod => this.attemptLogin(loginMethod),
loginToken: this._loginToken
})));
this.emitChange("completeSSOLoginViewModel");
}
else {
this._errorMessage = "";
try {
this._isFetchingLoginOptions = true;
this.emitChange("isFetchingLoginOptions");
this._loginOptions = await this._sessionContainer.queryLogin(homeserver);
}
catch (e) {
this._loginOptions = null;
}
this._isFetchingLoginOptions = false;
this.emitChange("isFetchingLoginOptions");
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 neither supports SSO nor Password based login flows");
}
}
else {
this._showError("Could not query login methods supported by the homeserver");
}
}
}
_showPasswordLogin() {
this._passwordLoginViewModel = this.track(new PasswordLoginViewModel(
this.childOptions({
loginOptions: this._loginOptions,
attemptLogin: loginMethod => this.attemptLogin(loginMethod)
})));
this.emitChange("passwordLoginViewModel");
}
_showSSOLogin() {
this._startSSOLoginViewModel = this.track(
new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions}))
);
this.emitChange("startSSOLoginViewModel");
}
_showError(message) {
this._errorMessage = message;
this.emitChange("errorMessage");
}
_setBusy(status) {
this._isBusy = status;
this._passwordLoginViewModel?.setBusy(status);
this._startSSOLoginViewModel?.setBusy(status);
this.emitChange("isBusy");
}
async attemptLogin(loginMethod) {
this._setBusy(true);
this._sessionContainer.startWithLogin(loginMethod);
const loadStatus = this._sessionContainer.loadStatus;
const handle = loadStatus.waitFor(status => status !== LoadStatus.Login);
await handle.promise;
this._setBusy(false);
const status = loadStatus.get();
if (status === LoadStatus.LoginFailed) {
return this._sessionContainer.loginFailure;
}
this._hideHomeserver = true;
this.emitChange("hideHomeserver");
this._disposeViewModels();
this._createLoadViewModel();
return null;
}
_createLoadViewModel() {
this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription);
this._loadViewModel = this.disposeTracked(this._loadViewModel);
this._loadViewModel = this.track(
new SessionLoadViewModel(
this.childOptions({
ready: (sessionContainer) => {
// make sure we don't delete the session in dispose when navigating away
this._sessionContainer = null;
this._ready(sessionContainer);
},
sessionContainer: this._sessionContainer,
homeserver: this._homeserver
})
)
);
this._loadViewModel.start();
this.emitChange("loadViewModel");
this._loadViewModelSubscription = this.track(
this._loadViewModel.disposableOn("change", () => {
if (!this._loadViewModel.loading) {
this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription);
}
this._setBusy(false);
})
);
}
_disposeViewModels() {
this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel);
this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel);
this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel);
this.emitChange("disposeViewModels");
}
updateHomeServer(newHomeserver) {
this._errorMessage = "";
this.emitChange("errorMessage");
this._homeserver = newHomeserver;
this._disposeViewModels();
this._createViewModels(newHomeserver);
}
dispose() {
super.dispose();
if (this._sessionContainer) {
// if we move away before we're done with initial sync
// delete the session
this._sessionContainer.deleteSession();
}
}
}

View File

@ -0,0 +1,64 @@
/*
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 {ViewModel} from "../ViewModel.js";
import {LoginFailure} from "../../matrix/SessionContainer.js";
export class PasswordLoginViewModel extends ViewModel {
constructor(options) {
super(options);
const {loginOptions, attemptLogin} = options;
this._loginOptions = loginOptions;
this._attemptLogin = attemptLogin;
this._isBusy = false;
this._errorMessage = "";
}
get isBusy() { return this._isBusy; }
get errorMessage() { return this._errorMessage; }
setBusy(status) {
this._isBusy = status;
this.emitChange("isBusy");
}
_showError(message) {
this._errorMessage = message;
this.emitChange("errorMessage");
}
async login(username, password) {
this._errorMessage = "";
this.emitChange("errorMessage");
const loginMethod = this._loginOptions.password(username, password);
const status = await this._attemptLogin(loginMethod);
let error = "";
switch (status) {
case LoginFailure.Credentials:
error = this.i18n`Your username and/or password don't seem to be correct.`;
break;
case LoginFailure.Connection:
error = this.i18n`Can't connect to ${loginMethod.homeServer}.`;
break;
case LoginFailure.Unknown:
error = this.i18n`Something went wrong while checking your login and password.`;
break;
}
if (error) {
this._showError(error);
}
}
}

View File

@ -0,0 +1,38 @@
/*
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 {ViewModel} from "../ViewModel.js";
export class StartSSOLoginViewModel extends ViewModel{
constructor(options) {
super(options);
this._sso = options.loginOptions.sso;
this._isBusy = false;
}
get isBusy() { return this._isBusy; }
setBusy(status) {
this._isBusy = status;
this.emitChange("isBusy");
}
async startSSOLogin() {
await this.platform.settingsStorage.setString("sso_ongoing_login_homeserver", this._sso.homeserver);
const link = this._sso.createSSORedirectURL(this.urlCreator.createSSOCallbackURL());
this.platform.openUrl(link);
}
}

View File

@ -120,4 +120,14 @@ export class URLRouter {
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`; const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`;
return this._history.pathAsUrl(urlPath); return this._history.pathAsUrl(urlPath);
} }
createSSOCallbackURL() {
return window.location.origin;
}
normalizeUrl() {
// Remove any queryParameters from the URL
// Gets rid of the loginToken after SSO
this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`);
}
} }

View File

@ -30,7 +30,7 @@ function allowsChild(parent, child) {
switch (parent?.type) { switch (parent?.type) {
case undefined: case undefined:
// allowed root segments // allowed root segments
return type === "login" || type === "session"; return type === "login" || type === "session" || type === "sso";
case "session": case "session":
return type === "room" || type === "rooms" || type === "settings"; return type === "room" || type === "rooms" || type === "settings";
case "rooms": case "rooms":
@ -152,6 +152,10 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
const userId = iterator.next().value; const userId = iterator.next().value;
if (!userId) { break; } if (!userId) { break; }
pushRightPanelSegment(segments, type, userId); pushRightPanelSegment(segments, type, userId);
} else if (type.includes("loginToken")) {
// Special case for SSO-login with query parameter loginToken=<token>
const loginToken = type.split("=").pop();
segments.push(new Segment("sso", loginToken));
} else { } else {
// might be undefined, which will be turned into true by Segment // might be undefined, which will be turned into true by Segment
const value = iterator.next().value; const value = iterator.next().value;
@ -181,7 +185,8 @@ export function stringifyPath(path) {
} }
break; break;
case "right-panel": case "right-panel":
// Ignore right-panel in url case "sso":
// Do not put these segments in URL
continue; continue;
default: default:
urlPath += `/${segment.type}`; urlPath += `/${segment.type}`;
@ -228,6 +233,12 @@ export function tests() {
const urlPath = stringifyPath(path); const urlPath = stringifyPath(path);
assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details"); assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details");
}, },
"Parse loginToken query parameter into SSO segment": assert => {
const segments = parseUrlPath("?loginToken=a1232aSD123");
assert.equal(segments.length, 1);
assert.equal(segments[0].type, "sso");
assert.equal(segments[0].value, "a1232aSD123");
},
"parse grid url path with focused empty tile": assert => { "parse grid url path with focused empty tile": assert => {
const segments = parseUrlPath("/session/1/rooms/a,b,c/3"); const segments = parseUrlPath("/session/1/rooms/a,b,c/3");
assert.equal(segments.length, 3); assert.equal(segments.length, 3);

View File

@ -23,6 +23,17 @@ import {MediaRepository} from "./net/MediaRepository.js";
import {RequestScheduler} from "./net/RequestScheduler.js"; import {RequestScheduler} from "./net/RequestScheduler.js";
import {Sync, SyncStatus} from "./Sync.js"; import {Sync, SyncStatus} from "./Sync.js";
import {Session} from "./Session.js"; import {Session} from "./Session.js";
import {PasswordLoginMethod} from "./login/PasswordLoginMethod.js";
import {TokenLoginMethod} from "./login/TokenLoginMethod.js";
import {SSOLoginHelper} from "./login/SSOLoginHelper.js";
function normalizeHomeserver(homeServer) {
try {
return new URL(homeServer).origin;
} catch (err) {
return new URL(`https://${homeServer}`).origin;
}
}
export const LoadStatus = createEnum( export const LoadStatus = createEnum(
"NotLoading", "NotLoading",
@ -42,14 +53,6 @@ export const LoginFailure = createEnum(
"Unknown", "Unknown",
); );
function normalizeHomeserver(homeServer) {
try {
return new URL(homeServer).origin;
} catch (err) {
return new URL(`https://${homeServer}`).origin;
}
}
export class SessionContainer { export class SessionContainer {
constructor({platform, olmPromise, workerPromise}) { constructor({platform, olmPromise, workerPromise}) {
this._platform = platform; this._platform = platform;
@ -97,25 +100,56 @@ export class SessionContainer {
}); });
} }
async startWithLogin(homeServer, username, password) { _parseLoginOptions(options, homeServer) {
if (this._status.get() !== LoadStatus.NotLoading) { /*
Take server response and return new object which has two props password and sso which
implements LoginMethod
*/
const flows = options.flows;
const result = {};
for (const flow of flows) {
if (flow.type === "m.login.password") {
result.password = (username, password) => new PasswordLoginMethod({homeServer, username, password});
}
else if (flow.type === "m.login.sso" && flows.find(flow => flow.type === "m.login.token")) {
result.sso = new SSOLoginHelper(homeServer);
}
else if (flow.type === "m.login.token") {
result.token = loginToken => new TokenLoginMethod({homeServer, loginToken});
}
}
return result;
}
async queryLogin(homeServer) {
const normalizedHS = normalizeHomeserver(homeServer);
const hsApi = new HomeServerApi({homeServer: normalizedHS, request: this._platform.request});
const response = await hsApi.getLoginFlows().response();
return this._parseLoginOptions(response, normalizedHS);
}
async startWithLogin(loginMethod) {
const currentStatus = this._status.get();
if (currentStatus !== LoadStatus.LoginFailed &&
currentStatus !== LoadStatus.NotLoading &&
currentStatus !== LoadStatus.Error) {
return; return;
} }
this._resetStatus();
await this._platform.logger.run("login", async log => { await this._platform.logger.run("login", async log => {
this._status.set(LoadStatus.Login); this._status.set(LoadStatus.Login);
homeServer = normalizeHomeserver(homeServer);
const clock = this._platform.clock; const clock = this._platform.clock;
let sessionInfo; let sessionInfo;
try { try {
const request = this._platform.request; const request = this._platform.request;
const hsApi = new HomeServerApi({homeServer, request}); const hsApi = new HomeServerApi({homeServer: loginMethod.homeServer, request});
const loginData = await hsApi.passwordLogin(username, password, "Hydrogen", {log}).response(); const loginData = await loginMethod.login(hsApi, "Hydrogen", log);
const sessionId = this.createNewSessionId(); const sessionId = this.createNewSessionId();
sessionInfo = { sessionInfo = {
id: sessionId, id: sessionId,
deviceId: loginData.device_id, deviceId: loginData.device_id,
userId: loginData.user_id, userId: loginData.user_id,
homeServer: homeServer, homeServer: loginMethod.homeServer,
accessToken: loginData.access_token, accessToken: loginData.access_token,
lastUsed: clock.now() lastUsed: clock.now()
}; };
@ -270,6 +304,10 @@ export class SessionContainer {
return this._error; return this._error;
} }
get loginFailure() {
return this._loginFailure;
}
/** only set at loadStatus InitialSync, CatchupSync or Ready */ /** only set at loadStatus InitialSync, CatchupSync or Ready */
get sync() { get sync() {
return this._sync; return this._sync;
@ -319,4 +357,10 @@ export class SessionContainer {
this._sessionId = null; this._sessionId = null;
} }
} }
_resetStatus() {
this._status.set(LoadStatus.NotLoading);
this._error = null;
this._loginFailure = null;
}
} }

View File

@ -0,0 +1,30 @@
/*
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.
*/
export class LoginMethod {
constructor({homeServer}) {
this.homeServer = homeServer;
}
// eslint-disable-next-line no-unused-vars
async login(hsApi, deviceName, log) {
/*
Regardless of the login method, SessionContainer.startWithLogin()
can do SomeLoginMethod.login()
*/
throw("Not Implemented");
}
}

View File

@ -0,0 +1,29 @@
/*
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 {LoginMethod} from "./LoginMethod.js";
export class PasswordLoginMethod extends LoginMethod {
constructor(options) {
super(options);
this.username = options.username;
this.password = options.password;
}
async login(hsApi, deviceName, log) {
return await hsApi.passwordLogin(this.username, this.password, deviceName, {log}).response();
}
}

View File

@ -0,0 +1,27 @@
/*
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.
*/
export class SSOLoginHelper{
constructor(homeserver) {
this._homeserver = homeserver;
}
get homeserver() { return this._homeserver; }
createSSORedirectURL(returnURL) {
return `${this._homeserver}/_matrix/client/r0/login/sso/redirect?redirectUrl=${returnURL}`;
}
}

View File

@ -0,0 +1,29 @@
/*
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 {LoginMethod} from "./LoginMethod.js";
import {makeTxnId} from "../common.js";
export class TokenLoginMethod extends LoginMethod {
constructor(options) {
super(options);
this._loginToken = options.loginToken;
}
async login(hsApi, deviceName, log) {
return await hsApi.tokenLogin(this._loginToken, makeTxnId(), deviceName, {log}).response();
}
}

View File

@ -134,6 +134,10 @@ export class HomeServerApi {
return this._get(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, null, options); return this._get(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, null, options);
} }
getLoginFlows() {
return this._unauthedRequest("GET", this._url("/login"), null, null, null);
}
passwordLogin(username, password, initialDeviceDisplayName, options = null) { passwordLogin(username, password, initialDeviceDisplayName, options = null) {
return this._unauthedRequest("POST", this._url("/login"), null, { return this._unauthedRequest("POST", this._url("/login"), null, {
"type": "m.login.password", "type": "m.login.password",
@ -146,6 +150,18 @@ export class HomeServerApi {
}, options); }, options);
} }
tokenLogin(loginToken, txnId, initialDeviceDisplayName, options = null) {
return this._unauthedRequest("POST", this._url("/login"), null, {
"type": "m.login.token",
"identifier": {
"type": "m.id.user",
},
"token": loginToken,
"txn_id": txnId,
"initial_device_display_name": initialDeviceDisplayName
}, options);
}
createFilter(userId, filter, options = null) { createFilter(userId, filter, options = null) {
return this._post(`/user/${encodeURIComponent(userId)}/filter`, null, filter, options); return this._post(`/user/${encodeURIComponent(userId)}/filter`, null, filter, options);
} }

View File

@ -240,6 +240,10 @@ export class Platform {
return promise; return promise;
} }
openUrl(url) {
location.href = url;
}
parseHTML(html) { parseHTML(html) {
return parseHTML(html); return parseHTML(html);
} }

View File

@ -25,6 +25,14 @@ export class History extends BaseObservableValue {
} }
get() { get() {
/*
All URLS in Hydrogen will use <root>/#/segment/value/...
But for SSO, we need to handle <root>/?loginToken=<TOKEN>
Handle that as a special case for now.
*/
if (document.location.search.includes("loginToken")) {
return document.location.search;
}
return document.location.hash; return document.location.hash;
} }

View File

@ -43,6 +43,14 @@ export class SettingsStorage {
return defaultValue; return defaultValue;
} }
async setString(key, value) {
this._set(key, value);
}
async getString(key) {
return window.localStorage.getItem(`${this._prefix}${key}`);
}
async remove(key) { async remove(key) {
window.localStorage.removeItem(`${this._prefix}${key}`); window.localStorage.removeItem(`${this._prefix}${key}`);
} }

View File

@ -36,7 +36,7 @@ limitations under the License.
align-items: center; align-items: center;
} }
.SessionPickerView .session-info > :not(:first-child) { .SessionPickerView .session-info> :not(:first-child) {
margin-left: 8px; margin-left: 8px;
} }
@ -50,19 +50,19 @@ limitations under the License.
margin: 0 20px; margin: 0 20px;
} }
.LoginView { .PasswordLoginView {
padding: 0.4em; padding: 0 0.4em 0.4em;
} }
.SessionLoadStatusView { .SessionLoadStatusView, .LoginView_query-spinner {
display: flex; display: flex;
} }
.SessionLoadStatusView > :not(:first-child) { .SessionLoadStatusView> :not(:first-child), .LoginView_query-spinner> :not(:first-child) {
margin-left: 12px; margin-left: 12px;
} }
.SessionLoadStatusView p { .SessionLoadStatusView p, .LoginView_query-spinner p {
flex: 1; flex: 1;
margin: 0; margin: 0;
} }
@ -70,3 +70,29 @@ limitations under the License.
.SessionLoadStatusView .spinner { .SessionLoadStatusView .spinner {
--size: 20px; --size: 20px;
} }
.StartSSOLoginView {
display: flex;
flex-direction: column;
padding: 0 0.4em 0;
}
.StartSSOLoginView_button {
flex: 1;
margin-top: 12px;
}
.LoginView_separator {
justify-content: center;
display: flex;
margin: 8px;
}
.CompleteSSOView_title {
display: flex;
justify-content: center;
}
.LoginView_sso {
padding: 0.4em 0.4em 0;
}

View File

@ -223,6 +223,25 @@ a.button-action {
padding-top: 16px; padding-top: 16px;
} }
.StartSSOLoginView_button {
border: 1px solid #03B381;
border-radius: 8px;
}
.LoginView_back {
background-image: url("./icons/chevron-left.svg");
background-color: transparent;
}
.LoginView_separator {
font-weight: 500;
font-size: 1.5rem;
}
.CompleteSSOView_title {
font-weight: 500;
}
@media screen and (min-width: 600px) { @media screen and (min-width: 600px) {
.PreSessionScreen { .PreSessionScreen {
box-shadow: 0px 6px 32px rgba(0, 0, 0, 0.1); box-shadow: 0px 6px 32px rgba(0, 0, 0, 0.1);

View File

@ -0,0 +1,30 @@
/*
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 {TemplateView} from "../general/TemplateView.js";
import {SessionLoadStatusView} from "./SessionLoadStatusView.js";
export class CompleteSSOView extends TemplateView {
render(t) {
return t.div({ className: "CompleteSSOView" },
[
t.p({ className: "CompleteSSOView_title" }, "Finishing up your SSO Login"),
t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))),
t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null),
]
);
}
}

View File

@ -16,61 +16,58 @@ limitations under the License.
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView.js";
import {hydrogenGithubLink} from "./common.js"; import {hydrogenGithubLink} from "./common.js";
import {PasswordLoginView} from "./PasswordLoginView.js";
import {CompleteSSOView} from "./CompleteSSOView.js";
import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js";
import {spinner} from "../common.js";
export class LoginView extends TemplateView { export class LoginView extends TemplateView {
render(t, vm) { render(t, vm) {
const disabled = vm => !!vm.isBusy; const disabled = vm => vm.isBusy;
const username = t.input({
id: "username", return t.div({className: "PreSessionScreen"}, [
type: "text", t.button({
placeholder: vm.i18n`Username`, className: "button-utility LoginView_back",
onClick: () => vm.goBack(),
disabled disabled
}); }),
const password = t.input({ t.div({className: "logo"}),
id: "password", t.h1([vm.i18n`Sign In`]),
type: "password", t.mapView(vm => vm.completeSSOLoginViewModel, vm => vm ? new CompleteSSOView(vm) : null),
placeholder: vm.i18n`Password`, t.if(vm => vm.showHomeserver, (t, vm) => t.div({ className: "LoginView_sso form form-row" },
disabled [
}); t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))),
const homeserver = t.input({ t.label({for: "homeserver"}, vm.i18n`Homeserver`),
t.input({
id: "homeserver", id: "homeserver",
type: "text", type: "text",
placeholder: vm.i18n`Your matrix homeserver`, placeholder: vm.i18n`Your matrix homeserver`,
value: vm.defaultHomeServer, value: vm.homeserver,
disabled disabled,
}); onChange: event => vm.updateHomeServer(event.target.value),
})
return t.div({className: "PreSessionScreen"}, [ ]
t.div({className: "logo"}), )),
t.div({className: "LoginView form"}, [ t.if(vm => vm.isFetchingLoginOptions, t => t.div({className: "LoginView_query-spinner"}, [spinner(t), t.p("Fetching available login options...")])),
t.h1([vm.i18n`Sign In`]), t.mapView(vm => vm.passwordLoginViewModel, vm => vm ? new PasswordLoginView(vm): null),
t.if(vm => vm.error, t => t.div({className: "error"}, vm => vm.error)), t.if(vm => vm.passwordLoginViewModel && vm.startSSOLoginViewModel, t => t.p({className: "LoginView_separator"}, vm.i18n`or`)),
t.form({ t.mapView(vm => vm.startSSOLoginViewModel, vm => vm ? new StartSSOLoginView(vm) : null),
onSubmit: evnt => {
evnt.preventDefault();
vm.login(username.value, password.value, homeserver.value);
}
}, [
t.div({className: "form-row"}, [t.label({for: "username"}, vm.i18n`Username`), username]),
t.div({className: "form-row"}, [t.label({for: "password"}, vm.i18n`Password`), password]),
t.div({className: "form-row"}, [t.label({for: "homeserver"}, vm.i18n`Homeserver`), homeserver]),
t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null),
t.div({className: "button-row"}, [
t.a({
className: "button-action secondary",
href: vm.cancelUrl
}, [vm.i18n`Go Back`]),
t.button({
className: "button-action primary",
type: "submit"
}, vm.i18n`Log In`),
]),
]),
// use t.mapView rather than t.if to create a new view when the view model changes too // use t.mapView rather than t.if to create a new view when the view model changes too
t.p(hydrogenGithubLink(t)) t.p(hydrogenGithubLink(t))
])
]); ]);
} }
} }
class StartSSOLoginView extends TemplateView {
render(t, vm) {
return t.div({ className: "StartSSOLoginView" },
t.button({
className: "StartSSOLoginView_button button-action secondary",
type: "button",
onClick: () => vm.startSSOLogin(),
disabled: vm => vm.isBusy
}, vm.i18n`Log in with SSO`)
);
}
}

View File

@ -0,0 +1,57 @@
/*
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 {TemplateView} from "../general/TemplateView.js";
export class PasswordLoginView extends TemplateView {
render(t, vm) {
const disabled = vm => !!vm.isBusy;
const username = t.input({
id: "username",
type: "text",
placeholder: vm.i18n`Username`,
disabled
});
const password = t.input({
id: "password",
type: "password",
placeholder: vm.i18n`Password`,
disabled
});
return t.div({className: "PasswordLoginView form"}, [
t.if(vm => vm.error, t => t.div({ className: "error" }, vm => vm.error)),
t.form({
onSubmit: evnt => {
evnt.preventDefault();
vm.login(username.value, password.value);
}
}, [
t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))),
t.div({ className: "form-row" }, [t.label({ for: "username" }, vm.i18n`Username`), username]),
t.div({ className: "form-row" }, [t.label({ for: "password" }, vm.i18n`Password`), password]),
t.div({ className: "button-row" }, [
t.button({
className: "button-action primary",
type: "submit",
disabled
}, vm.i18n`Log In`),
]),
])
]);
}
}