1 | /*
|
2 | Copyright 2016 OpenMarket Ltd
|
3 | Copyright 2017 Vector Creations Ltd
|
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 |
|
19 | /** @module interactive-auth */
|
20 |
|
21 | var _getIterator2 = require("babel-runtime/core-js/get-iterator");
|
22 |
|
23 | var _getIterator3 = _interopRequireDefault(_getIterator2);
|
24 |
|
25 | var _stringify = require("babel-runtime/core-js/json/stringify");
|
26 |
|
27 | var _stringify2 = _interopRequireDefault(_stringify);
|
28 |
|
29 | var _bluebird = require("bluebird");
|
30 |
|
31 | var _bluebird2 = _interopRequireDefault(_bluebird);
|
32 |
|
33 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
34 |
|
35 | var url = require("url");
|
36 |
|
37 | var utils = require("./utils");
|
38 |
|
39 | var EMAIL_STAGE_TYPE = "m.login.email.identity";
|
40 | var 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 | */
|
106 | function 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 |
|
123 | InteractiveAuth.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 | /** */
|
484 | module.exports = InteractiveAuth;
|
485 | //# sourceMappingURL=interactive-auth.js.map |
\ | No newline at end of file |