1 // Copyright 2014 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.
5 <include src="saml_handler.js">
8 * @fileoverview An UI component to authenciate to Chrome. The component hosts
9 * IdP web pages in a webview. A client who is interested in monitoring
10 * authentication events should pass a listener object of type
11 * cr.login.GaiaAuthHost.Listener as defined in this file. After initialization,
12 * call {@code load} to start the authentication flow.
15 cr.define('cr.login', function() {
18 // TODO(rogerta): should use gaia URL from GaiaUrls::gaia_url() instead
19 // of hardcoding the prod URL here. As is, this does not work with staging
21 var IDP_ORIGIN = 'https://accounts.google.com/';
22 var IDP_PATH = 'ServiceLogin?skipvpage=true&sarp=1&rm=hide';
24 'chrome-extension://mfffpogegjflfpflabcdkioaeobkgjik/success.html';
25 var SIGN_IN_HEADER = 'google-accounts-signin';
26 var EMBEDDED_FORM_HEADER = 'google-accounts-embedded';
27 var LOCATION_HEADER = 'location';
28 var SET_COOKIE_HEADER = 'set-cookie';
29 var OAUTH_CODE_COOKIE = 'oauth_code';
30 var SERVICE_ID = 'chromeoslogin';
31 var EMBEDDED_SETUP_CHROMEOS_ENDPOINT = 'embedded/setup/chromeos';
32 var X_DEVICE_ID_HEADER = 'X-Device-ID';
33 var EPHEMERAL_DEVICE_ID_PREFIX = 't_';
36 * The source URL parameter for the constrained signin flow.
38 var CONSTRAINED_FLOW_SOURCE = 'chrome';
41 * Enum for the authorization mode, must match AuthMode defined in
42 * chrome/browser/ui/webui/inline_login_ui.cc.
52 * Enum for the authorization type.
61 * Supported Authenticator params.
62 * @type {!Array<string>}
65 var SUPPORTED_PARAMS = [
66 'gaiaId', // Obfuscated GAIA ID to skip the email prompt page
67 // during the re-auth flow.
68 'gaiaUrl', // Gaia url to use.
69 'gaiaPath', // Gaia path to use without a leading slash.
70 'hl', // Language code for the user interface.
71 'email', // Pre-fill the email field in Gaia UI.
72 'service', // Name of Gaia service.
73 'continueUrl', // Continue url to use.
74 'frameUrl', // Initial frame URL to use. If empty defaults to
76 'constrained', // Whether the extension is loaded in a constrained
78 'clientId', // Chrome client id.
79 'useEafe', // Whether to use EAFE.
80 'needPassword', // Whether the host is interested in getting a password.
81 // If this set to |false|, |confirmPasswordCallback| is
82 // not called before dispatching |authCopleted|.
84 'flow', // One of 'default', 'enterprise', or 'theftprotection'.
85 'enterpriseDomain', // Domain in which hosting device is (or should be)
87 'emailDomain', // Value used to prefill domain for email.
88 'deviceId', // User device ID (sync Id).
89 'sessionIsEphemeral', // User session would be ephemeral.
90 'clientVersion', // Version of the Chrome build.
91 'platformVersion', // Version of the OS build.
92 'releaseChannel', // Installation channel.
93 'endpointGen', // Current endpoint generation.
97 * Initializes the authenticator component.
98 * @param {webview|string} webview The webview element or its ID to host IdP
102 function Authenticator(webview) {
103 this.webview_ = typeof webview == 'string' ? $(webview) : webview;
104 assert(this.webview_);
107 this.password_ = null;
109 this.sessionIndex_ = null;
110 this.chooseWhatToSync_ = false;
111 this.skipForNow_ = false;
112 this.authFlow = AuthFlow.DEFAULT;
113 this.authDomain = '';
114 this.loaded_ = false;
115 this.idpOrigin_ = null;
116 this.continueUrl_ = null;
117 this.continueUrlWithoutParams_ = null;
118 this.initialFrameUrl_ = null;
119 this.reloadUrl_ = null;
120 this.trusted_ = true;
121 this.oauth_code_ = null;
122 this.deviceId_ = null;
123 this.sessionIsEphemeral_ = null;
124 this.onBeforeSetHeadersSet_ = false;
126 this.useEafe_ = false;
127 this.clientId_ = null;
129 this.samlHandler_ = new cr.login.SamlHandler(this.webview_);
130 this.confirmPasswordCallback = null;
131 this.noPasswordCallback = null;
132 this.insecureContentBlockedCallback = null;
133 this.samlApiUsedCallback = null;
134 this.missingGaiaInfoCallback = null;
135 this.needPassword = true;
136 this.samlHandler_.addEventListener(
137 'insecureContentBlocked',
138 this.onInsecureContentBlocked_.bind(this));
139 this.samlHandler_.addEventListener(
141 this.onAuthPageLoaded_.bind(this));
143 this.webview_.addEventListener('droplink', this.onDropLink_.bind(this));
144 this.webview_.addEventListener(
145 'newwindow', this.onNewWindow_.bind(this));
146 this.webview_.addEventListener(
147 'contentload', this.onContentLoad_.bind(this));
148 this.webview_.addEventListener(
149 'loadabort', this.onLoadAbort_.bind(this));
150 this.webview_.addEventListener(
151 'loadstop', this.onLoadStop_.bind(this));
152 this.webview_.addEventListener(
153 'loadcommit', this.onLoadCommit_.bind(this));
154 this.webview_.request.onCompleted.addListener(
155 this.onRequestCompleted_.bind(this),
156 {urls: ['<all_urls>'], types: ['main_frame']},
157 ['responseHeaders']);
158 this.webview_.request.onHeadersReceived.addListener(
159 this.onHeadersReceived_.bind(this),
160 {urls: ['<all_urls>'], types: ['main_frame', 'xmlhttprequest']},
161 ['responseHeaders']);
162 window.addEventListener(
163 'message', this.onMessageFromWebview_.bind(this), false);
164 window.addEventListener(
165 'focus', this.onFocus_.bind(this), false);
166 window.addEventListener(
167 'popstate', this.onPopState_.bind(this), false);
170 Authenticator.prototype = Object.create(cr.EventTarget.prototype);
173 * Reinitializes authentication parameters so that a failed login attempt
174 * would not result in an infinite loop.
176 Authenticator.prototype.clearCredentials_ = function() {
179 this.password_ = null;
180 this.oauth_code_ = null;
181 this.chooseWhatToSync_ = false;
182 this.skipForNow_ = false;
183 this.sessionIndex_ = null;
184 this.trusted_ = true;
185 this.authFlow = AuthFlow.DEFAULT;
186 this.samlHandler_.reset();
190 * Loads the authenticator component with the given parameters.
191 * @param {AuthMode} authMode Authorization mode.
192 * @param {Object} data Parameters for the authorization flow.
194 Authenticator.prototype.load = function(authMode, data) {
195 this.clearCredentials_();
196 this.loaded_ = false;
197 this.idpOrigin_ = data.gaiaUrl || IDP_ORIGIN;
198 this.continueUrl_ = data.continueUrl || CONTINUE_URL;
199 this.continueUrlWithoutParams_ =
200 this.continueUrl_.substring(0, this.continueUrl_.indexOf('?')) ||
202 this.isConstrainedWindow_ = data.constrained == '1';
203 this.isNewGaiaFlowChromeOS = data.isNewGaiaFlowChromeOS;
204 this.useEafe_ = data.useEafe || false;
205 this.clientId_ = data.clientId;
207 this.initialFrameUrl_ = this.constructInitialFrameUrl_(data);
208 this.reloadUrl_ = data.frameUrl || this.initialFrameUrl_;
209 // Don't block insecure content for desktop flow because it lands on
210 // http. Otherwise, block insecure content as long as gaia is https.
211 this.samlHandler_.blockInsecureContent = authMode != AuthMode.DESKTOP &&
212 this.idpOrigin_.indexOf('https://') == 0;
213 this.needPassword = !('needPassword' in data) || data.needPassword;
215 if (this.isNewGaiaFlowChromeOS) {
216 this.webview_.contextMenus.onShow.addListener(function(e) {
219 if (!this.onBeforeSetHeadersSet_) {
220 this.onBeforeSetHeadersSet_ = true;
221 var filterPrefix = this.idpOrigin_ + EMBEDDED_SETUP_CHROMEOS_ENDPOINT;
222 this.webview_.request.onBeforeSendHeaders.addListener(
223 this.onBeforeSendHeaders_.bind(this),
224 {urls: [filterPrefix + '?*', filterPrefix + '/*']},
225 ['requestHeaders', 'blocking']);
229 this.webview_.src = this.reloadUrl_;
233 * Reloads the authenticator component.
235 Authenticator.prototype.reload = function() {
236 this.clearCredentials_();
237 this.loaded_ = false;
238 this.webview_.src = this.reloadUrl_;
241 Authenticator.prototype.constructInitialFrameUrl_ = function(data) {
242 var path = data.gaiaPath;
243 if (!path && this.isNewGaiaFlowChromeOS)
244 path = EMBEDDED_SETUP_CHROMEOS_ENDPOINT;
247 var url = this.idpOrigin_ + path;
249 if (this.isNewGaiaFlowChromeOS) {
251 url = appendParam(url, 'chrometype', data.chromeType);
253 url = appendParam(url, 'client_id', data.clientId);
254 if (data.enterpriseDomain)
255 url = appendParam(url, 'manageddomain', data.enterpriseDomain);
256 if (data.clientVersion)
257 url = appendParam(url, 'client_version', data.clientVersion);
258 if (data.platformVersion)
259 url = appendParam(url, 'platform_version', data.platformVersion);
260 if (data.releaseChannel)
261 url = appendParam(url, 'release_channel', data.releaseChannel);
262 if (data.endpointGen)
263 url = appendParam(url, 'endpoint_gen', data.endpointGen);
264 this.deviceId_ = data.deviceId;
265 this.sessionIsEphemeral_ = data.sessionIsEphemeral;
267 url = appendParam(url, 'continue', this.continueUrl_);
268 url = appendParam(url, 'service', data.service || SERVICE_ID);
271 url = appendParam(url, 'hl', data.hl);
273 url = appendParam(url, 'user_id', data.gaiaId);
275 url = appendParam(url, 'Email', data.email);
276 if (this.isConstrainedWindow_)
277 url = appendParam(url, 'source', CONSTRAINED_FLOW_SOURCE);
279 url = appendParam(url, 'flow', data.flow);
280 if (data.emailDomain)
281 url = appendParam(url, 'emaildomain', data.emailDomain);
286 * Invoked when a main frame request in the webview has completed.
289 Authenticator.prototype.onRequestCompleted_ = function(details) {
290 var currentUrl = details.url;
292 if (currentUrl.lastIndexOf(this.continueUrlWithoutParams_, 0) == 0) {
293 if (currentUrl.indexOf('ntp=1') >= 0)
294 this.skipForNow_ = true;
296 this.maybeCompleteAuth_();
300 if (currentUrl.indexOf('https') != 0)
301 this.trusted_ = false;
303 if (this.isConstrainedWindow_) {
304 var isEmbeddedPage = false;
305 if (this.idpOrigin_ && currentUrl.lastIndexOf(this.idpOrigin_) == 0) {
306 var headers = details.responseHeaders;
307 for (var i = 0; headers && i < headers.length; ++i) {
308 if (headers[i].name.toLowerCase() == EMBEDDED_FORM_HEADER) {
309 isEmbeddedPage = true;
314 if (!isEmbeddedPage) {
315 this.dispatchEvent(new CustomEvent('resize', {detail: currentUrl}));
320 this.updateHistoryState_(currentUrl);
324 * Manually updates the history. Invoked upon completion of a webview
326 * @param {string} url Request URL.
329 Authenticator.prototype.updateHistoryState_ = function(url) {
330 if (history.state && history.state.url != url)
331 history.pushState({url: url}, '');
333 history.replaceState({url: url}, '');
337 * Invoked when the sign-in page takes focus.
338 * @param {object} e The focus event being triggered.
341 Authenticator.prototype.onFocus_ = function(e) {
342 this.webview_.focus();
346 * Invoked when the history state is changed.
347 * @param {object} e The popstate event being triggered.
350 Authenticator.prototype.onPopState_ = function(e) {
352 if (state && state.url)
353 this.webview_.src = state.url;
357 * Invoked when headers are received in the main frame of the webview. It
358 * 1) reads the authenticated user info from a signin header,
359 * 2) signals the start of a saml flow upon receiving a saml header.
360 * @return {!Object} Modified request headers.
363 Authenticator.prototype.onHeadersReceived_ = function(details) {
364 var currentUrl = details.url;
365 if (currentUrl.lastIndexOf(this.idpOrigin_, 0) != 0)
368 var headers = details.responseHeaders;
369 for (var i = 0; headers && i < headers.length; ++i) {
370 var header = headers[i];
371 var headerName = header.name.toLowerCase();
372 if (headerName == SIGN_IN_HEADER) {
373 var headerValues = header.value.toLowerCase().split(',');
374 var signinDetails = {};
375 headerValues.forEach(function(e) {
376 var pair = e.split('=');
377 signinDetails[pair[0].trim()] = pair[1].trim();
379 // Removes "" around.
380 this.email_ = signinDetails['email'].slice(1, -1);
381 this.gaiaId_ = signinDetails['obfuscatedid'].slice(1, -1);
382 this.sessionIndex_ = signinDetails['sessionindex'];
383 } else if (headerName == LOCATION_HEADER) {
384 // If the "choose what to sync" checkbox was clicked, then the continue
385 // URL will contain a source=3 field.
386 var location = decodeURIComponent(header.value);
387 this.chooseWhatToSync_ = !!location.match(/(\?|&)source=3($|&)/);
389 this.isNewGaiaFlowChromeOS && headerName == SET_COOKIE_HEADER) {
390 var headerValue = header.value;
391 if (headerValue.indexOf(OAUTH_CODE_COOKIE + '=', 0) == 0) {
393 headerValue.substring(OAUTH_CODE_COOKIE.length + 1).split(';')[0];
400 * Handler for webView.request.onBeforeSendHeaders .
401 * @return {!Object} Modified request headers.
404 Authenticator.prototype.onBeforeSendHeaders_ = function(details) {
405 // deviceId_ is empty when we do not need to send it. For example,
406 // in case of device enrollment.
407 if (this.isNewGaiaFlowChromeOS && this.deviceId_) {
408 var headers = details.requestHeaders;
410 var deviceId = this.getGAIADeviceId_();
412 for (var i = 0, l = headers.length; i < l; ++i) {
413 if (headers[i].name == X_DEVICE_ID_HEADER) {
414 headers[i].value = deviceId;
420 details.requestHeaders.push(
421 {name: X_DEVICE_ID_HEADER, value: deviceId});
425 requestHeaders: details.requestHeaders
430 * Returns true if given HTML5 message is received from the webview element.
431 * @param {object} e Payload of the received HTML5 message.
433 Authenticator.prototype.isGaiaMessage = function(e) {
434 if (!this.isWebviewEvent_(e))
437 // The event origin does not have a trailing slash.
438 if (e.origin != this.idpOrigin_.substring(0, this.idpOrigin_.length - 1)) {
442 // EAFE passes back auth code via message.
444 typeof e.data == 'object' &&
445 e.data.hasOwnProperty('authorizationCode')) {
446 assert(!this.oauth_code_);
447 this.oauth_code_ = e.data.authorizationCode;
449 new CustomEvent('authCompleted',
453 authCode: this.oauth_code_
459 // Gaia messages must be an object with 'method' property.
460 if (typeof e.data != 'object' || !e.data.hasOwnProperty('method')) {
467 * Invoked when an HTML5 message is received from the webview element.
468 * @param {object} e Payload of the received HTML5 message.
471 Authenticator.prototype.onMessageFromWebview_ = function(e) {
472 if (!this.isGaiaMessage(e))
476 if (msg.method == 'attemptLogin') {
477 this.email_ = msg.email;
478 this.password_ = msg.password;
479 this.chooseWhatToSync_ = msg.chooseWhatToSync;
480 // We need to dispatch only first event, before user enters password.
483 new CustomEvent('attemptLogin', {detail: msg.email}));
485 } else if (msg.method == 'dialogShown') {
486 this.dispatchEvent(new Event('dialogShown'));
487 } else if (msg.method == 'dialogHidden') {
488 this.dispatchEvent(new Event('dialogHidden'));
489 } else if (msg.method == 'backButton') {
490 this.dispatchEvent(new CustomEvent('backButton', {detail: msg.show}));
491 } else if (msg.method == 'showView') {
492 this.dispatchEvent(new Event('showView'));
494 console.warn('Unrecognized message from GAIA: ' + msg.method);
499 * Invoked by the hosting page to verify the Saml password.
501 Authenticator.prototype.verifyConfirmedPassword = function(password) {
502 if (!this.samlHandler_.verifyConfirmedPassword(password)) {
503 // Invoke confirm password callback asynchronously because the
504 // verification was based on messages and caller (GaiaSigninScreen)
505 // does not expect it to be called immediately.
506 // TODO(xiyuan): Change to synchronous call when iframe based code
508 var invokeConfirmPassword = (function() {
509 this.confirmPasswordCallback(this.email_,
510 this.samlHandler_.scrapedPasswordCount);
512 window.setTimeout(invokeConfirmPassword, 0);
516 this.password_ = password;
517 this.onAuthCompleted_();
521 * Check Saml flow and start password confirmation flow if needed. Otherwise,
522 * continue with auto completion.
525 Authenticator.prototype.maybeCompleteAuth_ = function() {
526 var missingGaiaInfo = !this.email_ || !this.gaiaId_ || !this.sessionIndex_;
527 if (missingGaiaInfo && !this.skipForNow_) {
528 if (this.missingGaiaInfoCallback)
529 this.missingGaiaInfoCallback();
531 this.webview_.src = this.initialFrameUrl_;
535 if (this.authFlow != AuthFlow.SAML) {
536 this.onAuthCompleted_();
540 if (this.samlHandler_.samlApiUsed) {
541 if (this.samlApiUsedCallback) {
542 this.samlApiUsedCallback();
544 this.password_ = this.samlHandler_.apiPasswordBytes;
545 } else if (this.samlHandler_.scrapedPasswordCount == 0) {
546 if (this.noPasswordCallback) {
547 this.noPasswordCallback(this.email_);
549 console.error('Authenticator: No password scraped for SAML.');
552 } else if (this.needPassword) {
553 if (this.confirmPasswordCallback) {
554 // Confirm scraped password. The flow follows in
555 // verifyConfirmedPassword.
556 this.confirmPasswordCallback(this.email_,
557 this.samlHandler_.scrapedPasswordCount);
562 this.onAuthCompleted_();
566 * Invoked to process authentication completion.
569 Authenticator.prototype.onAuthCompleted_ = function() {
570 assert(this.skipForNow_ ||
571 (this.email_ && this.gaiaId_ && this.sessionIndex_));
573 new CustomEvent('authCompleted',
574 // TODO(rsorokin): get rid of the stub values.
577 email: this.email_ || '',
578 gaiaId: this.gaiaId_ || '',
579 password: this.password_ || '',
580 authCode: this.oauth_code_,
581 usingSAML: this.authFlow == AuthFlow.SAML,
582 chooseWhatToSync: this.chooseWhatToSync_,
583 skipForNow: this.skipForNow_,
584 sessionIndex: this.sessionIndex_ || '',
585 trusted: this.trusted_,
586 deviceId: this.deviceId_ || ''
589 this.clearCredentials_();
593 * Invoked when |samlHandler_| fires 'insecureContentBlocked' event.
596 Authenticator.prototype.onInsecureContentBlocked_ = function(e) {
597 if (this.insecureContentBlockedCallback) {
598 this.insecureContentBlockedCallback(e.detail.url);
600 console.error('Authenticator: Insecure content blocked.');
605 * Invoked when |samlHandler_| fires 'authPageLoaded' event.
608 Authenticator.prototype.onAuthPageLoaded_ = function(e) {
609 if (!e.detail.isSAMLPage)
612 this.authDomain = this.samlHandler_.authDomain;
613 this.authFlow = AuthFlow.SAML;
617 * Invoked when a link is dropped on the webview.
620 Authenticator.prototype.onDropLink_ = function(e) {
621 this.dispatchEvent(new CustomEvent('dropLink', {detail: e.url}));
625 * Invoked when the webview attempts to open a new window.
628 Authenticator.prototype.onNewWindow_ = function(e) {
629 this.dispatchEvent(new CustomEvent('newWindow', {detail: e}));
633 * Invoked when a new document is loaded.
636 Authenticator.prototype.onContentLoad_ = function(e) {
637 if (this.isConstrainedWindow_) {
638 // Signin content in constrained windows should not zoom. Isolate the
639 // webview from the zooming of other webviews using the 'per-view' zoom
640 // mode, and then set it to 100% zoom.
641 this.webview_.setZoomMode('per-view');
642 this.webview_.setZoom(1);
645 // Posts a message to IdP pages to initiate communication.
646 var currentUrl = this.webview_.src;
647 if (currentUrl.lastIndexOf(this.idpOrigin_) == 0) {
649 'method': 'handshake',
650 'deviceId': this.getGAIADeviceId_(),
653 this.webview_.contentWindow.postMessage(msg, currentUrl);
658 * Invoked when the webview fails loading a page.
661 Authenticator.prototype.onLoadAbort_ = function(e) {
662 this.dispatchEvent(new CustomEvent('loadAbort',
663 {detail: {error: e.reason,
664 src: this.webview_.src}}));
668 * Invoked when the webview finishes loading a page.
671 Authenticator.prototype.onLoadStop_ = function(e) {
674 this.dispatchEvent(new Event('ready'));
675 // Focus webview after dispatching event when webview is already visible.
676 this.webview_.focus();
679 // Sends client id to EAFE on every loadstop after a small timeout. This is
680 // needed because EAFE sits behind SSO and initialize asynchrounouly
681 // and we don't know for sure when it is loaded and ready to listen
682 // for message. The postMessage is guarded by EAFE's origin.
684 // An arbitrary small timeout for delivering the initial message.
685 var EAFE_INITIAL_MESSAGE_DELAY_IN_MS = 500;
686 window.setTimeout((function() {
688 'clientId': this.clientId_
690 this.webview_.contentWindow.postMessage(msg, this.idpOrigin_);
691 }).bind(this), EAFE_INITIAL_MESSAGE_DELAY_IN_MS);
696 * Invoked when the webview navigates withing the current document.
699 Authenticator.prototype.onLoadCommit_ = function(e) {
700 if (this.oauth_code_) {
701 this.skipForNow_ = true;
702 this.maybeCompleteAuth_();
707 * Returns |true| if event |e| was sent from the hosted webview.
710 Authenticator.prototype.isWebviewEvent_ = function(e) {
711 // Note: <webview> prints error message to console if |contentWindow| is not
713 // TODO(dzhioev): remove the message. http://crbug.com/469522
714 var webviewWindow = this.webview_.contentWindow;
715 return !!webviewWindow && webviewWindow === e.source;
719 * Format deviceId for GAIA .
720 * @return {string} deviceId.
723 Authenticator.prototype.getGAIADeviceId_ = function() {
724 // deviceId_ is empty when we do not need to send it. For example,
725 // in case of device enrollment.
726 if (!(this.isNewGaiaFlowChromeOS && this.deviceId_))
729 if (this.sessionIsEphemeral_)
730 return EPHEMERAL_DEVICE_ID_PREFIX + this.deviceId_;
732 return this.deviceId_;
736 * Informs Gaia of new deviceId to be used.
738 Authenticator.prototype.updateDeviceId = function(deviceId) {
739 this.deviceId_ = deviceId;
741 'method': 'updateDeviceId',
742 'deviceId': this.getGAIADeviceId_(),
745 var currentUrl = this.webview_.src;
746 this.webview_.contentWindow.postMessage(msg, currentUrl);
750 * The current auth flow of the hosted auth page.
753 cr.defineProperty(Authenticator, 'authFlow');
756 * The domain name of the current auth page.
759 cr.defineProperty(Authenticator, 'authDomain');
761 Authenticator.AuthFlow = AuthFlow;
762 Authenticator.AuthMode = AuthMode;
763 Authenticator.SUPPORTED_PARAMS = SUPPORTED_PARAMS;
766 // TODO(guohui, xiyuan): Rename GaiaAuthHost to Authenticator once the old
767 // iframe-based flow is deprecated.
768 GaiaAuthHost: Authenticator,
769 Authenticator: Authenticator