blob: 50bc38b6effd4ad6afb61babd2c60fc98d138cea [file] [log] [blame]
// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview
* OAuth2 class that handles retrieval/storage of an OAuth2 token.
*
* Uses a content script to trampoline the OAuth redirect page back into the
* extension context. This works around the lack of native support for
* chrome-extensions in OAuth2.
*/
'use strict';
/** @suppress {duplicate} */
var remoting = remoting || {};
/** @type {remoting.OAuth2} */
remoting.oauth2 = null;
/** @constructor */
remoting.OAuth2 = function() {
};
// Constants representing keys used for storing persistent state.
/** @private */
remoting.OAuth2.prototype.KEY_REFRESH_TOKEN_ = 'oauth2-refresh-token';
/** @private */
remoting.OAuth2.prototype.KEY_REFRESH_TOKEN_REVOKABLE_ =
'oauth2-refresh-token-revokable';
/** @private */
remoting.OAuth2.prototype.KEY_ACCESS_TOKEN_ = 'oauth2-access-token';
/** @private */
remoting.OAuth2.prototype.KEY_EMAIL_ = 'remoting-email';
// Constants for parameters used in retrieving the OAuth2 credentials.
/** @private */
remoting.OAuth2.prototype.SCOPE_ =
'https://www.googleapis.com/auth/chromoting ' +
'https://www.googleapis.com/auth/googletalk ' +
'https://www.googleapis.com/auth/userinfo#email';
/** @private */
remoting.OAuth2.prototype.OAUTH2_TOKEN_ENDPOINT_ =
'https://accounts.google.com/o/oauth2/token';
/** @private */
remoting.OAuth2.prototype.OAUTH2_REVOKE_TOKEN_ENDPOINT_ =
'https://accounts.google.com/o/oauth2/revoke';
/** @return {boolean} True if the app is already authenticated. */
remoting.OAuth2.prototype.isAuthenticated = function() {
if (this.getRefreshToken_()) {
return true;
}
return false;
};
/**
* Removes all storage, and effectively unauthenticates the user.
*
* @return {void} Nothing.
*/
remoting.OAuth2.prototype.clear = function() {
window.localStorage.removeItem(this.KEY_EMAIL_);
this.clearAccessToken_();
this.clearRefreshToken_();
};
/**
* Sets the refresh token.
*
* This method also marks the token as revokable, so that this object will
* revoke the token when it no longer needs it.
*
* @param {string} token The new refresh token.
* @return {void} Nothing.
*/
remoting.OAuth2.prototype.setRefreshToken = function(token) {
window.localStorage.setItem(this.KEY_REFRESH_TOKEN_, escape(token));
window.localStorage.setItem(this.KEY_REFRESH_TOKEN_REVOKABLE_, true);
this.clearAccessToken_();
};
/**
* Gets the refresh token.
*
* This method also marks the refresh token as not revokable, so that this
* object will not revoke the token when it no longer needs it. After this
* object has exported the token, it cannot know whether it is still in use
* when this object no longer needs it.
*
* @return {?string} The refresh token, if authenticated, or NULL.
*/
remoting.OAuth2.prototype.exportRefreshToken = function() {
window.localStorage.removeItem(this.KEY_REFRESH_TOKEN_REVOKABLE_);
return this.getRefreshToken_();
};
/**
* @return {?string} The refresh token, if authenticated, or NULL.
* @private
*/
remoting.OAuth2.prototype.getRefreshToken_ = function() {
var value = window.localStorage.getItem(this.KEY_REFRESH_TOKEN_);
if (typeof value == 'string') {
return unescape(value);
}
return null;
};
/**
* Clears the refresh token.
*
* @return {void} Nothing.
* @private
*/
remoting.OAuth2.prototype.clearRefreshToken_ = function() {
if (window.localStorage.getItem(this.KEY_REFRESH_TOKEN_REVOKABLE_)) {
this.revokeToken_(this.getRefreshToken_());
}
window.localStorage.removeItem(this.KEY_REFRESH_TOKEN_);
window.localStorage.removeItem(this.KEY_REFRESH_TOKEN_REVOKABLE_);
};
/**
* @param {string} token The new access token.
* @param {number} expiration Expiration time in milliseconds since epoch.
* @return {void} Nothing.
*/
remoting.OAuth2.prototype.setAccessToken = function(token, expiration) {
var access_token = {'token': token, 'expiration': expiration};
window.localStorage.setItem(this.KEY_ACCESS_TOKEN_,
JSON.stringify(access_token));
};
/**
* Returns the current access token, setting it to a invalid value if none
* existed before.
*
* @private
* @return {{token: string, expiration: number}} The current access token, or
* an invalid token if not authenticated.
*/
remoting.OAuth2.prototype.getAccessTokenInternal_ = function() {
if (!window.localStorage.getItem(this.KEY_ACCESS_TOKEN_)) {
// Always be able to return structured data.
this.setAccessToken('', 0);
}
var accessToken = window.localStorage.getItem(this.KEY_ACCESS_TOKEN_);
if (typeof accessToken == 'string') {
var result = jsonParseSafe(accessToken);
if (result && 'token' in result && 'expiration' in result) {
return /** @type {{token: string, expiration: number}} */ result;
}
}
console.log('Invalid access token stored.');
return {'token': '', 'expiration': 0};
};
/**
* Returns true if the access token is expired, or otherwise invalid.
*
* Will throw if !isAuthenticated().
*
* @return {boolean} True if a new access token is needed.
* @private
*/
remoting.OAuth2.prototype.needsNewAccessToken_ = function() {
if (!this.isAuthenticated()) {
throw 'Not Authenticated.';
}
var access_token = this.getAccessTokenInternal_();
if (!access_token['token']) {
return true;
}
if (Date.now() > access_token['expiration']) {
return true;
}
return false;
};
/**
* @return {void} Nothing.
* @private
*/
remoting.OAuth2.prototype.clearAccessToken_ = function() {
window.localStorage.removeItem(this.KEY_ACCESS_TOKEN_);
};
/**
* Update state based on token response from the OAuth2 /token endpoint.
*
* @private
* @param {function(XMLHttpRequest, string): void} onDone Callback to invoke on
* completion.
* @param {XMLHttpRequest} xhr The XHR object for this request.
* @return {void} Nothing.
*/
remoting.OAuth2.prototype.processTokenResponse_ = function(onDone, xhr) {
/** @type {string} */
var accessToken = '';
if (xhr.status == 200) {
try {
// Don't use jsonParseSafe here unless you move the definition out of
// remoting.js, otherwise this won't work from the OAuth trampoline.
// TODO(jamiewalch): Fix this once we're no longer using the trampoline.
var tokens = JSON.parse(xhr.responseText);
if ('refresh_token' in tokens) {
this.setRefreshToken(tokens['refresh_token']);
}
// Offset by 120 seconds so that we can guarantee that the token
// we return will be valid for at least 2 minutes.
// If the access token is to be useful, this object must make some
// guarantee as to how long the token will be valid for.
// The choice of 2 minutes is arbitrary, but that length of time
// is part of the contract satisfied by callWithToken().
// Offset by a further 30 seconds to account for RTT issues.
accessToken = /** @type {string} */ (tokens['access_token']);
this.setAccessToken(accessToken,
(tokens['expires_in'] - (120 + 30)) * 1000 + Date.now());
} catch (err) {
console.error('Invalid "token" response from server:',
/** @type {*} */ (err));
}
} else {
console.error('Failed to get tokens. Status: ' + xhr.status +
' response: ' + xhr.responseText);
}
onDone(xhr, accessToken);
};
/**
* Asynchronously retrieves a new access token from the server.
*
* Will throw if !isAuthenticated().
*
* @param {function(XMLHttpRequest): void} onDone Callback to invoke on
* completion.
* @return {void} Nothing.
* @private
*/
remoting.OAuth2.prototype.refreshAccessToken_ = function(onDone) {
if (!this.isAuthenticated()) {
throw 'Not Authenticated.';
}
var parameters = {
'client_id': this.CLIENT_ID_,
'client_secret': this.CLIENT_SECRET_,
'refresh_token': this.getRefreshToken_(),
'grant_type': 'refresh_token'
};
remoting.xhr.post(this.OAUTH2_TOKEN_ENDPOINT_,
this.processTokenResponse_.bind(this, onDone),
parameters);
};
/**
* Redirect page to get a new OAuth2 Refresh Token.
*
* @return {void} Nothing.
*/
remoting.OAuth2.prototype.doAuthRedirect = function() {
var GET_CODE_URL = 'https://accounts.google.com/o/oauth2/auth?' +
remoting.xhr.urlencodeParamHash({
'client_id': this.CLIENT_ID_,
'redirect_uri': this.REDIRECT_URI_,
'scope': this.SCOPE_,
'response_type': 'code',
'access_type': 'offline',
'approval_prompt': 'force'
});
window.location.replace(GET_CODE_URL);
};
/**
* Asynchronously exchanges an authorization code for a refresh token.
*
* @param {string} code The new refresh token.
* @param {function(XMLHttpRequest):void} onDone Callback to invoke on
* completion.
* @return {void} Nothing.
*/
remoting.OAuth2.prototype.exchangeCodeForToken = function(code, onDone) {
var parameters = {
'client_id': this.CLIENT_ID_,
'client_secret': this.CLIENT_SECRET_,
'redirect_uri': this.REDIRECT_URI_,
'code': code,
'grant_type': 'authorization_code'
};
remoting.xhr.post(this.OAUTH2_TOKEN_ENDPOINT_,
this.processTokenResponse_.bind(this, onDone),
parameters);
};
/**
* Revokes a refresh or an access token.
*
* @param {string?} token An access or refresh token.
* @return {void} Nothing.
* @private
*/
remoting.OAuth2.prototype.revokeToken_ = function(token) {
if (!token || (token.length == 0)) {
return;
}
var parameters = { 'token': token };
/** @param {XMLHttpRequest} xhr The XHR reply. */
var processResponse = function(xhr) {
if (xhr.status != 200) {
console.log('Failed to revoke token. Status: ' + xhr.status +
' ; response: ' + xhr.responseText + ' ; xhr: ', xhr);
}
};
remoting.xhr.post(this.OAUTH2_REVOKE_TOKEN_ENDPOINT_,
processResponse,
parameters);
};
/**
* Call a function with an access token, refreshing it first if necessary.
* The access token will remain valid for at least 2 minutes.
*
* @param {function(string):void} onOk Function to invoke with access token if
* an access token was successfully retrieved.
* @param {function(remoting.Error):void} onError Function to invoke with an
* error code on failure.
* @return {void} Nothing.
*/
remoting.OAuth2.prototype.callWithToken = function(onOk, onError) {
if (this.isAuthenticated()) {
if (this.needsNewAccessToken_()) {
this.refreshAccessToken_(this.onRefreshToken_.bind(this, onOk, onError));
} else {
onOk(this.getAccessTokenInternal_()['token']);
}
} else {
onError(remoting.Error.NOT_AUTHENTICATED);
}
};
/**
* Process token refresh results and notify caller.
*
* @param {function(string):void} onOk Function to invoke with access token if
* an access token was successfully retrieved.
* @param {function(remoting.Error):void} onError Function to invoke with an
* error code on failure.
* @param {XMLHttpRequest} xhr The result of the refresh operation.
* @param {string} accessToken The fresh access token.
* @private
*/
remoting.OAuth2.prototype.onRefreshToken_ = function(onOk, onError, xhr,
accessToken) {
var error = remoting.Error.UNEXPECTED;
if (xhr.status == 200) {
onOk(accessToken);
return;
} else if (xhr.status == 400) {
var result =
/** @type {{error: string}} */ (jsonParseSafe(xhr.responseText));
if (result && result.error == 'invalid_grant') {
error = remoting.Error.AUTHENTICATION_FAILED;
}
} else if (xhr.status == 401) {
// According to the OAuth2 draft RFC, the server shouldn't return 401,
// but AUTHENTICATION_FAILED is the obvious interpretation if it does.
console.warn('Unexpected 401 in response to refresh.');
error = remoting.Error.AUTHENTICATION_FAILED;
} else if (xhr.status == 503) {
error = remoting.Error.SERVICE_UNAVAILABLE;
}
// TODO(jamiewalch): Add timeout support.
onError(error);
};
/**
* Get the user's email address.
*
* @param {function(?string):void} setEmail Callback invoked when the email
* address is available, or on error.
* @return {void} Nothing.
*/
remoting.OAuth2.prototype.getEmail = function(setEmail) {
/** @type {remoting.OAuth2} */
var that = this;
/** @param {XMLHttpRequest} xhr The XHR response. */
var onResponse = function(xhr) {
that.email = null;
if (xhr.status == 200) {
// TODO(ajwong): See if we can't find a JSON endpoint.
that.email = xhr.responseText.split('&')[0].split('=')[1];
window.localStorage.setItem(that.KEY_EMAIL_, that.email);
} else {
console.error('Unable to get email address:', xhr.status, xhr);
}
setEmail(that.email);
};
/** @param {string} token The access token. */
var getEmailFromToken = function(token) {
var headers = { 'Authorization': 'OAuth ' + token };
// TODO(ajwong): Update to new v2 API.
remoting.xhr.get('https://www.googleapis.com/userinfo/email',
onResponse, '', headers);
};
/** @param {remoting.Error} error */
var onError = function(error) {
console.error('Unable to get email address: ' + error);
setEmail(null);
};
this.callWithToken(getEmailFromToken, onError);
};
/**
* If the user's email address is cached, return it, otherwise return null.
*
* @return {?string} The email address, if it has been cached by a previous call
* to getEmail, otherwise null.
*/
remoting.OAuth2.prototype.getCachedEmail = function() {
var value = window.localStorage.getItem(this.KEY_EMAIL_);
if (typeof value == 'string') {
return value;
}
return null;
};