1 // Copyright 2015 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="post_message_channel.js">
8 * @fileoverview Saml support for webview based auth.
11 cr.define('cr.login', function() {
15 * The lowest version of the credentials passing API supported.
18 var MIN_API_VERSION_VERSION = 1;
21 * The highest version of the credentials passing API supported.
24 var MAX_API_VERSION_VERSION = 1;
27 * The key types supported by the credentials passing API.
28 * @type {Array} Array of strings.
31 'KEY_TYPE_PASSWORD_PLAIN',
35 var SAML_HEADER = 'google-accounts-saml';
38 * The script to inject into webview and its sub frames.
41 var injectedJs = String.raw`
42 <include src="webview_saml_injected.js">
46 * Creates a new URL by striping all query parameters.
47 * @param {string} url The original URL.
48 * @return {string} The new URL with all query parameters stripped.
50 function stripParams(url) {
51 return url.substring(0, url.indexOf('?')) || url;
55 * Extract domain name from an URL.
56 * @param {string} url An URL string.
57 * @return {string} The host name of the URL.
59 function extractDomain(url) {
60 var a = document.createElement('a');
66 * A handler to provide saml support for the given webview that hosts the
68 * @extends {cr.EventTarget}
69 * @param {webview} webview
72 function SamlHandler(webview) {
74 * The webview that serves IdP pages.
77 this.webview_ = webview;
80 * Whether a Saml IdP page is display in the webview.
83 this.isSamlPage_ = false;
86 * Pending Saml IdP page flag that is set when a SAML_HEADER is received
87 * and is copied to |isSamlPage_| in loadcommit.
90 this.pendingIsSamlPage_ = false;
93 * The last aborted top level url. It is recorded in loadabort event and
94 * used to skip injection into Chrome's error page in the following
98 this.abortedTopLevelUrl_ = null;
101 * The domain of the Saml IdP.
104 this.authDomain = '';
107 * Scraped password stored in an id to password field value map.
108 * @type {Object<string, string>}
111 this.passwordStore_ = {};
114 * Whether Saml API is initialized.
117 this.apiInitialized_ = false;
120 * Saml API version to use.
123 this.apiVersion_ = 0;
126 * Saml API token received.
129 this.apiToken_ = null;
132 * Saml API password bytes.
135 this.apiPasswordBytes_ = null;
138 * Whether to abort the authentication flow and show an error messagen when
139 * content served over an unencrypted connection is detected.
142 this.blockInsecureContent = false;
144 this.webview_.addEventListener(
145 'contentload', this.onContentLoad_.bind(this));
146 this.webview_.addEventListener(
147 'loadabort', this.onLoadAbort_.bind(this));
148 this.webview_.addEventListener(
149 'loadcommit', this.onLoadCommit_.bind(this));
151 this.webview_.request.onBeforeRequest.addListener(
152 this.onInsecureRequest.bind(this),
153 {urls: ['http://*/*', 'file://*/*', 'ftp://*/*']},
155 this.webview_.request.onHeadersReceived.addListener(
156 this.onHeadersReceived_.bind(this),
157 {urls: ['<all_urls>'], types: ['main_frame', 'xmlhttprequest']},
158 ['blocking', 'responseHeaders']);
160 this.webview_.addContentScripts([{
161 name: 'samlInjected',
162 matches: ['http://*/*', 'https://*/*'],
167 run_at: 'document_start'
170 PostMessageChannel.runAsDaemon(this.onConnected_.bind(this));
173 SamlHandler.prototype = {
174 __proto__: cr.EventTarget.prototype,
177 * Whether Saml API is used during auth.
181 return !!this.apiPasswordBytes_;
185 * Returns the Saml API password bytes.
188 get apiPasswordBytes() {
189 return this.apiPasswordBytes_;
193 * Returns the number of scraped passwords.
196 get scrapedPasswordCount() {
197 return this.getConsolidatedScrapedPasswords_().length;
201 * Gets the de-duped scraped passwords.
202 * @return {Array<string>}
205 getConsolidatedScrapedPasswords_: function() {
207 for (var property in this.passwordStore_) {
208 passwords[this.passwordStore_[property]] = true;
210 return Object.keys(passwords);
214 * Resets all auth states
217 this.isSamlPage_ = false;
218 this.pendingIsSamlPage_ = false;
219 this.passwordStore_ = {};
221 this.apiInitialized_ = false;
222 this.apiVersion_ = 0;
223 this.apiToken_ = null;
224 this.apiPasswordBytes_ = null;
228 * Check whether the given |password| is in the scraped passwords.
229 * @return {boolean} True if the |password| is found.
231 verifyConfirmedPassword: function(password) {
232 return this.getConsolidatedScrapedPasswords_().indexOf(password) >= 0;
236 * Invoked on the webview's contentload event.
239 onContentLoad_: function(e) {
240 PostMessageChannel.init(this.webview_.contentWindow);
244 * Invoked on the webview's loadabort event.
247 onLoadAbort_: function(e) {
249 this.abortedTopLevelUrl_ = e.url;
253 * Invoked on the webview's loadcommit event for both main and sub frames.
256 onLoadCommit_: function(e) {
257 // Skip this loadcommit if the top level load is just aborted.
258 if (e.isTopLevel && e.url === this.abortedTopLevelUrl_) {
259 this.abortedTopLevelUrl_ = null;
263 // Skip for none http/https url.
264 if (e.url.indexOf('https://') != 0 &&
265 e.url.indexOf('http://') != 0) {
269 this.isSamlPage_ = this.pendingIsSamlPage_;
273 * Handler for webRequest.onBeforeRequest, invoked when content served over
274 * an unencrypted connection is detected. Determines whether the request
275 * should be blocked and if so, signals that an error message needs to be
277 * @param {Object} details
278 * @return {!Object} Decision whether to block the request.
280 onInsecureRequest: function(details) {
281 if (!this.blockInsecureContent)
283 var strippedUrl = stripParams(details.url);
284 this.dispatchEvent(new CustomEvent('insecureContentBlocked',
285 {detail: {url: strippedUrl}}));
286 return {cancel: true};
290 * Invoked when headers are received for the main frame.
293 onHeadersReceived_: function(details) {
294 var headers = details.responseHeaders;
296 // Check whether GAIA headers indicating the start or end of a SAML
297 // redirect are present. If so, synthesize cookies to mark these points.
298 for (var i = 0; headers && i < headers.length; ++i) {
299 var header = headers[i];
300 var headerName = header.name.toLowerCase();
302 if (headerName == SAML_HEADER) {
303 var action = header.value.toLowerCase();
304 if (action == 'start') {
305 this.pendingIsSamlPage_ = true;
307 // GAIA is redirecting to a SAML IdP. Any cookies contained in the
308 // current |headers| were set by GAIA. Any cookies set in future
309 // requests will be coming from the IdP. Append a cookie to the
310 // current |headers| that marks the point at which the redirect
312 headers.push({name: 'Set-Cookie',
313 value: 'google-accounts-saml-start=now'});
314 return {responseHeaders: headers};
315 } else if (action == 'end') {
316 this.pendingIsSamlPage_ = false;
318 // The SAML IdP has redirected back to GAIA. Add a cookie that marks
319 // the point at which the redirect occurred occurred. It is
320 // important that this cookie be prepended to the current |headers|
321 // because any cookies contained in the |headers| were already set
322 // by GAIA, not the IdP. Due to limitations in the webRequest API,
323 // it is not trivial to prepend a cookie:
325 // The webRequest API only allows for deleting and appending
326 // headers. To prepend a cookie (C), three steps are needed:
327 // 1) Delete any headers that set cookies (e.g., A, B).
328 // 2) Append a header which sets the cookie (C).
329 // 3) Append the original headers (A, B).
331 // Due to a further limitation of the webRequest API, it is not
332 // possible to delete a header in step 1) and append an identical
333 // header in step 3). To work around this, a trailing semicolon is
334 // added to each header before appending it. Trailing semicolons are
335 // ignored by Chrome in cookie headers, causing the modified headers
336 // to actually set the original cookies.
337 var otherHeaders = [];
338 var cookies = [{name: 'Set-Cookie',
339 value: 'google-accounts-saml-end=now'}];
340 for (var j = 0; j < headers.length; ++j) {
341 if (headers[j].name.toLowerCase().indexOf('set-cookie') == 0) {
342 var header = headers[j];
344 cookies.push(header);
346 otherHeaders.push(headers[j]);
349 return {responseHeaders: otherHeaders.concat(cookies)};
358 * Invoked when the injected JS makes a connection.
360 onConnected_: function(port) {
361 if (port.targetWindow != this.webview_.contentWindow)
364 var channel = Channel.create();
367 channel.registerMessage(
368 'apiCall', this.onAPICall_.bind(this, channel));
369 channel.registerMessage(
370 'updatePassword', this.onUpdatePassword_.bind(this, channel));
371 channel.registerMessage(
372 'pageLoaded', this.onPageLoaded_.bind(this, channel));
373 channel.registerMessage(
374 'getSAMLFlag', this.onGetSAMLFlag_.bind(this, channel));
377 sendInitializationSuccess_: function(channel) {
378 channel.send({name: 'apiResponse', response: {
379 result: 'initialized',
380 version: this.apiVersion_,
381 keyTypes: API_KEY_TYPES
385 sendInitializationFailure_: function(channel) {
388 response: {result: 'initialization_failed'}
393 * Handlers for channel messages.
394 * @param {Channel} channel A channel to send back response.
395 * @param {Object} msg Received message.
398 onAPICall_: function(channel, msg) {
400 if (call.method == 'initialize') {
401 if (!Number.isInteger(call.requestedVersion) ||
402 call.requestedVersion < MIN_API_VERSION_VERSION) {
403 this.sendInitializationFailure_(channel);
407 this.apiVersion_ = Math.min(call.requestedVersion,
408 MAX_API_VERSION_VERSION);
409 this.apiInitialized_ = true;
410 this.sendInitializationSuccess_(channel);
414 if (call.method == 'add') {
415 if (API_KEY_TYPES.indexOf(call.keyType) == -1) {
416 console.error('SamlHandler.onAPICall_: unsupported key type');
419 // Not setting |email_| and |gaiaId_| because this API call will
420 // eventually be followed by onCompleteLogin_() which does set it.
421 this.apiToken_ = call.token;
422 this.apiPasswordBytes_ = call.passwordBytes;
423 } else if (call.method == 'confirm') {
424 if (call.token != this.apiToken_)
425 console.error('SamlHandler.onAPICall_: token mismatch');
427 console.error('SamlHandler.onAPICall_: unknown message');
431 onUpdatePassword_: function(channel, msg) {
432 if (this.isSamlPage_)
433 this.passwordStore_[msg.id] = msg.password;
436 onPageLoaded_: function(channel, msg) {
437 this.authDomain = extractDomain(msg.url);
438 this.dispatchEvent(new CustomEvent(
441 isSAMLPage: this.isSamlPage_,
442 domain: this.authDomain}}));
445 onGetSAMLFlag_: function(channel, msg) {
446 return this.isSamlPage_;
451 SamlHandler: SamlHandler