Commit 1118937d authored by Sean Hammond's avatar Sean Hammond Committed by GitHub

Merge pull request #221 from hypothesis/add-oauth-refresh-support-2

Add OAuth refresh support
parents 29af928e 37eacfc1
......@@ -16,23 +16,77 @@ function auth($http, settings) {
var accessTokenPromise;
var tokenUrl = resolve('token', settings.apiUrl);
/**
* An object holding the details of an access token from the tokenUrl endpoint.
* @typedef {Object} TokenInfo
* @property {string} accessToken - The access token itself.
* @property {number} expiresIn - The lifetime of the access token,
* in seconds.
* @property {string} refreshToken - The refresh token that can be used to
* get a new access token.
*/
/**
* Return a new TokenInfo object from the given tokenUrl endpoint response.
* @param {Object} response - The HTTP response from a POST to the tokenUrl
* endpoint (an Angular $http response object).
* @returns {TokenInfo}
*/
function tokenInfoFrom(response) {
var data = response.data;
return {
accessToken: data.access_token,
expiresIn: data.expires_in,
refreshToken: data.refresh_token,
};
}
// Post the given data to the tokenUrl endpoint as a form submission.
// Return a Promise for the access token response.
function postToTokenUrl(data) {
data = queryString.stringify(data);
var requestConfig = {
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
};
return $http.post(tokenUrl, data, requestConfig);
}
// Exchange the JWT grant token for an access token.
// See https://tools.ietf.org/html/rfc7523#section-4
function exchangeToken(grantToken) {
var data = queryString.stringify({
var data = {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: grantToken,
});
var requestConfig = {
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
};
return $http.post(tokenUrl, data, requestConfig)
.then(function (response) {
if (response.status !== 200) {
throw new Error('Failed to retrieve access token');
}
return response.data;
});
return postToTokenUrl(data).then(function (response) {
if (response.status !== 200) {
throw new Error('Failed to retrieve access token');
}
return tokenInfoFrom(response);
});
}
// Exchange the refresh token for a new access token and refresh token pair.
// See https://tools.ietf.org/html/rfc6749#section-6
function refreshAccessToken(refreshToken) {
var data = {grant_type: 'refresh_token', refresh_token: refreshToken};
postToTokenUrl(data).then(function (response) {
var tokenInfo = tokenInfoFrom(response);
refreshAccessTokenBeforeItExpires(tokenInfo);
accessTokenPromise = Promise.resolve(tokenInfo.accessToken);
});
}
// Set a timeout to refresh the access token a few minutes before it expires.
function refreshAccessTokenBeforeItExpires(tokenInfo) {
var delay = tokenInfo.expiresIn * 1000;
// We actually have to refresh the access token _before_ it expires.
// If the access token expires in one hour, this should refresh it in
// about 55 mins.
delay = Math.floor(delay * 0.91);
window.setTimeout(refreshAccessToken, delay, tokenInfo.refreshToken);
}
function tokenGetter() {
......@@ -45,7 +99,8 @@ function auth($http, settings) {
if (grantToken) {
accessTokenPromise = exchangeToken(grantToken).then(function (tokenInfo) {
return tokenInfo.access_token;
refreshAccessTokenBeforeItExpires(tokenInfo);
return tokenInfo.accessToken;
});
} else {
accessTokenPromise = Promise.resolve(null);
......
......@@ -10,6 +10,7 @@ describe('oauth auth', function () {
var nowStub;
var fakeHttp;
var fakeSettings;
var clock;
beforeEach(function () {
nowStub = sinon.stub(window.performance, 'now');
......@@ -19,8 +20,9 @@ describe('oauth auth', function () {
post: sinon.stub().returns(Promise.resolve({
status: 200,
data: {
access_token: 'an-access-token',
access_token: 'firstAccessToken',
expires_in: DEFAULT_TOKEN_EXPIRES_IN_SECS,
refresh_token: 'firstRefreshToken',
},
})),
};
......@@ -34,10 +36,13 @@ describe('oauth auth', function () {
};
auth = authService(fakeHttp, fakeSettings);
clock = sinon.useFakeTimers();
});
afterEach(function () {
performance.now.restore();
clock.restore();
});
describe('#tokenGetter', function () {
......@@ -49,7 +54,7 @@ describe('oauth auth', function () {
assert.calledWith(fakeHttp.post, 'https://hypothes.is/api/token', expectedBody, {
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
});
assert.equal(token, 'an-access-token');
assert.equal(token, 'firstAccessToken');
});
});
......@@ -67,10 +72,10 @@ describe('oauth auth', function () {
it('should cache tokens for future use', function () {
return auth.tokenGetter().then(function () {
fakeHttp.post.reset();
resetHttpSpy();
return auth.tokenGetter();
}).then(function (token) {
assert.equal(token, 'an-access-token');
assert.equal(token, 'firstAccessToken');
assert.notCalled(fakeHttp.post);
});
});
......@@ -80,9 +85,7 @@ describe('oauth auth', function () {
// the pending Promise for the first request again (and not send a second
// concurrent HTTP request).
it('should not make two concurrent access token requests', function () {
// Make $http.post() return a pending Promise (simulates an in-flight
// HTTP request).
fakeHttp.post.returns(new Promise(function() {}));
makeServerUnresponsive();
// The first time tokenGetter() is called it sends the access token HTTP
// request and returns a Promise for the access token.
......@@ -109,5 +112,98 @@ describe('oauth auth', function () {
assert.equal(token, null);
});
});
it('should refresh the access token before it expires', function () {
function callTokenGetter () {
var tokenPromise = auth.tokenGetter();
fakeHttp.post.returns(Promise.resolve({
status: 200,
data: {
access_token: 'secondAccessToken',
expires_in: DEFAULT_TOKEN_EXPIRES_IN_SECS,
refresh_token: 'secondRefreshToken',
},
}));
return tokenPromise;
}
function assertRefreshTokenWasUsed (refreshToken) {
return function() {
var expectedBody =
'grant_type=refresh_token&refresh_token=' + refreshToken;
assert.calledWith(
fakeHttp.post,
'https://hypothes.is/api/token',
expectedBody,
{headers: {'Content-Type': 'application/x-www-form-urlencoded'},
});
};
}
function assertThatTokenGetterNowReturnsNewAccessToken () {
return auth.tokenGetter().then(function (token) {
assert.equal(token, 'secondAccessToken');
});
}
return callTokenGetter()
.then(resetHttpSpy)
.then(expireAccessToken)
.then(assertRefreshTokenWasUsed('firstRefreshToken'))
.then(resetHttpSpy)
.then(assertThatTokenGetterNowReturnsNewAccessToken)
.then(expireAccessToken)
.then(assertRefreshTokenWasUsed('secondRefreshToken'));
});
// While a refresh token HTTP request is in-flight, calls to tokenGetter()
// should just return the old access token immediately.
it('returns the access token while a refresh is in-flight', function() {
return auth.tokenGetter().then(function(firstAccessToken) {
makeServerUnresponsive();
expireAccessToken();
// The refresh token request will still be in-flight, but tokenGetter()
// should still return a Promise for the old access token.
return auth.tokenGetter().then(function(secondAccessToken) {
assert.equal(firstAccessToken, secondAccessToken);
});
});
});
// It only sends one refresh request, even if tokenGetter() is called
// multiple times and the refresh response hasn't come back yet.
it('does not send more than one refresh request', function () {
return auth.tokenGetter()
.then(resetHttpSpy) // Reset fakeHttp.post.callCount to 0 so that the
// initial access token request isn't counted.
.then(auth.tokenGetter)
.then(makeServerUnresponsive)
.then(auth.tokenGetter)
.then(expireAccessToken)
.then(function () {
assert.equal(fakeHttp.post.callCount, 1);
});
});
});
// Advance time forward so that any current access tokens will have expired.
function expireAccessToken () {
clock.tick(DEFAULT_TOKEN_EXPIRES_IN_SECS * 1000);
}
// Make $http.post() return a pending Promise (simulates a still in-flight
// HTTP request).
function makeServerUnresponsive () {
fakeHttp.post.returns(new Promise(function () {}));
}
// Reset fakeHttp.post.callCount and other fields.
function resetHttpSpy () {
fakeHttp.post.reset();
}
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment