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) { ...@@ -16,23 +16,77 @@ function auth($http, settings) {
var accessTokenPromise; var accessTokenPromise;
var tokenUrl = resolve('token', settings.apiUrl); 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. // Exchange the JWT grant token for an access token.
// See https://tools.ietf.org/html/rfc7523#section-4 // See https://tools.ietf.org/html/rfc7523#section-4
function exchangeToken(grantToken) { function exchangeToken(grantToken) {
var data = queryString.stringify({ var data = {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: grantToken, assertion: grantToken,
});
var requestConfig = {
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
}; };
return $http.post(tokenUrl, data, requestConfig) return postToTokenUrl(data).then(function (response) {
.then(function (response) { if (response.status !== 200) {
if (response.status !== 200) { throw new Error('Failed to retrieve access token');
throw new Error('Failed to retrieve access token'); }
} return tokenInfoFrom(response);
return response.data; });
}); }
// 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() { function tokenGetter() {
...@@ -45,7 +99,8 @@ function auth($http, settings) { ...@@ -45,7 +99,8 @@ function auth($http, settings) {
if (grantToken) { if (grantToken) {
accessTokenPromise = exchangeToken(grantToken).then(function (tokenInfo) { accessTokenPromise = exchangeToken(grantToken).then(function (tokenInfo) {
return tokenInfo.access_token; refreshAccessTokenBeforeItExpires(tokenInfo);
return tokenInfo.accessToken;
}); });
} else { } else {
accessTokenPromise = Promise.resolve(null); accessTokenPromise = Promise.resolve(null);
......
...@@ -10,6 +10,7 @@ describe('oauth auth', function () { ...@@ -10,6 +10,7 @@ describe('oauth auth', function () {
var nowStub; var nowStub;
var fakeHttp; var fakeHttp;
var fakeSettings; var fakeSettings;
var clock;
beforeEach(function () { beforeEach(function () {
nowStub = sinon.stub(window.performance, 'now'); nowStub = sinon.stub(window.performance, 'now');
...@@ -19,8 +20,9 @@ describe('oauth auth', function () { ...@@ -19,8 +20,9 @@ describe('oauth auth', function () {
post: sinon.stub().returns(Promise.resolve({ post: sinon.stub().returns(Promise.resolve({
status: 200, status: 200,
data: { data: {
access_token: 'an-access-token', access_token: 'firstAccessToken',
expires_in: DEFAULT_TOKEN_EXPIRES_IN_SECS, expires_in: DEFAULT_TOKEN_EXPIRES_IN_SECS,
refresh_token: 'firstRefreshToken',
}, },
})), })),
}; };
...@@ -34,10 +36,13 @@ describe('oauth auth', function () { ...@@ -34,10 +36,13 @@ describe('oauth auth', function () {
}; };
auth = authService(fakeHttp, fakeSettings); auth = authService(fakeHttp, fakeSettings);
clock = sinon.useFakeTimers();
}); });
afterEach(function () { afterEach(function () {
performance.now.restore(); performance.now.restore();
clock.restore();
}); });
describe('#tokenGetter', function () { describe('#tokenGetter', function () {
...@@ -49,7 +54,7 @@ describe('oauth auth', function () { ...@@ -49,7 +54,7 @@ describe('oauth auth', function () {
assert.calledWith(fakeHttp.post, 'https://hypothes.is/api/token', expectedBody, { assert.calledWith(fakeHttp.post, 'https://hypothes.is/api/token', expectedBody, {
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 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 () { ...@@ -67,10 +72,10 @@ describe('oauth auth', function () {
it('should cache tokens for future use', function () { it('should cache tokens for future use', function () {
return auth.tokenGetter().then(function () { return auth.tokenGetter().then(function () {
fakeHttp.post.reset(); resetHttpSpy();
return auth.tokenGetter(); return auth.tokenGetter();
}).then(function (token) { }).then(function (token) {
assert.equal(token, 'an-access-token'); assert.equal(token, 'firstAccessToken');
assert.notCalled(fakeHttp.post); assert.notCalled(fakeHttp.post);
}); });
}); });
...@@ -80,9 +85,7 @@ describe('oauth auth', function () { ...@@ -80,9 +85,7 @@ describe('oauth auth', function () {
// the pending Promise for the first request again (and not send a second // the pending Promise for the first request again (and not send a second
// concurrent HTTP request). // concurrent HTTP request).
it('should not make two concurrent access token requests', function () { it('should not make two concurrent access token requests', function () {
// Make $http.post() return a pending Promise (simulates an in-flight makeServerUnresponsive();
// HTTP request).
fakeHttp.post.returns(new Promise(function() {}));
// The first time tokenGetter() is called it sends the access token HTTP // The first time tokenGetter() is called it sends the access token HTTP
// request and returns a Promise for the access token. // request and returns a Promise for the access token.
...@@ -109,5 +112,98 @@ describe('oauth auth', function () { ...@@ -109,5 +112,98 @@ describe('oauth auth', function () {
assert.equal(token, null); 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