Home | History | Annotate | Download | only in webapp
      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.
      4 
      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  */
     13 
     14 // TODO(jamiewalch): Delete this code once Chromoting is a v2 app and uses the
     15 // identity API (http://crbug.com/ 134213).
     16 
     17 'use strict';
     18 
     19 /** @suppress {duplicate} */
     20 var remoting = remoting || {};
     21 
     22 /** @type {remoting.OAuth2} */
     23 remoting.oauth2 = null;
     24 
     25 
     26 /** @constructor */
     27 remoting.OAuth2 = function() {
     28 };
     29 
     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_ACCESS_TOKEN_ = 'oauth2-access-token';
     35 /** @private */
     36 remoting.OAuth2.prototype.KEY_XSRF_TOKEN_ = 'oauth2-xsrf-token';
     37 /** @private */
     38 remoting.OAuth2.prototype.KEY_EMAIL_ = 'remoting-email';
     39 
     40 // Constants for parameters used in retrieving the OAuth2 credentials.
     41 /** @private */
     42 remoting.OAuth2.prototype.SCOPE_ =
     43       'https://www.googleapis.com/auth/chromoting ' +
     44       'https://www.googleapis.com/auth/googletalk ' +
     45       'https://www.googleapis.com/auth/userinfo#email';
     46 
     47 // Configurable URLs/strings.
     48 /** @private
     49  *  @return {string} OAuth2 redirect URI.
     50  */
     51 remoting.OAuth2.prototype.getRedirectUri_ = function() {
     52   return remoting.settings.OAUTH2_REDIRECT_URL;
     53 };
     54 
     55 /** @private
     56  *  @return {string} API client ID.
     57  */
     58 remoting.OAuth2.prototype.getClientId_ = function() {
     59   return remoting.settings.OAUTH2_CLIENT_ID;
     60 };
     61 
     62 /** @private
     63  *  @return {string} API client secret.
     64  */
     65 remoting.OAuth2.prototype.getClientSecret_ = function() {
     66   return remoting.settings.OAUTH2_CLIENT_SECRET;
     67 };
     68 
     69 /** @private
     70  *  @return {string} OAuth2 authentication URL.
     71  */
     72 remoting.OAuth2.prototype.getOAuth2AuthEndpoint_ = function() {
     73   return remoting.settings.OAUTH2_BASE_URL + '/auth';
     74 };
     75 
     76 /** @return {boolean} True if the app is already authenticated. */
     77 remoting.OAuth2.prototype.isAuthenticated = function() {
     78   if (this.getRefreshToken()) {
     79     return true;
     80   }
     81   return false;
     82 };
     83 
     84 /**
     85  * Removes all storage, and effectively unauthenticates the user.
     86  *
     87  * @return {void} Nothing.
     88  */
     89 remoting.OAuth2.prototype.clear = function() {
     90   window.localStorage.removeItem(this.KEY_EMAIL_);
     91   this.clearAccessToken_();
     92   this.clearRefreshToken_();
     93 };
     94 
     95 /**
     96  * Sets the refresh token.
     97  *
     98  * @param {string} token The new refresh token.
     99  * @return {void} Nothing.
    100  * @private
    101  */
    102 remoting.OAuth2.prototype.setRefreshToken_ = function(token) {
    103   window.localStorage.setItem(this.KEY_REFRESH_TOKEN_, escape(token));
    104   window.localStorage.removeItem(this.KEY_EMAIL_);
    105   this.clearAccessToken_();
    106 };
    107 
    108 /**
    109  * @return {?string} The refresh token, if authenticated, or NULL.
    110  */
    111 remoting.OAuth2.prototype.getRefreshToken = function() {
    112   var value = window.localStorage.getItem(this.KEY_REFRESH_TOKEN_);
    113   if (typeof value == 'string') {
    114     return unescape(value);
    115   }
    116   return null;
    117 };
    118 
    119 /**
    120  * Clears the refresh token.
    121  *
    122  * @return {void} Nothing.
    123  * @private
    124  */
    125 remoting.OAuth2.prototype.clearRefreshToken_ = function() {
    126   window.localStorage.removeItem(this.KEY_REFRESH_TOKEN_);
    127 };
    128 
    129 /**
    130  * @param {string} token The new access token.
    131  * @param {number} expiration Expiration time in milliseconds since epoch.
    132  * @return {void} Nothing.
    133  * @private
    134  */
    135 remoting.OAuth2.prototype.setAccessToken_ = function(token, expiration) {
    136   // Offset expiration by 120 seconds so that we can guarantee that the token
    137   // we return will be valid for at least 2 minutes.
    138   // If the access token is to be useful, this object must make some
    139   // guarantee as to how long the token will be valid for.
    140   // The choice of 2 minutes is arbitrary, but that length of time
    141   // is part of the contract satisfied by callWithToken().
    142   // Offset by a further 30 seconds to account for RTT issues.
    143   var access_token = {
    144     'token': token,
    145     'expiration': (expiration - (120 + 30)) * 1000 + Date.now()
    146   };
    147   window.localStorage.setItem(this.KEY_ACCESS_TOKEN_,
    148                               JSON.stringify(access_token));
    149 };
    150 
    151 /**
    152  * Returns the current access token, setting it to a invalid value if none
    153  * existed before.
    154  *
    155  * @private
    156  * @return {{token: string, expiration: number}} The current access token, or
    157  * an invalid token if not authenticated.
    158  */
    159 remoting.OAuth2.prototype.getAccessTokenInternal_ = function() {
    160   if (!window.localStorage.getItem(this.KEY_ACCESS_TOKEN_)) {
    161     // Always be able to return structured data.
    162     this.setAccessToken_('', 0);
    163   }
    164   var accessToken = window.localStorage.getItem(this.KEY_ACCESS_TOKEN_);
    165   if (typeof accessToken == 'string') {
    166     var result = jsonParseSafe(accessToken);
    167     if (result && 'token' in result && 'expiration' in result) {
    168       return /** @type {{token: string, expiration: number}} */ result;
    169     }
    170   }
    171   console.log('Invalid access token stored.');
    172   return {'token': '', 'expiration': 0};
    173 };
    174 
    175 /**
    176  * Returns true if the access token is expired, or otherwise invalid.
    177  *
    178  * Will throw if !isAuthenticated().
    179  *
    180  * @return {boolean} True if a new access token is needed.
    181  * @private
    182  */
    183 remoting.OAuth2.prototype.needsNewAccessToken_ = function() {
    184   if (!this.isAuthenticated()) {
    185     throw 'Not Authenticated.';
    186   }
    187   var access_token = this.getAccessTokenInternal_();
    188   if (!access_token['token']) {
    189     return true;
    190   }
    191   if (Date.now() > access_token['expiration']) {
    192     return true;
    193   }
    194   return false;
    195 };
    196 
    197 /**
    198  * @return {void} Nothing.
    199  * @private
    200  */
    201 remoting.OAuth2.prototype.clearAccessToken_ = function() {
    202   window.localStorage.removeItem(this.KEY_ACCESS_TOKEN_);
    203 };
    204 
    205 /**
    206  * Update state based on token response from the OAuth2 /token endpoint.
    207  *
    208  * @param {function(string):void} onOk Called with the new access token.
    209  * @param {string} accessToken Access token.
    210  * @param {number} expiresIn Expiration time for the access token.
    211  * @return {void} Nothing.
    212  * @private
    213  */
    214 remoting.OAuth2.prototype.onAccessToken_ =
    215     function(onOk, accessToken, expiresIn) {
    216   this.setAccessToken_(accessToken, expiresIn);
    217   onOk(accessToken);
    218 };
    219 
    220 /**
    221  * Update state based on token response from the OAuth2 /token endpoint.
    222  *
    223  * @param {function():void} onOk Called after the new tokens are stored.
    224  * @param {string} refreshToken Refresh token.
    225  * @param {string} accessToken Access token.
    226  * @param {number} expiresIn Expiration time for the access token.
    227  * @return {void} Nothing.
    228  * @private
    229  */
    230 remoting.OAuth2.prototype.onTokens_ =
    231     function(onOk, refreshToken, accessToken, expiresIn) {
    232   this.setAccessToken_(accessToken, expiresIn);
    233   this.setRefreshToken_(refreshToken);
    234   onOk();
    235 };
    236 
    237 /**
    238  * Redirect page to get a new OAuth2 Refresh Token.
    239  *
    240  * @return {void} Nothing.
    241  */
    242 remoting.OAuth2.prototype.doAuthRedirect = function() {
    243   /** @type {remoting.OAuth2} */
    244   var that = this;
    245   var xsrf_token = remoting.generateXsrfToken();
    246   window.localStorage.setItem(this.KEY_XSRF_TOKEN_, xsrf_token);
    247   var GET_CODE_URL = this.getOAuth2AuthEndpoint_() + '?' +
    248     remoting.xhr.urlencodeParamHash({
    249           'client_id': this.getClientId_(),
    250           'redirect_uri': this.getRedirectUri_(),
    251           'scope': this.SCOPE_,
    252           'state': xsrf_token,
    253           'response_type': 'code',
    254           'access_type': 'offline',
    255           'approval_prompt': 'force'
    256         });
    257 
    258   /**
    259    * Processes the results of the oauth flow.
    260    *
    261    * @param {Object.<string, string>} message Dictionary containing the parsed
    262    *   OAuth redirect URL parameters.
    263    */
    264   function oauth2MessageListener(message) {
    265     if ('code' in message && 'state' in message) {
    266       var onDone = function() {
    267         window.location.reload();
    268       };
    269       that.exchangeCodeForToken(
    270           message['code'], message['state'], onDone);
    271     } else {
    272       if ('error' in message) {
    273         console.error(
    274             'Could not obtain authorization code: ' + message['error']);
    275       } else {
    276         // We intentionally don't log the response - since we don't understand
    277         // it, we can't tell if it has sensitive data.
    278         console.error('Invalid oauth2 response.');
    279       }
    280     }
    281     chrome.extension.onMessage.removeListener(oauth2MessageListener);
    282   }
    283   chrome.extension.onMessage.addListener(oauth2MessageListener);
    284   window.open(GET_CODE_URL, '_blank', 'location=yes,toolbar=no,menubar=no');
    285 };
    286 
    287 /**
    288  * Asynchronously exchanges an authorization code for a refresh token.
    289  *
    290  * @param {string} code The OAuth2 authorization code.
    291  * @param {string} state The state parameter received from the OAuth redirect.
    292  * @param {function():void} onDone Callback to invoke on completion.
    293  * @return {void} Nothing.
    294  */
    295 remoting.OAuth2.prototype.exchangeCodeForToken = function(code, state, onDone) {
    296   var xsrf_token = window.localStorage.getItem(this.KEY_XSRF_TOKEN_);
    297   window.localStorage.removeItem(this.KEY_XSRF_TOKEN_);
    298   if (xsrf_token == undefined || state != xsrf_token) {
    299     // Invalid XSRF token, or unexpected OAuth2 redirect. Abort.
    300     onDone();
    301   }
    302   /** @param {remoting.Error} error */
    303   var onError = function(error) {
    304     console.error('Unable to exchange code for token: ', error);
    305   };
    306 
    307   remoting.OAuth2Api.exchangeCodeForTokens(
    308       this.onTokens_.bind(this, onDone), onError,
    309       this.getClientId_(), this.getClientSecret_(), code,
    310       this.getRedirectUri_());
    311 };
    312 
    313 /**
    314  * Call a function with an access token, refreshing it first if necessary.
    315  * The access token will remain valid for at least 2 minutes.
    316  *
    317  * @param {function(string):void} onOk Function to invoke with access token if
    318  *     an access token was successfully retrieved.
    319  * @param {function(remoting.Error):void} onError Function to invoke with an
    320  *     error code on failure.
    321  * @return {void} Nothing.
    322  */
    323 remoting.OAuth2.prototype.callWithToken = function(onOk, onError) {
    324   var refreshToken = this.getRefreshToken();
    325   if (refreshToken) {
    326     if (this.needsNewAccessToken_()) {
    327       remoting.OAuth2Api.refreshAccessToken(
    328           this.onAccessToken_.bind(this, onOk), onError,
    329           this.getClientId_(), this.getClientSecret_(),
    330           refreshToken);
    331     } else {
    332       onOk(this.getAccessTokenInternal_()['token']);
    333     }
    334   } else {
    335     onError(remoting.Error.NOT_AUTHENTICATED);
    336   }
    337 };
    338 
    339 /**
    340  * Get the user's email address.
    341  *
    342  * @param {function(string):void} onOk Callback invoked when the email
    343  *     address is available.
    344  * @param {function(remoting.Error):void} onError Callback invoked if an
    345  *     error occurs.
    346  * @return {void} Nothing.
    347  */
    348 remoting.OAuth2.prototype.getEmail = function(onOk, onError) {
    349   var cached = window.localStorage.getItem(this.KEY_EMAIL_);
    350   if (typeof cached == 'string') {
    351     onOk(cached);
    352     return;
    353   }
    354   /** @type {remoting.OAuth2} */
    355   var that = this;
    356   /** @param {string} email */
    357   var onResponse = function(email) {
    358     window.localStorage.setItem(that.KEY_EMAIL_, email);
    359     onOk(email);
    360   };
    361 
    362   this.callWithToken(
    363       remoting.OAuth2Api.getEmail.bind(null, onResponse, onError), onError);
    364 };
    365 
    366 /**
    367  * If the user's email address is cached, return it, otherwise return null.
    368  *
    369  * @return {?string} The email address, if it has been cached by a previous call
    370  *     to getEmail, otherwise null.
    371  */
    372 remoting.OAuth2.prototype.getCachedEmail = function() {
    373   var value = window.localStorage.getItem(this.KEY_EMAIL_);
    374   if (typeof value == 'string') {
    375     return value;
    376   }
    377   return null;
    378 };
    379