Separate Simple Backend creation from initialization.
[chromium-blink-merge.git] / remoting / webapp / oauth2.js
blob752c880c26dd1e846ec0f604b9620ab754445304
1 // Copyright (c) 2012 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 /**
6  * @fileoverview
7  * OAuth2 class that handles retrieval/storage of an OAuth2 token.
8  *
9  * Uses a content script to trampoline the OAuth redirect page back into the
10  * extension context.  This works around the lack of native support for
11  * chrome-extensions in OAuth2.
12  */
14 // TODO(jamiewalch): Delete this code once Chromoting is a v2 app and uses the
15 // identity API (http://crbug.com/ 134213).
17 'use strict';
19 /** @suppress {duplicate} */
20 var remoting = remoting || {};
22 /** @type {remoting.OAuth2} */
23 remoting.oauth2 = null;
26 /** @constructor */
27 remoting.OAuth2 = function() {
30 // Constants representing keys used for storing persistent state.
31 /** @private */
32 remoting.OAuth2.prototype.KEY_REFRESH_TOKEN_ = 'oauth2-refresh-token';
33 /** @private */
34 remoting.OAuth2.prototype.KEY_REFRESH_TOKEN_REVOKABLE_ =
35     'oauth2-refresh-token-revokable';
36 /** @private */
37 remoting.OAuth2.prototype.KEY_ACCESS_TOKEN_ = 'oauth2-access-token';
38 /** @private */
39 remoting.OAuth2.prototype.KEY_XSRF_TOKEN_ = 'oauth2-xsrf-token';
40 /** @private */
41 remoting.OAuth2.prototype.KEY_EMAIL_ = 'remoting-email';
43 // Constants for parameters used in retrieving the OAuth2 credentials.
44 /** @private */
45 remoting.OAuth2.prototype.SCOPE_ =
46       'https://www.googleapis.com/auth/chromoting ' +
47       'https://www.googleapis.com/auth/googletalk ' +
48       'https://www.googleapis.com/auth/userinfo#email';
50 // Configurable URLs/strings.
51 /** @private
52  *  @return {string} OAuth2 redirect URI.
53  */
54 remoting.OAuth2.prototype.getRedirectUri_ = function() {
55   return remoting.settings.OAUTH2_REDIRECT_URL;
58 /** @private
59  *  @return {string} API client ID.
60  */
61 remoting.OAuth2.prototype.getClientId_ = function() {
62   return remoting.settings.OAUTH2_CLIENT_ID;
65 /** @private
66  *  @return {string} API client secret.
67  */
68 remoting.OAuth2.prototype.getClientSecret_ = function() {
69   return remoting.settings.OAUTH2_CLIENT_SECRET;
72 /** @private
73  *  @return {string} OAuth2 authentication URL.
74  */
75 remoting.OAuth2.prototype.getOAuth2AuthEndpoint_ = function() {
76   return remoting.settings.OAUTH2_BASE_URL + '/auth';
79 /** @private
80  *  @return {string} OAuth2 token URL.
81  */
82 remoting.OAuth2.prototype.getOAuth2TokenEndpoint_ = function() {
83   return remoting.settings.OAUTH2_BASE_URL + '/token';
86 /** @private
87  *  @return {string} OAuth token revocation URL.
88  */
89 remoting.OAuth2.prototype.getOAuth2RevokeTokenEndpoint_ = function() {
90   return remoting.settings.OAUTH2_BASE_URL + '/revoke';
93 /** @private
94  *  @return {string} OAuth2 userinfo API URL.
95  */
96 remoting.OAuth2.prototype.getOAuth2ApiUserInfoEndpoint_ = function() {
97   return remoting.settings.OAUTH2_API_BASE_URL + '/v1/userinfo';
100 /** @return {boolean} True if the app is already authenticated. */
101 remoting.OAuth2.prototype.isAuthenticated = function() {
102   if (this.getRefreshToken_()) {
103     return true;
104   }
105   return false;
109  * Removes all storage, and effectively unauthenticates the user.
111  * @return {void} Nothing.
112  */
113 remoting.OAuth2.prototype.clear = function() {
114   window.localStorage.removeItem(this.KEY_EMAIL_);
115   this.clearAccessToken_();
116   this.clearRefreshToken_();
120  * Sets the refresh token.
122  * This method also marks the token as revokable, so that this object will
123  * revoke the token when it no longer needs it.
125  * @param {string} token The new refresh token.
126  * @return {void} Nothing.
127  */
128 remoting.OAuth2.prototype.setRefreshToken = function(token) {
129   window.localStorage.setItem(this.KEY_REFRESH_TOKEN_, escape(token));
130   window.localStorage.setItem(this.KEY_REFRESH_TOKEN_REVOKABLE_, true);
131   this.clearAccessToken_();
135  * Gets the refresh token.
137  * This method also marks the refresh token as not revokable, so that this
138  * object will not revoke the token when it no longer needs it. After this
139  * object has exported the token, it cannot know whether it is still in use
140  * when this object no longer needs it.
142  * @return {?string} The refresh token, if authenticated, or NULL.
143  */
144 remoting.OAuth2.prototype.exportRefreshToken = function() {
145   window.localStorage.removeItem(this.KEY_REFRESH_TOKEN_REVOKABLE_);
146   return this.getRefreshToken_();
150  * @return {?string} The refresh token, if authenticated, or NULL.
151  * @private
152  */
153 remoting.OAuth2.prototype.getRefreshToken_ = function() {
154   var value = window.localStorage.getItem(this.KEY_REFRESH_TOKEN_);
155   if (typeof value == 'string') {
156     return unescape(value);
157   }
158   return null;
162  * Clears the refresh token.
164  * @return {void} Nothing.
165  * @private
166  */
167 remoting.OAuth2.prototype.clearRefreshToken_ = function() {
168   if (window.localStorage.getItem(this.KEY_REFRESH_TOKEN_REVOKABLE_)) {
169     this.revokeToken_(this.getRefreshToken_());
170   }
171   window.localStorage.removeItem(this.KEY_REFRESH_TOKEN_);
172   window.localStorage.removeItem(this.KEY_REFRESH_TOKEN_REVOKABLE_);
176  * @param {string} token The new access token.
177  * @param {number} expiration Expiration time in milliseconds since epoch.
178  * @return {void} Nothing.
179  */
180 remoting.OAuth2.prototype.setAccessToken = function(token, expiration) {
181   var access_token = {'token': token, 'expiration': expiration};
182   window.localStorage.setItem(this.KEY_ACCESS_TOKEN_,
183                               JSON.stringify(access_token));
187  * Returns the current access token, setting it to a invalid value if none
188  * existed before.
190  * @private
191  * @return {{token: string, expiration: number}} The current access token, or
192  * an invalid token if not authenticated.
193  */
194 remoting.OAuth2.prototype.getAccessTokenInternal_ = function() {
195   if (!window.localStorage.getItem(this.KEY_ACCESS_TOKEN_)) {
196     // Always be able to return structured data.
197     this.setAccessToken('', 0);
198   }
199   var accessToken = window.localStorage.getItem(this.KEY_ACCESS_TOKEN_);
200   if (typeof accessToken == 'string') {
201     var result = jsonParseSafe(accessToken);
202     if (result && 'token' in result && 'expiration' in result) {
203       return /** @type {{token: string, expiration: number}} */ result;
204     }
205   }
206   console.log('Invalid access token stored.');
207   return {'token': '', 'expiration': 0};
211  * Returns true if the access token is expired, or otherwise invalid.
213  * Will throw if !isAuthenticated().
215  * @return {boolean} True if a new access token is needed.
216  * @private
217  */
218 remoting.OAuth2.prototype.needsNewAccessToken_ = function() {
219   if (!this.isAuthenticated()) {
220     throw 'Not Authenticated.';
221   }
222   var access_token = this.getAccessTokenInternal_();
223   if (!access_token['token']) {
224     return true;
225   }
226   if (Date.now() > access_token['expiration']) {
227     return true;
228   }
229   return false;
233  * @return {void} Nothing.
234  * @private
235  */
236 remoting.OAuth2.prototype.clearAccessToken_ = function() {
237   window.localStorage.removeItem(this.KEY_ACCESS_TOKEN_);
241  * Update state based on token response from the OAuth2 /token endpoint.
243  * @private
244  * @param {function(XMLHttpRequest, string): void} onDone Callback to invoke on
245  *     completion.
246  * @param {XMLHttpRequest} xhr The XHR object for this request.
247  * @return {void} Nothing.
248  */
249 remoting.OAuth2.prototype.processTokenResponse_ = function(onDone, xhr) {
250   /** @type {string} */
251   var accessToken = '';
252   if (xhr.status == 200) {
253     try {
254       // Don't use jsonParseSafe here unless you move the definition out of
255       // remoting.js, otherwise this won't work from the OAuth trampoline.
256       // TODO(jamiewalch): Fix this once we're no longer using the trampoline.
257       var tokens = JSON.parse(xhr.responseText);
258       if ('refresh_token' in tokens) {
259         this.setRefreshToken(tokens['refresh_token']);
260       }
262       // Offset by 120 seconds so that we can guarantee that the token
263       // we return will be valid for at least 2 minutes.
264       // If the access token is to be useful, this object must make some
265       // guarantee as to how long the token will be valid for.
266       // The choice of 2 minutes is arbitrary, but that length of time
267       // is part of the contract satisfied by callWithToken().
268       // Offset by a further 30 seconds to account for RTT issues.
269       accessToken = /** @type {string} */ (tokens['access_token']);
270       this.setAccessToken(accessToken,
271           (tokens['expires_in'] - (120 + 30)) * 1000 + Date.now());
272     } catch (err) {
273       console.error('Invalid "token" response from server:',
274                     /** @type {*} */ (err));
275     }
276   } else {
277     console.error('Failed to get tokens. Status: ' + xhr.status +
278                   ' response: ' + xhr.responseText);
279   }
280   onDone(xhr, accessToken);
284  * Asynchronously retrieves a new access token from the server.
286  * Will throw if !isAuthenticated().
288  * @param {function(XMLHttpRequest): void} onDone Callback to invoke on
289  *     completion.
290  * @return {void} Nothing.
291  * @private
292  */
293 remoting.OAuth2.prototype.refreshAccessToken_ = function(onDone) {
294   if (!this.isAuthenticated()) {
295     throw 'Not Authenticated.';
296   }
298   var parameters = {
299     'client_id': this.getClientId_(),
300     'client_secret': this.getClientSecret_(),
301     'refresh_token': this.getRefreshToken_(),
302     'grant_type': 'refresh_token'
303   };
305   remoting.xhr.post(this.getOAuth2TokenEndpoint_(),
306                     this.processTokenResponse_.bind(this, onDone),
307                     parameters);
311  * @private
312  * @return {string} A URL-Safe Base64-encoded 128-bit random value. */
313 remoting.OAuth2.prototype.generateXsrfToken_ = function() {
314   var random = new Uint8Array(16);
315   window.crypto.getRandomValues(random);
316   var base64Token = window.btoa(String.fromCharCode.apply(null, random));
317   return base64Token.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
321  * Redirect page to get a new OAuth2 Refresh Token.
323  * @return {void} Nothing.
324  */
325 remoting.OAuth2.prototype.doAuthRedirect = function() {
326   var xsrf_token = this.generateXsrfToken_();
327   window.localStorage.setItem(this.KEY_XSRF_TOKEN_, xsrf_token);
328   var GET_CODE_URL = this.getOAuth2AuthEndpoint_() + '?' +
329     remoting.xhr.urlencodeParamHash({
330           'client_id': this.getClientId_(),
331           'redirect_uri': this.getRedirectUri_(),
332           'scope': this.SCOPE_,
333           'state': xsrf_token,
334           'response_type': 'code',
335           'access_type': 'offline',
336           'approval_prompt': 'force'
337         });
338   window.location.replace(GET_CODE_URL);
342  * Asynchronously exchanges an authorization code for a refresh token.
344  * @param {string} code The new refresh token.
345  * @param {string} state The state parameter received from the OAuth redirect.
346  * @param {function(XMLHttpRequest):void} onDone Callback to invoke on
347  *     completion.
348  * @return {void} Nothing.
349  */
350 remoting.OAuth2.prototype.exchangeCodeForToken = function(code, state, onDone) {
351   var xsrf_token = window.localStorage.getItem(this.KEY_XSRF_TOKEN_);
352   window.localStorage.removeItem(this.KEY_XSRF_TOKEN_);
353   if (xsrf_token == undefined || state != xsrf_token) {
354     // Invalid XSRF token, or unexpected OAuth2 redirect. Abort.
355     onDone(null);
356   }
357   var parameters = {
358     'client_id': this.getClientId_(),
359     'client_secret': this.getClientSecret_(),
360     'redirect_uri': this.getRedirectUri_(),
361     'code': code,
362     'grant_type': 'authorization_code'
363   };
364   remoting.xhr.post(this.getOAuth2TokenEndpoint_(),
365                     this.processTokenResponse_.bind(this, onDone),
366                     parameters);
370  * Interprets unexpected HTTP response codes to authentication XMLHttpRequests.
371  * The caller should handle the usual expected responses (200, 400) separately.
373  * @private
374  * @param {number} xhrStatus Status (HTTP response code) of the XMLHttpRequest.
375  * @return {remoting.Error} An error code to be raised.
376  */
377 remoting.OAuth2.prototype.interpretUnexpectedXhrStatus_ = function(xhrStatus) {
378   // Return AUTHENTICATION_FAILED by default, so that the user can try to
379   // recover from an unexpected failure by signing in again.
380   /** @type {remoting.Error} */
381   var error = remoting.Error.AUTHENTICATION_FAILED;
382   if (xhrStatus == 502 || xhrStatus == 503) {
383     error = remoting.Error.SERVICE_UNAVAILABLE;
384   } else if (xhrStatus == 0) {
385     error = remoting.Error.NETWORK_FAILURE;
386   } else {
387     console.warn('Unexpected authentication response code: ' + xhrStatus);
388   }
389   return error;
393  * Revokes a refresh or an access token.
395  * @param {string?} token An access or refresh token.
396  * @return {void} Nothing.
397  * @private
398  */
399 remoting.OAuth2.prototype.revokeToken_ = function(token) {
400   if (!token || (token.length == 0)) {
401     return;
402   }
403   var parameters = { 'token': token };
405   /** @param {XMLHttpRequest} xhr The XHR reply. */
406   var processResponse = function(xhr) {
407     if (xhr.status != 200) {
408       console.log('Failed to revoke token. Status: ' + xhr.status +
409                   ' ; response: ' + xhr.responseText + ' ; xhr: ', xhr);
410     }
411   };
412   remoting.xhr.post(this.getOAuth2RevokeTokenEndpoint_(),
413                     processResponse,
414                     parameters);
418  * Call a function with an access token, refreshing it first if necessary.
419  * The access token will remain valid for at least 2 minutes.
421  * @param {function(string):void} onOk Function to invoke with access token if
422  *     an access token was successfully retrieved.
423  * @param {function(remoting.Error):void} onError Function to invoke with an
424  *     error code on failure.
425  * @return {void} Nothing.
426  */
427 remoting.OAuth2.prototype.callWithToken = function(onOk, onError) {
428   if (this.isAuthenticated()) {
429     if (this.needsNewAccessToken_()) {
430       this.refreshAccessToken_(this.onRefreshToken_.bind(this, onOk, onError));
431     } else {
432       onOk(this.getAccessTokenInternal_()['token']);
433     }
434   } else {
435     onError(remoting.Error.NOT_AUTHENTICATED);
436   }
440  * Process token refresh results and notify caller.
442  * @param {function(string):void} onOk Function to invoke with access token if
443  *     an access token was successfully retrieved.
444  * @param {function(remoting.Error):void} onError Function to invoke with an
445  *     error code on failure.
446  * @param {XMLHttpRequest} xhr The result of the refresh operation.
447  * @param {string} accessToken The fresh access token.
448  * @private
449  */
450 remoting.OAuth2.prototype.onRefreshToken_ = function(onOk, onError, xhr,
451                                                      accessToken) {
452   /** @type {remoting.Error} */
453   var error = remoting.Error.UNEXPECTED;
454   if (xhr.status == 200) {
455     onOk(accessToken);
456     return;
457   } else if (xhr.status == 400) {
458     var result =
459         /** @type {{error: string}} */ (jsonParseSafe(xhr.responseText));
460     if (result && result.error == 'invalid_grant') {
461       error = remoting.Error.AUTHENTICATION_FAILED;
462     }
463   } else {
464     error = this.interpretUnexpectedXhrStatus_(xhr.status);
465   }
466   onError(error);
470  * Get the user's email address.
472  * @param {function(string):void} onOk Callback invoked when the email
473  *     address is available.
474  * @param {function(remoting.Error):void} onError Callback invoked if an
475  *     error occurs.
476  * @return {void} Nothing.
477  */
478 remoting.OAuth2.prototype.getEmail = function(onOk, onError) {
479   var cached = window.localStorage.getItem(this.KEY_EMAIL_);
480   if (typeof cached == 'string') {
481     onOk(cached);
482     return;
483   }
484   /** @type {remoting.OAuth2} */
485   var that = this;
486   /** @param {XMLHttpRequest} xhr The XHR response. */
487   var onResponse = function(xhr) {
488     var email = null;
489     if (xhr.status == 200) {
490       var result = jsonParseSafe(xhr.responseText);
491       if (result && 'email' in result) {
492         window.localStorage.setItem(that.KEY_EMAIL_, result['email']);
493         onOk(result['email']);
494         return;
495       } else {
496         console.error(
497             'Cannot parse userinfo response: ', xhr.responseText, xhr);
498         onError(remoting.Error.UNEXPECTED);
499         return;
500       }
501     }
502     console.error('Unable to get email address:', xhr.status, xhr);
503     if (xhr.status == 401) {
504       onError(remoting.Error.AUTHENTICATION_FAILED);
505     } else {
506       onError(that.interpretUnexpectedXhrStatus_(xhr.status));
507     }
508   };
510   /** @param {string} token The access token. */
511   var getEmailFromToken = function(token) {
512     var headers = { 'Authorization': 'OAuth ' + token };
513     remoting.xhr.get(that.getOAuth2ApiUserInfoEndpoint_(),
514                      onResponse, '', headers);
515   };
517   this.callWithToken(getEmailFromToken, onError);
521  * If the user's email address is cached, return it, otherwise return null.
523  * @return {?string} The email address, if it has been cached by a previous call
524  *     to getEmail, otherwise null.
525  */
526 remoting.OAuth2.prototype.getCachedEmail = function() {
527   var value = window.localStorage.getItem(this.KEY_EMAIL_);
528   if (typeof value == 'string') {
529     return value;
530   }
531   return null;