1 | /*
|
2 | Copyright 2018 New Vector Ltd
|
3 |
|
4 | Licensed under the Apache License, Version 2.0 (the "License");
|
5 | you may not use this file except in compliance with the License.
|
6 | You may obtain a copy of the License at
|
7 |
|
8 | http://www.apache.org/licenses/LICENSE-2.0
|
9 |
|
10 | Unless required by applicable law or agreed to in writing, software
|
11 | distributed under the License is distributed on an "AS IS" BASIS,
|
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 | See the License for the specific language governing permissions and
|
14 | limitations under the License.
|
15 | */
|
16 |
|
17 | /** @module auto-discovery */
|
18 |
|
19 | import Promise from 'bluebird';
|
20 | const logger = require("./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 | /**
|
106 | * The auto discovery failed. The client is expected to communicate
|
107 | * the error to the user and refuse logging in.
|
108 | * @return {string}
|
109 | * @constructor
|
110 | */
|
111 | static get FAIL_ERROR() { return "FAIL_ERROR"; }
|
112 |
|
113 | /**
|
114 | * The auto discovery failed, however the client may still recover
|
115 | * from the problem. The client is recommended to that the same
|
116 | * action it would for PROMPT while also warning the user about
|
117 | * what went wrong. The client may also treat this the same as
|
118 | * a FAIL_ERROR state.
|
119 | * @return {string}
|
120 | * @constructor
|
121 | */
|
122 | static get FAIL_PROMPT() { return "FAIL_PROMPT"; }
|
123 |
|
124 | /**
|
125 | * The auto discovery didn't fail but did not find anything of
|
126 | * interest. The client is expected to prompt the user for more
|
127 | * information, or fail if it prefers.
|
128 | * @return {string}
|
129 | * @constructor
|
130 | */
|
131 | static get PROMPT() { return "PROMPT"; }
|
132 |
|
133 | /**
|
134 | * The auto discovery was successful.
|
135 | * @return {string}
|
136 | * @constructor
|
137 | */
|
138 | static get SUCCESS() { return "SUCCESS"; }
|
139 |
|
140 | /**
|
141 | * Attempts to automatically discover client configuration information
|
142 | * prior to logging in. Such information includes the homeserver URL
|
143 | * and identity server URL the client would want. Additional details
|
144 | * may also be discovered, and will be transparently included in the
|
145 | * response object unaltered.
|
146 | * @param {string} domain The homeserver domain to perform discovery
|
147 | * on. For example, "matrix.org".
|
148 | * @return {Promise<DiscoveredClientConfig>} Resolves to the discovered
|
149 | * configuration, which may include error states. Rejects on unexpected
|
150 | * failure, not when discovery fails.
|
151 | */
|
152 | static async findClientConfig(domain) {
|
153 | if (!domain || typeof(domain) !== "string" || domain.length === 0) {
|
154 | throw new Error("'domain' must be a string of non-zero length");
|
155 | }
|
156 |
|
157 | // We use a .well-known lookup for all cases. According to the spec, we
|
158 | // can do other discovery mechanisms if we want such as custom lookups
|
159 | // however we won't bother with that here (mostly because the spec only
|
160 | // supports .well-known right now).
|
161 | //
|
162 | // By using .well-known, we need to ensure we at least pull out a URL
|
163 | // for the homeserver. We don't really need an identity server configuration
|
164 | // but will return one anyways (with state PROMPT) to make development
|
165 | // easier for clients. If we can't get a homeserver URL, all bets are
|
166 | // off on the rest of the config and we'll assume it is invalid too.
|
167 |
|
168 | // We default to an error state to make the first few checks easier to
|
169 | // write. We'll update the properties of this object over the duration
|
170 | // of this function.
|
171 | const clientConfig = {
|
172 | "m.homeserver": {
|
173 | state: AutoDiscovery.FAIL_ERROR,
|
174 | error: "Invalid homeserver discovery response",
|
175 | base_url: null,
|
176 | },
|
177 | "m.identity_server": {
|
178 | // Technically, we don't have a problem with the identity server
|
179 | // config at this point.
|
180 | state: AutoDiscovery.PROMPT,
|
181 | error: null,
|
182 | base_url: null,
|
183 | },
|
184 | };
|
185 |
|
186 | // Step 1: Actually request the .well-known JSON file and make sure it
|
187 | // at least has a homeserver definition.
|
188 | const wellknown = await this._fetchWellKnownObject(
|
189 | `https://${domain}/.well-known/matrix/client`,
|
190 | );
|
191 | if (!wellknown || wellknown.action !== "SUCCESS"
|
192 | || !wellknown.raw["m.homeserver"]
|
193 | || !wellknown.raw["m.homeserver"]["base_url"]) {
|
194 | logger.error("No m.homeserver key in well-known response");
|
195 | if (wellknown.reason) logger.error(wellknown.reason);
|
196 | if (wellknown.action === "IGNORE") {
|
197 | clientConfig["m.homeserver"] = {
|
198 | state: AutoDiscovery.PROMPT,
|
199 | error: null,
|
200 | base_url: null,
|
201 | };
|
202 | } else {
|
203 | // this can only ever be FAIL_PROMPT at this point.
|
204 | clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
|
205 | }
|
206 | return Promise.resolve(clientConfig);
|
207 | }
|
208 |
|
209 | // Step 2: Make sure the homeserver URL is valid *looking*. We'll make
|
210 | // sure it points to a homeserver in Step 3.
|
211 | const hsUrl = this._sanitizeWellKnownUrl(
|
212 | wellknown.raw["m.homeserver"]["base_url"],
|
213 | );
|
214 | if (!hsUrl) {
|
215 | logger.error("Invalid base_url for m.homeserver");
|
216 | return Promise.resolve(clientConfig);
|
217 | }
|
218 |
|
219 | // Step 3: Make sure the homeserver URL points to a homeserver.
|
220 | const hsVersions = await this._fetchWellKnownObject(
|
221 | `${hsUrl}/_matrix/client/versions`,
|
222 | );
|
223 | if (!hsVersions || !hsVersions.raw["versions"]) {
|
224 | logger.error("Invalid /versions response");
|
225 | return Promise.resolve(clientConfig);
|
226 | }
|
227 |
|
228 | // Step 4: Now that the homeserver looks valid, update our client config.
|
229 | clientConfig["m.homeserver"] = {
|
230 | state: AutoDiscovery.SUCCESS,
|
231 | error: null,
|
232 | base_url: hsUrl,
|
233 | };
|
234 |
|
235 | // Step 5: Try to pull out the identity server configuration
|
236 | let isUrl = "";
|
237 | if (wellknown.raw["m.identity_server"]) {
|
238 | // We prepare a failing identity server response to save lines later
|
239 | // in this branch. Note that we also fail the homeserver check in the
|
240 | // object because according to the spec we're supposed to FAIL_ERROR
|
241 | // if *anything* goes wrong with the IS validation, including invalid
|
242 | // format. This means we're supposed to stop discovery completely.
|
243 | const failingClientConfig = {
|
244 | "m.homeserver": {
|
245 | state: AutoDiscovery.FAIL_ERROR,
|
246 | error: "Invalid identity server discovery response",
|
247 |
|
248 | // We'll provide the base_url that was previously valid for
|
249 | // debugging purposes.
|
250 | base_url: clientConfig["m.homeserver"].base_url,
|
251 | },
|
252 | "m.identity_server": {
|
253 | state: AutoDiscovery.FAIL_ERROR,
|
254 | error: "Invalid identity server discovery response",
|
255 | base_url: null,
|
256 | },
|
257 | };
|
258 |
|
259 | // Step 5a: Make sure the URL is valid *looking*. We'll make sure it
|
260 | // points to an identity server in Step 5b.
|
261 | isUrl = this._sanitizeWellKnownUrl(
|
262 | wellknown.raw["m.identity_server"]["base_url"],
|
263 | );
|
264 | if (!isUrl) {
|
265 | logger.error("Invalid base_url for m.identity_server");
|
266 | return Promise.resolve(failingClientConfig);
|
267 | }
|
268 |
|
269 | // Step 5b: Verify there is an identity server listening on the provided
|
270 | // URL.
|
271 | const isResponse = await this._fetchWellKnownObject(
|
272 | `${isUrl}/_matrix/identity/api/v1`,
|
273 | );
|
274 | if (!isResponse || !isResponse.raw || isResponse.action !== "SUCCESS") {
|
275 | logger.error("Invalid /api/v1 response");
|
276 | return Promise.resolve(failingClientConfig);
|
277 | }
|
278 | }
|
279 |
|
280 | // Step 6: Now that the identity server is valid, or never existed,
|
281 | // populate the IS section.
|
282 | if (isUrl && isUrl.length > 0) {
|
283 | clientConfig["m.identity_server"] = {
|
284 | state: AutoDiscovery.SUCCESS,
|
285 | error: null,
|
286 | base_url: isUrl,
|
287 | };
|
288 | }
|
289 |
|
290 | // Step 7: Copy any other keys directly into the clientConfig. This is for
|
291 | // things like custom configuration of services.
|
292 | Object.keys(wellknown.raw)
|
293 | .filter((k) => k !== "m.homeserver" && k !== "m.identity_server")
|
294 | .map((k) => clientConfig[k] = wellknown.raw[k]);
|
295 |
|
296 | // Step 8: Give the config to the caller (finally)
|
297 | return Promise.resolve(clientConfig);
|
298 | }
|
299 |
|
300 | /**
|
301 | * Sanitizes a given URL to ensure it is either an HTTP or HTTP URL and
|
302 | * is suitable for the requirements laid out by .well-known auto discovery.
|
303 | * If valid, the URL will also be stripped of any trailing slashes.
|
304 | * @param {string} url The potentially invalid URL to sanitize.
|
305 | * @return {string|boolean} The sanitized URL or a falsey value if the URL is invalid.
|
306 | * @private
|
307 | */
|
308 | static _sanitizeWellKnownUrl(url) {
|
309 | if (!url) return false;
|
310 |
|
311 | try {
|
312 | // We have to try and parse the URL using the NodeJS URL
|
313 | // library if we're on NodeJS and use the browser's URL
|
314 | // library when we're in a browser. To accomplish this, we
|
315 | // try the NodeJS version first and fall back to the browser.
|
316 | let parsed = null;
|
317 | try {
|
318 | if (NodeURL) parsed = new NodeURL(url);
|
319 | else parsed = new URL(url);
|
320 | } catch (e) {
|
321 | parsed = new URL(url);
|
322 | }
|
323 |
|
324 | if (!parsed || !parsed.hostname) return false;
|
325 | if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
326 |
|
327 | const port = parsed.port ? `:${parsed.port}` : "";
|
328 | const path = parsed.pathname ? parsed.pathname : "";
|
329 | let saferUrl = `${parsed.protocol}//${parsed.hostname}${port}${path}`;
|
330 | if (saferUrl.endsWith("/")) {
|
331 | saferUrl = saferUrl.substring(0, saferUrl.length - 1);
|
332 | }
|
333 | return saferUrl;
|
334 | } catch (e) {
|
335 | logger.error(e);
|
336 | return false;
|
337 | }
|
338 | }
|
339 |
|
340 | /**
|
341 | * Fetches a JSON object from a given URL, as expected by all .well-known
|
342 | * related lookups. If the server gives a 404 then the `action` will be
|
343 | * IGNORE. If the server returns something that isn't JSON, the `action`
|
344 | * will be FAIL_PROMPT. For any other failure the `action` will be FAIL_PROMPT.
|
345 | *
|
346 | * The returned object will be a result of the call in object form with
|
347 | * the following properties:
|
348 | * raw: The JSON object returned by the server.
|
349 | * action: One of SUCCESS, IGNORE, or FAIL_PROMPT.
|
350 | * reason: Relatively human readable description of what went wrong.
|
351 | * error: The actual Error, if one exists.
|
352 | * @param {string} url The URL to fetch a JSON object from.
|
353 | * @return {Promise<object>} Resolves to the returned state.
|
354 | * @private
|
355 | */
|
356 | static async _fetchWellKnownObject(url) {
|
357 | return new Promise(function(resolve, reject) {
|
358 | const request = require("./matrix").getRequest();
|
359 | if (!request) throw new Error("No request library available");
|
360 | request(
|
361 | { method: "GET", uri: url },
|
362 | (err, response, body) => {
|
363 | if (err || response.statusCode < 200 || response.statusCode >= 300) {
|
364 | let action = "FAIL_PROMPT";
|
365 | let reason = (err ? err.message : null) || "General failure";
|
366 | if (response.statusCode === 404) {
|
367 | action = "IGNORE";
|
368 | reason = "No .well-known JSON file found";
|
369 | }
|
370 | resolve({raw: {}, action: action, reason: reason, error: err});
|
371 | return;
|
372 | }
|
373 |
|
374 | try {
|
375 | resolve({raw: JSON.parse(body), action: "SUCCESS"});
|
376 | } catch (e) {
|
377 | let reason = "General failure";
|
378 | if (e.name === "SyntaxError") reason = "Invalid JSON";
|
379 | resolve({
|
380 | raw: {},
|
381 | action: "FAIL_PROMPT",
|
382 | reason: reason, error: e,
|
383 | });
|
384 | }
|
385 | },
|
386 | );
|
387 | });
|
388 | }
|
389 | }
|