UNPKG

18 kBJavaScriptView Raw
1/*
2Copyright 2016 OpenMarket Ltd
3Copyright 2017 Vector Creations Ltd
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"use strict";
18
19/** @module interactive-auth */
20
21var _getIterator2 = require("babel-runtime/core-js/get-iterator");
22
23var _getIterator3 = _interopRequireDefault(_getIterator2);
24
25var _stringify = require("babel-runtime/core-js/json/stringify");
26
27var _stringify2 = _interopRequireDefault(_stringify);
28
29var _bluebird = require("bluebird");
30
31var _bluebird2 = _interopRequireDefault(_bluebird);
32
33function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
34
35var url = require("url");
36
37var utils = require("./utils");
38
39var EMAIL_STAGE_TYPE = "m.login.email.identity";
40var MSISDN_STAGE_TYPE = "m.login.msisdn";
41
42/**
43 * Abstracts the logic used to drive the interactive auth process.
44 *
45 * <p>Components implementing an interactive auth flow should instantiate one of
46 * these, passing in the necessary callbacks to the constructor. They should
47 * then call attemptAuth, which will return a promise which will resolve or
48 * reject when the interactive-auth process completes.
49 *
50 * <p>Meanwhile, calls will be made to the startAuthStage and doRequest
51 * callbacks, and information gathered from the user can be submitted with
52 * submitAuthDict.
53 *
54 * @constructor
55 * @alias module:interactive-auth
56 *
57 * @param {object} opts options object
58 *
59 * @param {object} opts.matrixClient A matrix client to use for the auth process
60 *
61 * @param {object?} opts.authData error response from the last request. If
62 * null, a request will be made with no auth before starting.
63 *
64 * @param {function(object?, bool?): module:client.Promise} opts.doRequest
65 * called with the new auth dict to submit the request and a flag set
66 * to true if this request is a background request. Should return a
67 * promise which resolves to the successful response or rejects with a
68 * MatrixError.
69 *
70 * @param {function(string, object?)} opts.stateUpdated
71 * called when the status of the UI auth changes, ie. when the state of
72 * an auth stage changes of when the auth flow moves to a new stage.
73 * The arguments are: the login type (eg m.login.password); and an object
74 * which is either an error or an informational object specific to the
75 * login type. If the 'errcode' key is defined, the object is an error,
76 * and has keys:
77 * errcode: string, the textual error code, eg. M_UNKNOWN
78 * error: string, human readable string describing the error
79 *
80 * The login type specific objects are as follows:
81 * m.login.email.identity:
82 * * emailSid: string, the sid of the active email auth session
83 *
84 * @param {object?} opts.inputs Inputs provided by the user and used by different
85 * stages of the auto process. The inputs provided will affect what flow is chosen.
86 *
87 * @param {string?} opts.inputs.emailAddress An email address. If supplied, a flow
88 * using email verification will be chosen.
89 *
90 * @param {string?} opts.inputs.phoneCountry An ISO two letter country code. Gives
91 * the country that opts.phoneNumber should be resolved relative to.
92 *
93 * @param {string?} opts.inputs.phoneNumber A phone number. If supplied, a flow
94 * using phone number validation will be chosen.
95 *
96 * @param {string?} opts.sessionId If resuming an existing interactive auth session,
97 * the sessionId of that session.
98 *
99 * @param {string?} opts.clientSecret If resuming an existing interactive auth session,
100 * the client secret for that session
101 *
102 * @param {string?} opts.emailSid If returning from having completed m.login.email.identity
103 * auth, the sid for the email verification session.
104 *
105 */
106function InteractiveAuth(opts) {
107 this._matrixClient = opts.matrixClient;
108 this._data = opts.authData || {};
109 this._requestCallback = opts.doRequest;
110 // startAuthStage included for backwards compat
111 this._stateUpdatedCallback = opts.stateUpdated || opts.startAuthStage;
112 this._completionDeferred = null;
113 this._inputs = opts.inputs || {};
114
115 if (opts.sessionId) this._data.session = opts.sessionId;
116 this._clientSecret = opts.clientSecret || this._matrixClient.generateClientSecret();
117 this._emailSid = opts.emailSid;
118 if (this._emailSid === undefined) this._emailSid = null;
119
120 this._currentStage = null;
121}
122
123InteractiveAuth.prototype = {
124 /**
125 * begin the authentication process.
126 *
127 * @return {module:client.Promise} which resolves to the response on success,
128 * or rejects with the error on failure. Rejects with NoAuthFlowFoundError if
129 * no suitable authentication flow can be found
130 */
131 attemptAuth: function attemptAuth() {
132 var _this = this;
133
134 this._completionDeferred = _bluebird2.default.defer();
135
136 // wrap in a promise so that if _startNextAuthStage
137 // throws, it rejects the promise in a consistent way
138 return _bluebird2.default.resolve().then(function () {
139 // if we have no flows, try a request (we'll have
140 // just a session ID in _data if resuming)
141 if (!_this._data.flows) {
142 _this._doRequest(_this._data);
143 } else {
144 _this._startNextAuthStage();
145 }
146 return _this._completionDeferred.promise;
147 });
148 },
149
150 /**
151 * Poll to check if the auth session or current stage has been
152 * completed out-of-band. If so, the attemptAuth promise will
153 * be resolved.
154 */
155 poll: function poll() {
156 if (!this._data.session) return;
157
158 var authDict = {};
159 if (this._currentStage == EMAIL_STAGE_TYPE) {
160 // The email can be validated out-of-band, but we need to provide the
161 // creds so the HS can go & check it.
162 if (this._emailSid) {
163 var idServerParsedUrl = url.parse(this._matrixClient.getIdentityServerUrl());
164 authDict = {
165 type: EMAIL_STAGE_TYPE,
166 threepid_creds: {
167 sid: this._emailSid,
168 client_secret: this._clientSecret,
169 id_server: idServerParsedUrl.host
170 }
171 };
172 }
173 }
174
175 this.submitAuthDict(authDict, true);
176 },
177
178 /**
179 * get the auth session ID
180 *
181 * @return {string} session id
182 */
183 getSessionId: function getSessionId() {
184 return this._data ? this._data.session : undefined;
185 },
186
187 /**
188 * get the client secret used for validation sessions
189 * with the ID server.
190 *
191 * @return {string} client secret
192 */
193 getClientSecret: function getClientSecret() {
194 return this._clientSecret;
195 },
196
197 /**
198 * get the server params for a given stage
199 *
200 * @param {string} loginType login type for the stage
201 * @return {object?} any parameters from the server for this stage
202 */
203 getStageParams: function getStageParams(loginType) {
204 var params = {};
205 if (this._data && this._data.params) {
206 params = this._data.params;
207 }
208 return params[loginType];
209 },
210
211 /**
212 * submit a new auth dict and fire off the request. This will either
213 * make attemptAuth resolve/reject, or cause the startAuthStage callback
214 * to be called for a new stage.
215 *
216 * @param {object} authData new auth dict to send to the server. Should
217 * include a `type` propterty denoting the login type, as well as any
218 * other params for that stage.
219 * @param {bool} background If true, this request failing will not result
220 * in the attemptAuth promise being rejected. This can be set to true
221 * for requests that just poll to see if auth has been completed elsewhere.
222 */
223 submitAuthDict: function submitAuthDict(authData, background) {
224 if (!this._completionDeferred) {
225 throw new Error("submitAuthDict() called before attemptAuth()");
226 }
227
228 // use the sessionid from the last request.
229 var auth = {
230 session: this._data.session
231 };
232 utils.extend(auth, authData);
233
234 this._doRequest(auth, background);
235 },
236
237 /**
238 * Gets the sid for the email validation session
239 * Specific to m.login.email.identity
240 *
241 * @returns {string} The sid of the email auth session
242 */
243 getEmailSid: function getEmailSid() {
244 return this._emailSid;
245 },
246
247 /**
248 * Sets the sid for the email validation session
249 * This must be set in order to successfully poll for completion
250 * of the email validation.
251 * Specific to m.login.email.identity
252 *
253 * @param {string} sid The sid for the email validation session
254 */
255 setEmailSid: function setEmailSid(sid) {
256 this._emailSid = sid;
257 },
258
259 /**
260 * Fire off a request, and either resolve the promise, or call
261 * startAuthStage.
262 *
263 * @private
264 * @param {object?} auth new auth dict, including session id
265 * @param {bool?} background If true, this request is a background poll, so it
266 * failing will not result in the attemptAuth promise being rejected.
267 * This can be set to true for requests that just poll to see if auth has
268 * been completed elsewhere.
269 */
270 _doRequest: function _doRequest(auth, background) {
271 var _this2 = this;
272
273 var self = this;
274
275 // hackery to make sure that synchronous exceptions end up in the catch
276 // handler (without the additional event loop entailed by q.fcall or an
277 // extra Promise.resolve().then)
278 var prom = void 0;
279 try {
280 prom = this._requestCallback(auth, background);
281 } catch (e) {
282 prom = _bluebird2.default.reject(e);
283 }
284
285 prom = prom.then(function (result) {
286 console.log("result from request: ", result);
287 self._completionDeferred.resolve(result);
288 }, function (error) {
289 // sometimes UI auth errors don't come with flows
290 var errorFlows = error.data ? error.data.flows : null;
291 var haveFlows = Boolean(self._data.flows) || Boolean(errorFlows);
292 if (error.httpStatus !== 401 || !error.data || !haveFlows) {
293 // doesn't look like an interactive-auth failure. fail the whole lot.
294 throw error;
295 }
296 // if the error didn't come with flows, completed flows or session ID,
297 // copy over the ones we have. Synapse sometimes sends responses without
298 // any UI auth data (eg. when polling for email validation, if the email
299 // has not yet been validated). This appears to be a Synapse bug, which
300 // we workaround here.
301 if (!error.data.flows && !error.data.completed && !error.data.session) {
302 error.data.flows = self._data.flows;
303 error.data.completed = self._data.completed;
304 error.data.session = self._data.session;
305 }
306 self._data = error.data;
307 self._startNextAuthStage();
308 });
309 if (!background) {
310 prom = prom.catch(function (e) {
311 _this2._completionDeferred.reject(e);
312 });
313 } else {
314 // We ignore all failures here (even non-UI auth related ones)
315 // since we don't want to suddenly fail if the internet connection
316 // had a blip whilst we were polling
317 prom = prom.catch(function (error) {
318 console.log("Ignoring error from UI auth: " + error);
319 });
320 }
321 prom.done();
322 },
323
324 /**
325 * Pick the next stage and call the callback
326 *
327 * @private
328 * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found
329 */
330 _startNextAuthStage: function _startNextAuthStage() {
331 var nextStage = this._chooseStage();
332 if (!nextStage) {
333 throw new Error("No incomplete flows from the server");
334 }
335 this._currentStage = nextStage;
336
337 if (nextStage == 'm.login.dummy') {
338 this.submitAuthDict({
339 type: 'm.login.dummy'
340 });
341 return;
342 }
343
344 if (this._data.errcode || this._data.error) {
345 this._stateUpdatedCallback(nextStage, {
346 errcode: this._data.errcode || "",
347 error: this._data.error || ""
348 });
349 return;
350 }
351
352 var stageStatus = {};
353 if (nextStage == EMAIL_STAGE_TYPE) {
354 stageStatus.emailSid = this._emailSid;
355 }
356 this._stateUpdatedCallback(nextStage, stageStatus);
357 },
358
359 /**
360 * Pick the next auth stage
361 *
362 * @private
363 * @return {string?} login type
364 * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found
365 */
366 _chooseStage: function _chooseStage() {
367 var flow = this._chooseFlow();
368 console.log("Active flow => %s", (0, _stringify2.default)(flow));
369 var nextStage = this._firstUncompletedStage(flow);
370 console.log("Next stage: %s", nextStage);
371 return nextStage;
372 },
373
374 /**
375 * Pick one of the flows from the returned list
376 * If a flow using all of the inputs is found, it will
377 * be returned, otherwise, null will be returned.
378 *
379 * Only flows using all given inputs are chosen because it
380 * is likley to be surprising if the user provides a
381 * credential and it is not used. For example, for registration,
382 * this could result in the email not being used which would leave
383 * the account with no means to reset a password.
384 *
385 * @private
386 * @return {object} flow
387 * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found
388 */
389 _chooseFlow: function _chooseFlow() {
390 var flows = this._data.flows || [];
391
392 // we've been given an email or we've already done an email part
393 var haveEmail = Boolean(this._inputs.emailAddress) || Boolean(this._emailSid);
394 var haveMsisdn = Boolean(this._inputs.phoneCountry) && Boolean(this._inputs.phoneNumber);
395
396 var _iteratorNormalCompletion = true;
397 var _didIteratorError = false;
398 var _iteratorError = undefined;
399
400 try {
401 for (var _iterator = (0, _getIterator3.default)(flows), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) {
402 var flow = _step.value;
403
404 var flowHasEmail = false;
405 var flowHasMsisdn = false;
406 var _iteratorNormalCompletion2 = true;
407 var _didIteratorError2 = false;
408 var _iteratorError2 = undefined;
409
410 try {
411 for (var _iterator2 = (0, _getIterator3.default)(flow.stages), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) {
412 var stage = _step2.value;
413
414 if (stage === EMAIL_STAGE_TYPE) {
415 flowHasEmail = true;
416 } else if (stage == MSISDN_STAGE_TYPE) {
417 flowHasMsisdn = true;
418 }
419 }
420 } catch (err) {
421 _didIteratorError2 = true;
422 _iteratorError2 = err;
423 } finally {
424 try {
425 if (!_iteratorNormalCompletion2 && _iterator2.return) {
426 _iterator2.return();
427 }
428 } finally {
429 if (_didIteratorError2) {
430 throw _iteratorError2;
431 }
432 }
433 }
434
435 if (flowHasEmail == haveEmail && flowHasMsisdn == haveMsisdn) {
436 return flow;
437 }
438 }
439 // Throw an error with a fairly generic description, but with more
440 // information such that the app can give a better one if so desired.
441 } catch (err) {
442 _didIteratorError = true;
443 _iteratorError = err;
444 } finally {
445 try {
446 if (!_iteratorNormalCompletion && _iterator.return) {
447 _iterator.return();
448 }
449 } finally {
450 if (_didIteratorError) {
451 throw _iteratorError;
452 }
453 }
454 }
455
456 var err = new Error("No appropriate authentication flow found");
457 err.name = 'NoAuthFlowFoundError';
458 err.required_stages = [];
459 if (haveEmail) err.required_stages.push(EMAIL_STAGE_TYPE);
460 if (haveMsisdn) err.required_stages.push(MSISDN_STAGE_TYPE);
461 err.available_flows = flows;
462 throw err;
463 },
464
465 /**
466 * Get the first uncompleted stage in the given flow
467 *
468 * @private
469 * @param {object} flow
470 * @return {string} login type
471 */
472 _firstUncompletedStage: function _firstUncompletedStage(flow) {
473 var completed = (this._data || {}).completed || [];
474 for (var i = 0; i < flow.stages.length; ++i) {
475 var stageType = flow.stages[i];
476 if (completed.indexOf(stageType) === -1) {
477 return stageType;
478 }
479 }
480 }
481};
482
483/** */
484module.exports = InteractiveAuth;
485//# sourceMappingURL=interactive-auth.js.map
\No newline at end of file