Only grant permissions to new extensions from sync if they have the expected version
[chromium-blink-merge.git] / chrome / browser / resources / gaia_auth_host / saml_handler.js
blob9b6ceb94c001034cff1594c0687406de6c1657c9
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">
7 /**
8  * @fileoverview Saml support for webview based auth.
9  */
11 cr.define('cr.login', function() {
12   'use strict';
14   /**
15    * The lowest version of the credentials passing API supported.
16    * @type {number}
17    */
18   var MIN_API_VERSION_VERSION = 1;
20   /**
21    * The highest version of the credentials passing API supported.
22    * @type {number}
23    */
24   var MAX_API_VERSION_VERSION = 1;
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   ];
34   /** @const */
35   var SAML_HEADER = 'google-accounts-saml';
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   `;
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   }
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   }
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;
79     /**
80      * Whether a Saml IdP page is display in the webview.
81      * @type {boolean}
82      */
83     this.isSamlPage_ = false;
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;
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;
100     /**
101      * The domain of the Saml IdP.
102      * @type {string}
103      */
104     this.authDomain = '';
106     /**
107      * Scraped password stored in an id to password field value map.
108      * @type {Object<string, string>}
109      * @private
110      */
111     this.passwordStore_ = {};
113     /**
114      * Whether Saml API is initialized.
115      * @type {boolean}
116      */
117     this.apiInitialized_ = false;
119     /**
120      * Saml API version to use.
121      * @type {number}
122      */
123     this.apiVersion_ = 0;
125     /**
126      * Saml API token received.
127      * @type {string}
128      */
129     this.apiToken_ = null;
131     /**
132      * Saml API password bytes.
133      * @type {string}
134      */
135     this.apiPasswordBytes_ = null;
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;
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://*/*']},
154         ['blocking']);
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://*/*'],
163       js: {
164         code: injectedJs
165       },
166       all_frames: true,
167       run_at: 'document_start'
168     }]);
170     PostMessageChannel.runAsDaemon(this.onConnected_.bind(this));
171   }
173   SamlHandler.prototype = {
174     __proto__: cr.EventTarget.prototype,
176     /**
177      * Whether Saml API is used during auth.
178      * @return {boolean}
179      */
180     get samlApiUsed() {
181       return !!this.apiPasswordBytes_;
182     },
184     /**
185      * Returns the Saml API password bytes.
186      * @return {string}
187      */
188     get apiPasswordBytes() {
189       return this.apiPasswordBytes_;
190     },
192     /**
193      * Returns the number of scraped passwords.
194      * @return {number}
195      */
196     get scrapedPasswordCount() {
197       return this.getConsolidatedScrapedPasswords_().length;
198     },
200     /**
201      * Gets the de-duped scraped passwords.
202      * @return {Array<string>}
203      * @private
204      */
205     getConsolidatedScrapedPasswords_: function() {
206       var passwords = {};
207       for (var property in this.passwordStore_) {
208         passwords[this.passwordStore_[property]] = true;
209       }
210       return Object.keys(passwords);
211     },
213     /**
214      * Resets all auth states
215      */
216     reset: function() {
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;
225     },
227     /**
228      * Check whether the given |password| is in the scraped passwords.
229      * @return {boolean} True if the |password| is found.
230      */
231     verifyConfirmedPassword: function(password) {
232       return this.getConsolidatedScrapedPasswords_().indexOf(password) >= 0;
233     },
235     /**
236      * Invoked on the webview's contentload event.
237      * @private
238      */
239     onContentLoad_: function(e) {
240       PostMessageChannel.init(this.webview_.contentWindow);
241     },
243     /**
244      * Invoked on the webview's loadabort event.
245      * @private
246      */
247     onLoadAbort_: function(e) {
248       if (e.isTopLevel)
249         this.abortedTopLevelUrl_ = e.url;
250     },
252     /**
253      * Invoked on the webview's loadcommit event for both main and sub frames.
254      * @private
255      */
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;
260         return;
261       }
263       // Skip for none http/https url.
264       if (e.url.indexOf('https://') != 0 &&
265           e.url.indexOf('http://') != 0) {
266         return;
267       }
269       this.isSamlPage_ = this.pendingIsSamlPage_;
270     },
272     /**
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
276      * shown.
277      * @param {Object} details
278      * @return {!Object} Decision whether to block the request.
279      */
280     onInsecureRequest: function(details) {
281       if (!this.blockInsecureContent)
282         return {};
283       var strippedUrl = stripParams(details.url);
284       this.dispatchEvent(new CustomEvent('insecureContentBlocked',
285                                          {detail: {url: strippedUrl}}));
286       return {cancel: true};
287     },
289     /**
290      * Invoked when headers are received for the main frame.
291      * @private
292      */
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
311             // occurred.
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:
324             //
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).
330             //
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];
343                 header.value += ';';
344                 cookies.push(header);
345               } else {
346                 otherHeaders.push(headers[j]);
347               }
348             }
349             return {responseHeaders: otherHeaders.concat(cookies)};
350           }
351         }
352       }
354       return {};
355     },
357     /**
358      * Invoked when the injected JS makes a connection.
359      */
360     onConnected_: function(port) {
361       if (port.targetWindow != this.webview_.contentWindow)
362         return;
364       var channel = Channel.create();
365       channel.init(port);
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));
375     },
377     sendInitializationSuccess_: function(channel) {
378       channel.send({name: 'apiResponse', response: {
379         result: 'initialized',
380         version: this.apiVersion_,
381         keyTypes: API_KEY_TYPES
382       }});
383     },
385     sendInitializationFailure_: function(channel) {
386       channel.send({
387         name: 'apiResponse',
388         response: {result: 'initialization_failed'}
389       });
390     },
392     /**
393      * Handlers for channel messages.
394      * @param {Channel} channel A channel to send back response.
395      * @param {Object} msg Received message.
396      * @private
397      */
398     onAPICall_: function(channel, msg) {
399       var call = msg.call;
400       if (call.method == 'initialize') {
401         if (!Number.isInteger(call.requestedVersion) ||
402             call.requestedVersion < MIN_API_VERSION_VERSION) {
403           this.sendInitializationFailure_(channel);
404           return;
405         }
407         this.apiVersion_ = Math.min(call.requestedVersion,
408                                     MAX_API_VERSION_VERSION);
409         this.apiInitialized_ = true;
410         this.sendInitializationSuccess_(channel);
411         return;
412       }
414       if (call.method == 'add') {
415         if (API_KEY_TYPES.indexOf(call.keyType) == -1) {
416           console.error('SamlHandler.onAPICall_: unsupported key type');
417           return;
418         }
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');
426       } else {
427         console.error('SamlHandler.onAPICall_: unknown message');
428       }
429     },
431     onUpdatePassword_: function(channel, msg) {
432       if (this.isSamlPage_)
433         this.passwordStore_[msg.id] = msg.password;
434     },
436     onPageLoaded_: function(channel, msg) {
437       this.authDomain = extractDomain(msg.url);
438       this.dispatchEvent(new CustomEvent(
439           'authPageLoaded',
440           {detail: {url: url,
441                     isSAMLPage: this.isSamlPage_,
442                     domain: this.authDomain}}));
443     },
445     onGetSAMLFlag_: function(channel, msg) {
446       return this.isSamlPage_;
447     },
448   };
450   return {
451     SamlHandler: SamlHandler
452   };