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