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 'loadabort', this.onLoadAbort_
.bind(this));
146 this.webview_
.addEventListener(
147 'loadcommit', this.onLoadCommit_
.bind(this));
149 this.webview_
.request
.onBeforeRequest
.addListener(
150 this.onInsecureRequest
.bind(this),
151 {urls
: ['http://*/*', 'file://*/*', 'ftp://*/*']},
153 this.webview_
.request
.onHeadersReceived
.addListener(
154 this.onHeadersReceived_
.bind(this),
155 {urls
: ['<all_urls>'], types
: ['main_frame', 'xmlhttprequest']},
156 ['blocking', 'responseHeaders']);
158 PostMessageChannel
.runAsDaemon(this.onConnected_
.bind(this));
161 SamlHandler
.prototype = {
162 __proto__
: cr
.EventTarget
.prototype,
165 * Whether Saml API is used during auth.
169 return !!this.apiPasswordBytes_
;
173 * Returns the Saml API password bytes.
176 get apiPasswordBytes() {
177 return this.apiPasswordBytes_
;
181 * Returns the number of scraped passwords.
184 get scrapedPasswordCount() {
185 return this.getConsolidatedScrapedPasswords_().length
;
189 * Gets the de-duped scraped passwords.
190 * @return {Array.<string>}
193 getConsolidatedScrapedPasswords_: function() {
195 for (var property
in this.passwordStore_
) {
196 passwords
[this.passwordStore_
[property
]] = true;
198 return Object
.keys(passwords
);
202 * Resets all auth states
205 this.isSamlPage_
= false;
206 this.pendingIsSamlPage_
= false;
207 this.passwordStore_
= {};
209 this.apiInitialized_
= false;
210 this.apiVersion_
= 0;
211 this.apiToken_
= null;
212 this.apiPasswordBytes_
= null;
216 * Check whether the given |password| is in the scraped passwords.
217 * @return {boolean} True if the |password| is found.
219 verifyConfirmedPassword: function(password
) {
220 return this.getConsolidatedScrapedPasswords_().indexOf(password
) >= 0;
224 * Injects JS code to all frames.
227 injectJs_: function() {
231 // TODO(xiyuan): Replace this with webview.addContentScript.
232 this.webview_
.executeScript({
235 runAt
: 'document_start'
237 PostMessageChannel
.init(this.webview_
.contentWindow
);
242 * Invoked on the webview's loadabort event.
245 onLoadAbort_: function(e
) {
247 this.abortedTopLevelUrl_
= e
.url
;
251 * Invoked on the webview's loadcommit event for both main and sub frames.
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;
261 this.isSamlPage_
= this.pendingIsSamlPage_
;
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
270 * @param {Object} details
271 * @return {!Object} Decision whether to block the request.
273 onInsecureRequest: function(details
) {
274 if (!this.blockInsecureContent
)
276 var strippedUrl
= stripParams(details
.url
);
277 this.dispatchEvent(new CustomEvent('insecureContentBlocked',
278 {detail
: {url
: strippedUrl
}}));
279 return {cancel
: true};
283 * Invoked when headers are received for the main frame.
286 onHeadersReceived_: function(details
) {
287 var headers
= details
.responseHeaders
;
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();
295 if (headerName
== SAML_HEADER
) {
296 var action
= header
.value
.toLowerCase();
297 if (action
== 'start') {
298 this.pendingIsSamlPage_
= true;
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
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;
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:
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).
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
];
337 cookies
.push(header
);
339 otherHeaders
.push(headers
[j
]);
342 return {responseHeaders
: otherHeaders
.concat(cookies
)};
351 * Invoked when the injected JS makes a connection.
353 onConnected_: function(port
) {
354 if (port
.targetWindow
!= this.webview_
.contentWindow
)
357 var channel
= Channel
.create();
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
));
370 sendInitializationSuccess_: function(channel
) {
371 channel
.send({name
: 'apiResponse', response
: {
372 result
: 'initialized',
373 version
: this.apiVersion_
,
374 keyTypes
: API_KEY_TYPES
378 sendInitializationFailure_: function(channel
) {
381 response
: {result
: 'initialization_failed'}
386 * Handlers for channel messages.
387 * @param {Channel} channel A channel to send back response.
388 * @param {Object} msg Received message.
391 onAPICall_: function(channel
, msg
) {
393 if (call
.method
== 'initialize') {
394 if (!Number
.isInteger(call
.requestedVersion
) ||
395 call
.requestedVersion
< MIN_API_VERSION_VERSION
) {
396 this.sendInitializationFailure_(channel
);
400 this.apiVersion_
= Math
.min(call
.requestedVersion
,
401 MAX_API_VERSION_VERSION
);
402 this.apiInitialized_
= true;
403 this.sendInitializationSuccess_(channel
);
407 if (call
.method
== 'add') {
408 if (API_KEY_TYPES
.indexOf(call
.keyType
) == -1) {
409 console
.error('SamlHandler.onAPICall_: unsupported key type');
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');
420 console
.error('SamlHandler.onAPICall_: unknown message');
424 onUpdatePassword_: function(channel
, msg
) {
425 if (this.isSamlPage_
)
426 this.passwordStore_
[msg
.id
] = msg
.password
;
429 onPageLoaded_: function(channel
, msg
) {
430 this.authDomain
= extractDomain(msg
.url
);
431 this.dispatchEvent(new CustomEvent(
434 isSAMLPage
: this.isSamlPage_
,
435 domain
: this.authDomain
}}));
438 onGetSAMLFlag_: function(channel
, msg
) {
439 return this.isSamlPage_
;
444 * Sets the saml injected JS code.
445 * @param {string} samlInjectedJs JS code to inejct for Saml.
447 SamlHandler
.setSamlInjectedJs = function(samlInjectedJs
) {
448 injectedJs
= samlInjectedJs
;
452 SamlHandler
: SamlHandler