Merge pull request #464 from vector-im/bwindels/well-known

Add .well-known support
This commit is contained in:
Bruno Windels 2021-08-23 20:13:30 +02:00 committed by GitHub
commit 7946a3e4d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 131 additions and 58 deletions

View file

@ -105,7 +105,7 @@ export class RootViewModel extends ViewModel {
_showLogin(loginToken) { _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"],
createSessionContainer: this._createSessionContainer, createSessionContainer: this._createSessionContainer,
ready: sessionContainer => { ready: sessionContainer => {
// we don't want to load the session container again, // we don't want to load the session container again,

View file

@ -46,7 +46,7 @@ export class CompleteSSOLoginViewModel extends ViewModel {
const homeserver = await this.platform.settingsStorage.getString("sso_ongoing_login_homeserver"); const homeserver = await this.platform.settingsStorage.getString("sso_ongoing_login_homeserver");
let loginOptions; let loginOptions;
try { try {
loginOptions = await this._sessionContainer.queryLogin(homeserver); loginOptions = await this._sessionContainer.queryLogin(homeserver).result;
} }
catch (err) { catch (err) {
this._showError(err.message); this._showError(err.message);

View file

@ -24,7 +24,7 @@ import {SessionLoadViewModel} from "../SessionLoadViewModel.js";
export class LoginViewModel extends ViewModel { export class LoginViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {ready, defaultHomeServer, createSessionContainer, loginToken} = options; const {ready, defaultHomeserver, createSessionContainer, loginToken} = options;
this._createSessionContainer = createSessionContainer; this._createSessionContainer = createSessionContainer;
this._ready = ready; this._ready = ready;
this._loginToken = loginToken; this._loginToken = loginToken;
@ -35,7 +35,8 @@ export class LoginViewModel extends ViewModel {
this._completeSSOLoginViewModel = null; this._completeSSOLoginViewModel = null;
this._loadViewModel = null; this._loadViewModel = null;
this._loadViewModelSubscription = null; this._loadViewModelSubscription = null;
this._homeserver = defaultHomeServer; this._homeserver = defaultHomeserver;
this._queriedHomeserver = null;
this._errorMessage = ""; this._errorMessage = "";
this._hideHomeserver = false; this._hideHomeserver = false;
this._isBusy = false; this._isBusy = false;
@ -48,6 +49,7 @@ export class LoginViewModel extends ViewModel {
get startSSOLoginViewModel() { return this._startSSOLoginViewModel; } get startSSOLoginViewModel() { return this._startSSOLoginViewModel; }
get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; } get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; }
get homeserver() { return this._homeserver; } get homeserver() { return this._homeserver; }
get resolvedHomeserver() { return this._loginOptions?.homeserver; }
get errorMessage() { return this._errorMessage; } get errorMessage() { return this._errorMessage; }
get showHomeserver() { return !this._hideHomeserver; } get showHomeserver() { return !this._hideHomeserver; }
get loadViewModel() {return this._loadViewModel; } get loadViewModel() {return this._loadViewModel; }
@ -71,7 +73,7 @@ export class LoginViewModel extends ViewModel {
this.emitChange("completeSSOLoginViewModel"); this.emitChange("completeSSOLoginViewModel");
} }
else { else {
await this.queryHomeServer(); await this.queryHomeserver();
} }
} }
@ -156,13 +158,18 @@ export class LoginViewModel extends ViewModel {
this.emitChange("disposeViewModels"); this.emitChange("disposeViewModels");
} }
async setHomeServer(newHomeserver) { async setHomeserver(newHomeserver) {
this._homeserver = newHomeserver; this._homeserver = newHomeserver;
// abort ongoing query, if any // clear everything set by queryHomeserver
this._loginOptions = null;
this._queriedHomeserver = null;
this._showError("");
this._disposeViewModels();
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
this.emitChange("isFetchingLoginOptions"); this.emitChange(); // multiple fields changing
// also clear the timeout if it is still running
this.disposeTracked(this._abortHomeserverQueryTimeout); this.disposeTracked(this._abortHomeserverQueryTimeout);
const timeout = this.clock.createTimeout(2000); const timeout = this.clock.createTimeout(1000);
this._abortHomeserverQueryTimeout = this.track(() => timeout.abort()); this._abortHomeserverQueryTimeout = this.track(() => timeout.abort());
try { try {
await timeout.elapsed(); await timeout.elapsed();
@ -174,22 +181,30 @@ export class LoginViewModel extends ViewModel {
} }
} }
this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout);
this.queryHomeServer(); this.queryHomeserver();
} }
async queryHomeServer() { async queryHomeserver() {
this._errorMessage = ""; // don't repeat a query we've just done
this.emitChange("errorMessage"); if (this._homeserver === this._queriedHomeserver || this._homeserver === "") {
// if query is called before the typing timeout hits (e.g. field lost focus), cancel the timeout so we don't query again. return;
}
this._queriedHomeserver = this._homeserver;
// given that setHomeserver already clears everything set here,
// and that is the only way to change the homeserver,
// we don't need to reset things again here.
// However, clear things set by setHomeserver:
// if query is called before the typing timeout hits (e.g. field lost focus),
// cancel the timeout so we don't query again.
this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout);
// cancel ongoing query operation, if any // cancel ongoing query operation, if any
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
this._disposeViewModels();
try { try {
const queryOperation = this._sessionContainer.queryLogin(this._homeserver); const queryOperation = this._sessionContainer.queryLogin(this._homeserver);
this._abortQueryOperation = this.track(() => queryOperation.abort()); this._abortQueryOperation = this.track(() => queryOperation.abort());
this.emitChange("isFetchingLoginOptions"); this.emitChange("isFetchingLoginOptions");
this._loginOptions = await queryOperation.result; this._loginOptions = await queryOperation.result;
this.emitChange("resolvedHomeserver");
} }
catch (e) { catch (e) {
if (e.name === "AbortError") { if (e.name === "AbortError") {
@ -209,7 +224,7 @@ export class LoginViewModel extends ViewModel {
} }
} }
else { else {
this._showError("Could not query login methods supported by the homeserver"); this._showError(`Could not query login methods supported by ${this.homeserver}`);
} }
} }

View file

@ -43,15 +43,14 @@ export class PasswordLoginViewModel extends ViewModel {
async login(username, password) { async login(username, password) {
this._errorMessage = ""; this._errorMessage = "";
this.emitChange("errorMessage"); this.emitChange("errorMessage");
const loginMethod = this._loginOptions.password(username, password); const status = await this._attemptLogin(this._loginOptions.password(username, password));
const status = await this._attemptLogin(loginMethod);
let error = ""; let error = "";
switch (status) { switch (status) {
case LoginFailure.Credentials: case LoginFailure.Credentials:
error = this.i18n`Your username and/or password don't seem to be correct.`; error = this.i18n`Your username and/or password don't seem to be correct.`;
break; break;
case LoginFailure.Connection: case LoginFailure.Connection:
error = this.i18n`Can't connect to ${loginMethod.homeServer}.`; error = this.i18n`Can't connect to ${this._loginOptions.homeserver}.`;
break; break;
case LoginFailure.Unknown: case LoginFailure.Unknown:
error = this.i18n`Something went wrong while checking your login and password.`; error = this.i18n`Something went wrong while checking your login and password.`;

View file

@ -151,7 +151,7 @@ export class SettingsViewModel extends ViewModel {
this.pushNotifications.enabledOnServer = null; this.pushNotifications.enabledOnServer = null;
this.pushNotifications.serverError = null; this.pushNotifications.serverError = null;
try { try {
this.pushNotifications.enabledOnServer = await this._session.checkPusherEnabledOnHomeServer(); this.pushNotifications.enabledOnServer = await this._session.checkPusherEnabledOnHomeserver();
this.emitChange("pushNotifications.enabledOnServer"); this.emitChange("pushNotifications.enabledOnServer");
} catch (err) { } catch (err) {
this.pushNotifications.serverError = err; this.pushNotifications.serverError = err;

View file

@ -46,7 +46,7 @@ const PICKLE_KEY = "DEFAULT_KEY";
const PUSHER_KEY = "pusher"; const PUSHER_KEY = "pusher";
export class Session { export class Session {
// sessionInfo contains deviceId, userId and homeServer // sessionInfo contains deviceId, userId and homeserver
constructor({storage, hsApi, sessionInfo, olm, olmWorker, platform, mediaRepository}) { constructor({storage, hsApi, sessionInfo, olm, olmWorker, platform, mediaRepository}) {
this._platform = platform; this._platform = platform;
this._storage = storage; this._storage = storage;
@ -636,7 +636,7 @@ export class Session {
return !!pusherData; return !!pusherData;
} }
async checkPusherEnabledOnHomeServer() { async checkPusherEnabledOnHomeserver() {
const readTxn = await this._storage.readTxn([this._storage.storeNames.session]); const readTxn = await this._storage.readTxn([this._storage.storeNames.session]);
const pusherData = await readTxn.session.get(PUSHER_KEY); const pusherData = await readTxn.session.get(PUSHER_KEY);
if (!pusherData) { if (!pusherData) {

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,6 +16,7 @@ limitations under the License.
*/ */
import {createEnum} from "../utils/enum.js"; import {createEnum} from "../utils/enum.js";
import {lookupHomeserver} from "./well-known.js";
import {AbortableOperation} from "../utils/AbortableOperation"; import {AbortableOperation} from "../utils/AbortableOperation";
import {ObservableValue} from "../observable/ObservableValue.js"; import {ObservableValue} from "../observable/ObservableValue.js";
import {HomeServerApi} from "./net/HomeServerApi.js"; import {HomeServerApi} from "./net/HomeServerApi.js";
@ -28,14 +30,6 @@ import {PasswordLoginMethod} from "./login/PasswordLoginMethod.js";
import {TokenLoginMethod} from "./login/TokenLoginMethod.js"; import {TokenLoginMethod} from "./login/TokenLoginMethod.js";
import {SSOLoginHelper} from "./login/SSOLoginHelper.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",
"Login", "Login",
@ -54,7 +48,6 @@ export const LoginFailure = createEnum(
"Unknown", "Unknown",
); );
export class SessionContainer { export class SessionContainer {
constructor({platform, olmPromise, workerPromise}) { constructor({platform, olmPromise, workerPromise}) {
this._platform = platform; this._platform = platform;
@ -102,33 +95,35 @@ export class SessionContainer {
}); });
} }
_parseLoginOptions(options, homeServer) { _parseLoginOptions(options, homeserver) {
/* /*
Take server response and return new object which has two props password and sso which Take server response and return new object which has two props password and sso which
implements LoginMethod implements LoginMethod
*/ */
const flows = options.flows; const flows = options.flows;
const result = {}; const result = {homeserver};
for (const flow of flows) { for (const flow of flows) {
if (flow.type === "m.login.password") { if (flow.type === "m.login.password") {
result.password = (username, password) => new PasswordLoginMethod({homeServer, username, 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")) { else if (flow.type === "m.login.sso" && flows.find(flow => flow.type === "m.login.token")) {
result.sso = new SSOLoginHelper(homeServer); result.sso = new SSOLoginHelper(homeserver);
} }
else if (flow.type === "m.login.token") { else if (flow.type === "m.login.token") {
result.token = loginToken => new TokenLoginMethod({homeServer, loginToken}); result.token = loginToken => new TokenLoginMethod({homeserver, loginToken});
} }
} }
return result; return result;
} }
queryLogin(homeServer) { queryLogin(homeserver) {
const normalizedHS = normalizeHomeserver(homeServer);
const hsApi = new HomeServerApi({homeServer: normalizedHS, request: this._platform.request});
return new AbortableOperation(async setAbortable => { return new AbortableOperation(async setAbortable => {
homeserver = await lookupHomeserver(homeserver, (url, options) => {
return setAbortable(this._platform.request(url, options));
});
const hsApi = new HomeServerApi({homeserver, request: this._platform.request});
const response = await setAbortable(hsApi.getLoginFlows()).response(); const response = await setAbortable(hsApi.getLoginFlows()).response();
return this._parseLoginOptions(response, normalizedHS); return this._parseLoginOptions(response, homeserver);
}); });
} }
@ -146,14 +141,15 @@ export class SessionContainer {
let sessionInfo; let sessionInfo;
try { try {
const request = this._platform.request; const request = this._platform.request;
const hsApi = new HomeServerApi({homeServer: loginMethod.homeServer, request}); const hsApi = new HomeServerApi({homeserver: loginMethod.homeserver, request});
const loginData = await loginMethod.login(hsApi, "Hydrogen", log); 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: loginMethod.homeServer, homeServer: loginMethod.homeserver, // deprecate this over time
homeserver: loginMethod.homeserver,
accessToken: loginData.access_token, accessToken: loginData.access_token,
lastUsed: clock.now() lastUsed: clock.now()
}; };
@ -202,7 +198,7 @@ export class SessionContainer {
createMeasure: clock.createMeasure createMeasure: clock.createMeasure
}); });
const hsApi = new HomeServerApi({ const hsApi = new HomeServerApi({
homeServer: sessionInfo.homeServer, homeserver: sessionInfo.homeServer,
accessToken: sessionInfo.accessToken, accessToken: sessionInfo.accessToken,
request: this._platform.request, request: this._platform.request,
reconnector: this._reconnector, reconnector: this._reconnector,
@ -214,7 +210,7 @@ export class SessionContainer {
id: sessionInfo.id, id: sessionInfo.id,
deviceId: sessionInfo.deviceId, deviceId: sessionInfo.deviceId,
userId: sessionInfo.userId, userId: sessionInfo.userId,
homeServer: sessionInfo.homeServer, homeserver: sessionInfo.homeServer,
}; };
const olm = await this._olmPromise; const olm = await this._olmPromise;
let olmWorker = null; let olmWorker = null;
@ -224,7 +220,7 @@ export class SessionContainer {
this._requestScheduler = new RequestScheduler({hsApi, clock}); this._requestScheduler = new RequestScheduler({hsApi, clock});
this._requestScheduler.start(); this._requestScheduler.start();
const mediaRepository = new MediaRepository({ const mediaRepository = new MediaRepository({
homeServer: sessionInfo.homeServer, homeserver: sessionInfo.homeServer,
platform: this._platform, platform: this._platform,
}); });
this._session = new Session({ this._session = new Session({

View file

@ -15,8 +15,8 @@ limitations under the License.
*/ */
export class LoginMethod { export class LoginMethod {
constructor({homeServer}) { constructor({homeserver}) {
this.homeServer = homeServer; this.homeserver = homeserver;
} }
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars

View file

@ -19,10 +19,10 @@ import {encodeQueryParams, encodeBody} from "./common.js";
import {HomeServerRequest} from "./HomeServerRequest.js"; import {HomeServerRequest} from "./HomeServerRequest.js";
export class HomeServerApi { export class HomeServerApi {
constructor({homeServer, accessToken, request, reconnector}) { constructor({homeserver, accessToken, request, reconnector}) {
// store these both in a closure somehow so it's harder to get at in case of XSS? // store these both in a closure somehow so it's harder to get at in case of XSS?
// one could change the homeserver as well so the token gets sent there, so both must be protected from read/write // one could change the homeserver as well so the token gets sent there, so both must be protected from read/write
this._homeserver = homeServer; this._homeserver = homeserver;
this._accessToken = accessToken; this._accessToken = accessToken;
this._requestFn = request; this._requestFn = request;
this._reconnector = reconnector; this._reconnector = reconnector;
@ -234,7 +234,7 @@ export function tests() {
"superficial happy path for GET": async assert => { "superficial happy path for GET": async assert => {
const hsApi = new HomeServerApi({ const hsApi = new HomeServerApi({
request: () => new MockRequest().respond(200, 42), request: () => new MockRequest().respond(200, 42),
homeServer: "https://hs.tld" homeserver: "https://hs.tld"
}); });
const result = await hsApi._get("foo", null, null, null).response(); const result = await hsApi._get("foo", null, null, null).response();
assert.strictEqual(result, 42); assert.strictEqual(result, 42);

View file

@ -18,8 +18,8 @@ import {encodeQueryParams} from "./common.js";
import {decryptAttachment} from "../e2ee/attachment.js"; import {decryptAttachment} from "../e2ee/attachment.js";
export class MediaRepository { export class MediaRepository {
constructor({homeServer, platform}) { constructor({homeserver, platform}) {
this._homeServer = homeServer; this._homeserver = homeserver;
this._platform = platform; this._platform = platform;
} }
@ -27,7 +27,7 @@ export class MediaRepository {
const parts = this._parseMxcUrl(url); const parts = this._parseMxcUrl(url);
if (parts) { if (parts) {
const [serverName, mediaId] = parts; const [serverName, mediaId] = parts;
const httpUrl = `${this._homeServer}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
return httpUrl + "?" + encodeQueryParams({width: Math.round(width), height: Math.round(height), method}); return httpUrl + "?" + encodeQueryParams({width: Math.round(width), height: Math.round(height), method});
} }
return null; return null;
@ -37,7 +37,7 @@ export class MediaRepository {
const parts = this._parseMxcUrl(url); const parts = this._parseMxcUrl(url);
if (parts) { if (parts) {
const [serverName, mediaId] = parts; const [serverName, mediaId] = parts;
return `${this._homeServer}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
} else { } else {
return null; return null;
} }

53
src/matrix/well-known.js Normal file
View file

@ -0,0 +1,53 @@
/*
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.
*/
function normalizeHomeserver(homeserver) {
try {
return new URL(homeserver).origin;
} catch (err) {
return new URL(`https://${homeserver}`).origin;
}
}
async function getWellKnownResponse(homeserver, request) {
const requestOptions = {format: "json", timeout: 30000, method: "GET"};
try {
const wellKnownUrl = `${homeserver}/.well-known/matrix/client`;
return await request(wellKnownUrl, requestOptions).response();
} catch (err) {
if (err.name === "ConnectionError") {
// don't fail lookup on a ConnectionError,
// there might be a missing CORS header on a 404 response or something,
// which won't be a problem necessarily with homeserver requests later on ...
return null;
} else {
throw err;
}
}
}
export async function lookupHomeserver(homeserver, request) {
homeserver = normalizeHomeserver(homeserver);
const wellKnownResponse = await getWellKnownResponse(homeserver, request);
if (wellKnownResponse && wellKnownResponse.status === 200) {
const {body} = wellKnownResponse;
const wellKnownHomeserver = body["m.homeserver"]?.["base_url"];
if (typeof wellKnownHomeserver === "string") {
homeserver = normalizeHomeserver(wellKnownHomeserver);
}
}
return homeserver;
}

View file

@ -238,6 +238,12 @@ a.button-action {
font-size: 1.5rem; font-size: 1.5rem;
} }
.LoginView_forwardInfo {
font-size: 0.9em;
margin-left: 1em;
color: #777;
}
.CompleteSSOView_title { .CompleteSSOView_title {
font-weight: 500; font-weight: 500;
} }

View file

@ -43,9 +43,13 @@ export class LoginView extends TemplateView {
placeholder: vm.i18n`Your matrix homeserver`, placeholder: vm.i18n`Your matrix homeserver`,
value: vm.homeserver, value: vm.homeserver,
disabled, disabled,
onInput: () => vm.setHomeServer(event.target.value), onInput: event => vm.setHomeserver(event.target.value),
onChange: event => vm.queryHomeServer(), onChange: () => vm.queryHomeserver(),
}), }),
t.p({className: {
LoginView_forwardInfo: true,
hidden: vm => !vm.resolvedHomeserver
}}, vm => vm.i18n`You will connect to ${vm.resolvedHomeserver}.`),
t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))), t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))),
] ]
)), )),

View file

@ -43,7 +43,7 @@
<script id="main" type="module"> <script id="main" type="module">
import {LoginView} from "./login/LoginView.js"; import {LoginView} from "./login/LoginView.js";
const view = new LoginView(vm({ const view = new LoginView(vm({
defaultHomeServer: "https://hs.tld", defaultHomeserver: "https://hs.tld",
login: () => alert("Logging in!"), login: () => alert("Logging in!"),
cancelUrl: "#/session" cancelUrl: "#/session"
})); }));
@ -60,7 +60,7 @@
loading: true, loading: true,
}), }),
cancelUrl: "#/session", cancelUrl: "#/session",
defaultHomeServer: "https://hs.tld", defaultHomeserver: "https://hs.tld",
})); }));
document.getElementById("login-loading").appendChild(view.mount()); document.getElementById("login-loading").appendChild(view.mount());
</script> </script>