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