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  isSAMLEnabled_: 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    document.addEventListener('DOMContentLoaded', this.onPageLoad_.bind(this));
91    if (!this.desktopMode_) {
92      // SAML is always enabled in desktop mode, thus no need to listen for
93      // enableSAML event.
94      document.addEventListener('enableSAML', this.onEnableSAML_.bind(this));
95    }
96  },
97
98  isGaiaMessage_: function(msg) {
99    // Not quite right, but good enough.
100    return this.gaiaUrl_.indexOf(msg.origin) == 0 ||
101           this.GAIA_URL.indexOf(msg.origin) == 0;
102  },
103
104  isInternalMessage_: function(msg) {
105    return msg.origin == Authenticator.THIS_EXTENSION_ORIGIN;
106  },
107
108  isParentMessage_: function(msg) {
109    return msg.origin == this.parentPage_;
110  },
111
112  constructInitialFrameUrl_: function() {
113    var url = this.gaiaUrl_ + this.gaiaPath_;
114
115    url = appendParam(url, 'service', this.service_);
116    url = appendParam(url, 'continue', this.continueUrl_);
117    if (this.inputLang_)
118      url = appendParam(url, 'hl', this.inputLang_);
119    if (this.inputEmail_)
120      url = appendParam(url, 'Email', this.inputEmail_);
121    if (this.isConstrainedWindow_)
122      url = appendParam(url, 'source', this.CONSTRAINED_FLOW_SOURCE);
123    return url;
124  },
125
126  onPageLoad_: function() {
127    window.addEventListener('message', this.onMessage.bind(this), false);
128
129    var gaiaFrame = $('gaia-frame');
130    gaiaFrame.src = this.initialFrameUrl_;
131
132    if (this.desktopMode_) {
133      var handler = function() {
134        this.onLoginUILoaded_();
135        gaiaFrame.removeEventListener('load', handler);
136
137        this.initDesktopChannel_();
138      }.bind(this);
139      gaiaFrame.addEventListener('load', handler);
140    }
141  },
142
143  initDesktopChannel_: function() {
144    this.supportChannel_ = new Channel();
145    this.supportChannel_.connect('authMain');
146
147    var channelConnected = false;
148    this.supportChannel_.registerMessage('channelConnected', function() {
149      channelConnected = true;
150
151      this.supportChannel_.send({
152        name: 'initDesktopFlow',
153        gaiaUrl: this.gaiaUrl_,
154        continueUrl: stripParams(this.continueUrl_),
155        isConstrainedWindow: this.isConstrainedWindow_
156      });
157      this.supportChannel_.registerMessage(
158          'switchToFullTab', this.switchToFullTab_.bind(this));
159      this.supportChannel_.registerMessage(
160          'completeLogin', this.completeLogin_.bind(this));
161
162      this.onEnableSAML_();
163    }.bind(this));
164
165    window.setTimeout(function() {
166      if (!channelConnected) {
167        // Re-initialize the channel if it is not connected properly, e.g.
168        // connect may be called before background script started running.
169        this.initDesktopChannel_();
170      }
171    }.bind(this), 200);
172  },
173
174  /**
175   * Invoked when the login UI is initialized or reset.
176   */
177  onLoginUILoaded_: function() {
178    var msg = {
179      'method': 'loginUILoaded'
180    };
181    window.parent.postMessage(msg, this.parentPage_);
182  },
183
184  /**
185   * Invoked when the background script sends a message to indicate that the
186   * current content does not fit in a constrained window.
187   * @param {Object=} opt_extraMsg Optional extra info to send.
188   */
189  switchToFullTab_: function(msg) {
190    var parentMsg = {
191      'method': 'switchToFullTab',
192      'url': msg.url
193    };
194    window.parent.postMessage(parentMsg, this.parentPage_);
195  },
196
197  /**
198   * Invoked when the signin flow is complete.
199   * @param {Object=} opt_extraMsg Optional extra info to send.
200   */
201  completeLogin_: function(opt_extraMsg) {
202    var msg = {
203      'method': 'completeLogin',
204      'email': (opt_extraMsg && opt_extraMsg.email) || this.email_,
205      'password': (opt_extraMsg && opt_extraMsg.password) ||
206                  this.passwordBytes_,
207      'usingSAML': this.isSAMLFlow_,
208      'chooseWhatToSync': this.chooseWhatToSync_ || false,
209      'skipForNow': opt_extraMsg && opt_extraMsg.skipForNow,
210      'sessionIndex': opt_extraMsg && opt_extraMsg.sessionIndex
211    };
212    window.parent.postMessage(msg, this.parentPage_);
213    if (this.isSAMLEnabled_)
214      this.supportChannel_.send({name: 'resetAuth'});
215  },
216
217  /**
218   * Invoked when 'enableSAML' event is received to initialize SAML support on
219   * Chrome OS, or when initDesktopChannel_ is called on desktop.
220   */
221  onEnableSAML_: function() {
222    this.isSAMLEnabled_ = true;
223    this.isSAMLFlow_ = false;
224
225    if (!this.supportChannel_) {
226      this.supportChannel_ = new Channel();
227      this.supportChannel_.connect('authMain');
228    }
229
230    this.supportChannel_.registerMessage(
231        'onAuthPageLoaded', this.onAuthPageLoaded_.bind(this));
232    this.supportChannel_.registerMessage(
233        'onInsecureContentBlocked', this.onInsecureContentBlocked_.bind(this));
234    this.supportChannel_.registerMessage(
235        'apiCall', this.onAPICall_.bind(this));
236    this.supportChannel_.send({
237      name: 'setGaiaUrl',
238      gaiaUrl: this.gaiaUrl_
239    });
240    if (!this.desktopMode_ && this.gaiaUrl_.indexOf('https://') == 0) {
241      // Abort the login flow when content served over an unencrypted connection
242      // is detected on Chrome OS. This does not apply to tests that explicitly
243      // set a non-https GAIA URL and want to perform all authentication over
244      // http.
245      this.supportChannel_.send({
246        name: 'setBlockInsecureContent',
247        blockInsecureContent: true
248      });
249    }
250  },
251
252  /**
253   * Invoked when the background page sends 'onHostedPageLoaded' message.
254   * @param {!Object} msg Details sent with the message.
255   */
256  onAuthPageLoaded_: function(msg) {
257    var isSAMLPage = msg.url.indexOf(this.gaiaUrl_) != 0;
258
259    if (isSAMLPage && !this.isSAMLFlow_) {
260      // GAIA redirected to a SAML login page. The credentials provided to this
261      // page will determine what user gets logged in. The credentials obtained
262      // from the GAIA login form are no longer relevant and can be discarded.
263      this.isSAMLFlow_ = true;
264      this.email_ = null;
265      this.passwordBytes_ = null;
266    }
267
268    window.parent.postMessage({
269      'method': 'authPageLoaded',
270      'isSAML': this.isSAMLFlow_,
271      'domain': extractDomain(msg.url)
272    }, this.parentPage_);
273  },
274
275  /**
276   * Invoked when the background page sends an 'onInsecureContentBlocked'
277   * message.
278   * @param {!Object} msg Details sent with the message.
279   */
280  onInsecureContentBlocked_: function(msg) {
281    window.parent.postMessage({
282      'method': 'insecureContentBlocked',
283      'url': stripParams(msg.url)
284    }, this.parentPage_);
285  },
286
287  /**
288   * Invoked when one of the credential passing API methods is called by a SAML
289   * provider.
290   * @param {!Object} msg Details of the API call.
291   */
292  onAPICall_: function(msg) {
293    var call = msg.call;
294    if (call.method == 'initialize') {
295      if (!Number.isInteger(call.requestedVersion) ||
296          call.requestedVersion < Authenticator.MIN_API_VERSION_VERSION) {
297        this.sendInitializationFailure_();
298        return;
299      }
300
301      this.apiVersion_ = Math.min(call.requestedVersion,
302                                  Authenticator.MAX_API_VERSION_VERSION);
303      this.initialized_ = true;
304      this.sendInitializationSuccess_();
305      return;
306    }
307
308    if (call.method == 'add') {
309      if (Authenticator.API_KEY_TYPES.indexOf(call.keyType) == -1) {
310        console.error('Authenticator.onAPICall_: unsupported key type');
311        return;
312      }
313      this.apiToken_ = call.token;
314      this.email_ = call.user;
315      this.passwordBytes_ = call.passwordBytes;
316    } else if (call.method == 'confirm') {
317      if (call.token != this.apiToken_)
318        console.error('Authenticator.onAPICall_: token mismatch');
319    } else {
320      console.error('Authenticator.onAPICall_: unknown message');
321    }
322  },
323
324  sendInitializationSuccess_: function() {
325    this.supportChannel_.send({name: 'apiResponse', response: {
326      result: 'initialized',
327      version: this.apiVersion_,
328      keyTypes: Authenticator.API_KEY_TYPES
329    }});
330  },
331
332  sendInitializationFailure_: function() {
333    this.supportChannel_.send({
334      name: 'apiResponse',
335      response: {result: 'initialization_failed'}
336    });
337  },
338
339  onConfirmLogin_: function() {
340    if (!this.isSAMLFlow_) {
341      this.completeLogin_();
342      return;
343    }
344
345    var apiUsed = !!this.passwordBytes_;
346
347    // Retrieve the e-mail address of the user who just authenticated from GAIA.
348    window.parent.postMessage({method: 'retrieveAuthenticatedUserEmail',
349                               attemptToken: this.attemptToken_,
350                               apiUsed: apiUsed},
351                              this.parentPage_);
352
353    if (!apiUsed) {
354      this.supportChannel_.sendWithCallback(
355          {name: 'getScrapedPasswords'},
356          function(passwords) {
357            if (passwords.length == 0) {
358              window.parent.postMessage(
359                  {method: 'noPassword', email: this.email_},
360                  this.parentPage_);
361            } else {
362              window.parent.postMessage({method: 'confirmPassword',
363                                         email: this.email_,
364                                         passwordCount: passwords.length},
365                                        this.parentPage_);
366            }
367          }.bind(this));
368    }
369  },
370
371  maybeCompleteSAMLLogin_: function() {
372    // SAML login is complete when the user's e-mail address has been retrieved
373    // from GAIA and the user has successfully confirmed the password.
374    if (this.email_ !== null && this.passwordBytes_ !== null)
375      this.completeLogin_();
376  },
377
378  onVerifyConfirmedPassword_: function(password) {
379    this.supportChannel_.sendWithCallback(
380        {name: 'getScrapedPasswords'},
381        function(passwords) {
382          for (var i = 0; i < passwords.length; ++i) {
383            if (passwords[i] == password) {
384              this.passwordBytes_ = passwords[i];
385              this.maybeCompleteSAMLLogin_();
386              return;
387            }
388          }
389          window.parent.postMessage(
390              {method: 'confirmPassword', email: this.email_},
391              this.parentPage_);
392        }.bind(this));
393  },
394
395  onMessage: function(e) {
396    var msg = e.data;
397    if (msg.method == 'attemptLogin' && this.isGaiaMessage_(e)) {
398      this.email_ = msg.email;
399      this.passwordBytes_ = msg.password;
400      this.attemptToken_ = msg.attemptToken;
401      this.chooseWhatToSync_ = msg.chooseWhatToSync;
402      this.isSAMLFlow_ = false;
403      if (this.isSAMLEnabled_)
404        this.supportChannel_.send({name: 'startAuth'});
405    } else if (msg.method == 'clearOldAttempts' && this.isGaiaMessage_(e)) {
406      this.email_ = null;
407      this.passwordBytes_ = null;
408      this.attemptToken_ = null;
409      this.isSAMLFlow_ = false;
410      this.onLoginUILoaded_();
411      if (this.isSAMLEnabled_)
412        this.supportChannel_.send({name: 'resetAuth'});
413    } else if (msg.method == 'setAuthenticatedUserEmail' &&
414               this.isParentMessage_(e)) {
415      if (this.attemptToken_ == msg.attemptToken) {
416        this.email_ = msg.email;
417        this.maybeCompleteSAMLLogin_();
418      }
419    } else if (msg.method == 'confirmLogin' && this.isInternalMessage_(e)) {
420      if (this.attemptToken_ == msg.attemptToken)
421        this.onConfirmLogin_();
422      else
423        console.error('Authenticator.onMessage: unexpected attemptToken!?');
424    } else if (msg.method == 'verifyConfirmedPassword' &&
425               this.isParentMessage_(e)) {
426      this.onVerifyConfirmedPassword_(msg.password);
427    } else if (msg.method == 'redirectToSignin' &&
428               this.isParentMessage_(e)) {
429      $('gaia-frame').src = this.constructInitialFrameUrl_();
430    } else {
431       console.error('Authenticator.onMessage: unknown message + origin!?');
432    }
433  }
434};
435
436Authenticator.getInstance().initialize();
437