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 | import Promise from 'bluebird';
|
21 | const url = require("url");
|
22 |
|
23 | const utils = require("./utils");
|
24 |
|
25 | const EMAIL_STAGE_TYPE = "m.login.email.identity";
|
26 | const 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 | */
|
92 | function 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 |
|
109 | InteractiveAuth.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 | /** */
|
431 | module.exports = InteractiveAuth;
|