(function(global)
{
Gitana.OAuth2Http = Gitana.Http.extend(
/** @lends Gitana.OAuth2Http.prototype */
{
/**
* @constructs
*
* @class Gitana.OAuth2Http
*/
constructor: function(options, storage)
{
var self = this;
// storage for OAuth credentials
// this can either be a string ("local", "session", "memory") or a storage instance or empty
// if empty, memory-based storage is assumed
if (storage === null || typeof(storage) === "string")
{
storage = new Gitana.OAuth2Http.Storage(storage);
}
// cookie mode
this.cookieMode = null;
// ticket mode
this.ticketMode = null;
// preset the error state
this.error = null;
this.errorDescription = null;
this.errorUri = null;
// gitana urls
var tokenURL = "/oauth/token";
if (options.tokenURL)
{
tokenURL = options.tokenURL;
}
// base URL?
var baseURL = null;
if (options.baseURL)
{
baseURL = options.baseURL;
}
// client
var clientKey = options.clientKey;
var clientSecret = options.clientSecret;
// authorization flow
// if none specified, assume CODE
this.authorizationFlow = options.authorizationFlow || Gitana.OAuth2Http.AUTHORIZATION_CODE;
// optional
if (options.requestedScope)
{
this.requestedScope = options.requestedScope;
}
if (this.authorizationFlow == Gitana.OAuth2Http.AUTHORIZATION_CODE)
{
this.code = options.code;
this.redirectUri = options.redirectUri;
}
if (this.authorizationFlow == Gitana.OAuth2Http.PASSWORD)
{
this.username = options.username;
if (options.password)
{
this.password = options.password;
}
else
{
this.password = "";
}
}
if (this.authorizationFlow == Gitana.OAuth2Http.COOKIE)
{
this.cookieMode = true;
}
if (this.authorizationFlow == Gitana.OAuth2Http.TICKET)
{
this.ticketMode = options.ticket;
}
this.ticketMaxAge = options.ticketMaxAge;
////////////////////////////////////////////////////////////////////////////////////////////////
//
// ACCESSORS
//
////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Clears persisted storage of auth data
*/
this.clearStorage = function()
{
storage.clear();
};
/**
* Gets or saves the access token
*
* @param value [String] optional value
*/
this.accessToken = function(value)
{
return storage.poke("accessToken", value);
};
/**
* Gets or saves the refresh token
*
* @param value [String] optional value
*/
this.refreshToken = function(value)
{
return storage.poke("refreshToken", value);
};
/**
* Gets or saves the granted scope
*
* @param value [String] optional value
*/
this.grantedScope = function(value)
{
return storage.poke("grantedScope", value);
};
/**
* Gets or saves the expires in value
*
* @param value [String] optional value
*/
this.expiresIn = function(value)
{
return storage.poke("expiresIn", value);
};
/**
* Gets or saves the grant time
*
* @param value [String] optional value
*/
this.grantTime = function(value)
{
return storage.poke("grantTime", value);
};
this.getClientAuthorizationHeader = function() {
var basicString = clientKey + ":";
if (clientSecret)
{
basicString += clientSecret;
}
return "Basic " + Gitana.btoa(basicString);
};
this.getBearerAuthorizationHeader = function()
{
return "Bearer " + self.accessToken();
};
this.getPrefixedTokenURL = function()
{
return this.getPrefixedURL(tokenURL);
};
this.getPrefixedURL = function(url)
{
var rebasedURL = url;
if (baseURL && Gitana.startsWith(url, "/"))
{
rebasedURL = baseURL + url;
}
return rebasedURL;
};
// if they initiatialized with an access token, clear and write into persisted state
// unless they're continuing an existing token
if (this.authorizationFlow == Gitana.OAuth2Http.TOKEN)
{
var existingAccessToken = this.accessToken();
if (existingAccessToken !== options.accessToken)
{
storage.clear();
}
this.accessToken(existingAccessToken);
}
this.base();
},
/**
* Performs an HTTP call using OAuth2.
*
* @param options
*/
request: function(options)
{
var self = this;
/**
* Call over to Gitana and acquires an access token using flow authorization.
*
* @param success
* @param failure
*/
var doGetAccessToken = function(success, failure)
{
var onSuccess = function(response)
{
var object = JSON.parse(response.text);
if (response["error"])
{
self.error = object["error"];
self.errorDescription = object["error_description"];
self.errorUri = object["error_uri"];
}
else
{
var _accessToken = object["access_token"];
var _refreshToken = object["refresh_token"];
var _expiresIn = object["expires_in"];
var _grantedScope = object["scope"];
var _grantTime = new Date().getTime();
// store into persistent storage
self.clearStorage();
self.accessToken(_accessToken);
self.refreshToken(_refreshToken);
self.expiresIn(_expiresIn);
self.grantedScope(_grantedScope);
self.grantTime(_grantTime);
// console.log("doGetAccessToken -> " + JSON.stringify(object));
}
success();
};
var onFailure = function(http, xhr) {
failure(http, xhr);
};
var o = {
success: onSuccess,
failure: onFailure,
headers: {
"Authorization": self.getClientAuthorizationHeader()
},
url: self.getPrefixedTokenURL(),
method: Gitana.OAuth2Http.TOKEN_METHOD
};
// query string
var qs = {};
// json payload
qs["grant_type"] = self.authorizationFlow;
if (self.requestedScope) {
qs["scope"] = self.requestedScope;
}
if (self.authorizationFlow === Gitana.OAuth2Http.AUTHORIZATION_CODE)
{
qs["code"] = self.code;
if (self.redirectUri) {
qs["redirect_uri"] = self.redirectUri;
}
}
else if (self.authorizationFlow === Gitana.OAuth2Http.PASSWORD)
{
qs["username"] = self.username;
qs["password"] = self.password;
}
// ticket max age
if (self.ticketMaxAge)
{
qs["ticketMaxAge"] = self.ticketMaxAge;
}
// if we're POSTing, do so as application/x-www-form-urlencoded to make secure over the wire
if ("post" === Gitana.OAuth2Http.TOKEN_METHOD.toLowerCase())
{
o.headers["Content-Type"] = "application/x-www-form-urlencoded";
}
// append into query string
var queryString = Gitana.Http.toQueryString(qs);
if (queryString)
{
if (o.url.indexOf("?") > -1)
{
o.url = o.url + "&" + queryString;
}
else
{
o.url = o.url + "?" + queryString;
}
}
self.invoke(o);
};
/**
* Calls over to Gitana and acquires an access token using an existing refresh token.
*
* @param success
* @param failure
*/
var doRefreshAccessToken = function(success, failure)
{
var onSuccess = function(response)
{
var object = JSON.parse(response.text);
if (response["error"])
{
self.error = object["error"];
self.errorDescription = object["error_description"];
self.errorUri = object["error_uri"];
}
else
{
var _accessToken = object["access_token"];
var _refreshToken = object["refresh_token"];
var _expiresIn = object["expires_in"];
//self.grantedScope = object["scope"]; // this doesn't come back on refresh, assumed the same
var _grantTime = new Date().getTime();
var _grantedScope = self.grantedScope();
// store into persistent storage
self.clearStorage();
self.accessToken(_accessToken);
self.refreshToken(_refreshToken);
self.expiresIn(_expiresIn);
self.grantedScope(_grantedScope);
self.grantTime(_grantTime);
// console.log("doRefreshAccessToken -> " + JSON.stringify(object));
}
success();
};
var onFailure = function(http, xhr) {
Gitana.REFRESH_TOKEN_FAILURE_FN(self, http, xhr);
failure(http, xhr);
};
var o = {
success: onSuccess,
failure: onFailure,
headers: {
"Authorization": self.getClientAuthorizationHeader()
},
url: self.getPrefixedTokenURL(),
method: Gitana.OAuth2Http.TOKEN_METHOD
};
// query string
var qs = {};
// json payload
qs["grant_type"] = "refresh_token";
qs["refresh_token"] = self.refreshToken();
if (self.requestedScope)
{
qs["scope"] = self.requestedScope;
}
// ticket max age
if (self.ticketMaxAge)
{
qs["ticketMaxAge"] = self.ticketMaxAge;
}
// if we're POSTing, do so as application/x-www-form-urlencoded to make secure over the wire
if ("post" === Gitana.OAuth2Http.TOKEN_METHOD.toLowerCase())
{
o.headers["Content-Type"] = "application/x-www-form-urlencoded";
}
// append into query string
var queryString = Gitana.Http.toQueryString(qs);
if (queryString)
{
if (o.url.indexOf("?") > -1)
{
o.url = o.url + "&" + queryString;
}
else
{
o.url = o.url + "?" + queryString;
}
}
self.invoke(o);
};
var doCall = function(autoAttemptRefresh)
{
var successHandler = function(response)
{
options.success(response);
};
var failureHandler = function(http, xhr)
{
if (autoAttemptRefresh)
{
// there are a few good reasons why this might naturally fail
//
// 1. our access token is invalid, has expired or has been forcefully invalidated on the Cloud CMS server
// in this case, we get back a 200 and something like http.text =
// {"error":"invalid_token","error_description":"Invalid access token: blahblahblah"}
//
// 2. the access token no longer permits access to the resource
// in this case, we get back a 401
// it might not make much sense to re-request a new access token, but we do just in case.
var notJson = false;
var isInvalidToken = false;
if (http.text)
{
var responseData = {};
// catch if http.text is not JSON
try
{
responseData = JSON.parse(http.text);
}
catch (e)
{
console.log("Error response is not json");
console.log(e);
notJson = true;
}
if (responseData.error)
{
if (responseData.error == "invalid_token")
{
isInvalidToken = true;
}
}
}
var is401 = (http.code == 401);
var is400 = (http.code == 400);
var is403 = (http.code == 403);
// handle both cases
if (is401 || is400 || is403 || isInvalidToken || notJson)
{
if (self.refreshToken())
{
// use the refresh token to acquire a new access token
doRefreshAccessToken(function() {
// success, got a new access token
doCall(false);
}, function() {
// failure, nothing else we can do
// call into intended failure handler with the original failure http object
options.failure(http, xhr);
});
}
else
{
// fail case - nothing we can do
options.failure(http, xhr);
}
}
else
{
// some other kind of error
options.failure(http, xhr);
}
}
else
{
// we aren't allowed to automatically attempt to get a new token via refresh token
options.failure(http, xhr);
}
};
// call through to the protected resource (with custom success/failure handling)
var o = {};
Gitana.copyInto(o, options);
o.success = successHandler;
o.failure = failureHandler;
if (!o.headers)
{
o.headers = {};
}
if (!self.cookieMode && !self.ticketMode)
{
o.headers["Authorization"] = self.getBearerAuthorizationHeader();
}
if (self.ticketMode)
{
o.headers["GITANA_TICKET"] = encodeURIComponent(self.ticketMode);
}
o.url = self.getPrefixedURL(o.url);
// make the call
self.invoke(o);
};
// if we have an access token and it's about to expire (within 20 seconds of it's expiry),
// we force an early refresh of the access token so that concurrent requests don't get access problems
// this is important for any browser-originated requests that rely on a persisted cookie (GITANA_TICKET)
//
// also provide some debugging if needed
var forceRefresh = false;
if (self.accessToken())
{
var grantTime = self.grantTime();
if (grantTime)
{
var expiresIn = self.expiresIn();
if (expiresIn)
{
// NOTE: expiresIn is in seconds
var expirationTimeMs = self.grantTime() + (self.expiresIn() * 1000);
var nowTimeMs = new Date().getTime();
var timeRemainingMs = expirationTimeMs - nowTimeMs;
if (timeRemainingMs <= 0)
{
// console.log("Access Token is expired, refresh will be attempted!");
}
else
{
// console.log("Access Token Time Remaining: " + timeRemainingMs);
}
if (timeRemainingMs <= 20000)
{
// console.log("Access Token only has 20 seconds left, forcing early refresh");
forceRefresh = true;
}
}
}
}
// if no access token, request one
if ((!self.accessToken() || forceRefresh) && !this.cookieMode && !this.ticketMode)
{
if (!self.refreshToken())
{
// no refresh token, do an authorization call
doGetAccessToken(function() {
// got an access token, so proceed
doCall(true);
}, function(http, xhr) {
// access denied
options.failure(http, xhr);
});
}
else
{
// we have a refresh token, so do a refresh call
doRefreshAccessToken(function() {
// got an access token, so proceed
doCall(true);
}, function(http, xhr) {
// unable to get an access token
options.failure(http, xhr);
});
}
}
else
{
// we already have an access token
doCall(true);
}
},
/**
* Refreshes the OAuth2 access token.
*/
refresh: function(callback)
{
var self = this;
var onSuccess = function(response)
{
var object = JSON.parse(response.text);
if (response["error"])
{
self.error = object["error"];
self.errorDescription = object["error_description"];
self.errorUri = object["error_uri"];
callback({
"error": self.error,
"message": self.errorDescription
});
}
else
{
var _accessToken = object["access_token"];
var _refreshToken = object["refresh_token"];
var _expiresIn = object["expires_in"];
//self.grantedScope = object["scope"]; // this doesn't come back on refresh, assumed the same
var _grantTime = new Date().getTime();
var _grantedScope = self.grantedScope();
// store into persistent storage
self.clearStorage();
self.accessToken(_accessToken);
self.refreshToken(_refreshToken);
self.expiresIn(_expiresIn);
self.grantedScope(_grantedScope);
self.grantTime(_grantTime);
callback();
}
};
var onFailure = function(http, xhr)
{
Gitana.REFRESH_TOKEN_FAILURE_FN(self, http, xhr);
callback({
"message": "Unable to refresh access token"
});
};
var o = {
success: onSuccess,
failure: onFailure,
headers: {
"Authorization": self.getClientAuthorizationHeader()
},
url: self.getPrefixedTokenURL(),
method: Gitana.OAuth2Http.TOKEN_METHOD
};
// query string
var qs = {};
// json payload
qs["grant_type"] = "refresh_token";
qs["refresh_token"] = self.refreshToken();
if (self.requestedScope)
{
qs["scope"] = self.requestedScope;
}
// ticket max age
if (self.ticketMaxAge)
{
qs["ticketMaxAge"] = self.ticketMaxAge;
}
// if we're POSTing, do so as application/x-www-form-urlencoded to make secure over the wire
if ("post" === Gitana.OAuth2Http.TOKEN_METHOD.toLowerCase())
{
o.headers["Content-Type"] = "application/x-www-form-urlencoded";
}
// append into query string
var queryString = Gitana.Http.toQueryString(qs);
if (queryString)
{
if (o.url.indexOf("?") > -1)
{
o.url = o.url + "&" + queryString;
}
else
{
o.url = o.url + "?" + queryString;
}
}
self.invoke(o);
}
});
/**
* Provides a storage location for OAuth2 credentials
*
* @param type
* @param scope
*
* @return storage instance
* @constructor
*/
Gitana.OAuth2Http.Storage = function(scope)
{
// in-memory implementation of HTML5 storage interface
var memoryStorage = function() {
var memory = {};
var m = {};
m.removeItem = function(key)
{
delete memory[key];
};
m.getItem = function(key)
{
return memory[key];
};
m.setItem = function(key, value)
{
memory[key] = value;
};
return m;
}();
/**
* Determines whether the current runtime environment supports HTML5 local storage
*
* @return {Boolean}
*/
var supportsLocalStorage = function()
{
try {
return 'localStorage' in window && window['localStorage'] !== null;
} catch (e) {
return false;
}
};
/**
* Determines whether the current runtime environment supports HTML5 session storage.
*
* @return {Boolean}
*/
var supportsSessionStorage = function()
{
try {
return 'sessionStorage' in window && window['sessionStorage'] !== null;
} catch (e) {
return false;
}
};
var acquireStorage = function()
{
var storage = null;
// store
if (scope == "session" && supportsSessionStorage())
{
storage = sessionStorage;
}
else if (scope == "local" && supportsLocalStorage())
{
storage = localStorage;
}
else
{
// fall back to memory-based storage
storage = memoryStorage;
}
return storage;
};
// result object
var r = {};
/**
* Clears state.
*/
r.clear = function()
{
acquireStorage().removeItem("gitanaAuthState");
};
/**
* Pokes and peeks the value of a key in the state.
*
* @param key
* @param value
*
* @return {*}
*/
r.poke = function(key, value)
{
var state = {};
var stateString = acquireStorage().getItem("gitanaAuthState");
if (stateString) {
state = JSON.parse(stateString);
}
var touch = false;
if (typeof(value) !== "undefined" && value !== null)
{
state[key] = value;
touch = true;
}
else if (value === null)
{
delete state[key];
touch = true;
}
if (touch) {
acquireStorage().setItem("gitanaAuthState", JSON.stringify(state));
}
return state[key];
};
return r;
};
}(this));
// statics
Gitana.OAuth2Http.PASSWORD = "password";
Gitana.OAuth2Http.AUTHORIZATION_CODE = "authorization_code";
Gitana.OAuth2Http.TOKEN = "token";
Gitana.OAuth2Http.COOKIE = "cookie";
Gitana.OAuth2Http.TICKET = "ticket";
// method to use for retrieving access and refresh tokens
//Gitana.OAuth2Http.TOKEN_METHOD = "GET";
Gitana.OAuth2Http.TOKEN_METHOD = "POST";