1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * Authenticator class wraps the communications between Gaia and its host.
7 */
8function Authenticator() {
9}
10
11/**
12 * Gaia auth extension url origin.
13 * @type {string}
14 */
15Authenticator.THIS_EXTENSION_ORIGIN =
16    'chrome-extension://mfffpogegjflfpflabcdkioaeobkgjik';
17
18/**
19 * The lowest version of the credentials passing API supported.
20 * @type {number}
21 */
22Authenticator.MIN_API_VERSION_VERSION = 1;
23
24/**
25 * The highest version of the credentials passing API supported.
26 * @type {number}
27 */
28Authenticator.MAX_API_VERSION_VERSION = 1;
29
30/**
31 * The key types supported by the credentials passing API.
32 * @type {Array} Array of strings.
33 */
34Authenticator.API_KEY_TYPES = [
35  'KEY_TYPE_PASSWORD_PLAIN',
36];
37
38/**
39 * Singleton getter of Authenticator.
40 * @return {Object} The singleton instance of Authenticator.
41 */
42Authenticator.getInstance = function() {
43  if (!Authenticator.instance_) {
44    Authenticator.instance_ = new Authenticator();
45  }
46  return Authenticator.instance_;
47};
48
49Authenticator.prototype = {
50  email_: null,
51
52  // Depending on the key type chosen, this will contain the plain text password
53  // or a credential derived from it along with the information required to
54  // repeat the derivation, such as a salt. The information will be encoded so
55  // that it contains printable ASCII characters only. The exact encoding is TBD
56  // when support for key types other than plain text password is added.
57  passwordBytes_: null,
58
59  attemptToken_: null,
60
61  // Input params from extension initialization URL.
62  inputLang_: undefined,
63  intputEmail_: undefined,
64
65  isSAMLFlow_: false,
66  gaiaLoaded_: false,
67  supportChannel_: null,
68
69  GAIA_URL: 'https://accounts.google.com/',
70  GAIA_PAGE_PATH: 'ServiceLogin?skipvpage=true&sarp=1&rm=hide',
71  PARENT_PAGE: 'chrome://oobe/',
72  SERVICE_ID: 'chromeoslogin',
73  CONTINUE_URL: Authenticator.THIS_EXTENSION_ORIGIN + '/success.html',
74  CONSTRAINED_FLOW_SOURCE: 'chrome',
75
76  initialize: function() {
77    var params = getUrlSearchParams(location.search);
78    this.parentPage_ = params.parentPage || this.PARENT_PAGE;
79    this.gaiaUrl_ = params.gaiaUrl || this.GAIA_URL;
80    this.gaiaPath_ = params.gaiaPath || this.GAIA_PAGE_PATH;
81    this.inputLang_ = params.hl;
82    this.inputEmail_ = params.email;
83    this.service_ = params.service || this.SERVICE_ID;
84    this.continueUrl_ = params.continueUrl || this.CONTINUE_URL;
85    this.desktopMode_ = params.desktopMode == '1';
86    this.isConstrainedWindow_ = params.constrained == '1';
87    this.initialFrameUrl_ = params.frameUrl || this.constructInitialFrameUrl_();
88    this.initialFrameUrlWithoutParams_ = stripParams(this.initialFrameUrl_);
89
90    // For CrOS 'ServiceLogin' we assume that Gaia is loaded if we recieved
91    // 'clearOldAttempts' message. For other scenarios Gaia doesn't send this
92    // message so we have to rely on 'load' event.
93    // TODO(dzhioev): Do not rely on 'load' event after b/16313327 is fixed.
94    this.assumeLoadedOnLoadEvent_ =
95        this.gaiaPath_.indexOf('ServiceLogin') !== 0 ||
96        this.service_ !== 'chromeoslogin';
97
98    document.addEventListener('DOMContentLoaded', this.onPageLoad_.bind(this));
99  },
100
101  isGaiaMessage_: function(msg) {
102    // Not quite right, but good enough.
103    return this.gaiaUrl_.indexOf(msg.origin) == 0 ||
104           this.GAIA_URL.indexOf(msg.origin) == 0;
105  },
106
107  isInternalMessage_: function(msg) {
108    return msg.origin == Authenticator.THIS_EXTENSION_ORIGIN;
109  },
110
111  isParentMessage_: function(msg) {
112    return msg.origin == this.parentPage_;
113  },
114
115  constructInitialFrameUrl_: function() {
116    var url = this.gaiaUrl_ + this.gaiaPath_;
117
118    url = appendParam(url, 'service', this.service_);
119    url = appendParam(url, 'continue', this.continueUrl_);
120    if (this.inputLang_)
121      url = appendParam(url, 'hl', this.inputLang_);
122    if (this.inputEmail_)
123      url = appendParam(url, 'Email', this.inputEmail_);
124    if (this.isConstrainedWindow_)
125      url = appendParam(url, 'source', this.CONSTRAINED_FLOW_SOURCE);
126    return url;
127  },
128
129  onPageLoad_: function() {
130    window.addEventListener('message', this.onMessage.bind(this), false);
131    this.initSupportChannel_();
132
133    var gaiaFrame = $('gaia-frame');
134    gaiaFrame.src = this.initialFrameUrl_;
135
136    if (this.assumeLoadedOnLoadEvent_) {
137      var handler = function() {
138        gaiaFrame.removeEventListener('load', handler);
139        if (!this.gaiaLoaded_) {
140          this.gaiaLoaded_ = true;
141          this.maybeInitialized_();
142        }
143      }.bind(this);
144      gaiaFrame.addEventListener('load', handler);
145    }
146  },
147
148  initSupportChannel_: function() {
149    var supportChannel = new Channel();
150    supportChannel.connect('authMain');
151
152    supportChannel.registerMessage('channelConnected', function() {
153      if (this.supportChannel_) {
154        console.error('Support channel is already initialized.');
155        return;
156      }
157      this.supportChannel_ = supportChannel;
158
159      if (this.desktopMode_) {
160        this.supportChannel_.send({
161          name: 'initDesktopFlow',
162          gaiaUrl: this.gaiaUrl_,
163          continueUrl: stripParams(this.continueUrl_),
164          isConstrainedWindow: this.isConstrainedWindow_
165        });
166        this.supportChannel_.registerMessage(
167            'switchToFullTab', this.switchToFullTab_.bind(this));
168        this.supportChannel_.registerMessage(
169            'completeLogin', this.completeLogin_.bind(this));
170      }
171      this.initSAML_();
172      this.maybeInitialized_();
173    }.bind(this));
174
175    window.setTimeout(function() {
176      if (!this.supportChannel_) {
177        // Re-initialize the channel if it is not connected properly, e.g.
178        // connect may be called before background script started running.
179        this.initSupportChannel_();
180      }
181    }.bind(this), 200);
182  },
183
184  /**
185   * Called when one of the initialization stages has finished. If all the
186   * needed parts are initialized, notifies parent about successfull
187   * initialization.
188   */
189  maybeInitialized_: function() {
190    if (!this.gaiaLoaded_ || !this.supportChannel_)
191      return;
192    var msg = {
193      'method': 'loginUILoaded'
194    };
195    window.parent.postMessage(msg, this.parentPage_);
196  },
197
198  /**
199   * Invoked when the background script sends a message to indicate that the
200   * current content does not fit in a constrained window.
201   * @param {Object=} opt_extraMsg Optional extra info to send.
202   */
203  switchToFullTab_: function(msg) {
204    var parentMsg = {
205      'method': 'switchToFullTab',
206      'url': msg.url
207    };
208    window.parent.postMessage(parentMsg, this.parentPage_);
209  },
210
211  /**
212   * Invoked when the signin flow is complete.
213   * @param {Object=} opt_extraMsg Optional extra info to send.
214   */
215  completeLogin_: function(opt_extraMsg) {
216    var msg = {
217      'method': 'completeLogin',
218      'email': (opt_extraMsg && opt_extraMsg.email) || this.email_,
219      'password': (opt_extraMsg && opt_extraMsg.password) ||
220                  this.passwordBytes_,
221      'usingSAML': this.isSAMLFlow_,
222      'chooseWhatToSync': this.chooseWhatToSync_ || false,
223      'skipForNow': opt_extraMsg && opt_extraMsg.skipForNow,
224      'sessionIndex': opt_extraMsg && opt_extraMsg.sessionIndex
225    };
226    window.parent.postMessage(msg, this.parentPage_);
227    this.supportChannel_.send({name: 'resetAuth'});
228  },
229
230  /**
231   * Invoked when support channel is connected.
232   */
233  initSAML_: function() {
234    this.isSAMLFlow_ = false;
235
236    this.supportChannel_.registerMessage(
237        'onAuthPageLoaded', this.onAuthPageLoaded_.bind(this));
238    this.supportChannel_.registerMessage(
239        'onInsecureContentBlocked', this.onInsecureContentBlocked_.bind(this));
240    this.supportChannel_.registerMessage(
241        'apiCall', this.onAPICall_.bind(this));
242    this.supportChannel_.send({
243      name: 'setGaiaUrl',
244      gaiaUrl: this.gaiaUrl_
245    });
246    if (!this.desktopMode_ && this.gaiaUrl_.indexOf('https://') == 0) {
247      // Abort the login flow when content served over an unencrypted connection
248      // is detected on Chrome OS. This does not apply to tests that explicitly
249      // set a non-https GAIA URL and want to perform all authentication over
250      // http.
251      this.supportChannel_.send({
252        name: 'setBlockInsecureContent',
253        blockInsecureContent: true
254      });
255    }
256  },
257
258  /**
259   * Invoked when the background page sends 'onHostedPageLoaded' message.
260   * @param {!Object} msg Details sent with the message.
261   */
262  onAuthPageLoaded_: function(msg) {
263    var isSAMLPage = msg.url.indexOf(this.gaiaUrl_) != 0;
264
265    if (isSAMLPage && !this.isSAMLFlow_) {
266      // GAIA redirected to a SAML login page. The credentials provided to this
267      // page will determine what user gets logged in. The credentials obtained
268      // from the GAIA login form are no longer relevant and can be discarded.
269      this.isSAMLFlow_ = true;
270      this.email_ = null;
271      this.passwordBytes_ = null;
272    }
273
274    window.parent.postMessage({
275      'method': 'authPageLoaded',
276      'isSAML': this.isSAMLFlow_,
277      'domain': extractDomain(msg.url)
278    }, this.parentPage_);
279  },
280
281  /**
282   * Invoked when the background page sends an 'onInsecureContentBlocked'
283   * message.
284   * @param {!Object} msg Details sent with the message.
285   */
286  onInsecureContentBlocked_: function(msg) {
287    window.parent.postMessage({
288      'method': 'insecureContentBlocked',
289      'url': stripParams(msg.url)
290    }, this.parentPage_);
291  },
292
293  /**
294   * Invoked when one of the credential passing API methods is called by a SAML
295   * provider.
296   * @param {!Object} msg Details of the API call.
297   */
298  onAPICall_: function(msg) {
299    var call = msg.call;
300    if (call.method == 'initialize') {
301      if (!Number.isInteger(call.requestedVersion) ||
302          call.requestedVersion < Authenticator.MIN_API_VERSION_VERSION) {
303        this.sendInitializationFailure_();
304        return;
305      }
306
307      this.apiVersion_ = Math.min(call.requestedVersion,
308                                  Authenticator.MAX_API_VERSION_VERSION);
309      this.initialized_ = true;
310      this.sendInitializationSuccess_();
311      return;
312    }
313
314    if (call.method == 'add') {
315      if (Authenticator.API_KEY_TYPES.indexOf(call.keyType) == -1) {
316        console.error('Authenticator.onAPICall_: unsupported key type');
317        return;
318      }
319      this.apiToken_ = call.token;
320      this.email_ = call.user;
321      this.passwordBytes_ = call.passwordBytes;
322    } else if (call.method == 'confirm') {
323      if (call.token != this.apiToken_)
324        console.error('Authenticator.onAPICall_: token mismatch');
325    } else {
326      console.error('Authenticator.onAPICall_: unknown message');
327    }
328  },
329
330  sendInitializationSuccess_: function() {
331    this.supportChannel_.send({name: 'apiResponse', response: {
332      result: 'initialized',
333      version: this.apiVersion_,
334      keyTypes: Authenticator.API_KEY_TYPES
335    }});
336  },
337
338  sendInitializationFailure_: function() {
339    this.supportChannel_.send({
340      name: 'apiResponse',
341      response: {result: 'initialization_failed'}
342    });
343  },
344
345  onConfirmLogin_: function() {
346    if (!this.isSAMLFlow_) {
347      this.completeLogin_();
348      return;
349    }
350
351    var apiUsed = !!this.passwordBytes_;
352
353    // Retrieve the e-mail address of the user who just authenticated from GAIA.
354    window.parent.postMessage({method: 'retrieveAuthenticatedUserEmail',
355                               attemptToken: this.attemptToken_,
356                               apiUsed: apiUsed},
357                              this.parentPage_);
358
359    if (!apiUsed) {
360      this.supportChannel_.sendWithCallback(
361          {name: 'getScrapedPasswords'},
362          function(passwords) {
363            if (passwords.length == 0) {
364              window.parent.postMessage(
365                  {method: 'noPassword', email: this.email_},
366                  this.parentPage_);
367            } else {
368              window.parent.postMessage({method: 'confirmPassword',
369                                         email: this.email_,
370                                         passwordCount: passwords.length},
371                                        this.parentPage_);
372            }
373          }.bind(this));
374    }
375  },
376
377  maybeCompleteSAMLLogin_: function() {
378    // SAML login is complete when the user's e-mail address has been retrieved
379    // from GAIA and the user has successfully confirmed the password.
380    if (this.email_ !== null && this.passwordBytes_ !== null)
381      this.completeLogin_();
382  },
383
384  onVerifyConfirmedPassword_: function(password) {
385    this.supportChannel_.sendWithCallback(
386        {name: 'getScrapedPasswords'},
387        function(passwords) {
388          for (var i = 0; i < passwords.length; ++i) {
389            if (passwords[i] == password) {
390              this.passwordBytes_ = passwords[i];
391              this.maybeCompleteSAMLLogin_();
392              return;
393            }
394          }
395          window.parent.postMessage(
396              {method: 'confirmPassword', email: this.email_},
397              this.parentPage_);
398        }.bind(this));
399  },
400
401  onMessage: function(e) {
402    var msg = e.data;
403    if (msg.method == 'attemptLogin' && this.isGaiaMessage_(e)) {
404      this.email_ = msg.email;
405      this.passwordBytes_ = msg.password;
406      this.attemptToken_ = msg.attemptToken;
407      this.chooseWhatToSync_ = msg.chooseWhatToSync;
408      this.isSAMLFlow_ = false;
409      if (this.supportChannel_)
410        this.supportChannel_.send({name: 'startAuth'});
411      else
412        console.error('Support channel is not initialized.');
413    } else if (msg.method == 'clearOldAttempts' && this.isGaiaMessage_(e)) {
414      if (!this.gaiaLoaded_) {
415        this.gaiaLoaded_ = true;
416        this.maybeInitialized_();
417      }
418      this.email_ = null;
419      this.passwordBytes_ = null;
420      this.attemptToken_ = null;
421      this.isSAMLFlow_ = false;
422      if (this.supportChannel_)
423        this.supportChannel_.send({name: 'resetAuth'});
424    } else if (msg.method == 'setAuthenticatedUserEmail' &&
425               this.isParentMessage_(e)) {
426      if (this.attemptToken_ == msg.attemptToken) {
427        this.email_ = msg.email;
428        this.maybeCompleteSAMLLogin_();
429      }
430    } else if (msg.method == 'confirmLogin' && this.isInternalMessage_(e)) {
431      // In the desktop mode, Chrome needs to wait for extra info such as
432      // session index from the background JS.
433      if (this.desktopMode_)
434        return;
435
436      if (this.attemptToken_ == msg.attemptToken)
437        this.onConfirmLogin_();
438      else
439        console.error('Authenticator.onMessage: unexpected attemptToken!?');
440    } else if (msg.method == 'verifyConfirmedPassword' &&
441               this.isParentMessage_(e)) {
442      this.onVerifyConfirmedPassword_(msg.password);
443    } else if (msg.method == 'redirectToSignin' &&
444               this.isParentMessage_(e)) {
445      $('gaia-frame').src = this.constructInitialFrameUrl_();
446    } else {
447       console.error('Authenticator.onMessage: unknown message + origin!?');
448    }
449  }
450};
451
452Authenticator.getInstance().initialize();
453