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_ACCESS_TOKEN_ = 'oauth2-access-token'; 35/** @private */ 36remoting.OAuth2.prototype.KEY_XSRF_TOKEN_ = 'oauth2-xsrf-token'; 37/** @private */ 38remoting.OAuth2.prototype.KEY_EMAIL_ = 'remoting-email'; 39 40// Constants for parameters used in retrieving the OAuth2 credentials. 41/** @private */ 42remoting.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 */ 51remoting.OAuth2.prototype.getRedirectUri_ = function() { 52 return remoting.settings.OAUTH2_REDIRECT_URL; 53}; 54 55/** @private 56 * @return {string} API client ID. 57 */ 58remoting.OAuth2.prototype.getClientId_ = function() { 59 return remoting.settings.OAUTH2_CLIENT_ID; 60}; 61 62/** @private 63 * @return {string} API client secret. 64 */ 65remoting.OAuth2.prototype.getClientSecret_ = function() { 66 return remoting.settings.OAUTH2_CLIENT_SECRET; 67}; 68 69/** @private 70 * @return {string} OAuth2 authentication URL. 71 */ 72remoting.OAuth2.prototype.getOAuth2AuthEndpoint_ = function() { 73 return remoting.settings.OAUTH2_BASE_URL + '/auth'; 74}; 75 76/** @return {boolean} True if the app is already authenticated. */ 77remoting.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 */ 89remoting.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 */ 102remoting.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 */ 111remoting.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 */ 125remoting.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 */ 135remoting.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 */ 159remoting.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 */ 183remoting.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 */ 201remoting.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 */ 214remoting.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 */ 230remoting.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 */ 242remoting.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 */ 295remoting.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 */ 323remoting.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 */ 348remoting.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 */ 372remoting.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