-
Notifications
You must be signed in to change notification settings - Fork 204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add OAuth refresh support #221
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this fails, the error will be silently dropped [1]. In general, a promise chain should either be returned from the function or rejection should be explicitly handled. [1] Not always true - modern browsers have heuristics that they will use to warn about unhandled Promise rejections, but I think I still favour handling them explicitly. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I know it will be, but oauth error handling in the client is generally pretty nonexistent, so I want to do a separate PR (probably multiple PRs) for it. |
||
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah. I did not know that |
||
} | ||
|
||
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); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Unless you are going to re-use a |
||
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(); | ||
} | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In any variables that stores durations, it is helpful to make sure the units are clear (seconds, mills, nano?). This could be either via naming (eg.
expiresInSecs
) or via documentation of the type somewhere (eg. a JSDoc@typedef
)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm.
expires_in
is part of the OAuth spec. I did change it tojavaScript
case styling but I think maybe we should stick to the spec's names for things as much as possible? So I think adding a typedef sounds good.