| OLD | NEW |
| (Empty) | |
| 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. |
| 4 |
| 5 <include src="post_message_channel.js"> |
| 6 |
| 7 /** |
| 8 * @fileoverview Saml support for webview based auth. |
| 9 */ |
| 10 |
| 11 cr.define('cr.login', function() { |
| 12 'use strict'; |
| 13 |
| 14 /** |
| 15 * The lowest version of the credentials passing API supported. |
| 16 * @type {number} |
| 17 */ |
| 18 var MIN_API_VERSION_VERSION = 1; |
| 19 |
| 20 /** |
| 21 * The highest version of the credentials passing API supported. |
| 22 * @type {number} |
| 23 */ |
| 24 var MAX_API_VERSION_VERSION = 1; |
| 25 |
| 26 /** |
| 27 * The key types supported by the credentials passing API. |
| 28 * @type {Array} Array of strings. |
| 29 */ |
| 30 var API_KEY_TYPES = [ |
| 31 'KEY_TYPE_PASSWORD_PLAIN', |
| 32 ]; |
| 33 |
| 34 /** @const */ |
| 35 var SAML_HEADER = 'google-accounts-saml'; |
| 36 |
| 37 /** |
| 38 * The script to inject into webview and its sub frames. |
| 39 * @type {string} |
| 40 */ |
| 41 var injectedJs = String.raw` |
| 42 <include src="webview_saml_injected.js"> |
| 43 `; |
| 44 |
| 45 /** |
| 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. |
| 49 */ |
| 50 function stripParams(url) { |
| 51 return url.substring(0, url.indexOf('?')) || url; |
| 52 } |
| 53 |
| 54 /** |
| 55 * Extract domain name from an URL. |
| 56 * @param {string} url An URL string. |
| 57 * @return {string} The host name of the URL. |
| 58 */ |
| 59 function extractDomain(url) { |
| 60 var a = document.createElement('a'); |
| 61 a.href = url; |
| 62 return a.hostname; |
| 63 } |
| 64 |
| 65 /** |
| 66 * A handler to provide saml support for the given webview that hosts the |
| 67 * auth IdP pages. |
| 68 * @extends {cr.EventTarget} |
| 69 * @param {webview} webview |
| 70 * @constructor |
| 71 */ |
| 72 function SamlHandler(webview) { |
| 73 /** |
| 74 * The webview that serves IdP pages. |
| 75 * @type {webview} |
| 76 */ |
| 77 this.webview_ = webview; |
| 78 |
| 79 /** |
| 80 * Whether a Saml IdP page is display in the webview. |
| 81 * @type {boolean} |
| 82 */ |
| 83 this.isSamlPage_ = false; |
| 84 |
| 85 /** |
| 86 * Pending Saml IdP page flag that is set when a SAML_HEADER is received |
| 87 * and is copied to |isSamlPage_| in loadcommit. |
| 88 * @type {boolean} |
| 89 */ |
| 90 this.pendingIsSamlPage_ = false; |
| 91 |
| 92 /** |
| 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 |
| 95 * loadcommit event. |
| 96 * @type {string} |
| 97 */ |
| 98 this.abortedTopLevelUrl_ = null; |
| 99 |
| 100 /** |
| 101 * The domain of the Saml IdP. |
| 102 * @type {string} |
| 103 */ |
| 104 this.authDomain = ''; |
| 105 |
| 106 /** |
| 107 * Scraped password stored in an id to password field value map. |
| 108 * @type {Object.<string, string>} |
| 109 * @private |
| 110 */ |
| 111 this.passwordStore_ = {}; |
| 112 |
| 113 /** |
| 114 * Whether Saml API is initialized. |
| 115 * @type {boolean} |
| 116 */ |
| 117 this.apiInitialized_ = false; |
| 118 |
| 119 /** |
| 120 * Saml API version to use. |
| 121 * @type {number} |
| 122 */ |
| 123 this.apiVersion_ = 0; |
| 124 |
| 125 /** |
| 126 * Saml API token received. |
| 127 * @type {string} |
| 128 */ |
| 129 this.apiToken_ = null; |
| 130 |
| 131 /** |
| 132 * Saml API password bytes. |
| 133 * @type {string} |
| 134 */ |
| 135 this.apiPasswordBytes_ = null; |
| 136 |
| 137 /* |
| 138 * Whether to abort the authentication flow and show an error messagen when |
| 139 * content served over an unencrypted connection is detected. |
| 140 * @type {boolean} |
| 141 */ |
| 142 this.blockInsecureContent = false; |
| 143 |
| 144 this.webview_.addEventListener( |
| 145 'loadabort', this.onLoadAbort_.bind(this)); |
| 146 this.webview_.addEventListener( |
| 147 'loadcommit', this.onLoadCommit_.bind(this)); |
| 148 |
| 149 this.webview_.request.onBeforeRequest.addListener( |
| 150 this.onInsecureRequest.bind(this), |
| 151 {urls: ['http://*/*', 'file://*/*', 'ftp://*/*']}, |
| 152 ['blocking']); |
| 153 this.webview_.request.onHeadersReceived.addListener( |
| 154 this.onHeadersReceived_.bind(this), |
| 155 {urls: ['<all_urls>'], types: ['main_frame', 'xmlhttprequest']}, |
| 156 ['blocking', 'responseHeaders']); |
| 157 |
| 158 PostMessageChannel.runAsDaemon(this.onConnected_.bind(this)); |
| 159 } |
| 160 |
| 161 SamlHandler.prototype = { |
| 162 __proto__: cr.EventTarget.prototype, |
| 163 |
| 164 /** |
| 165 * Whether Saml API is used during auth. |
| 166 * @return {boolean} |
| 167 */ |
| 168 get samlApiUsed() { |
| 169 return !!this.apiPasswordBytes_; |
| 170 }, |
| 171 |
| 172 /** |
| 173 * Returns the Saml API password bytes. |
| 174 * @return {string} |
| 175 */ |
| 176 get apiPasswordBytes() { |
| 177 return this.apiPasswordBytes_; |
| 178 }, |
| 179 |
| 180 /** |
| 181 * Returns the number of scraped passwords. |
| 182 * @return {number} |
| 183 */ |
| 184 get scrapedPasswordCount() { |
| 185 return this.getConsolidatedScrapedPasswords_().length; |
| 186 }, |
| 187 |
| 188 /** |
| 189 * Gets the de-duped scraped passwords. |
| 190 * @return {Array.<string>} |
| 191 * @private |
| 192 */ |
| 193 getConsolidatedScrapedPasswords_: function() { |
| 194 var passwords = {}; |
| 195 for (var property in this.passwordStore_) { |
| 196 passwords[this.passwordStore_[property]] = true; |
| 197 } |
| 198 return Object.keys(passwords); |
| 199 }, |
| 200 |
| 201 /** |
| 202 * Resets all auth states |
| 203 */ |
| 204 reset: function() { |
| 205 this.isSamlPage_ = false; |
| 206 this.pendingIsSamlPage_ = false; |
| 207 this.passwordStore_ = {}; |
| 208 |
| 209 this.apiInitialized_ = false; |
| 210 this.apiVersion_ = 0; |
| 211 this.apiToken_ = null; |
| 212 this.apiPasswordBytes_ = null; |
| 213 }, |
| 214 |
| 215 /** |
| 216 * Check whether the given |password| is in the scraped passwords. |
| 217 * @return {boolean} True if the |password| is found. |
| 218 */ |
| 219 verifyConfirmedPassword: function(password) { |
| 220 return this.getConsolidatedScrapedPasswords_().indexOf(password) >= 0; |
| 221 }, |
| 222 |
| 223 /** |
| 224 * Injects JS code to all frames. |
| 225 * @private |
| 226 */ |
| 227 injectJs_: function() { |
| 228 if (!injectedJs) |
| 229 return; |
| 230 |
| 231 // TODO(xiyuan): Replace this with webview.addContentScript. |
| 232 this.webview_.executeScript({ |
| 233 code: injectedJs, |
| 234 allFrames: true, |
| 235 runAt: 'document_start' |
| 236 }, (function() { |
| 237 PostMessageChannel.init(this.webview_.contentWindow); |
| 238 }).bind(this)); |
| 239 }, |
| 240 |
| 241 /** |
| 242 * Invoked on the webview's loadabort event. |
| 243 * @private |
| 244 */ |
| 245 onLoadAbort_: function(e) { |
| 246 if (e.isTopLevel) |
| 247 this.abortedTopLevelUrl_ = e.url; |
| 248 }, |
| 249 |
| 250 /** |
| 251 * Invoked on the webview's loadcommit event for both main and sub frames. |
| 252 * @private |
| 253 */ |
| 254 onLoadCommit_: function(e) { |
| 255 // Skip this loadcommit if the top level load is just aborted. |
| 256 if (e.isTopLevel && e.url === this.abortedTopLevelUrl_) { |
| 257 this.abortedTopLevelUrl_ = null; |
| 258 return; |
| 259 } |
| 260 |
| 261 this.isSamlPage_ = this.pendingIsSamlPage_; |
| 262 this.injectJs_(); |
| 263 }, |
| 264 |
| 265 /** |
| 266 * Handler for webRequest.onBeforeRequest, invoked when content served over |
| 267 * an unencrypted connection is detected. Determines whether the request |
| 268 * should be blocked and if so, signals that an error message needs to be |
| 269 * shown. |
| 270 * @param {Object} details |
| 271 * @return {!Object} Decision whether to block the request. |
| 272 */ |
| 273 onInsecureRequest: function(details) { |
| 274 if (!this.blockInsecureContent) |
| 275 return {}; |
| 276 var strippedUrl = stripParams(details.url); |
| 277 this.dispatchEvent(new CustomEvent('insecureContentBlocked', |
| 278 {detail: {url: strippedUrl}})); |
| 279 return {cancel: true}; |
| 280 }, |
| 281 |
| 282 /** |
| 283 * Invoked when headers are received for the main frame. |
| 284 * @private |
| 285 */ |
| 286 onHeadersReceived_: function(details) { |
| 287 var headers = details.responseHeaders; |
| 288 |
| 289 // Check whether GAIA headers indicating the start or end of a SAML |
| 290 // redirect are present. If so, synthesize cookies to mark these points. |
| 291 for (var i = 0; headers && i < headers.length; ++i) { |
| 292 var header = headers[i]; |
| 293 var headerName = header.name.toLowerCase(); |
| 294 |
| 295 if (headerName == SAML_HEADER) { |
| 296 var action = header.value.toLowerCase(); |
| 297 if (action == 'start') { |
| 298 this.pendingIsSamlPage_ = true; |
| 299 |
| 300 // GAIA is redirecting to a SAML IdP. Any cookies contained in the |
| 301 // current |headers| were set by GAIA. Any cookies set in future |
| 302 // requests will be coming from the IdP. Append a cookie to the |
| 303 // current |headers| that marks the point at which the redirect |
| 304 // occurred. |
| 305 headers.push({name: 'Set-Cookie', |
| 306 value: 'google-accounts-saml-start=now'}); |
| 307 return {responseHeaders: headers}; |
| 308 } else if (action == 'end') { |
| 309 this.pendingIsSamlPage_ = false; |
| 310 |
| 311 // The SAML IdP has redirected back to GAIA. Add a cookie that marks |
| 312 // the point at which the redirect occurred occurred. It is |
| 313 // important that this cookie be prepended to the current |headers| |
| 314 // because any cookies contained in the |headers| were already set |
| 315 // by GAIA, not the IdP. Due to limitations in the webRequest API, |
| 316 // it is not trivial to prepend a cookie: |
| 317 // |
| 318 // The webRequest API only allows for deleting and appending |
| 319 // headers. To prepend a cookie (C), three steps are needed: |
| 320 // 1) Delete any headers that set cookies (e.g., A, B). |
| 321 // 2) Append a header which sets the cookie (C). |
| 322 // 3) Append the original headers (A, B). |
| 323 // |
| 324 // Due to a further limitation of the webRequest API, it is not |
| 325 // possible to delete a header in step 1) and append an identical |
| 326 // header in step 3). To work around this, a trailing semicolon is |
| 327 // added to each header before appending it. Trailing semicolons are |
| 328 // ignored by Chrome in cookie headers, causing the modified headers |
| 329 // to actually set the original cookies. |
| 330 var otherHeaders = []; |
| 331 var cookies = [{name: 'Set-Cookie', |
| 332 value: 'google-accounts-saml-end=now'}]; |
| 333 for (var j = 0; j < headers.length; ++j) { |
| 334 if (headers[j].name.toLowerCase().indexOf('set-cookie') == 0) { |
| 335 var header = headers[j]; |
| 336 header.value += ';'; |
| 337 cookies.push(header); |
| 338 } else { |
| 339 otherHeaders.push(headers[j]); |
| 340 } |
| 341 } |
| 342 return {responseHeaders: otherHeaders.concat(cookies)}; |
| 343 } |
| 344 } |
| 345 } |
| 346 |
| 347 return {}; |
| 348 }, |
| 349 |
| 350 /** |
| 351 * Invoked when the injected JS makes a connection. |
| 352 */ |
| 353 onConnected_: function(port) { |
| 354 if (port.targetWindow != this.webview_.contentWindow) |
| 355 return; |
| 356 |
| 357 var channel = Channel.create(); |
| 358 channel.init(port); |
| 359 |
| 360 channel.registerMessage( |
| 361 'apiCall', this.onAPICall_.bind(this, channel)); |
| 362 channel.registerMessage( |
| 363 'updatePassword', this.onUpdatePassword_.bind(this, channel)); |
| 364 channel.registerMessage( |
| 365 'pageLoaded', this.onPageLoaded_.bind(this, channel)); |
| 366 channel.registerMessage( |
| 367 'getSAMLFlag', this.onGetSAMLFlag_.bind(this, channel)); |
| 368 }, |
| 369 |
| 370 sendInitializationSuccess_: function(channel) { |
| 371 channel.send({name: 'apiResponse', response: { |
| 372 result: 'initialized', |
| 373 version: this.apiVersion_, |
| 374 keyTypes: API_KEY_TYPES |
| 375 }}); |
| 376 }, |
| 377 |
| 378 sendInitializationFailure_: function(channel) { |
| 379 channel.send({ |
| 380 name: 'apiResponse', |
| 381 response: {result: 'initialization_failed'} |
| 382 }); |
| 383 }, |
| 384 |
| 385 /** |
| 386 * Handlers for channel messages. |
| 387 * @param {Channel} channel A channel to send back response. |
| 388 * @param {Object} msg Received message. |
| 389 * @private |
| 390 */ |
| 391 onAPICall_: function(channel, msg) { |
| 392 var call = msg.call; |
| 393 if (call.method == 'initialize') { |
| 394 if (!Number.isInteger(call.requestedVersion) || |
| 395 call.requestedVersion < MIN_API_VERSION_VERSION) { |
| 396 this.sendInitializationFailure_(channel); |
| 397 return; |
| 398 } |
| 399 |
| 400 this.apiVersion_ = Math.min(call.requestedVersion, |
| 401 MAX_API_VERSION_VERSION); |
| 402 this.apiInitialized_ = true; |
| 403 this.sendInitializationSuccess_(channel); |
| 404 return; |
| 405 } |
| 406 |
| 407 if (call.method == 'add') { |
| 408 if (API_KEY_TYPES.indexOf(call.keyType) == -1) { |
| 409 console.error('SamlHandler.onAPICall_: unsupported key type'); |
| 410 return; |
| 411 } |
| 412 // Not setting |email_| and |gaiaId_| because this API call will |
| 413 // eventually be followed by onCompleteLogin_() which does set it. |
| 414 this.apiToken_ = call.token; |
| 415 this.apiPasswordBytes_ = call.passwordBytes; |
| 416 } else if (call.method == 'confirm') { |
| 417 if (call.token != this.apiToken_) |
| 418 console.error('SamlHandler.onAPICall_: token mismatch'); |
| 419 } else { |
| 420 console.error('SamlHandler.onAPICall_: unknown message'); |
| 421 } |
| 422 }, |
| 423 |
| 424 onUpdatePassword_: function(channel, msg) { |
| 425 if (this.isSamlPage_) |
| 426 this.passwordStore_[msg.id] = msg.password; |
| 427 }, |
| 428 |
| 429 onPageLoaded_: function(channel, msg) { |
| 430 this.authDomain = extractDomain(msg.url); |
| 431 this.dispatchEvent(new CustomEvent( |
| 432 'authPageLoaded', |
| 433 {detail: {url: url, |
| 434 isSAMLPage: this.isSamlPage_, |
| 435 domain: this.authDomain}})); |
| 436 }, |
| 437 |
| 438 onGetSAMLFlag_: function(channel, msg) { |
| 439 return this.isSamlPage_; |
| 440 }, |
| 441 }; |
| 442 |
| 443 /** |
| 444 * Sets the saml injected JS code. |
| 445 * @param {string} samlInjectedJs JS code to inejct for Saml. |
| 446 */ |
| 447 SamlHandler.setSamlInjectedJs = function(samlInjectedJs) { |
| 448 injectedJs = samlInjectedJs; |
| 449 }; |
| 450 |
| 451 return { |
| 452 SamlHandler: SamlHandler |
| 453 }; |
| 454 }); |
| OLD | NEW |