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

Merge pull request #199 from hypothesis/oauth-token-and-profile-fetch

Exchange access token and fetch profile via API when a grant token is provided
parents 4610489c e71daa78
...@@ -34,3 +34,15 @@ _Boolean_. Controls whether the sidebar opens automatically on startup. ...@@ -34,3 +34,15 @@ _Boolean_. Controls whether the sidebar opens automatically on startup.
_Boolean_. Controls whether the in-document highlights are shown by default. _Boolean_. Controls whether the in-document highlights are shown by default.
(Default: _true_.) (Default: _true_.)
### `services`
_Array_. A list of additional annotation services which the client should
retrieve annotations from, optionally including information about the identity
of the user on that service. This list is in addition to the public
[Hypothesis](https://hypothes.is/) service.
Each service description is an object with the keys:
* `authority` _String_. The domain name which the annotation service is associated with.
* `grantToken` _String|null_. An OAuth grant token which the client can exchange for an access token in order to make authenticated requests to the service. If _null_, the user will only be able to read rather than create or modify annotations. (Default: _null_)
...@@ -99,6 +99,13 @@ function processAppOpts() { ...@@ -99,6 +99,13 @@ function processAppOpts() {
} }
} }
var authService;
if (Array.isArray(settings.services)) {
authService = require('./oauth-auth');
} else {
authService = require('./auth');
}
module.exports = angular.module('h', [ module.exports = angular.module('h', [
// Angular addons which export the Angular module name // Angular addons which export the Angular module name
// via module.exports // via module.exports
...@@ -163,7 +170,7 @@ module.exports = angular.module('h', [ ...@@ -163,7 +170,7 @@ module.exports = angular.module('h', [
.service('analytics', require('./analytics')) .service('analytics', require('./analytics'))
.service('annotationMapper', require('./annotation-mapper')) .service('annotationMapper', require('./annotation-mapper'))
.service('annotationUI', require('./annotation-ui')) .service('annotationUI', require('./annotation-ui'))
.service('auth', require('./auth')) .service('auth', authService)
.service('bridge', require('../shared/bridge')) .service('bridge', require('../shared/bridge'))
.service('drafts', require('./drafts')) .service('drafts', require('./drafts'))
.service('features', require('./features')) .service('features', require('./features'))
......
...@@ -26,6 +26,7 @@ function hostPageConfig(window) { ...@@ -26,6 +26,7 @@ function hostPageConfig(window) {
'openLoginForm', 'openLoginForm',
'openSidebar', 'openSidebar',
'showHighlights', 'showHighlights',
'services',
]; ];
return Object.keys(config).reduce(function (result, key) { return Object.keys(config).reduce(function (result, key) {
......
'use strict';
var queryString = require('query-string');
var resolve = require('./util/url-util').resolve;
/**
* OAuth-based authentication service used for publisher accounts.
*
* A grant token embedded on the page by the publisher is exchanged for
* an opaque access token.
*/
// @ngInject
function auth($http, settings) {
var cachedToken;
var tokenUrl = resolve('token', settings.apiUrl);
var grantToken;
if (Array.isArray(settings.services) && settings.services.length > 0) {
grantToken = settings.services[0].grantToken;
}
// 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({
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;
});
}
function tokenGetter() {
// performance.now() is used instead of Date.now() because it is
// monotonically increasing.
if (cachedToken && cachedToken.expiresAt > performance.now()) {
return Promise.resolve(cachedToken.token);
} else if (grantToken) {
var refreshStart = performance.now();
return exchangeToken(grantToken).then(function (tokenInfo) {
cachedToken = {
token: tokenInfo.access_token,
expiresAt: refreshStart + tokenInfo.expires_in * 1000,
};
return cachedToken.token;
});
} else {
return Promise.resolve(null);
}
}
function clearCache() {
cachedToken = null;
}
return {
clearCache: clearCache,
tokenGetter: tokenGetter,
};
}
module.exports = auth;
...@@ -49,7 +49,7 @@ function sessionActions(options) { ...@@ -49,7 +49,7 @@ function sessionActions(options) {
* @ngInject * @ngInject
*/ */
function session($http, $resource, $rootScope, annotationUI, auth, function session($http, $resource, $rootScope, annotationUI, auth,
flash, raven, settings) { flash, raven, settings, store) {
// Headers sent by every request made by the session service. // Headers sent by every request made by the session service.
var headers = {}; var headers = {};
var actions = sessionActions({ var actions = sessionActions({
...@@ -84,7 +84,15 @@ function session($http, $resource, $rootScope, annotationUI, auth, ...@@ -84,7 +84,15 @@ function session($http, $resource, $rootScope, annotationUI, auth,
// the /app endpoint. // the /app endpoint.
lastLoadTime = Date.now(); lastLoadTime = Date.now();
lastLoad = retryUtil.retryPromiseOperation(function () { lastLoad = retryUtil.retryPromiseOperation(function () {
var authority;
if (Array.isArray(settings.services) && settings.services.length > 0) {
authority = settings.services[0].authority;
}
if (authority) {
return store.profile({authority: authority}).then(update);
} else {
return resource._load().$promise; return resource._load().$promise;
}
}).then(function (session) { }).then(function (session) {
lastLoadTime = Date.now(); lastLoadTime = Date.now();
return session; return session;
......
...@@ -143,6 +143,7 @@ function store($http, $q, auth, settings) { ...@@ -143,6 +143,7 @@ function store($http, $q, auth, settings) {
get: apiCall('annotation.read'), get: apiCall('annotation.read'),
update: apiCall('annotation.update'), update: apiCall('annotation.update'),
}, },
profile: apiCall('profile'),
}; };
} }
......
...@@ -18,6 +18,9 @@ describe('hostPageConfig', function () { ...@@ -18,6 +18,9 @@ describe('hostPageConfig', function () {
openSidebar: true, openSidebar: true,
openLoginForm: true, openLoginForm: true,
showHighlights: true, showHighlights: true,
services: [{
authority: 'hypothes.is',
}],
}); });
assert.deepEqual(hostPageConfig(window_), { assert.deepEqual(hostPageConfig(window_), {
...@@ -26,6 +29,9 @@ describe('hostPageConfig', function () { ...@@ -26,6 +29,9 @@ describe('hostPageConfig', function () {
openSidebar: true, openSidebar: true,
openLoginForm: true, openLoginForm: true,
showHighlights: true, showHighlights: true,
services: [{
authority: 'hypothes.is',
}],
}); });
}); });
......
'use strict';
var authService = require('../oauth-auth');
var DEFAULT_TOKEN_EXPIRES_IN_SECS = 1000;
describe('oauth auth', function () {
var auth;
var nowStub;
var fakeHttp;
var fakeSettings;
beforeEach(function () {
nowStub = sinon.stub(window.performance, 'now');
nowStub.returns(300);
fakeHttp = {
post: sinon.stub().returns(Promise.resolve({
status: 200,
data: {
access_token: 'an-access-token',
expires_in: DEFAULT_TOKEN_EXPIRES_IN_SECS,
},
})),
};
fakeSettings = {
apiUrl: 'https://hypothes.is/api/',
services: [{
authority: 'publisher.org',
grantToken: 'a.jwt.token',
}],
};
auth = authService(fakeHttp, fakeSettings);
});
afterEach(function () {
performance.now.restore();
});
describe('#tokenGetter', function () {
it('should request an access token if a grant token was provided', function () {
return auth.tokenGetter().then(function (token) {
var expectedBody =
'assertion=a.jwt.token' +
'&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer';
assert.calledWith(fakeHttp.post, 'https://hypothes.is/api/token', expectedBody, {
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
});
assert.equal(token, 'an-access-token');
});
});
it('should cache tokens for future use', function () {
return auth.tokenGetter().then(function () {
fakeHttp.post.reset();
return auth.tokenGetter();
}).then(function (token) {
assert.equal(token, 'an-access-token');
assert.notCalled(fakeHttp.post);
});
});
it('should return null if no grant token was provided', function () {
var auth = authService(fakeHttp, {
services: [{authority: 'publisher.org'}],
});
return auth.tokenGetter().then(function (token) {
assert.notCalled(fakeHttp.post);
assert.equal(token, null);
});
});
it('should refresh the access token if it has expired', function () {
return auth.tokenGetter().then(function () {
var now = performance.now();
nowStub.returns(now + DEFAULT_TOKEN_EXPIRES_IN_SECS * 1000 + 100);
fakeHttp.post.returns(Promise.resolve({
status: 200,
data: {
access_token: 'a-different-access-token',
expires_in: DEFAULT_TOKEN_EXPIRES_IN_SECS,
},
}));
return auth.tokenGetter();
}).then(function (token) {
assert.equal(token, 'a-different-access-token');
});
});
});
describe('#clearCache', function () {
it('should clear cached tokens', function () {
return auth.tokenGetter().then(function () {
fakeHttp.post.reset();
auth.clearCache();
return auth.tokenGetter();
}).then(function () {
assert.calledOnce(fakeHttp.post);
});
});
});
});
...@@ -13,6 +13,8 @@ describe('session', function () { ...@@ -13,6 +13,8 @@ describe('session', function () {
var fakeAuth; var fakeAuth;
var fakeFlash; var fakeFlash;
var fakeRaven; var fakeRaven;
var fakeSettings;
var fakeStore;
var sandbox; var sandbox;
var session; var session;
...@@ -40,15 +42,20 @@ describe('session', function () { ...@@ -40,15 +42,20 @@ describe('session', function () {
fakeRaven = { fakeRaven = {
setUserInfo: sandbox.spy(), setUserInfo: sandbox.spy(),
}; };
fakeStore = {
profile: sandbox.stub(),
};
fakeSettings = {
serviceUrl: 'https://test.hypothes.is/root/',
};
mock.module('h', { mock.module('h', {
annotationUI: fakeAnnotationUI, annotationUI: fakeAnnotationUI,
auth: fakeAuth, auth: fakeAuth,
flash: fakeFlash, flash: fakeFlash,
raven: fakeRaven, raven: fakeRaven,
settings: { settings: fakeSettings,
serviceUrl: 'https://test.hypothes.is/root/', store: fakeStore,
},
}); });
}); });
...@@ -155,6 +162,30 @@ describe('session', function () { ...@@ -155,6 +162,30 @@ describe('session', function () {
$httpBackend.flush(); $httpBackend.flush();
}); });
context('when the host page provides an OAuth grant token', function () {
beforeEach(function () {
fakeSettings.services = [{
authority: 'publisher.org',
grantToken: 'a.jwt.token',
}];
fakeStore.profile.returns(Promise.resolve({
userid: 'acct:user@publisher.org',
}));
});
it('should fetch profile data from the API', function () {
return session.load().then(function () {
assert.calledWith(fakeStore.profile, {authority: 'publisher.org'});
});
});
it('should update the session with the profile data from the API', function () {
return session.load().then(function () {
assert.equal(session.state.userid, 'acct:user@publisher.org');
});
});
});
it('should cache the session data', function () { it('should cache the session data', function () {
$httpBackend.expectGET(url).respond({}); $httpBackend.expectGET(url).respond({});
session.load(); session.load();
......
...@@ -71,6 +71,10 @@ describe('store', function () { ...@@ -71,6 +71,10 @@ describe('store', function () {
method: 'GET', method: 'GET',
url: 'http://example.com/api/search', url: 'http://example.com/api/search',
}, },
profile: {
method: 'GET',
url: 'http://example.com/api/profile',
},
}, },
}); });
$httpBackend.flush(); $httpBackend.flush();
...@@ -141,4 +145,15 @@ describe('store', function () { ...@@ -141,4 +145,15 @@ describe('store', function () {
.respond(function () { return [200, {}, {}]; }); .respond(function () { return [200, {}, {}]; });
$httpBackend.flush(); $httpBackend.flush();
}); });
it("fetches the user's profile", function (done) {
var profile = {userid: 'acct:user@publisher.org'};
store.profile({authority: 'publisher.org'}).then(function (profile_) {
assert.deepEqual(profile_, profile);
done();
});
$httpBackend.expectGET('http://example.com/api/profile?authority=publisher.org')
.respond(function () { return [200, profile, {}]; });
$httpBackend.flush();
});
}); });
...@@ -25,6 +25,17 @@ function replaceURLParams(url, params) { ...@@ -25,6 +25,17 @@ function replaceURLParams(url, params) {
return {url: url, params: unusedParams}; return {url: url, params: unusedParams};
} }
/**
* Resolve a relative URL against a base URL to get an absolute URL.
*
* @param {string} relativeURL
* @param {string} baseURL
*/
function resolve(relativeURL, baseURL) {
return new URL(relativeURL, baseURL).href;
}
module.exports = { module.exports = {
replaceURLParams: replaceURLParams, replaceURLParams: replaceURLParams,
resolve: resolve,
}; };
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