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) {
this._setSection(() => {
this._loginViewModel = new LoginViewModel(this.childOptions({
defaultHomeServer: this.platform.config["defaultHomeServer"],
defaultHomeserver: this.platform.config["defaultHomeServer"],
createSessionContainer: this._createSessionContainer,
ready: sessionContainer => {
// 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");
let loginOptions;
try {
loginOptions = await this._sessionContainer.queryLogin(homeserver);
loginOptions = await this._sessionContainer.queryLogin(homeserver).result;
}
catch (err) {
this._showError(err.message);

View file

@ -24,7 +24,7 @@ import {SessionLoadViewModel} from "../SessionLoadViewModel.js";
export class LoginViewModel extends ViewModel {
constructor(options) {
super(options);
const {ready, defaultHomeServer, createSessionContainer, loginToken} = options;
const {ready, defaultHomeserver, createSessionContainer, loginToken} = options;
this._createSessionContainer = createSessionContainer;
this._ready = ready;
this._loginToken = loginToken;
@ -35,7 +35,8 @@ export class LoginViewModel extends ViewModel {
this._completeSSOLoginViewModel = null;
this._loadViewModel = null;
this._loadViewModelSubscription = null;
this._homeserver = defaultHomeServer;
this._homeserver = defaultHomeserver;
this._queriedHomeserver = null;
this._errorMessage = "";
this._hideHomeserver = false;
this._isBusy = false;
@ -48,6 +49,7 @@ export class LoginViewModel extends ViewModel {
get startSSOLoginViewModel() { return this._startSSOLoginViewModel; }
get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; }
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; }
@ -71,7 +73,7 @@ export class LoginViewModel extends ViewModel {
this.emitChange("completeSSOLoginViewModel");
}
else {
await this.queryHomeServer();
await this.queryHomeserver();
}
}
@ -156,13 +158,18 @@ export class LoginViewModel extends ViewModel {
this.emitChange("disposeViewModels");
}
async setHomeServer(newHomeserver) {
async setHomeserver(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.emitChange("isFetchingLoginOptions");
this.emitChange(); // multiple fields changing
// also clear the timeout if it is still running
this.disposeTracked(this._abortHomeserverQueryTimeout);
const timeout = this.clock.createTimeout(2000);
const timeout = this.clock.createTimeout(1000);
this._abortHomeserverQueryTimeout = this.track(() => timeout.abort());
try {
await timeout.elapsed();
@ -174,22 +181,30 @@ export class LoginViewModel extends ViewModel {
}
}
this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout);
this.queryHomeServer();
this.queryHomeserver();
}
async queryHomeServer() {
this._errorMessage = "";
this.emitChange("errorMessage");
// if query is called before the typing timeout hits (e.g. field lost focus), cancel the timeout so we don't query again.
async queryHomeserver() {
// don't repeat a query we've just done
if (this._homeserver === this._queriedHomeserver || this._homeserver === "") {
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);
// cancel ongoing query operation, if any
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
this._disposeViewModels();
try {
const queryOperation = this._sessionContainer.queryLogin(this._homeserver);
this._abortQueryOperation = this.track(() => queryOperation.abort());
this.emitChange("isFetchingLoginOptions");
this._loginOptions = await queryOperation.result;
this.emitChange("resolvedHomeserver");
}
catch (e) {
if (e.name === "AbortError") {
@ -209,7 +224,7 @@ export class LoginViewModel extends ViewModel {
}
}
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) {
this._errorMessage = "";
this.emitChange("errorMessage");
const loginMethod = this._loginOptions.password(username, password);
const status = await this._attemptLogin(loginMethod);
const status = await this._attemptLogin(this._loginOptions.password(username, password));
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}.`;
error = this.i18n`Can't connect to ${this._loginOptions.homeserver}.`;
break;
case LoginFailure.Unknown:
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.serverError = null;
try {
this.pushNotifications.enabledOnServer = await this._session.checkPusherEnabledOnHomeServer();
this.pushNotifications.enabledOnServer = await this._session.checkPusherEnabledOnHomeserver();
this.emitChange("pushNotifications.enabledOnServer");
} catch (err) {
this.pushNotifications.serverError = err;

View file

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

View file

@ -1,5 +1,6 @@
/*
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");
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 {lookupHomeserver} from "./well-known.js";
import {AbortableOperation} from "../utils/AbortableOperation";
import {ObservableValue} from "../observable/ObservableValue.js";
import {HomeServerApi} from "./net/HomeServerApi.js";
@ -28,14 +30,6 @@ 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(
"NotLoading",
"Login",
@ -54,7 +48,6 @@ export const LoginFailure = createEnum(
"Unknown",
);
export class SessionContainer {
constructor({platform, olmPromise, workerPromise}) {
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
implements LoginMethod
*/
const flows = options.flows;
const result = {};
const result = {homeserver};
for (const flow of flows) {
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")) {
result.sso = new SSOLoginHelper(homeServer);
result.sso = new SSOLoginHelper(homeserver);
}
else if (flow.type === "m.login.token") {
result.token = loginToken => new TokenLoginMethod({homeServer, loginToken});
result.token = loginToken => new TokenLoginMethod({homeserver, loginToken});
}
}
return result;
}
queryLogin(homeServer) {
const normalizedHS = normalizeHomeserver(homeServer);
const hsApi = new HomeServerApi({homeServer: normalizedHS, request: this._platform.request});
queryLogin(homeserver) {
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();
return this._parseLoginOptions(response, normalizedHS);
return this._parseLoginOptions(response, homeserver);
});
}
@ -146,14 +141,15 @@ export class SessionContainer {
let sessionInfo;
try {
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 sessionId = this.createNewSessionId();
sessionInfo = {
id: sessionId,
deviceId: loginData.device_id,
userId: loginData.user_id,
homeServer: loginMethod.homeServer,
homeServer: loginMethod.homeserver, // deprecate this over time
homeserver: loginMethod.homeserver,
accessToken: loginData.access_token,
lastUsed: clock.now()
};
@ -202,7 +198,7 @@ export class SessionContainer {
createMeasure: clock.createMeasure
});
const hsApi = new HomeServerApi({
homeServer: sessionInfo.homeServer,
homeserver: sessionInfo.homeServer,
accessToken: sessionInfo.accessToken,
request: this._platform.request,
reconnector: this._reconnector,
@ -214,7 +210,7 @@ export class SessionContainer {
id: sessionInfo.id,
deviceId: sessionInfo.deviceId,
userId: sessionInfo.userId,
homeServer: sessionInfo.homeServer,
homeserver: sessionInfo.homeServer,
};
const olm = await this._olmPromise;
let olmWorker = null;
@ -224,7 +220,7 @@ export class SessionContainer {
this._requestScheduler = new RequestScheduler({hsApi, clock});
this._requestScheduler.start();
const mediaRepository = new MediaRepository({
homeServer: sessionInfo.homeServer,
homeserver: sessionInfo.homeServer,
platform: this._platform,
});
this._session = new Session({

View file

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

View file

@ -19,10 +19,10 @@ import {encodeQueryParams, encodeBody} from "./common.js";
import {HomeServerRequest} from "./HomeServerRequest.js";
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?
// 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._requestFn = request;
this._reconnector = reconnector;
@ -234,7 +234,7 @@ export function tests() {
"superficial happy path for GET": async assert => {
const hsApi = new HomeServerApi({
request: () => new MockRequest().respond(200, 42),
homeServer: "https://hs.tld"
homeserver: "https://hs.tld"
});
const result = await hsApi._get("foo", null, null, null).response();
assert.strictEqual(result, 42);

View file

@ -18,8 +18,8 @@ import {encodeQueryParams} from "./common.js";
import {decryptAttachment} from "../e2ee/attachment.js";
export class MediaRepository {
constructor({homeServer, platform}) {
this._homeServer = homeServer;
constructor({homeserver, platform}) {
this._homeserver = homeserver;
this._platform = platform;
}
@ -27,7 +27,7 @@ export class MediaRepository {
const parts = this._parseMxcUrl(url);
if (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 null;
@ -37,7 +37,7 @@ export class MediaRepository {
const parts = this._parseMxcUrl(url);
if (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 {
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;
}
.LoginView_forwardInfo {
font-size: 0.9em;
margin-left: 1em;
color: #777;
}
.CompleteSSOView_title {
font-weight: 500;
}

View file

@ -43,9 +43,13 @@ export class LoginView extends TemplateView {
placeholder: vm.i18n`Your matrix homeserver`,
value: vm.homeserver,
disabled,
onInput: () => vm.setHomeServer(event.target.value),
onChange: event => vm.queryHomeServer(),
onInput: event => vm.setHomeserver(event.target.value),
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))),
]
)),

View file

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