blob: a165c0d0ed395131ae605517a9421518130c6a25 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// <include src="saml_handler.js">
// Note: webview_event_manager.js is already included by saml_handler.js.
/**
* @fileoverview An UI component to authenciate to Chrome. The component hosts
* IdP web pages in a webview. A client who is interested in monitoring
* authentication events should subscribe itself via addEventListener(). After
* initialization, call {@code load} to start the authentication flow.
*
* See go/cros-auth-design for details on Google API.
*/
cr.define('cr.login', function() {
'use strict';
// TODO(rogerta): should use gaia URL from GaiaUrls::gaia_url() instead
// of hardcoding the prod URL here. As is, this does not work with staging
// environments.
const IDP_ORIGIN = 'https://accounts.google.com/';
const CONTINUE_URL =
'chrome-extension://mfffpogegjflfpflabcdkioaeobkgjik/success.html';
const SIGN_IN_HEADER = 'google-accounts-signin';
const EMBEDDED_FORM_HEADER = 'google-accounts-embedded';
const LOCATION_HEADER = 'location';
const SERVICE_ID = 'chromeoslogin';
const EMBEDDED_SETUP_CHROMEOS_ENDPOINT_V2 = 'embedded/setup/v2/chromeos';
const SAML_REDIRECTION_PATH = 'samlredirect';
const BLANK_PAGE_URL = 'about:blank';
/**
* The source URL parameter for the constrained signin flow.
*/
const CONSTRAINED_FLOW_SOURCE = 'chrome';
/**
* Enum for the authorization mode, must match AuthMode defined in
* chrome/browser/ui/webui/inline_login_ui.cc.
* @enum {number}
*/
const AuthMode = {DEFAULT: 0, OFFLINE: 1, DESKTOP: 2};
/**
* Enum for the authorization type.
* @enum {number}
*/
const AuthFlow = {DEFAULT: 0, SAML: 1};
/**
* Supported Authenticator params.
* @type {!Array<string>}
* @const
*/
const SUPPORTED_PARAMS = [
'gaiaId', // Obfuscated GAIA ID to skip the email prompt page
// during the re-auth flow.
'gaiaUrl', // Gaia url to use.
'gaiaPath', // Gaia path to use without a leading slash.
'hl', // Language code for the user interface.
'service', // Name of Gaia service.
'continueUrl', // Continue url to use.
'frameUrl', // Initial frame URL to use. If empty defaults to
// gaiaUrl.
'constrained', // Whether the extension is loaded in a constrained
// window.
'clientId', // Chrome client id.
'needPassword', // Whether the host is interested in getting a password.
// If this set to |false|, |confirmPasswordCallback| is
// not called before dispatching |authCopleted|.
// Default is |true|.
'flow', // One of 'default', 'enterprise', or 'theftprotection'.
'enterpriseDisplayDomain', // Current domain name to be displayed.
'enterpriseEnrollmentDomain', // Domain in which hosting device is (or
// should be) enrolled.
'emailDomain', // Value used to prefill domain for email.
'chromeType', // Type of Chrome OS device, e.g. "chromebox".
'clientVersion', // Version of the Chrome build.
'platformVersion', // Version of the OS build.
'releaseChannel', // Installation channel.
'endpointGen', // Current endpoint generation.
'menuGuestMode', // Enables "Guest mode" menu item
'menuKeyboardOptions', // Enables "Keyboard options" menu item
'menuEnterpriseEnrollment', // Enables "Enterprise enrollment" menu item.
'lsbReleaseBoard', // Chrome OS Release board name
'isFirstUser', // True if this is non-enterprise device,
// and there are no users yet.
'obfuscatedOwnerId', // Obfuscated device owner ID, if needed.
'extractSamlPasswordAttributes', // If enabled attempts to extract password
// attributes from the SAML response.
// The email fields allow for the following possibilities:
//
// 1/ If 'email' is not supplied, then the email text field is blank and the
// user must type an email to proceed.
//
// 2/ If 'email' is supplied, and 'readOnlyEmail' is truthy, then the email
// is hardcoded and the user cannot change it. The user is asked for
// password. This is useful for re-auth scenarios, where chrome needs the
// user to authenticate for a specific account and only that account.
//
// 3/ If 'email' is supplied, and 'readOnlyEmail' is falsy, gaia will
// prefill the email text field using the given email address, but the user
// can still change it and then proceed. This is used on desktop when the
// user disconnects their profile then reconnects, to encourage them to use
// the same account.
'email',
'readOnlyEmail',
'realm',
];
/**
* Initializes the authenticator component.
*/
class Authenticator extends cr.EventTarget {
/**
* @param {webview|string} webview The webview element or its ID to host
* IdP web pages.
*/
constructor(webview) {
super();
this.isLoaded_ = false;
this.email_ = null;
this.password_ = null;
this.gaiaId_ = null, this.sessionIndex_ = null;
this.chooseWhatToSync_ = false;
this.skipForNow_ = false;
this.authFlow = AuthFlow.DEFAULT;
this.authDomain = '';
this.videoEnabled = false;
this.idpOrigin_ = null;
this.continueUrl_ = null;
this.continueUrlWithoutParams_ = null;
this.initialFrameUrl_ = null;
this.reloadUrl_ = null;
this.trusted_ = true;
this.readyFired_ = false;
this.webviewEventManager_ = WebviewEventManager.create();
this.clientId_ = null;
this.confirmPasswordCallback = null;
this.noPasswordCallback = null;
this.insecureContentBlockedCallback = null;
this.samlApiUsedCallback = null;
this.missingGaiaInfoCallback = null;
/**
* Callback allowing to request whether the specified user which
* authenticates via SAML is a user without a password (neither a manually
* entered one nor one provided via Credentials Passing API).
* @type {function(string, string, function(boolean))} Arguments are the
* e-mail, the GAIA ID, and the response callback.
*/
this.getIsSamlUserPasswordlessCallback = null;
this.needPassword = true;
this.services_ = null;
/**
* Caches the result of |getIsSamlUserPasswordlessCallback| invocation for
* the current user. Null if no result is obtained yet.
* @type {?boolean}
* @private
*/
this.isSamlUserPasswordless_ = null;
this.bindToWebview_(webview);
window.addEventListener(
'message', this.onMessageFromWebview_.bind(this), false);
window.addEventListener('focus', this.onFocus_.bind(this), false);
window.addEventListener('popstate', this.onPopState_.bind(this), false);
}
/**
* Reinitializes authentication parameters so that a failed login attempt
* would not result in an infinite loop.
*/
resetStates() {
this.isLoaded_ = false;
this.email_ = null;
this.gaiaId_ = null;
this.password_ = null;
this.readyFired_ = false;
this.chooseWhatToSync_ = false;
this.skipForNow_ = false;
this.sessionIndex_ = null;
this.trusted_ = true;
this.authFlow = AuthFlow.DEFAULT;
this.samlHandler_.reset();
this.videoEnabled = false;
this.services_ = null;
this.isSamlUserPasswordless_ = null;
}
/**
* Resets the webview to the blank page.
*/
resetWebview() {
if (this.webview_.src && this.webview_.src != BLANK_PAGE_URL) {
this.webview_.src = BLANK_PAGE_URL;
}
}
/**
* Binds this authenticator to the passed webview.
* @param {!Object} webview the new webview to be used by this
* Authenticator.
* @private
*/
bindToWebview_(webview) {
assert(!this.webview_);
assert(!this.samlHandler_);
this.webview_ = typeof webview == 'string' ? $(webview) : webview;
this.samlHandler_ =
new cr.login.SamlHandler(this.webview_, false /* startsOnSamlPage */);
this.webviewEventManager_.addEventListener(
this.samlHandler_, 'insecureContentBlocked',
this.onInsecureContentBlocked_.bind(this));
this.webviewEventManager_.addEventListener(
this.samlHandler_, 'authPageLoaded',
this.onAuthPageLoaded_.bind(this));
this.webviewEventManager_.addEventListener(
this.samlHandler_, 'videoEnabled', this.onVideoEnabled_.bind(this));
this.webviewEventManager_.addEventListener(
this.samlHandler_, 'apiPasswordAdded',
this.onSamlApiPasswordAdded_.bind(this));
this.webviewEventManager_.addEventListener(
this.webview_, 'droplink', this.onDropLink_.bind(this));
this.webviewEventManager_.addEventListener(
this.webview_, 'newwindow', this.onNewWindow_.bind(this));
this.webviewEventManager_.addEventListener(
this.webview_, 'contentload', this.onContentLoad_.bind(this));
this.webviewEventManager_.addEventListener(
this.webview_, 'loadabort', this.onLoadAbort_.bind(this));
this.webviewEventManager_.addEventListener(
this.webview_, 'loadcommit', this.onLoadCommit_.bind(this));
this.webviewEventManager_.addWebRequestEventListener(
this.webview_.request.onCompleted,
this.onRequestCompleted_.bind(this),
{urls: ['<all_urls>'], types: ['main_frame']}, ['responseHeaders']);
this.webviewEventManager_.addWebRequestEventListener(
this.webview_.request.onHeadersReceived,
this.onHeadersReceived_.bind(this),
{urls: ['<all_urls>'], types: ['main_frame', 'xmlhttprequest']},
['responseHeaders']);
}
/**
* Unbinds this Authenticator from the currently bound webview.
* @private
*/
unbindFromWebview_() {
assert(this.webview_);
assert(this.samlHandler_);
this.webviewEventManager_.removeAllListeners();
this.webview_ = undefined;
this.samlHandler_.unbindFromWebview();
this.samlHandler_ = undefined;
}
/**
* Re-binds to another webview.
* @param {Object} webview the new webview to be used by this Authenticator.
*/
rebindWebview(webview) {
this.unbindFromWebview_();
this.bindToWebview_(webview);
}
/**
* Loads the authenticator component with the given parameters.
* @param {AuthMode} authMode Authorization mode.
* @param {Object} data Parameters for the authorization flow.
*/
load(authMode, data) {
this.authMode = authMode;
this.resetStates();
// gaiaUrl parameter is used for testing. Once defined, it is never
// changed.
this.idpOrigin_ = data.gaiaUrl || IDP_ORIGIN;
this.continueUrl_ = data.continueUrl || CONTINUE_URL;
this.continueUrlWithoutParams_ =
this.continueUrl_.substring(0, this.continueUrl_.indexOf('?')) ||
this.continueUrl_;
this.isConstrainedWindow_ = data.constrained == '1';
this.clientId_ = data.clientId;
this.dontResizeNonEmbeddedPages = data.dontResizeNonEmbeddedPages;
this.initialFrameUrl_ = this.constructInitialFrameUrl_(data);
this.reloadUrl_ = data.frameUrl || this.initialFrameUrl_;
// Don't block insecure content for desktop flow because it lands on
// http. Otherwise, block insecure content as long as gaia is https.
this.samlHandler_.blockInsecureContent = authMode != AuthMode.DESKTOP &&
this.idpOrigin_.startsWith('https://');
this.samlHandler_.extractSamlPasswordAttributes =
data.extractSamlPasswordAttributes;
this.needPassword = !('needPassword' in data) || data.needPassword;
this.webview_.contextMenus.onShow.addListener(function(e) {
e.preventDefault();
});
this.webview_.src = this.reloadUrl_;
this.isLoaded_ = true;
}
constructChromeOSAPIUrl_() {
return this.idpOrigin_ + EMBEDDED_SETUP_CHROMEOS_ENDPOINT_V2;
}
/**
* Reloads the authenticator component.
*/
reload() {
this.resetStates();
this.webview_.src = this.reloadUrl_;
this.isLoaded_ = true;
}
constructInitialFrameUrl_(data) {
if (data.doSamlRedirect) {
let url = this.idpOrigin_ + SAML_REDIRECTION_PATH;
url = appendParam(url, 'domain', data.enterpriseEnrollmentDomain);
url = appendParam(
url, 'continue',
data.gaiaUrl + 'programmatic_auth_chromeos?hl=' + data.hl +
'&scope=https%3A%2F%2Fwww.google.com%2Faccounts%2FOAuthLogin&' +
'client_id=' + encodeURIComponent(data.clientId) +
'&access_type=offline');
return url;
}
let url;
if (data.gaiaPath) {
url = this.idpOrigin_ + data.gaiaPath;
} else {
url = this.constructChromeOSAPIUrl_();
}
if (data.chromeType) {
url = appendParam(url, 'chrometype', data.chromeType);
}
if (data.clientId) {
url = appendParam(url, 'client_id', data.clientId);
}
if (data.enterpriseDisplayDomain) {
url = appendParam(url, 'manageddomain', data.enterpriseDisplayDomain);
}
if (data.clientVersion) {
url = appendParam(url, 'client_version', data.clientVersion);
}
if (data.platformVersion) {
url = appendParam(url, 'platform_version', data.platformVersion);
}
if (data.releaseChannel) {
url = appendParam(url, 'release_channel', data.releaseChannel);
}
if (data.endpointGen) {
url = appendParam(url, 'endpoint_gen', data.endpointGen);
}
let mi = '';
if (data.menuGuestMode) {
mi += 'gm,';
}
if (data.menuKeyboardOptions) {
mi += 'ko,';
}
if (data.menuEnterpriseEnrollment) {
mi += 'ee,';
}
if (mi.length) {
url = appendParam(url, 'mi', mi);
}
if (data.lsbReleaseBoard) {
url = appendParam(url, 'chromeos_board', data.lsbReleaseBoard);
}
if (data.isFirstUser) {
url = appendParam(url, 'is_first_user', true);
}
if (data.obfuscatedOwnerId) {
url = appendParam(url, 'obfuscated_owner_id', data.obfuscatedOwnerId);
}
if (data.hl) {
url = appendParam(url, 'hl', data.hl);
}
if (data.gaiaId) {
url = appendParam(url, 'user_id', data.gaiaId);
}
if (data.email) {
if (data.readOnlyEmail) {
url = appendParam(url, 'Email', data.email);
} else {
url = appendParam(url, 'email_hint', data.email);
}
}
if (this.isConstrainedWindow_) {
url = appendParam(url, 'source', CONSTRAINED_FLOW_SOURCE);
}
if (data.flow) {
url = appendParam(url, 'flow', data.flow);
}
if (data.emailDomain) {
url = appendParam(url, 'emaildomain', data.emailDomain);
// ChromeOS embedded signin page uses 'hd' (hosted domain) as the query
// argument to show an email domain.
url = appendParam(url, 'hd', data.emailDomain);
}
return url;
}
/**
* Dispatches the 'ready' event if it hasn't been dispatched already for the
* current content.
* @private
*/
fireReadyEvent_() {
if (!this.readyFired_) {
this.dispatchEvent(new Event('ready'));
this.readyFired_ = true;
}
}
/**
* Invoked when a main frame request in the webview has completed.
* @private
*/
onRequestCompleted_(details) {
const currentUrl = details.url;
if (!currentUrl.startsWith('https')) {
this.trusted_ = false;
}
if (this.isConstrainedWindow_) {
let isEmbeddedPage = false;
if (this.idpOrigin_ && currentUrl.lastIndexOf(this.idpOrigin_) == 0) {
const headers = details.responseHeaders;
for (let i = 0; headers && i < headers.length; ++i) {
if (headers[i].name.toLowerCase() == EMBEDDED_FORM_HEADER) {
isEmbeddedPage = true;
break;
}
}
}
// In some cases, non-embedded pages should not be resized. For
// example, on desktop when reauthenticating for purposes of unlocking
// a profile, resizing would cause a browser window to open in the
// system profile, which is not allowed.
if (!isEmbeddedPage && !this.dontResizeNonEmbeddedPages) {
this.dispatchEvent(new CustomEvent('resize', {detail: currentUrl}));
return;
}
}
this.updateHistoryState_(currentUrl);
}
/**
* Manually updates the history. Invoked upon completion of a webview
* navigation.
* @param {string} url Request URL.
* @private
*/
updateHistoryState_(url) {
if (history.state && history.state.url != url) {
history.pushState({url: url}, '');
} else {
history.replaceState({url: url}, '');
}
}
/**
* Invoked when the sign-in page takes focus.
* @param {object} e The focus event being triggered.
* @private
*/
onFocus_(e) {
if (this.authMode == AuthMode.DESKTOP &&
document.activeElement == document.body) {
this.webview_.focus();
}
}
/**
* Invoked when the history state is changed.
* @param {object} e The popstate event being triggered.
* @private
*/
onPopState_(e) {
const state = e.state;
if (state && state.url) {
this.webview_.src = state.url;
}
}
/**
* Invoked when headers are received in the main frame of the webview. It
* 1) reads the authenticated user info from a signin header,
* 2) signals the start of a saml flow upon receiving a saml header.
* @return {!Object} Modified request headers.
* @private
*/
onHeadersReceived_(details) {
const currentUrl = details.url;
if (currentUrl.lastIndexOf(this.idpOrigin_, 0) != 0) {
return;
}
const headers = details.responseHeaders;
for (let i = 0; headers && i < headers.length; ++i) {
const header = headers[i];
const headerName = header.name.toLowerCase();
if (headerName == SIGN_IN_HEADER) {
const headerValues = header.value.toLowerCase().split(',');
const signinDetails = {};
headerValues.forEach(function(e) {
const pair = e.split('=');
signinDetails[pair[0].trim()] = pair[1].trim();
});
// Removes "" around.
this.email_ = signinDetails['email'].slice(1, -1);
this.gaiaId_ = signinDetails['obfuscatedid'].slice(1, -1);
this.sessionIndex_ = signinDetails['sessionindex'];
this.isSamlUserPasswordless_ = null;
} else if (headerName == LOCATION_HEADER) {
// If the "choose what to sync" checkbox was clicked, then the
// continue URL will contain a source=3 field.
const location = decodeURIComponent(header.value);
this.chooseWhatToSync_ = !!location.match(/(\?|&)source=3($|&)/);
}
}
}
/**
* Returns true if given HTML5 message is received from the webview element.
* @param {object} e Payload of the received HTML5 message.
*/
isGaiaMessage(e) {
if (!this.isWebviewEvent_(e)) {
return false;
}
// The event origin does not have a trailing slash.
if (e.origin !=
this.idpOrigin_.substring(0, this.idpOrigin_.length - 1)) {
return false;
}
// Gaia messages must be an object with 'method' property.
if (typeof e.data != 'object' || !e.data.hasOwnProperty('method')) {
return false;
}
return true;
}
/**
* Invoked when an HTML5 message is received from the webview element.
* @param {object} e Payload of the received HTML5 message.
* @private
*/
onMessageFromWebview_(e) {
if (!this.isGaiaMessage(e)) {
return;
}
const msg = e.data;
if (msg.method == 'attemptLogin') {
this.email_ = msg.email;
if (this.authMode == AuthMode.DESKTOP) {
this.password_ = msg.password;
}
this.isSamlUserPasswordless_ = null;
this.chooseWhatToSync_ = msg.chooseWhatToSync;
// We need to dispatch only first event, before user enters password.
this.dispatchEvent(
new CustomEvent('attemptLogin', {detail: msg.email}));
} else if (msg.method == 'dialogShown') {
this.dispatchEvent(new Event('dialogShown'));
} else if (msg.method == 'dialogHidden') {
this.dispatchEvent(new Event('dialogHidden'));
} else if (msg.method == 'backButton') {
this.dispatchEvent(new CustomEvent('backButton', {detail: msg.show}));
} else if (msg.method == 'showView') {
this.dispatchEvent(new Event('showView'));
} else if (msg.method == 'menuItemClicked') {
this.dispatchEvent(
new CustomEvent('menuItemClicked', {detail: msg.item}));
} else if (msg.method == 'identifierEntered') {
this.dispatchEvent(new CustomEvent(
'identifierEntered',
{detail: {accountIdentifier: msg.accountIdentifier}}));
} else if (msg.method == 'userInfo') {
this.services_ = msg.services;
if (this.email_ && this.gaiaId_ && this.sessionIndex_) {
this.maybeCompleteAuth_();
}
} else if (msg.method == 'showIncognito') {
this.dispatchEvent(new Event('showIncognito'));
} else {
console.warn('Unrecognized message from GAIA: ' + msg.method);
}
}
/**
* Invoked by the hosting page to verify the Saml password.
*/
verifyConfirmedPassword(password) {
if (!this.samlHandler_.verifyConfirmedPassword(password)) {
// Invoke confirm password callback asynchronously because the
// verification was based on messages and caller (GaiaSigninScreen)
// does not expect it to be called immediately.
// TODO(xiyuan): Change to synchronous call when iframe based code
// is removed.
const invokeConfirmPassword =
(function() {
this.confirmPasswordCallback(
this.email_, this.samlHandler_.scrapedPasswordCount);
}).bind(this);
window.setTimeout(invokeConfirmPassword, 0);
return;
}
this.password_ = password;
this.onAuthCompleted_();
}
/**
* Check Saml flow and start password confirmation flow if needed.
* Otherwise, continue with auto completion.
* @private
*/
maybeCompleteAuth_() {
const missingGaiaInfo =
!this.email_ || !this.gaiaId_ || !this.sessionIndex_;
if (missingGaiaInfo && !this.skipForNow_) {
if (this.missingGaiaInfoCallback) {
this.missingGaiaInfoCallback();
}
this.webview_.src = this.initialFrameUrl_;
return;
}
// TODO(https://crbug.com/837107): remove this once API is fully
// stabilized.
// @example.com is used in tests.
if (!this.services_ && !this.email_.endsWith('@gmail.com') &&
!this.email_.endsWith('@example.com')) {
console.warn('Forcing empty services.');
this.services_ = [];
}
if (!this.services_) {
return;
}
if (this.isSamlUserPasswordless_ === null &&
this.authFlow == AuthFlow.SAML && this.email_ && this.gaiaId_ &&
this.getIsSamlUserPasswordlessCallback) {
// Start a request to obtain the |isSamlUserPasswordless_| value for the
// current user. Once the response arrives, maybeCompleteAuth_() will be
// called again.
this.getIsSamlUserPasswordlessCallback(
this.email_, this.gaiaId_,
this.onGotIsSamlUserPasswordless_.bind(
this, this.email_, this.gaiaId_));
return;
}
if (this.isSamlUserPasswordless_ && this.authFlow == AuthFlow.SAML &&
this.email_ && this.gaiaId_) {
// No password needed for this user, so complete immediately.
this.onAuthCompleted_();
return;
}
if (this.samlHandler_.samlApiUsed) {
if (this.samlApiUsedCallback) {
this.samlApiUsedCallback();
}
this.password_ = this.samlHandler_.apiPasswordBytes;
this.onAuthCompleted_();
return;
}
if (this.samlHandler_.scrapedPasswordCount == 0) {
if (this.noPasswordCallback) {
this.noPasswordCallback(this.email_);
return;
}
// Fall through to finish the auth flow even if this.needPassword
// is true. This is because the flag is used as an intention to get
// password when it is available but not a mandatory requirement.
console.warn('Authenticator: No password scraped for SAML.');
} else if (this.needPassword) {
if (this.samlHandler_.scrapedPasswordCount == 1) {
// If we scraped exactly one password, we complete the authentication
// right away.
this.password_ = this.samlHandler_.firstScrapedPassword;
this.onAuthCompleted_();
return;
}
if (this.confirmPasswordCallback) {
// Confirm scraped password. The flow follows in
// verifyConfirmedPassword.
this.confirmPasswordCallback(
this.email_, this.samlHandler_.scrapedPasswordCount);
return;
}
}
this.onAuthCompleted_();
}
/**
* Invoked to complete the authentication using the password the user enters
* manually for non-principals API SAML IdPs that we couldn't scrape their
* password input.
*/
completeAuthWithManualPassword(password) {
this.password_ = password;
this.onAuthCompleted_();
}
/**
* Invoked when the result of |getIsSamlUserPasswordlessCallback| arrives.
* @param {string} email
* @param {string} gaiaId
* @param {boolean} isSamlUserPasswordless
* @private
*/
onGotIsSamlUserPasswordless_(email, gaiaId, isSamlUserPasswordless) {
// Compare the request's user identifier with the currently set one, in
// order to ignore responses to old requests.
if (this.email_ && this.email_ == email && this.gaiaId_ &&
this.gaiaId_ == gaiaId) {
this.isSamlUserPasswordless_ = isSamlUserPasswordless;
this.maybeCompleteAuth_();
}
}
/**
* Asserts the |arr| which is known as |nameOfArr| is an array of strings.
* @private
*/
assertStringArray_(arr, nameOfArr) {
console.assert(Array.isArray(arr),
'FATAL: Bad %s type: %s', nameOfArr, typeof arr);
for (let i = 0; i < arr.length; ++i) {
this.assertStringElement_(arr[i], nameOfArr, i);
}
}
/**
* Asserts the |dict| which is known as |nameOfDict| is a dict of strings.
* @private
*/
assertStringDict_(dict, nameOfDict) {
console.assert(typeof dict == 'object',
'FATAL: Bad %s type: %s', nameOfDict, typeof dict);
for (const key in dict) {
this.assertStringElement_(dict[key], nameOfDict, key);
}
}
/** Asserts an element |elem| in a certain collection is a string. */
assertStringElement_(elem, nameOfCollection, index) {
console.assert(typeof elem == 'string',
'FATAL: Bad %s[%s] type: %s', nameOfCollection, index, typeof elem);
}
/**
* Invoked to process authentication completion.
* @private
*/
onAuthCompleted_() {
assert(
this.skipForNow_ ||
(this.email_ && this.gaiaId_ && this.sessionIndex_));
// Chrome will crash on incorrect data type, so log some error message
// here.
if (this.services_) {
this.assertStringArray_(this.services_, 'services');
}
if (this.isSamlUserPasswordless_ && this.authFlow == AuthFlow.SAML &&
this.email_) {
// In the passwordless case, the user data will be protected by non
// password based mechanisms. Clear anything that got collected into
// |password_|, if any.
this.password_ = '';
}
let passwordAttributes = {};
if (this.authFlow == AuthFlow.SAML &&
this.samlHandler_.extractSamlPasswordAttributes &&
!this.isSamlUserPasswordless_) {
passwordAttributes = this.samlHandler_.passwordAttributes;
}
this.assertStringDict_(passwordAttributes, 'passwordAttributes');
this.dispatchEvent(new CustomEvent(
'authCompleted',
// TODO(rsorokin): get rid of the stub values.
{
detail: {
email: this.email_ || '',
gaiaId: this.gaiaId_ || '',
password: this.password_ || '',
usingSAML: this.authFlow == AuthFlow.SAML,
chooseWhatToSync: this.chooseWhatToSync_,
skipForNow: this.skipForNow_,
sessionIndex: this.sessionIndex_ || '',
trusted: this.trusted_,
services: this.services_ || [],
passwordAttributes: passwordAttributes
}
}));
this.resetStates();
}
/**
* Invoked when |samlHandler_| fires 'insecureContentBlocked' event.
* @private
*/
onInsecureContentBlocked_(e) {
if (!this.isLoaded_) {
return;
}
if (this.insecureContentBlockedCallback) {
this.insecureContentBlockedCallback(e.detail.url);
} else {
console.error('Authenticator: Insecure content blocked.');
}
}
/**
* Invoked when |samlHandler_| fires 'authPageLoaded' event.
* @private
*/
onAuthPageLoaded_(e) {
if (!this.isLoaded_) {
return;
}
if (!e.detail.isSAMLPage) {
return;
}
this.authDomain = this.samlHandler_.authDomain;
this.authFlow = AuthFlow.SAML;
this.webview_.focus();
this.fireReadyEvent_();
}
/**
* Invoked when |samlHandler_| fires 'videoEnabled' event.
* @private
*/
onVideoEnabled_(e) {
this.videoEnabled = true;
}
/**
* Invoked when |samlHandler_| fires 'apiPasswordAdded' event.
* @private
*/
onSamlApiPasswordAdded_(e) {
// Saml API 'add' password might be received after the 'loadcommit' event.
// In such case, maybeCompleteAuth_ should be attempted again if GAIA ID
// is available.
if (this.gaiaId_) {
this.maybeCompleteAuth_();
}
}
/**
* Invoked when a link is dropped on the webview.
* @private
*/
onDropLink_(e) {
this.dispatchEvent(new CustomEvent('dropLink', {detail: e.url}));
}
/**
* Invoked when the webview attempts to open a new window.
* @private
*/
onNewWindow_(e) {
this.dispatchEvent(new CustomEvent('newWindow', {detail: e}));
}
/**
* Invoked when a new document is loaded.
* @private
*/
onContentLoad_(e) {
if (this.isConstrainedWindow_) {
// Signin content in constrained windows should not zoom. Isolate the
// webview from the zooming of other webviews using the 'per-view' zoom
// mode, and then set it to 100% zoom.
this.webview_.setZoomMode('per-view');
this.webview_.setZoom(1);
}
// Posts a message to IdP pages to initiate communication.
const currentUrl = this.webview_.src;
if (currentUrl.lastIndexOf(this.idpOrigin_) == 0) {
const msg = {
'method': 'handshake',
};
// |this.webview_.contentWindow| may be null after network error screen
// is shown. See crbug.com/770999.
if (this.webview_.contentWindow) {
this.webview_.contentWindow.postMessage(msg, currentUrl);
} else {
console.error('Authenticator: contentWindow is null.');
}
if (this.authMode == AuthMode.DEFAULT) {
chrome.send('metricsHandler:recordBooleanHistogram', [
'ChromeOS.GAIA.AuthenticatorContentWindowNull',
!this.webview_.contentWindow
]);
}
this.fireReadyEvent_();
// Focus webview after dispatching event when webview is already
// visible.
this.webview_.focus();
} else if (currentUrl == BLANK_PAGE_URL) {
this.fireReadyEvent_();
}
}
/**
* Invoked when the webview fails loading a page.
* @private
*/
onLoadAbort_(e) {
this.dispatchEvent(new CustomEvent(
'loadAbort', {detail: {error: e.reason, src: e.url}}));
}
/**
* Invoked when the webview navigates withing the current document.
* @private
*/
onLoadCommit_(e) {
if (this.gaiaId_) {
this.maybeCompleteAuth_();
}
}
/**
* Returns |true| if event |e| was sent from the hosted webview.
* @private
*/
isWebviewEvent_(e) {
// Note: <webview> prints error message to console if |contentWindow| is
// not defined.
// TODO(dzhioev): remove the message. http://crbug.com/469522
const webviewWindow = this.webview_.contentWindow;
return !!webviewWindow && webviewWindow === e.source;
}
}
/**
* The current auth flow of the hosted auth page.
* @type {AuthFlow}
*/
cr.defineProperty(Authenticator, 'authFlow');
/**
* The domain name of the current auth page.
* @type {string}
*/
cr.defineProperty(Authenticator, 'authDomain');
/**
* True if the page has requested media access.
* @type {boolean}
*/
cr.defineProperty(Authenticator, 'videoEnabled');
Authenticator.AuthFlow = AuthFlow;
Authenticator.AuthMode = AuthMode;
Authenticator.SUPPORTED_PARAMS = SUPPORTED_PARAMS;
return {
Authenticator: Authenticator
};
});