oauth2.js revision c2e0dbddbe15c98d52c4786dac06cb8952a8ae6d
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} */ 20var remoting = remoting || {}; 21 22/** @type {remoting.OAuth2} */ 23remoting.oauth2 = null; 24 25 26/** @constructor */ 27remoting.OAuth2 = function() { 28}; 29 30// Constants representing keys used for storing persistent state. 31/** @private */ 32remoting.OAuth2.prototype.KEY_REFRESH_TOKEN_ = 'oauth2-refresh-token'; 33/** @private */ 34remoting.OAuth2.prototype.KEY_REFRESH_TOKEN_REVOKABLE_ = 35 'oauth2-refresh-token-revokable'; 36/** @private */ 37remoting.OAuth2.prototype.KEY_ACCESS_TOKEN_ = 'oauth2-access-token'; 38/** @private */ 39remoting.OAuth2.prototype.KEY_XSRF_TOKEN_ = 'oauth2-xsrf-token'; 40/** @private */ 41remoting.OAuth2.prototype.KEY_EMAIL_ = 'remoting-email'; 42 43// Constants for parameters used in retrieving the OAuth2 credentials. 44/** @private */ 45remoting.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 */ 54remoting.OAuth2.prototype.getRedirectUri_ = function() { 55 return remoting.settings.OAUTH2_REDIRECT_URL; 56}; 57 58/** @private 59 * @return {string} API client ID. 60 */ 61remoting.OAuth2.prototype.getClientId_ = function() { 62 return remoting.settings.OAUTH2_CLIENT_ID; 63}; 64 65/** @private 66 * @return {string} API client secret. 67 */ 68remoting.OAuth2.prototype.getClientSecret_ = function() { 69 return remoting.settings.OAUTH2_CLIENT_SECRET; 70}; 71 72/** @private 73 * @return {string} OAuth2 authentication URL. 74 */ 75remoting.OAuth2.prototype.getOAuth2AuthEndpoint_ = function() { 76 return remoting.settings.OAUTH2_BASE_URL + '/auth'; 77}; 78 79/** @private 80 * @return {string} OAuth2 token URL. 81 */ 82remoting.OAuth2.prototype.getOAuth2TokenEndpoint_ = function() { 83 return remoting.settings.OAUTH2_BASE_URL + '/token'; 84}; 85 86/** @private 87 * @return {string} OAuth token revocation URL. 88 */ 89remoting.OAuth2.prototype.getOAuth2RevokeTokenEndpoint_ = function() { 90 return remoting.settings.OAUTH2_BASE_URL + '/revoke'; 91}; 92 93/** @private 94 * @return {string} OAuth2 userinfo API URL. 95 */ 96remoting.OAuth2.prototype.getOAuth2ApiUserInfoEndpoint_ = function() { 97 return remoting.settings.OAUTH2_API_BASE_URL + '/v1/userinfo'; 98}; 99 100/** @return {boolean} True if the app is already authenticated. */ 101remoting.OAuth2.prototype.isAuthenticated = function() { 102 if (this.getRefreshToken_()) { 103 return true; 104 } 105 return false; 106}; 107 108/** 109 * Removes all storage, and effectively unauthenticates the user. 110 * 111 * @return {void} Nothing. 112 */ 113remoting.OAuth2.prototype.clear = function() { 114 window.localStorage.removeItem(this.KEY_EMAIL_); 115 this.clearAccessToken_(); 116 this.clearRefreshToken_(); 117}; 118 119/** 120 * Sets the refresh token. 121 * 122 * This method also marks the token as revokable, so that this object will 123 * revoke the token when it no longer needs it. 124 * 125 * @param {string} token The new refresh token. 126 * @return {void} Nothing. 127 */ 128remoting.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_(); 132}; 133 134/** 135 * Gets the refresh token. 136 * 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. 141 * 142 * @return {?string} The refresh token, if authenticated, or NULL. 143 */ 144remoting.OAuth2.prototype.exportRefreshToken = function() { 145 window.localStorage.removeItem(this.KEY_REFRESH_TOKEN_REVOKABLE_); 146 return this.getRefreshToken_(); 147}; 148 149/** 150 * @return {?string} The refresh token, if authenticated, or NULL. 151 * @private 152 */ 153remoting.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; 159}; 160 161/** 162 * Clears the refresh token. 163 * 164 * @return {void} Nothing. 165 * @private 166 */ 167remoting.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_); 173}; 174 175/** 176 * @param {string} token The new access token. 177 * @param {number} expiration Expiration time in milliseconds since epoch. 178 * @return {void} Nothing. 179 */ 180remoting.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)); 184}; 185 186/** 187 * Returns the current access token, setting it to a invalid value if none 188 * existed before. 189 * 190 * @private 191 * @return {{token: string, expiration: number}} The current access token, or 192 * an invalid token if not authenticated. 193 */ 194remoting.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}; 208}; 209 210/** 211 * Returns true if the access token is expired, or otherwise invalid. 212 * 213 * Will throw if !isAuthenticated(). 214 * 215 * @return {boolean} True if a new access token is needed. 216 * @private 217 */ 218remoting.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; 230}; 231 232/** 233 * @return {void} Nothing. 234 * @private 235 */ 236remoting.OAuth2.prototype.clearAccessToken_ = function() { 237 window.localStorage.removeItem(this.KEY_ACCESS_TOKEN_); 238}; 239 240/** 241 * Update state based on token response from the OAuth2 /token endpoint. 242 * 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 */ 249remoting.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 } 261 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); 281}; 282 283/** 284 * Asynchronously retrieves a new access token from the server. 285 * 286 * Will throw if !isAuthenticated(). 287 * 288 * @param {function(XMLHttpRequest): void} onDone Callback to invoke on 289 * completion. 290 * @return {void} Nothing. 291 * @private 292 */ 293remoting.OAuth2.prototype.refreshAccessToken_ = function(onDone) { 294 if (!this.isAuthenticated()) { 295 throw 'Not Authenticated.'; 296 } 297 298 var parameters = { 299 'client_id': this.getClientId_(), 300 'client_secret': this.getClientSecret_(), 301 'refresh_token': this.getRefreshToken_(), 302 'grant_type': 'refresh_token' 303 }; 304 305 remoting.xhr.post(this.getOAuth2TokenEndpoint_(), 306 this.processTokenResponse_.bind(this, onDone), 307 parameters); 308}; 309 310/** 311 * Redirect page to get a new OAuth2 Refresh Token. 312 * 313 * @return {void} Nothing. 314 */ 315remoting.OAuth2.prototype.doAuthRedirect = function() { 316 var xsrf_token = remoting.generateXsrfToken(); 317 window.localStorage.setItem(this.KEY_XSRF_TOKEN_, xsrf_token); 318 var GET_CODE_URL = this.getOAuth2AuthEndpoint_() + '?' + 319 remoting.xhr.urlencodeParamHash({ 320 'client_id': this.getClientId_(), 321 'redirect_uri': this.getRedirectUri_(), 322 'scope': this.SCOPE_, 323 'state': xsrf_token, 324 'response_type': 'code', 325 'access_type': 'offline', 326 'approval_prompt': 'force' 327 }); 328 window.location.replace(GET_CODE_URL); 329}; 330 331/** 332 * Asynchronously exchanges an authorization code for a refresh token. 333 * 334 * @param {string} code The new refresh token. 335 * @param {string} state The state parameter received from the OAuth redirect. 336 * @param {function(XMLHttpRequest):void} onDone Callback to invoke on 337 * completion. 338 * @return {void} Nothing. 339 */ 340remoting.OAuth2.prototype.exchangeCodeForToken = function(code, state, onDone) { 341 var xsrf_token = window.localStorage.getItem(this.KEY_XSRF_TOKEN_); 342 window.localStorage.removeItem(this.KEY_XSRF_TOKEN_); 343 if (xsrf_token == undefined || state != xsrf_token) { 344 // Invalid XSRF token, or unexpected OAuth2 redirect. Abort. 345 onDone(null); 346 } 347 var parameters = { 348 'client_id': this.getClientId_(), 349 'client_secret': this.getClientSecret_(), 350 'redirect_uri': this.getRedirectUri_(), 351 'code': code, 352 'grant_type': 'authorization_code' 353 }; 354 remoting.xhr.post(this.getOAuth2TokenEndpoint_(), 355 this.processTokenResponse_.bind(this, onDone), 356 parameters); 357}; 358 359/** 360 * Interprets unexpected HTTP response codes to authentication XMLHttpRequests. 361 * The caller should handle the usual expected responses (200, 400) separately. 362 * 363 * @private 364 * @param {number} xhrStatus Status (HTTP response code) of the XMLHttpRequest. 365 * @return {remoting.Error} An error code to be raised. 366 */ 367remoting.OAuth2.prototype.interpretUnexpectedXhrStatus_ = function(xhrStatus) { 368 // Return AUTHENTICATION_FAILED by default, so that the user can try to 369 // recover from an unexpected failure by signing in again. 370 /** @type {remoting.Error} */ 371 var error = remoting.Error.AUTHENTICATION_FAILED; 372 if (xhrStatus == 502 || xhrStatus == 503) { 373 error = remoting.Error.SERVICE_UNAVAILABLE; 374 } else if (xhrStatus == 0) { 375 error = remoting.Error.NETWORK_FAILURE; 376 } else { 377 console.warn('Unexpected authentication response code: ' + xhrStatus); 378 } 379 return error; 380}; 381 382/** 383 * Revokes a refresh or an access token. 384 * 385 * @param {string?} token An access or refresh token. 386 * @return {void} Nothing. 387 * @private 388 */ 389remoting.OAuth2.prototype.revokeToken_ = function(token) { 390 if (!token || (token.length == 0)) { 391 return; 392 } 393 var parameters = { 'token': token }; 394 395 /** @param {XMLHttpRequest} xhr The XHR reply. */ 396 var processResponse = function(xhr) { 397 if (xhr.status != 200) { 398 console.log('Failed to revoke token. Status: ' + xhr.status + 399 ' ; response: ' + xhr.responseText + ' ; xhr: ', xhr); 400 } 401 }; 402 remoting.xhr.post(this.getOAuth2RevokeTokenEndpoint_(), 403 processResponse, 404 parameters); 405}; 406 407/** 408 * Call a function with an access token, refreshing it first if necessary. 409 * The access token will remain valid for at least 2 minutes. 410 * 411 * @param {function(string):void} onOk Function to invoke with access token if 412 * an access token was successfully retrieved. 413 * @param {function(remoting.Error):void} onError Function to invoke with an 414 * error code on failure. 415 * @return {void} Nothing. 416 */ 417remoting.OAuth2.prototype.callWithToken = function(onOk, onError) { 418 if (this.isAuthenticated()) { 419 if (this.needsNewAccessToken_()) { 420 this.refreshAccessToken_(this.onRefreshToken_.bind(this, onOk, onError)); 421 } else { 422 onOk(this.getAccessTokenInternal_()['token']); 423 } 424 } else { 425 onError(remoting.Error.NOT_AUTHENTICATED); 426 } 427}; 428 429/** 430 * Process token refresh results and notify caller. 431 * 432 * @param {function(string):void} onOk Function to invoke with access token if 433 * an access token was successfully retrieved. 434 * @param {function(remoting.Error):void} onError Function to invoke with an 435 * error code on failure. 436 * @param {XMLHttpRequest} xhr The result of the refresh operation. 437 * @param {string} accessToken The fresh access token. 438 * @private 439 */ 440remoting.OAuth2.prototype.onRefreshToken_ = function(onOk, onError, xhr, 441 accessToken) { 442 /** @type {remoting.Error} */ 443 var error = remoting.Error.UNEXPECTED; 444 if (xhr.status == 200) { 445 onOk(accessToken); 446 return; 447 } else if (xhr.status == 400) { 448 var result = 449 /** @type {{error: string}} */ (jsonParseSafe(xhr.responseText)); 450 if (result && result.error == 'invalid_grant') { 451 error = remoting.Error.AUTHENTICATION_FAILED; 452 } 453 } else { 454 error = this.interpretUnexpectedXhrStatus_(xhr.status); 455 } 456 onError(error); 457}; 458 459/** 460 * Get the user's email address. 461 * 462 * @param {function(string):void} onOk Callback invoked when the email 463 * address is available. 464 * @param {function(remoting.Error):void} onError Callback invoked if an 465 * error occurs. 466 * @return {void} Nothing. 467 */ 468remoting.OAuth2.prototype.getEmail = function(onOk, onError) { 469 var cached = window.localStorage.getItem(this.KEY_EMAIL_); 470 if (typeof cached == 'string') { 471 onOk(cached); 472 return; 473 } 474 /** @type {remoting.OAuth2} */ 475 var that = this; 476 /** @param {XMLHttpRequest} xhr The XHR response. */ 477 var onResponse = function(xhr) { 478 var email = null; 479 if (xhr.status == 200) { 480 var result = jsonParseSafe(xhr.responseText); 481 if (result && 'email' in result) { 482 window.localStorage.setItem(that.KEY_EMAIL_, result['email']); 483 onOk(result['email']); 484 return; 485 } else { 486 console.error( 487 'Cannot parse userinfo response: ', xhr.responseText, xhr); 488 onError(remoting.Error.UNEXPECTED); 489 return; 490 } 491 } 492 console.error('Unable to get email address:', xhr.status, xhr); 493 if (xhr.status == 401) { 494 onError(remoting.Error.AUTHENTICATION_FAILED); 495 } else { 496 onError(that.interpretUnexpectedXhrStatus_(xhr.status)); 497 } 498 }; 499 500 /** @param {string} token The access token. */ 501 var getEmailFromToken = function(token) { 502 var headers = { 'Authorization': 'OAuth ' + token }; 503 remoting.xhr.get(that.getOAuth2ApiUserInfoEndpoint_(), 504 onResponse, '', headers); 505 }; 506 507 this.callWithToken(getEmailFromToken, onError); 508}; 509 510/** 511 * If the user's email address is cached, return it, otherwise return null. 512 * 513 * @return {?string} The email address, if it has been cached by a previous call 514 * to getEmail, otherwise null. 515 */ 516remoting.OAuth2.prototype.getCachedEmail = function() { 517 var value = window.localStorage.getItem(this.KEY_EMAIL_); 518 if (typeof value == 'string') { 519 return value; 520 } 521 return null; 522}; 523