UNPKG

21.7 kBJavaScriptView Raw
1/*
2Copyright 2018 New Vector Ltd
3Copyright 2019 The Matrix.org Foundation C.I.C.
4
5Licensed under the Apache License, Version 2.0 (the "License");
6you may not use this file except in compliance with the License.
7You may obtain a copy of the License at
8
9 http://www.apache.org/licenses/LICENSE-2.0
10
11Unless required by applicable law or agreed to in writing, software
12distributed under the License is distributed on an "AS IS" BASIS,
13WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14See the License for the specific language governing permissions and
15limitations under the License.
16*/
17
18/** @module auto-discovery */
19
20import {logger} from './logger';
21import {URL as NodeURL} from "url";
22
23// Dev note: Auto discovery is part of the spec.
24// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
25
26/**
27 * Description for what an automatically discovered client configuration
28 * would look like. Although this is a class, it is recommended that it
29 * be treated as an interface definition rather than as a class.
30 *
31 * Additional properties than those defined here may be present, and
32 * should follow the Java package naming convention.
33 */
34class DiscoveredClientConfig { // eslint-disable-line no-unused-vars
35 // Dev note: this is basically a copy/paste of the .well-known response
36 // object as defined in the spec. It does have additional information,
37 // however. Overall, this exists to serve as a place for documentation
38 // and not functionality.
39 // See https://matrix.org/docs/spec/client_server/r0.4.0.html#get-well-known-matrix-client
40
41 constructor() {
42 /**
43 * The homeserver configuration the client should use. This will
44 * always be present on the object.
45 * @type {{state: string, base_url: string}} The configuration.
46 */
47 this["m.homeserver"] = {
48 /**
49 * The lookup result state. If this is anything other than
50 * AutoDiscovery.SUCCESS then base_url may be falsey. Additionally,
51 * if this is not AutoDiscovery.SUCCESS then the client should
52 * assume the other properties in the client config (such as
53 * the identity server configuration) are not valid.
54 */
55 state: AutoDiscovery.PROMPT,
56
57 /**
58 * If the state is AutoDiscovery.FAIL_ERROR or .FAIL_PROMPT
59 * then this will contain a human-readable (English) message
60 * for what went wrong. If the state is none of those previously
61 * mentioned, this will be falsey.
62 */
63 error: "Something went wrong",
64
65 /**
66 * The base URL clients should use to talk to the homeserver,
67 * particularly for the login process. May be falsey if the
68 * state is not AutoDiscovery.SUCCESS.
69 */
70 base_url: "https://matrix.org",
71 };
72
73 /**
74 * The identity server configuration the client should use. This
75 * will always be present on teh object.
76 * @type {{state: string, base_url: string}} The configuration.
77 */
78 this["m.identity_server"] = {
79 /**
80 * The lookup result state. If this is anything other than
81 * AutoDiscovery.SUCCESS then base_url may be falsey.
82 */
83 state: AutoDiscovery.PROMPT,
84
85 /**
86 * The base URL clients should use for interacting with the
87 * identity server. May be falsey if the state is not
88 * AutoDiscovery.SUCCESS.
89 */
90 base_url: "https://vector.im",
91 };
92 }
93}
94
95/**
96 * Utilities for automatically discovery resources, such as homeservers
97 * for users to log in to.
98 */
99export class AutoDiscovery {
100 // Dev note: the constants defined here are related to but not
101 // exactly the same as those in the spec. This is to hopefully
102 // translate the meaning of the states in the spec, but also
103 // support our own if needed.
104
105 static get ERROR_INVALID() {
106 return "Invalid homeserver discovery response";
107 }
108
109 static get ERROR_GENERIC_FAILURE() {
110 return "Failed to get autodiscovery configuration from server";
111 }
112
113 static get ERROR_INVALID_HS_BASE_URL() {
114 return "Invalid base_url for m.homeserver";
115 }
116
117 static get ERROR_INVALID_HOMESERVER() {
118 return "Homeserver URL does not appear to be a valid Matrix homeserver";
119 }
120
121 static get ERROR_INVALID_IS_BASE_URL() {
122 return "Invalid base_url for m.identity_server";
123 }
124
125 static get ERROR_INVALID_IDENTITY_SERVER() {
126 return "Identity server URL does not appear to be a valid identity server";
127 }
128
129 static get ERROR_INVALID_IS() {
130 return "Invalid identity server discovery response";
131 }
132
133 static get ERROR_MISSING_WELLKNOWN() {
134 return "No .well-known JSON file found";
135 }
136
137 static get ERROR_INVALID_JSON() {
138 return "Invalid JSON";
139 }
140
141 static get ALL_ERRORS() {
142 return [
143 AutoDiscovery.ERROR_INVALID,
144 AutoDiscovery.ERROR_GENERIC_FAILURE,
145 AutoDiscovery.ERROR_INVALID_HS_BASE_URL,
146 AutoDiscovery.ERROR_INVALID_HOMESERVER,
147 AutoDiscovery.ERROR_INVALID_IS_BASE_URL,
148 AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER,
149 AutoDiscovery.ERROR_INVALID_IS,
150 AutoDiscovery.ERROR_MISSING_WELLKNOWN,
151 AutoDiscovery.ERROR_INVALID_JSON,
152 ];
153 }
154
155 /**
156 * The auto discovery failed. The client is expected to communicate
157 * the error to the user and refuse logging in.
158 * @return {string}
159 * @constructor
160 */
161 static get FAIL_ERROR() { return "FAIL_ERROR"; }
162
163 /**
164 * The auto discovery failed, however the client may still recover
165 * from the problem. The client is recommended to that the same
166 * action it would for PROMPT while also warning the user about
167 * what went wrong. The client may also treat this the same as
168 * a FAIL_ERROR state.
169 * @return {string}
170 * @constructor
171 */
172 static get FAIL_PROMPT() { return "FAIL_PROMPT"; }
173
174 /**
175 * The auto discovery didn't fail but did not find anything of
176 * interest. The client is expected to prompt the user for more
177 * information, or fail if it prefers.
178 * @return {string}
179 * @constructor
180 */
181 static get PROMPT() { return "PROMPT"; }
182
183 /**
184 * The auto discovery was successful.
185 * @return {string}
186 * @constructor
187 */
188 static get SUCCESS() { return "SUCCESS"; }
189
190 /**
191 * Validates and verifies client configuration information for purposes
192 * of logging in. Such information includes the homeserver URL
193 * and identity server URL the client would want. Additional details
194 * may also be included, and will be transparently brought into the
195 * response object unaltered.
196 * @param {string} wellknown The configuration object itself, as returned
197 * by the .well-known auto-discovery endpoint.
198 * @return {Promise<DiscoveredClientConfig>} Resolves to the verified
199 * configuration, which may include error states. Rejects on unexpected
200 * failure, not when verification fails.
201 */
202 static async fromDiscoveryConfig(wellknown) {
203 // Step 1 is to get the config, which is provided to us here.
204
205 // We default to an error state to make the first few checks easier to
206 // write. We'll update the properties of this object over the duration
207 // of this function.
208 const clientConfig = {
209 "m.homeserver": {
210 state: AutoDiscovery.FAIL_ERROR,
211 error: AutoDiscovery.ERROR_INVALID,
212 base_url: null,
213 },
214 "m.identity_server": {
215 // Technically, we don't have a problem with the identity server
216 // config at this point.
217 state: AutoDiscovery.PROMPT,
218 error: null,
219 base_url: null,
220 },
221 };
222
223 if (!wellknown || !wellknown["m.homeserver"]) {
224 logger.error("No m.homeserver key in config");
225
226 clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
227 clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID;
228
229 return Promise.resolve(clientConfig);
230 }
231
232 if (!wellknown["m.homeserver"]["base_url"]) {
233 logger.error("No m.homeserver base_url in config");
234
235 clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
236 clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL;
237
238 return Promise.resolve(clientConfig);
239 }
240
241 // Step 2: Make sure the homeserver URL is valid *looking*. We'll make
242 // sure it points to a homeserver in Step 3.
243 const hsUrl = this._sanitizeWellKnownUrl(
244 wellknown["m.homeserver"]["base_url"],
245 );
246 if (!hsUrl) {
247 logger.error("Invalid base_url for m.homeserver");
248 clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL;
249 return Promise.resolve(clientConfig);
250 }
251
252 // Step 3: Make sure the homeserver URL points to a homeserver.
253 const hsVersions = await this._fetchWellKnownObject(
254 `${hsUrl}/_matrix/client/versions`,
255 );
256 if (!hsVersions || !hsVersions.raw["versions"]) {
257 logger.error("Invalid /versions response");
258 clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER;
259
260 // Supply the base_url to the caller because they may be ignoring liveliness
261 // errors, like this one.
262 clientConfig["m.homeserver"].base_url = hsUrl;
263
264 return Promise.resolve(clientConfig);
265 }
266
267 // Step 4: Now that the homeserver looks valid, update our client config.
268 clientConfig["m.homeserver"] = {
269 state: AutoDiscovery.SUCCESS,
270 error: null,
271 base_url: hsUrl,
272 };
273
274 // Step 5: Try to pull out the identity server configuration
275 let isUrl = "";
276 if (wellknown["m.identity_server"]) {
277 // We prepare a failing identity server response to save lines later
278 // in this branch.
279 const failingClientConfig = {
280 "m.homeserver": clientConfig["m.homeserver"],
281 "m.identity_server": {
282 state: AutoDiscovery.FAIL_PROMPT,
283 error: AutoDiscovery.ERROR_INVALID_IS,
284 base_url: null,
285 },
286 };
287
288 // Step 5a: Make sure the URL is valid *looking*. We'll make sure it
289 // points to an identity server in Step 5b.
290 isUrl = this._sanitizeWellKnownUrl(
291 wellknown["m.identity_server"]["base_url"],
292 );
293 if (!isUrl) {
294 logger.error("Invalid base_url for m.identity_server");
295 failingClientConfig["m.identity_server"].error =
296 AutoDiscovery.ERROR_INVALID_IS_BASE_URL;
297 return Promise.resolve(failingClientConfig);
298 }
299
300 // Step 5b: Verify there is an identity server listening on the provided
301 // URL.
302 const isResponse = await this._fetchWellKnownObject(
303 `${isUrl}/_matrix/identity/api/v1`,
304 );
305 if (!isResponse || !isResponse.raw || isResponse.action !== "SUCCESS") {
306 logger.error("Invalid /api/v1 response");
307 failingClientConfig["m.identity_server"].error =
308 AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER;
309
310 // Supply the base_url to the caller because they may be ignoring
311 // liveliness errors, like this one.
312 failingClientConfig["m.identity_server"].base_url = isUrl;
313
314 return Promise.resolve(failingClientConfig);
315 }
316 }
317
318 // Step 6: Now that the identity server is valid, or never existed,
319 // populate the IS section.
320 if (isUrl && isUrl.length > 0) {
321 clientConfig["m.identity_server"] = {
322 state: AutoDiscovery.SUCCESS,
323 error: null,
324 base_url: isUrl,
325 };
326 }
327
328 // Step 7: Copy any other keys directly into the clientConfig. This is for
329 // things like custom configuration of services.
330 Object.keys(wellknown)
331 .map((k) => {
332 if (k === "m.homeserver" || k === "m.identity_server") {
333 // Only copy selected parts of the config to avoid overwriting
334 // properties computed by the validation logic above.
335 const notProps = ["error", "state", "base_url"];
336 for (const prop of Object.keys(wellknown[k])) {
337 if (notProps.includes(prop)) continue;
338 clientConfig[k][prop] = wellknown[k][prop];
339 }
340 } else {
341 // Just copy the whole thing over otherwise
342 clientConfig[k] = wellknown[k];
343 }
344 });
345
346 // Step 8: Give the config to the caller (finally)
347 return Promise.resolve(clientConfig);
348 }
349
350 /**
351 * Attempts to automatically discover client configuration information
352 * prior to logging in. Such information includes the homeserver URL
353 * and identity server URL the client would want. Additional details
354 * may also be discovered, and will be transparently included in the
355 * response object unaltered.
356 * @param {string} domain The homeserver domain to perform discovery
357 * on. For example, "matrix.org".
358 * @return {Promise<DiscoveredClientConfig>} Resolves to the discovered
359 * configuration, which may include error states. Rejects on unexpected
360 * failure, not when discovery fails.
361 */
362 static async findClientConfig(domain) {
363 if (!domain || typeof(domain) !== "string" || domain.length === 0) {
364 throw new Error("'domain' must be a string of non-zero length");
365 }
366
367 // We use a .well-known lookup for all cases. According to the spec, we
368 // can do other discovery mechanisms if we want such as custom lookups
369 // however we won't bother with that here (mostly because the spec only
370 // supports .well-known right now).
371 //
372 // By using .well-known, we need to ensure we at least pull out a URL
373 // for the homeserver. We don't really need an identity server configuration
374 // but will return one anyways (with state PROMPT) to make development
375 // easier for clients. If we can't get a homeserver URL, all bets are
376 // off on the rest of the config and we'll assume it is invalid too.
377
378 // We default to an error state to make the first few checks easier to
379 // write. We'll update the properties of this object over the duration
380 // of this function.
381 const clientConfig = {
382 "m.homeserver": {
383 state: AutoDiscovery.FAIL_ERROR,
384 error: AutoDiscovery.ERROR_INVALID,
385 base_url: null,
386 },
387 "m.identity_server": {
388 // Technically, we don't have a problem with the identity server
389 // config at this point.
390 state: AutoDiscovery.PROMPT,
391 error: null,
392 base_url: null,
393 },
394 };
395
396 // Step 1: Actually request the .well-known JSON file and make sure it
397 // at least has a homeserver definition.
398 const wellknown = await this._fetchWellKnownObject(
399 `https://${domain}/.well-known/matrix/client`,
400 );
401 if (!wellknown || wellknown.action !== "SUCCESS") {
402 logger.error("No response or error when parsing .well-known");
403 if (wellknown.reason) logger.error(wellknown.reason);
404 if (wellknown.action === "IGNORE") {
405 clientConfig["m.homeserver"] = {
406 state: AutoDiscovery.PROMPT,
407 error: null,
408 base_url: null,
409 };
410 } else {
411 // this can only ever be FAIL_PROMPT at this point.
412 clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
413 clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID;
414 }
415 return Promise.resolve(clientConfig);
416 }
417
418 // Step 2: Validate and parse the config
419 return AutoDiscovery.fromDiscoveryConfig(wellknown.raw);
420 }
421
422 /**
423 * Gets the raw discovery client configuration for the given domain name.
424 * Should only be used if there's no validation to be done on the resulting
425 * object, otherwise use findClientConfig().
426 * @param {string} domain The domain to get the client config for.
427 * @returns {Promise<object>} Resolves to the domain's client config. Can
428 * be an empty object.
429 */
430 static async getRawClientConfig(domain) {
431 if (!domain || typeof(domain) !== "string" || domain.length === 0) {
432 throw new Error("'domain' must be a string of non-zero length");
433 }
434
435 const response = await this._fetchWellKnownObject(
436 `https://${domain}/.well-known/matrix/client`,
437 );
438 if (!response) return {};
439 return response.raw || {};
440 }
441
442 /**
443 * Sanitizes a given URL to ensure it is either an HTTP or HTTP URL and
444 * is suitable for the requirements laid out by .well-known auto discovery.
445 * If valid, the URL will also be stripped of any trailing slashes.
446 * @param {string} url The potentially invalid URL to sanitize.
447 * @return {string|boolean} The sanitized URL or a falsey value if the URL is invalid.
448 * @private
449 */
450 static _sanitizeWellKnownUrl(url) {
451 if (!url) return false;
452
453 try {
454 // We have to try and parse the URL using the NodeJS URL
455 // library if we're on NodeJS and use the browser's URL
456 // library when we're in a browser. To accomplish this, we
457 // try the NodeJS version first and fall back to the browser.
458 let parsed = null;
459 try {
460 if (NodeURL) parsed = new NodeURL(url);
461 else parsed = new URL(url);
462 } catch (e) {
463 parsed = new URL(url);
464 }
465
466 if (!parsed || !parsed.hostname) return false;
467 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
468
469 const port = parsed.port ? `:${parsed.port}` : "";
470 const path = parsed.pathname ? parsed.pathname : "";
471 let saferUrl = `${parsed.protocol}//${parsed.hostname}${port}${path}`;
472 if (saferUrl.endsWith("/")) {
473 saferUrl = saferUrl.substring(0, saferUrl.length - 1);
474 }
475 return saferUrl;
476 } catch (e) {
477 logger.error(e);
478 return false;
479 }
480 }
481
482 /**
483 * Fetches a JSON object from a given URL, as expected by all .well-known
484 * related lookups. If the server gives a 404 then the `action` will be
485 * IGNORE. If the server returns something that isn't JSON, the `action`
486 * will be FAIL_PROMPT. For any other failure the `action` will be FAIL_PROMPT.
487 *
488 * The returned object will be a result of the call in object form with
489 * the following properties:
490 * raw: The JSON object returned by the server.
491 * action: One of SUCCESS, IGNORE, or FAIL_PROMPT.
492 * reason: Relatively human readable description of what went wrong.
493 * error: The actual Error, if one exists.
494 * @param {string} url The URL to fetch a JSON object from.
495 * @return {Promise<object>} Resolves to the returned state.
496 * @private
497 */
498 static async _fetchWellKnownObject(url) {
499 return new Promise(function(resolve, reject) {
500 const request = require("./matrix").getRequest();
501 if (!request) throw new Error("No request library available");
502 request(
503 { method: "GET", uri: url, timeout: 5000 },
504 (err, response, body) => {
505 if (err || response.statusCode < 200 || response.statusCode >= 300) {
506 let action = "FAIL_PROMPT";
507 let reason = (err ? err.message : null) || "General failure";
508 if (response.statusCode === 404) {
509 action = "IGNORE";
510 reason = AutoDiscovery.ERROR_MISSING_WELLKNOWN;
511 }
512 resolve({raw: {}, action: action, reason: reason, error: err});
513 return;
514 }
515
516 try {
517 resolve({raw: JSON.parse(body), action: "SUCCESS"});
518 } catch (e) {
519 let reason = AutoDiscovery.ERROR_INVALID;
520 if (e.name === "SyntaxError") {
521 reason = AutoDiscovery.ERROR_INVALID_JSON;
522 }
523 resolve({
524 raw: {},
525 action: "FAIL_PROMPT",
526 reason: reason,
527 error: e,
528 });
529 }
530 },
531 );
532 });
533 }
534}