UNPKG

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