Merge pull request #464 from vector-im/bwindels/well-known
Add .well-known support
This commit is contained in:
commit
7946a3e4d7
14 changed files with 131 additions and 58 deletions
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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.`;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
53
src/matrix/well-known.js
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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))),
|
||||
]
|
||||
)),
|
||||
|
|
|
@ -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>
|
||||
|
|
Reference in a new issue