-
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
Fix issue that could lead to user being logged into normal Hypothesis account on websites using third-party accounts #572
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 |
---|---|---|
|
@@ -94,14 +94,6 @@ function auth($http, $rootScope, $window, | |
return $http.post(url, data, requestConfig); | ||
} | ||
|
||
function grantTokenFromHostPage() { | ||
var cfg = serviceConfig(settings); | ||
if (!cfg) { | ||
return null; | ||
} | ||
return cfg.grantToken; | ||
} | ||
|
||
/** | ||
* Return the storage key used for storing access/refresh token data for a given | ||
* annotation service. | ||
|
@@ -223,17 +215,25 @@ function auth($http, $rootScope, $window, | |
*/ | ||
function tokenGetter() { | ||
if (!tokenInfoPromise) { | ||
var grantToken = grantTokenFromHostPage(); | ||
|
||
if (grantToken) { | ||
// Exchange host-page provided grant token for a new access token. | ||
tokenInfoPromise = exchangeJWT(grantToken).then((tokenInfo) => { | ||
return tokenInfo; | ||
}).catch(function(err) { | ||
showAccessTokenExpiredErrorMessage( | ||
'You must reload the page to annotate.'); | ||
throw err; | ||
}); | ||
var cfg = serviceConfig(settings); | ||
|
||
// Check if automatic login is being used, indicated by the presence of | ||
// the 'grantToken' property in the service configuration. | ||
if (cfg && typeof cfg.grantToken !== 'undefined') { | ||
if (cfg.grantToken) { | ||
// User is logged-in on the publisher's website. | ||
// Exchange the grant token for a new access token. | ||
tokenInfoPromise = exchangeJWT(cfg.grantToken).then((tokenInfo) => { | ||
return tokenInfo; | ||
}).catch(function(err) { | ||
showAccessTokenExpiredErrorMessage( | ||
'You must reload the page to annotate.'); | ||
throw err; | ||
}); | ||
} else { | ||
// User is anonymous on the publisher's website. | ||
tokenInfoPromise = Promise.resolve(null); | ||
} | ||
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. The logic here, if I understand it correctly, is that whenever there's a truthy But in the longer term I think what we want is for the default hypothes.is service to be a service like any other, one that appears in the Do you agree? And if so, do we need another way of deciding when to enable third-party accounts, instead of checking for the presence of a service config? I guess this also introduces another way to disable Hypothesis on a site, by embedding the client and including a services config (e.g. one with an authority that doesn't exist server-side and a grant token that doesn't work). 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.
Here we aren't testing for first vs third party services but rather the use of automatic login via a grant token. That is what matters because this method is responsible for obtaining an access token from somewhere. In future if h becomes "just another annotation service" then it would, in theory, be eligible to use grant tokens for login as well.
Yes, that is where I expect we'll eventually end up. |
||
} else if (authCode) { | ||
// Exchange authorization code retrieved from login popup for a new | ||
// access token. | ||
|
@@ -264,9 +264,18 @@ function auth($http, $rootScope, $window, | |
} | ||
|
||
if (Date.now() > token.expiresAt) { | ||
var shouldPersist = true; | ||
|
||
// If we are using automatic login via a grant token, do not persist the | ||
// initial access token or refreshed tokens. | ||
var cfg = serviceConfig(settings); | ||
if (cfg && typeof cfg.grantToken !== 'undefined') { | ||
shouldPersist = false; | ||
} | ||
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.
This is probably a sign that 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. Hmm. There are three things going on here:
I could split out (1) into separate functions but I'd prefer to do that as a separate PR to avoid distracting from the bug being fixed here. |
||
|
||
// Token expired. Attempt to refresh. | ||
tokenInfoPromise = refreshAccessToken(token.refreshToken, { | ||
persist: true, | ||
persist: shouldPersist, | ||
}).catch(() => { | ||
// If refreshing the token fails, the user is simply logged out. | ||
return null; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -181,12 +181,6 @@ describe('sidebar.oauth-auth', function () { | |
}); | ||
}); | ||
|
||
it('should not persist access tokens fetched using a grant token', function () { | ||
return auth.tokenGetter().then(() => { | ||
assert.notCalled(fakeLocalStorage.setObject); | ||
}); | ||
}); | ||
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. Ok, so this test was replaced by the parametrize-style one below |
||
|
||
context('when the access token request fails', function() { | ||
beforeEach('make access token requests fail', function () { | ||
fakeHttp.post.returns(Promise.resolve({status: 500})); | ||
|
@@ -353,6 +347,33 @@ describe('sidebar.oauth-auth', function () { | |
}); | ||
}); | ||
|
||
[{ | ||
// User is logged-in on the publisher's website. | ||
authority: 'publisher.org', | ||
grantToken: 'a.jwt.token', | ||
expectedToken: 'firstAccessToken', | ||
},{ | ||
// User is anonymous on the publisher's website. | ||
authority: 'publisher.org', | ||
grantToken: null, | ||
expectedToken: null, | ||
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. Do we need to add more cases to the
Case 2 is also tested currently, in |
||
}].forEach(({ authority, grantToken, expectedToken }) => { | ||
it('should not persist access tokens if a grant token was provided', () => { | ||
fakeSettings.services = [{ authority, grantToken }]; | ||
return auth.tokenGetter().then(() => { | ||
assert.notCalled(fakeLocalStorage.setObject); | ||
}); | ||
}); | ||
|
||
it('should not read persisted access tokens if a grant token was set', () => { | ||
fakeSettings.services = [{ authority, grantToken }]; | ||
return auth.tokenGetter().then(token => { | ||
assert.equal(token, expectedToken); | ||
assert.notCalled(fakeLocalStorage.getObject); | ||
}); | ||
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. Should these tests be in 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. Yes. Reading tokens from & writing tokens to storage is part of what is triggered by by |
||
}); | ||
}); | ||
|
||
it('persists tokens retrieved via auth code exchanges to storage', () => { | ||
fakeSettings.services = []; | ||
|
||
|
@@ -367,6 +388,20 @@ describe('sidebar.oauth-auth', function () { | |
}); | ||
}); | ||
|
||
function expireAndRefreshAccessToken() { | ||
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. Add a docstring here. |
||
fakeLocalStorage.setObject.reset(); | ||
fakeHttp.post.returns(Promise.resolve({ | ||
status: 200, | ||
data: { | ||
access_token: 'secondToken', | ||
expires_in: DEFAULT_TOKEN_EXPIRES_IN_SECS, | ||
refresh_token: 'secondRefreshToken', | ||
}, | ||
})); | ||
expireAccessToken(); | ||
return auth.tokenGetter(); | ||
} | ||
|
||
it('persists refreshed tokens to storage', () => { | ||
fakeSettings.services = []; | ||
|
||
|
@@ -375,17 +410,7 @@ describe('sidebar.oauth-auth', function () { | |
return auth.tokenGetter(); | ||
}).then(() => { | ||
// 2. Refresh access token. | ||
fakeLocalStorage.setObject.reset(); | ||
fakeHttp.post.returns(Promise.resolve({ | ||
status: 200, | ||
data: { | ||
access_token: 'secondToken', | ||
expires_in: DEFAULT_TOKEN_EXPIRES_IN_SECS, | ||
refresh_token: 'secondRefreshToken', | ||
}, | ||
})); | ||
expireAccessToken(); | ||
return auth.tokenGetter(); | ||
return expireAndRefreshAccessToken(); | ||
}).then(() => { | ||
// 3. Check that updated token was persisted to storage. | ||
assert.calledWith(fakeLocalStorage.setObject, TOKEN_KEY, { | ||
|
@@ -396,9 +421,19 @@ describe('sidebar.oauth-auth', function () { | |
}); | ||
}); | ||
|
||
it('does not persist refreshed tokens if the original token was temporary', () => { | ||
fakeSettings.services = [{ authority: 'publisher.org', grantToken: 'a.jwt.token' }]; | ||
|
||
return auth.tokenGetter().then(() => { | ||
return expireAndRefreshAccessToken(); | ||
}).then(() => { | ||
// Check that updated token was not persisted to storage. | ||
assert.notCalled(fakeLocalStorage.setObject); | ||
}); | ||
}); | ||
|
||
it('fetches and returns tokens from storage', () => { | ||
fakeSettings.services = []; | ||
|
||
fakeLocalStorage.getObject.withArgs(TOKEN_KEY).returns({ | ||
accessToken: 'foo', | ||
refreshToken: 'bar', | ||
|
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.
As discussed in Slack there's an unhandled case here when there is a service config but it doesn't contain any
grantToken
(not evennull
). This should be fixed elsewhere, in a separate PR: a service config without agrantToken
should be considered invalid and rejected. The docs forgrantToken
need to be updated to say that it's required and does not default tonull
.