1 | /*
|
2 | Copyright 2018 New Vector Ltd
|
3 | Copyright 2019 The Matrix.org Foundation C.I.C.
|
4 |
|
5 | Licensed under the Apache License, Version 2.0 (the "License");
|
6 | you may not use this file except in compliance with the License.
|
7 | You may obtain a copy of the License at
|
8 |
|
9 | http://www.apache.org/licenses/LICENSE-2.0
|
10 |
|
11 | Unless required by applicable law or agreed to in writing, software
|
12 | distributed under the License is distributed on an "AS IS" BASIS,
|
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14 | See the License for the specific language governing permissions and
|
15 | limitations under the License.
|
16 | */
|
17 |
|
18 | /** @module auto-discovery */
|
19 |
|
20 | import {logger} from './logger';
|
21 | import {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 | */
|
34 | class 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 | */
|
99 | export 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 | }
|