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.
7 * OAuth2 class that handles retrieval/storage of an OAuth2 token.
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.
14 // TODO(jamiewalch): Delete this code once Chromoting is a v2 app and uses the
15 // identity API (http://crbug.com/ 134213).
19 /** @suppress {duplicate} */
20 var remoting = remoting || {};
22 /** @type {remoting.OAuth2} */
23 remoting.oauth2 = null;
27 remoting.OAuth2 = function() {
30 // Constants representing keys used for storing persistent state.
32 remoting.OAuth2.prototype.KEY_REFRESH_TOKEN_ = 'oauth2-refresh-token';
34 remoting.OAuth2.prototype.KEY_REFRESH_TOKEN_REVOKABLE_ =
35 'oauth2-refresh-token-revokable';
37 remoting.OAuth2.prototype.KEY_ACCESS_TOKEN_ = 'oauth2-access-token';
39 remoting.OAuth2.prototype.KEY_XSRF_TOKEN_ = 'oauth2-xsrf-token';
41 remoting.OAuth2.prototype.KEY_EMAIL_ = 'remoting-email';
43 // Constants for parameters used in retrieving the OAuth2 credentials.
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';
50 // Configurable URLs/strings.
52 * @return {string} OAuth2 redirect URI.
54 remoting.OAuth2.prototype.getRedirectUri_ = function() {
55 return remoting.settings.OAUTH2_REDIRECT_URL;
59 * @return {string} API client ID.
61 remoting.OAuth2.prototype.getClientId_ = function() {
62 return remoting.settings.OAUTH2_CLIENT_ID;
66 * @return {string} API client secret.
68 remoting.OAuth2.prototype.getClientSecret_ = function() {
69 return remoting.settings.OAUTH2_CLIENT_SECRET;
73 * @return {string} OAuth2 authentication URL.
75 remoting.OAuth2.prototype.getOAuth2AuthEndpoint_ = function() {
76 return remoting.settings.OAUTH2_BASE_URL + '/auth';
80 * @return {string} OAuth2 token URL.
82 remoting.OAuth2.prototype.getOAuth2TokenEndpoint_ = function() {
83 return remoting.settings.OAUTH2_BASE_URL + '/token';
87 * @return {string} OAuth token revocation URL.
89 remoting.OAuth2.prototype.getOAuth2RevokeTokenEndpoint_ = function() {
90 return remoting.settings.OAUTH2_BASE_URL + '/revoke';
94 * @return {string} OAuth2 userinfo API URL.
96 remoting.OAuth2.prototype.getOAuth2ApiUserInfoEndpoint_ = function() {
97 return remoting.settings.OAUTH2_API_BASE_URL + '/v1/userinfo';
100 /** @return {boolean} True if the app is already authenticated. */
101 remoting.OAuth2.prototype.isAuthenticated = function() {
102 if (this.getRefreshToken_()) {
109 * Removes all storage, and effectively unauthenticates the user.
111 * @return {void} Nothing.
113 remoting.OAuth2.prototype.clear = function() {
114 window.localStorage.removeItem(this.KEY_EMAIL_);
115 this.clearAccessToken_();
116 this.clearRefreshToken_();
120 * Sets the refresh token.
122 * This method also marks the token as revokable, so that this object will
123 * revoke the token when it no longer needs it.
125 * @param {string} token The new refresh token.
126 * @return {void} Nothing.
128 remoting.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_();
135 * Gets the refresh token.
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.
142 * @return {?string} The refresh token, if authenticated, or NULL.
144 remoting.OAuth2.prototype.exportRefreshToken = function() {
145 window.localStorage.removeItem(this.KEY_REFRESH_TOKEN_REVOKABLE_);
146 return this.getRefreshToken_();
150 * @return {?string} The refresh token, if authenticated, or NULL.
153 remoting.OAuth2.prototype.getRefreshToken_ = function() {
154 var value = window.localStorage.getItem(this.KEY_REFRESH_TOKEN_);
155 if (typeof value == 'string') {
156 return unescape(value);
162 * Clears the refresh token.
164 * @return {void} Nothing.
167 remoting.OAuth2.prototype.clearRefreshToken_ = function() {
168 if (window.localStorage.getItem(this.KEY_REFRESH_TOKEN_REVOKABLE_)) {
169 this.revokeToken_(this.getRefreshToken_());
171 window.localStorage.removeItem(this.KEY_REFRESH_TOKEN_);
172 window.localStorage.removeItem(this.KEY_REFRESH_TOKEN_REVOKABLE_);
176 * @param {string} token The new access token.
177 * @param {number} expiration Expiration time in milliseconds since epoch.
178 * @return {void} Nothing.
180 remoting.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));
187 * Returns the current access token, setting it to a invalid value if none
191 * @return {{token: string, expiration: number}} The current access token, or
192 * an invalid token if not authenticated.
194 remoting.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);
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;
206 console.log('Invalid access token stored.');
207 return {'token': '', 'expiration': 0};
211 * Returns true if the access token is expired, or otherwise invalid.
213 * Will throw if !isAuthenticated().
215 * @return {boolean} True if a new access token is needed.
218 remoting.OAuth2.prototype.needsNewAccessToken_ = function() {
219 if (!this.isAuthenticated()) {
220 throw 'Not Authenticated.';
222 var access_token = this.getAccessTokenInternal_();
223 if (!access_token['token']) {
226 if (Date.now() > access_token['expiration']) {
233 * @return {void} Nothing.
236 remoting.OAuth2.prototype.clearAccessToken_ = function() {
237 window.localStorage.removeItem(this.KEY_ACCESS_TOKEN_);
241 * Update state based on token response from the OAuth2 /token endpoint.
244 * @param {function(XMLHttpRequest, string): void} onDone Callback to invoke on
246 * @param {XMLHttpRequest} xhr The XHR object for this request.
247 * @return {void} Nothing.
249 remoting.OAuth2.prototype.processTokenResponse_ = function(onDone, xhr) {
250 /** @type {string} */
251 var accessToken = '';
252 if (xhr.status == 200) {
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']);
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());
273 console.error('Invalid "token" response from server:',
274 /** @type {*} */ (err));
277 console.error('Failed to get tokens. Status: ' + xhr.status +
278 ' response: ' + xhr.responseText);
280 onDone(xhr, accessToken);
284 * Asynchronously retrieves a new access token from the server.
286 * Will throw if !isAuthenticated().
288 * @param {function(XMLHttpRequest): void} onDone Callback to invoke on
290 * @return {void} Nothing.
293 remoting.OAuth2.prototype.refreshAccessToken_ = function(onDone) {
294 if (!this.isAuthenticated()) {
295 throw 'Not Authenticated.';
299 'client_id': this.getClientId_(),
300 'client_secret': this.getClientSecret_(),
301 'refresh_token': this.getRefreshToken_(),
302 'grant_type': 'refresh_token'
305 remoting.xhr.post(this.getOAuth2TokenEndpoint_(),
306 this.processTokenResponse_.bind(this, onDone),
312 * @return {string} A URL-Safe Base64-encoded 128-bit random value. */
313 remoting.OAuth2.prototype.generateXsrfToken_ = function() {
314 var random = new Uint8Array(16);
315 window.crypto.getRandomValues(random);
316 var base64Token = window.btoa(String.fromCharCode.apply(null, random));
317 return base64Token.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
321 * Redirect page to get a new OAuth2 Refresh Token.
323 * @return {void} Nothing.
325 remoting.OAuth2.prototype.doAuthRedirect = function() {
326 var xsrf_token = this.generateXsrfToken_();
327 window.localStorage.setItem(this.KEY_XSRF_TOKEN_, xsrf_token);
328 var GET_CODE_URL = this.getOAuth2AuthEndpoint_() + '?' +
329 remoting.xhr.urlencodeParamHash({
330 'client_id': this.getClientId_(),
331 'redirect_uri': this.getRedirectUri_(),
332 'scope': this.SCOPE_,
334 'response_type': 'code',
335 'access_type': 'offline',
336 'approval_prompt': 'force'
338 window.location.replace(GET_CODE_URL);
342 * Asynchronously exchanges an authorization code for a refresh token.
344 * @param {string} code The new refresh token.
345 * @param {string} state The state parameter received from the OAuth redirect.
346 * @param {function(XMLHttpRequest):void} onDone Callback to invoke on
348 * @return {void} Nothing.
350 remoting.OAuth2.prototype.exchangeCodeForToken = function(code, state, onDone) {
351 var xsrf_token = window.localStorage.getItem(this.KEY_XSRF_TOKEN_);
352 window.localStorage.removeItem(this.KEY_XSRF_TOKEN_);
353 if (xsrf_token == undefined || state != xsrf_token) {
354 // Invalid XSRF token, or unexpected OAuth2 redirect. Abort.
358 'client_id': this.getClientId_(),
359 'client_secret': this.getClientSecret_(),
360 'redirect_uri': this.getRedirectUri_(),
362 'grant_type': 'authorization_code'
364 remoting.xhr.post(this.getOAuth2TokenEndpoint_(),
365 this.processTokenResponse_.bind(this, onDone),
370 * Interprets unexpected HTTP response codes to authentication XMLHttpRequests.
371 * The caller should handle the usual expected responses (200, 400) separately.
374 * @param {number} xhrStatus Status (HTTP response code) of the XMLHttpRequest.
375 * @return {remoting.Error} An error code to be raised.
377 remoting.OAuth2.prototype.interpretUnexpectedXhrStatus_ = function(xhrStatus) {
378 // Return AUTHENTICATION_FAILED by default, so that the user can try to
379 // recover from an unexpected failure by signing in again.
380 /** @type {remoting.Error} */
381 var error = remoting.Error.AUTHENTICATION_FAILED;
382 if (xhrStatus == 502 || xhrStatus == 503) {
383 error = remoting.Error.SERVICE_UNAVAILABLE;
384 } else if (xhrStatus == 0) {
385 error = remoting.Error.NETWORK_FAILURE;
387 console.warn('Unexpected authentication response code: ' + xhrStatus);
393 * Revokes a refresh or an access token.
395 * @param {string?} token An access or refresh token.
396 * @return {void} Nothing.
399 remoting.OAuth2.prototype.revokeToken_ = function(token) {
400 if (!token || (token.length == 0)) {
403 var parameters = { 'token': token };
405 /** @param {XMLHttpRequest} xhr The XHR reply. */
406 var processResponse = function(xhr) {
407 if (xhr.status != 200) {
408 console.log('Failed to revoke token. Status: ' + xhr.status +
409 ' ; response: ' + xhr.responseText + ' ; xhr: ', xhr);
412 remoting.xhr.post(this.getOAuth2RevokeTokenEndpoint_(),
418 * Call a function with an access token, refreshing it first if necessary.
419 * The access token will remain valid for at least 2 minutes.
421 * @param {function(string):void} onOk Function to invoke with access token if
422 * an access token was successfully retrieved.
423 * @param {function(remoting.Error):void} onError Function to invoke with an
424 * error code on failure.
425 * @return {void} Nothing.
427 remoting.OAuth2.prototype.callWithToken = function(onOk, onError) {
428 if (this.isAuthenticated()) {
429 if (this.needsNewAccessToken_()) {
430 this.refreshAccessToken_(this.onRefreshToken_.bind(this, onOk, onError));
432 onOk(this.getAccessTokenInternal_()['token']);
435 onError(remoting.Error.NOT_AUTHENTICATED);
440 * Process token refresh results and notify caller.
442 * @param {function(string):void} onOk Function to invoke with access token if
443 * an access token was successfully retrieved.
444 * @param {function(remoting.Error):void} onError Function to invoke with an
445 * error code on failure.
446 * @param {XMLHttpRequest} xhr The result of the refresh operation.
447 * @param {string} accessToken The fresh access token.
450 remoting.OAuth2.prototype.onRefreshToken_ = function(onOk, onError, xhr,
452 /** @type {remoting.Error} */
453 var error = remoting.Error.UNEXPECTED;
454 if (xhr.status == 200) {
457 } else if (xhr.status == 400) {
459 /** @type {{error: string}} */ (jsonParseSafe(xhr.responseText));
460 if (result && result.error == 'invalid_grant') {
461 error = remoting.Error.AUTHENTICATION_FAILED;
464 error = this.interpretUnexpectedXhrStatus_(xhr.status);
470 * Get the user's email address.
472 * @param {function(string):void} onOk Callback invoked when the email
473 * address is available.
474 * @param {function(remoting.Error):void} onError Callback invoked if an
476 * @return {void} Nothing.
478 remoting.OAuth2.prototype.getEmail = function(onOk, onError) {
479 var cached = window.localStorage.getItem(this.KEY_EMAIL_);
480 if (typeof cached == 'string') {
484 /** @type {remoting.OAuth2} */
486 /** @param {XMLHttpRequest} xhr The XHR response. */
487 var onResponse = function(xhr) {
489 if (xhr.status == 200) {
490 var result = jsonParseSafe(xhr.responseText);
491 if (result && 'email' in result) {
492 window.localStorage.setItem(that.KEY_EMAIL_, result['email']);
493 onOk(result['email']);
497 'Cannot parse userinfo response: ', xhr.responseText, xhr);
498 onError(remoting.Error.UNEXPECTED);
502 console.error('Unable to get email address:', xhr.status, xhr);
503 if (xhr.status == 401) {
504 onError(remoting.Error.AUTHENTICATION_FAILED);
506 onError(that.interpretUnexpectedXhrStatus_(xhr.status));
510 /** @param {string} token The access token. */
511 var getEmailFromToken = function(token) {
512 var headers = { 'Authorization': 'OAuth ' + token };
513 remoting.xhr.get(that.getOAuth2ApiUserInfoEndpoint_(),
514 onResponse, '', headers);
517 this.callWithToken(getEmailFromToken, onError);
521 * If the user's email address is cached, return it, otherwise return null.
523 * @return {?string} The email address, if it has been cached by a previous call
524 * to getEmail, otherwise null.
526 remoting.OAuth2.prototype.getCachedEmail = function() {
527 var value = window.localStorage.getItem(this.KEY_EMAIL_);
528 if (typeof value == 'string') {