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