Commit d4893e60 authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #517 from hypothesis/oauth-simplify-token-refresh

Remove logic to refresh access token before it expires.
parents 289c322c 28bdb52f
...@@ -11,6 +11,15 @@ var serviceConfig = require('./service-config'); ...@@ -11,6 +11,15 @@ var serviceConfig = require('./service-config');
* use in future sessions. * use in future sessions.
*/ */
/**
* 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} expiresAt - The date when the timestamp will expire.
* @property {string} refreshToken - The refresh token that can be used to
* get a new access token.
*/
/** /**
* OAuth-based authorization service. * OAuth-based authorization service.
* *
...@@ -27,10 +36,17 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -27,10 +36,17 @@ function auth($http, $window, flash, localStorage, random, settings) {
var authCode; var authCode;
/** /**
* Access token retrieved via `POST /token` endpoint. * Token info retrieved via `POST /api/token` endpoint.
* @type {Promise<string>} *
* Resolves to `null` if the user is not logged in.
*
* @type {Promise<TokenInfo|null>}
*/
var tokenInfoPromise;
/**
* Absolute URL of the `/api/token` endpoint.
*/ */
var accessTokenPromise;
var tokenUrl = resolve('token', settings.apiUrl); var tokenUrl = resolve('token', settings.apiUrl);
/** /**
...@@ -53,15 +69,6 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -53,15 +69,6 @@ function auth($http, $window, flash, localStorage, random, settings) {
); );
} }
/**
* 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} expiresAt - The date when the timestamp will expire.
* @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. * Return a new TokenInfo object from the given tokenUrl endpoint response.
* @param {Object} response - The HTTP response from a POST to the tokenUrl * @param {Object} response - The HTTP response from a POST to the tokenUrl
...@@ -73,9 +80,10 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -73,9 +80,10 @@ function auth($http, $window, flash, localStorage, random, settings) {
return { return {
accessToken: data.access_token, accessToken: data.access_token,
// Set the expiry date to some time before the actual expiry date so that // Set the expiry date to some time slightly before that implied by
// we will refresh it before it actually expires. // `expires_in` to account for the delay in the client receiving the
expiresAt: Date.now() + (data.expires_in * 1000 * 0.91), // response.
expiresAt: Date.now() + ((data.expires_in - 10) * 1000),
refreshToken: data.refresh_token, refreshToken: data.refresh_token,
}; };
...@@ -181,7 +189,7 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -181,7 +189,7 @@ function auth($http, $window, flash, localStorage, random, settings) {
* *
* @param {string} refreshToken * @param {string} refreshToken
* @param {RefreshOptions} options * @param {RefreshOptions} options
* @return {Promise<string|null>} Promise for the new access token * @return {Promise<TokenInfo>} Promise for the new access token
*/ */
function refreshAccessToken(refreshToken, options) { function refreshAccessToken(refreshToken, options) {
var data = { grant_type: 'refresh_token', refresh_token: refreshToken }; var data = { grant_type: 'refresh_token', refresh_token: refreshToken };
...@@ -192,61 +200,23 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -192,61 +200,23 @@ function auth($http, $window, flash, localStorage, random, settings) {
saveToken(tokenInfo); saveToken(tokenInfo);
} }
refreshAccessTokenBeforeItExpires(tokenInfo, { return tokenInfo;
persist: options.persist,
});
accessTokenPromise = Promise.resolve(tokenInfo.accessToken);
return tokenInfo.accessToken;
}).catch(function() {
showAccessTokenExpiredErrorMessage(
'You must reload the page to continue annotating.');
return null;
}); });
} }
/**
* Schedule a refresh of an access token a few minutes before it expires.
*
* @param {TokenInfo} tokenInfo
* @param {RefreshOptions} options
*/
function refreshAccessTokenBeforeItExpires(tokenInfo, options) {
// The delay, in milliseconds, before we will poll again to see if it's
// time to refresh the access token.
var delay = 30000;
// If the token info's refreshAfter time will have passed before the next
// time we poll, then refresh the token this time.
var refreshAfter = tokenInfo.expiresAt - delay;
function refreshAccessTokenIfNearExpiry() {
if (Date.now() > refreshAfter) {
refreshAccessToken(tokenInfo.refreshToken, {
persist: options.persist,
});
} else {
refreshAccessTokenBeforeItExpires(tokenInfo, options);
}
}
refreshTimer = $window.setTimeout(refreshAccessTokenIfNearExpiry, delay);
}
/** /**
* Retrieve an access token for the API. * Retrieve an access token for the API.
* *
* @return {Promise<string>} The API access token. * @return {Promise<string>} The API access token.
*/ */
function tokenGetter() { function tokenGetter() {
if (!accessTokenPromise) { if (!tokenInfoPromise) {
var grantToken = grantTokenFromHostPage(); var grantToken = grantTokenFromHostPage();
if (grantToken) { if (grantToken) {
// Exchange host-page provided grant token for a new access token. // Exchange host-page provided grant token for a new access token.
accessTokenPromise = exchangeJWT(grantToken).then((tokenInfo) => { tokenInfoPromise = exchangeJWT(grantToken).then((tokenInfo) => {
refreshAccessTokenBeforeItExpires(tokenInfo, { persist: false }); return tokenInfo;
return tokenInfo.accessToken;
}).catch(function(err) { }).catch(function(err) {
showAccessTokenExpiredErrorMessage( showAccessTokenExpiredErrorMessage(
'You must reload the page to annotate.'); 'You must reload the page to annotate.');
...@@ -255,31 +225,44 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -255,31 +225,44 @@ function auth($http, $window, flash, localStorage, random, settings) {
} else if (authCode) { } else if (authCode) {
// Exchange authorization code retrieved from login popup for a new // Exchange authorization code retrieved from login popup for a new
// access token. // access token.
accessTokenPromise = exchangeAuthCode(authCode).then((tokenInfo) => { tokenInfoPromise = exchangeAuthCode(authCode).then((tokenInfo) => {
saveToken(tokenInfo); saveToken(tokenInfo);
refreshAccessTokenBeforeItExpires(tokenInfo, { persist: true }); return tokenInfo;
return tokenInfo.accessToken;
}); });
} else { } else {
// Attempt to load the tokens from the previous session. // Attempt to load the tokens from the previous session.
var tokenInfo = loadToken(); tokenInfoPromise = Promise.resolve(loadToken());
if (!tokenInfo) {
// No token. The user will need to log in.
accessTokenPromise = Promise.resolve(null);
} else if (Date.now() > tokenInfo.expiresAt) {
// Token has expired. Attempt to refresh it.
accessTokenPromise = refreshAccessToken(tokenInfo.refreshToken, {
persist: true,
});
} else {
// Token still valid, but schedule a refresh.
refreshAccessTokenBeforeItExpires(tokenInfo, { persist: true });
accessTokenPromise = Promise.resolve(tokenInfo.accessToken);
} }
} }
var origToken = tokenInfoPromise;
return tokenInfoPromise.then(token => {
if (!token) {
// No token available. User will need to log in.
return null;
} }
return accessTokenPromise; if (origToken !== tokenInfoPromise) {
// A token refresh has been initiated via a call to `refreshAccessToken`
// below since `tokenGetter()` was called.
return tokenGetter();
}
if (Date.now() > token.expiresAt) {
// Token expired. Attempt to refresh.
tokenInfoPromise = refreshAccessToken(token.refreshToken, {
persist: true,
}).catch(() => {
// If refreshing the token fails, the user is simply logged out.
return null;
});
return tokenGetter();
} else {
return token.accessToken;
}
});
} }
/** /**
...@@ -288,7 +271,7 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -288,7 +271,7 @@ function auth($http, $window, flash, localStorage, random, settings) {
function clearCache() { function clearCache() {
// Once cookie auth has been removed, the `clearCache` method can be removed // Once cookie auth has been removed, the `clearCache` method can be removed
// from the public API of this service in favor of `logout`. // from the public API of this service in favor of `logout`.
accessTokenPromise = Promise.resolve(null); tokenInfoPromise = Promise.resolve(null);
localStorage.removeItem(storageKey()); localStorage.removeItem(storageKey());
$window.clearTimeout(refreshTimer); $window.clearTimeout(refreshTimer);
} }
...@@ -355,7 +338,7 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -355,7 +338,7 @@ function auth($http, $window, flash, localStorage, random, settings) {
// Save the auth code. It will be exchanged for an access token when the // Save the auth code. It will be exchanged for an access token when the
// next API request is made. // next API request is made.
authCode = resp.code; authCode = resp.code;
accessTokenPromise = null; tokenInfoPromise = null;
}); });
} }
...@@ -365,9 +348,9 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -365,9 +348,9 @@ function auth($http, $window, flash, localStorage, random, settings) {
* This revokes and then forgets any OAuth credentials that the user has. * This revokes and then forgets any OAuth credentials that the user has.
*/ */
function logout() { function logout() {
return accessTokenPromise.then(accessToken => { return tokenInfoPromise.then(token => {
return formPost(settings.oauthRevokeUrl, { return formPost(settings.oauthRevokeUrl, {
token: accessToken, token: token.accessToken,
}); });
}).then(() => { }).then(() => {
clearCache(); clearCache();
......
...@@ -208,22 +208,27 @@ describe('sidebar.oauth-auth', function () { ...@@ -208,22 +208,27 @@ describe('sidebar.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 () {
makeServerUnresponsive(); var respond;
fakeHttp.post.returns(new Promise(resolve => {
respond = resolve;
}));
// 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.
var firstAccessTokenPromise = auth.tokenGetter(); var tokens = [auth.tokenGetter(), auth.tokenGetter()];
// No matter how many times it's called while there's an HTTP request
// in-flight, tokenGetter() never sends a second concurrent HTTP request.
auth.tokenGetter();
auth.tokenGetter();
// It just keeps on returning the same Promise for the access token.
var accessTokenPromise = auth.tokenGetter();
assert.strictEqual(accessTokenPromise, firstAccessTokenPromise);
assert.equal(fakeHttp.post.callCount, 1); assert.equal(fakeHttp.post.callCount, 1);
// Resolve the initial request for an access token in exchange for a JWT.
respond({
status: 200,
data: {
access_token: 'foo',
refresh_token: 'bar',
expires_in: 3600,
},
});
return Promise.all(tokens);
}); });
it('should return null if no grant token was provided', function () { it('should return null if no grant token was provided', function () {
...@@ -234,7 +239,7 @@ describe('sidebar.oauth-auth', function () { ...@@ -234,7 +239,7 @@ describe('sidebar.oauth-auth', function () {
}); });
}); });
it('should refresh the access token before it expires', function () { it('should refresh the access token if it expired', function () {
function callTokenGetter () { function callTokenGetter () {
var tokenPromise = auth.tokenGetter(); var tokenPromise = auth.tokenGetter();
...@@ -264,49 +269,42 @@ describe('sidebar.oauth-auth', function () { ...@@ -264,49 +269,42 @@ describe('sidebar.oauth-auth', function () {
}; };
} }
function assertThatTokenGetterNowReturnsNewAccessToken () {
return auth.tokenGetter().then(function (token) {
assert.equal(token, 'secondAccessToken');
});
}
return callTokenGetter() return callTokenGetter()
.then(resetHttpSpy) .then(resetHttpSpy)
.then(expireAccessToken) .then(expireAccessToken)
.then(assertRefreshTokenWasUsed('firstRefreshToken')) .then(() => auth.tokenGetter())
.then(resetHttpSpy) .then(token => assert.equal(token, 'secondAccessToken'))
.then(assertThatTokenGetterNowReturnsNewAccessToken) .then(assertRefreshTokenWasUsed('firstRefreshToken'));
.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 // It only sends one refresh request, even if tokenGetter() is called
// multiple times and the refresh response hasn't come back yet. // multiple times and the refresh response hasn't come back yet.
it('does not send more than one refresh request', function () { it('does not send more than one refresh request', function () {
// Perform an initial token fetch which will exchange the JWT grant for an
// access token.
return auth.tokenGetter() return auth.tokenGetter()
.then(resetHttpSpy) // Reset fakeHttp.post.callCount to 0 so that the .then(() => {
// initial access token request isn't counted. // Expire the access token to trigger a refresh request on the next
.then(auth.tokenGetter) // token fetch.
.then(makeServerUnresponsive) fakeHttp.post.reset();
.then(auth.tokenGetter) expireAccessToken();
.then(expireAccessToken)
.then(function () { // Delay the response to the refresh request.
var respond;
fakeHttp.post.returns(new Promise(resolve => {
respond = resolve;
}));
// Request an auth token multiple times.
var tokens = Promise.all([auth.tokenGetter(), auth.tokenGetter()]);
// Finally, respond to the refresh request.
respond({ access_token: 'a_new_token', refresh_token: 'a_delayed_token', expires_in: 3600 });
return tokens;
})
.then(() => {
// Check that only one refresh request was made.
assert.equal(fakeHttp.post.callCount, 1); assert.equal(fakeHttp.post.callCount, 1);
}); });
}); });
...@@ -321,19 +319,12 @@ describe('sidebar.oauth-auth', function () { ...@@ -321,19 +319,12 @@ describe('sidebar.oauth-auth', function () {
}; };
}); });
it('shows an error message to the user', function () { it('logs the user out', function () {
function assertThatErrorMessageWasShown() { expireAccessToken();
assert.calledOnce(fakeFlash.error);
assert.equal(
fakeFlash.error.firstCall.args[0],
'You must reload the page to continue annotating.'
);
}
return auth.tokenGetter() return auth.tokenGetter(token => {
.then(expireAccessToken) assert.equal(token, null);
.then(function () { clock.tick(1); }) });
.then(assertThatErrorMessageWasShown);
}); });
}); });
}); });
...@@ -350,7 +341,7 @@ describe('sidebar.oauth-auth', function () { ...@@ -350,7 +341,7 @@ describe('sidebar.oauth-auth', function () {
assert.calledWith(fakeLocalStorage.setObject, TOKEN_KEY, { assert.calledWith(fakeLocalStorage.setObject, TOKEN_KEY, {
accessToken: 'firstAccessToken', accessToken: 'firstAccessToken',
refreshToken: 'firstRefreshToken', refreshToken: 'firstRefreshToken',
expiresAt: 910000, expiresAt: 990000,
}); });
}); });
}); });
...@@ -377,7 +368,7 @@ describe('sidebar.oauth-auth', function () { ...@@ -377,7 +368,7 @@ describe('sidebar.oauth-auth', function () {
assert.calledWith(fakeLocalStorage.setObject, TOKEN_KEY, { assert.calledWith(fakeLocalStorage.setObject, TOKEN_KEY, {
accessToken: 'secondToken', accessToken: 'secondToken',
refreshToken: 'secondRefreshToken', refreshToken: 'secondRefreshToken',
expiresAt: 1910000, expiresAt: 1990000,
}); });
}); });
}); });
...@@ -421,7 +412,7 @@ describe('sidebar.oauth-auth', function () { ...@@ -421,7 +412,7 @@ describe('sidebar.oauth-auth', function () {
{ {
accessToken: 'secondToken', accessToken: 'secondToken',
refreshToken: 'secondRefreshToken', refreshToken: 'secondRefreshToken',
expiresAt: 910200, expiresAt: 990200,
} }
); );
}); });
...@@ -599,12 +590,6 @@ describe('sidebar.oauth-auth', function () { ...@@ -599,12 +590,6 @@ describe('sidebar.oauth-auth', function () {
clock.tick(DEFAULT_TOKEN_EXPIRES_IN_SECS * 1000); 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's spy history (.called, .callCount, etc). // Reset fakeHttp's spy history (.called, .callCount, etc).
function resetHttpSpy () { function resetHttpSpy () {
fakeHttp.post.resetHistory(); fakeHttp.post.resetHistory();
......
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