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