UNPKG

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