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

Merge pull request #191 from hypothesis/remove-jwt-interceptor

Explicitly add Authorization header to API requests
parents 532dcaab cac26947
...@@ -83,12 +83,9 @@ function configureRoutes($routeProvider) { ...@@ -83,12 +83,9 @@ function configureRoutes($routeProvider) {
} }
// @ngInject // @ngInject
function configureHttp($httpProvider, jwtInterceptorProvider) { function configureHttp($httpProvider) {
// Use the Pyramid XSRF header name // Use the Pyramid XSRF header name
$httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token'; $httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token';
// Setup JWT tokens for API requests
$httpProvider.interceptors.push('jwtInterceptor');
jwtInterceptorProvider.tokenGetter = require('./auth').tokenGetter;
} }
// @ngInject // @ngInject
...@@ -166,7 +163,7 @@ module.exports = angular.module('h', [ ...@@ -166,7 +163,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) .service('auth', require('./auth'))
.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'))
......
'use strict'; 'use strict';
/**
* Provides functions for retrieving and caching API tokens required by
* API requests and logging out of the API.
*/
var INITIAL_TOKEN = { var INITIAL_TOKEN = {
// The user ID which the current cached token is valid for // The user ID which the current cached token is valid for
userid: undefined, userid: undefined,
...@@ -14,14 +9,11 @@ var INITIAL_TOKEN = { ...@@ -14,14 +9,11 @@ var INITIAL_TOKEN = {
token: undefined, token: undefined,
}; };
var cachedToken = INITIAL_TOKEN;
/** /**
* Fetches a new API token for the current logged-in user. * Fetches a new API token for the current logged-in user.
* *
* @return {Promise} - A promise for a new JWT token. * @return {Promise} - A promise for a new JWT token.
*/ */
// @ngInject
function fetchToken($http, session, settings) { function fetchToken($http, session, settings) {
var tokenUrl = new URL('token', settings.apiUrl).href; var tokenUrl = new URL('token', settings.apiUrl).href;
...@@ -43,13 +35,21 @@ function fetchToken($http, session, settings) { ...@@ -43,13 +35,21 @@ function fetchToken($http, session, settings) {
} }
/** /**
* Service for fetching and caching access tokens for the Hypothesis API.
*/
// @ngInject
function auth($http, flash, jwtHelper, session, settings) {
var cachedToken = INITIAL_TOKEN;
/**
* Fetches or returns a cached JWT API token for the current user. * Fetches or returns a cached JWT API token for the current user.
* *
* @return {Promise} - A promise for a JWT API token for the current * @return {Promise} - A promise for a JWT API token for the current
* user. * user.
*/ */
// @ngInject // @ngInject
function fetchOrReuseToken($http, jwtHelper, session, settings) { function fetchOrReuseToken($http, jwtHelper, session, settings) {
function refreshToken() { function refreshToken() {
return fetchToken($http, session, settings).then(function (token) { return fetchToken($http, session, settings).then(function (token) {
return token; return token;
...@@ -82,30 +82,12 @@ function fetchOrReuseToken($http, jwtHelper, session, settings) { ...@@ -82,30 +82,12 @@ function fetchOrReuseToken($http, jwtHelper, session, settings) {
return token; return token;
} }
}); });
}
/**
* JWT token fetcher function for use with 'angular-jwt'
*
* angular-jwt should be configured to use this function as its
* tokenGetter implementation.
*/
// @ngInject
function tokenGetter($http, config, jwtHelper, session, settings) {
// Only send the token on requests to the annotation storage service
if (config.url.slice(0, settings.apiUrl.length) === settings.apiUrl) {
return fetchOrReuseToken($http, jwtHelper, session, settings);
} else {
return null;
} }
}
function clearCache() { function clearCache() {
cachedToken = INITIAL_TOKEN; cachedToken = INITIAL_TOKEN;
} }
// @ngInject
function authService(flash, session) {
/** /**
* Log out from the API and clear any cached tokens. * Log out from the API and clear any cached tokens.
* *
...@@ -122,13 +104,20 @@ function authService(flash, session) { ...@@ -122,13 +104,20 @@ function authService(flash, session) {
}); });
} }
/**
* Return an access token for authenticating API requests.
*
* @return {Promise<string>}
*/
function tokenGetter() {
return fetchOrReuseToken($http, jwtHelper, session, settings);
}
return { return {
clearCache: clearCache,
tokenGetter: tokenGetter,
logout: logout, logout: logout,
}; };
} }
module.exports = { module.exports = auth;
tokenGetter: tokenGetter,
clearCache: clearCache,
service: authService,
};
...@@ -78,17 +78,33 @@ function serializeParams(params) { ...@@ -78,17 +78,33 @@ function serializeParams(params) {
* Creates a function that will make an API call to a named route. * Creates a function that will make an API call to a named route.
* *
* @param $http - The Angular HTTP service * @param $http - The Angular HTTP service
* @param $q - The Angular Promises ($q) service.
* @param links - Object or promise for an object mapping named API routes to * @param links - Object or promise for an object mapping named API routes to
* URL templates and methods * URL templates and methods
* @param route - The dotted path of the named API route (eg. `annotation.create`) * @param route - The dotted path of the named API route (eg. `annotation.create`)
* @param {Function} tokenGetter - Function which returns a Promise for an
* access token for the API.
*/ */
function createAPICall($http, links, route) { function createAPICall($http, $q, links, route, tokenGetter) {
return function (params, data) { return function (params, data) {
return links.then(function (links) { // `$q.all` is used here rather than `Promise.all` because testing code that
// mixes native Promises with the `$q` promises returned by `$http`
// functions gets awkward in tests.
return $q.all([links, tokenGetter()]).then(function (linksAndToken) {
var links = linksAndToken[0];
var token = linksAndToken[1];
var descriptor = get(links, route); var descriptor = get(links, route);
var url = urlUtil.replaceURLParams(descriptor.url, params); var url = urlUtil.replaceURLParams(descriptor.url, params);
var headers = {};
if (token) {
headers.Authorization = 'Bearer ' + token;
}
var req = { var req = {
data: data ? stripInternalProperties(data) : null, data: data ? stripInternalProperties(data) : null,
headers: headers,
method: descriptor.method, method: descriptor.method,
params: url.params, params: url.params,
paramSerializer: serializeParams, paramSerializer: serializeParams,
...@@ -108,20 +124,24 @@ function createAPICall($http, links, route) { ...@@ -108,20 +124,24 @@ function createAPICall($http, links, route) {
* the Hypothesis API (see http://h.readthedocs.io/en/latest/api/). * the Hypothesis API (see http://h.readthedocs.io/en/latest/api/).
*/ */
// @ngInject // @ngInject
function store($http, settings) { function store($http, $q, auth, settings) {
var links = retryUtil.retryPromiseOperation(function () { var links = retryUtil.retryPromiseOperation(function () {
return $http.get(settings.apiUrl); return $http.get(settings.apiUrl);
}).then(function (response) { }).then(function (response) {
return response.data.links; return response.data.links;
}); });
function apiCall(route) {
return createAPICall($http, $q, links, route, auth.tokenGetter);
}
return { return {
search: createAPICall($http, links, 'search'), search: apiCall('search'),
annotation: { annotation: {
create: createAPICall($http, links, 'annotation.create'), create: apiCall('annotation.create'),
delete: createAPICall($http, links, 'annotation.delete'), delete: apiCall('annotation.delete'),
get: createAPICall($http, links, 'annotation.read'), get: apiCall('annotation.read'),
update: createAPICall($http, links, 'annotation.update'), update: apiCall('annotation.update'),
}, },
}; };
} }
......
...@@ -43,27 +43,25 @@ describe('auth', function () { ...@@ -43,27 +43,25 @@ describe('auth', function () {
}; };
}); });
afterEach(function () { function authFactory() {
auth.clearCache(); var fakeFlash = { error: sinon.stub() };
}); return auth(fakeHttp, fakeFlash, fakeJwtHelper, fakeSession, fakeSettings);
describe('tokenGetter', function () {
function tokenGetter() {
var config = {url:'https://test.hypothes.is/api/search'};
return auth.tokenGetter(fakeHttp, config, fakeJwtHelper,
fakeSession, fakeSettings);
} }
describe('#tokenGetter', function () {
it('should fetch and return a new token', function () { it('should fetch and return a new token', function () {
return tokenGetter().then(function (token) { var auth = authFactory();
return auth.tokenGetter().then(function (token) {
assert.called(fakeHttp.get); assert.called(fakeHttp.get);
assert.equal(token, fakeTokens[0]); assert.equal(token, fakeTokens[0]);
}); });
}); });
it('should cache tokens for future use', function () { it('should cache tokens for future use', function () {
return tokenGetter().then(function () { var auth = authFactory();
return tokenGetter(); return auth.tokenGetter().then(function () {
return auth.tokenGetter();
}).then(function (token) { }).then(function (token) {
assert.calledOnce(fakeHttp.get); assert.calledOnce(fakeHttp.get);
assert.equal(token, fakeTokens[0]); assert.equal(token, fakeTokens[0]);
...@@ -71,11 +69,12 @@ describe('auth', function () { ...@@ -71,11 +69,12 @@ describe('auth', function () {
}); });
it('should refresh expired tokens', function () { it('should refresh expired tokens', function () {
return tokenGetter().then(function () { var auth = authFactory();
return auth.tokenGetter().then(function () {
fakeJwtHelper.isTokenExpired = function () { fakeJwtHelper.isTokenExpired = function () {
return true; return true;
}; };
return tokenGetter(); return auth.tokenGetter();
}).then(function (token) { }).then(function (token) {
assert.calledTwice(fakeHttp.get); assert.calledTwice(fakeHttp.get);
assert.equal(token, fakeTokens[1]); assert.equal(token, fakeTokens[1]);
...@@ -83,9 +82,10 @@ describe('auth', function () { ...@@ -83,9 +82,10 @@ describe('auth', function () {
}); });
it('should fetch a new token if the userid changes', function () { it('should fetch a new token if the userid changes', function () {
return tokenGetter().then(function () { var auth = authFactory();
return auth.tokenGetter().then(function () {
fakeSession.state.userid = 'new-user-id'; fakeSession.state.userid = 'new-user-id';
return tokenGetter(); return auth.tokenGetter();
}).then(function (token) { }).then(function (token) {
assert.calledTwice(fakeHttp.get); assert.calledTwice(fakeHttp.get);
assert.equal(token, fakeTokens[1]); assert.equal(token, fakeTokens[1]);
...@@ -93,10 +93,10 @@ describe('auth', function () { ...@@ -93,10 +93,10 @@ describe('auth', function () {
}); });
}); });
describe('.logout', function () { describe('#logout', function () {
it('should call session.logout', function () { it('should call session.logout', function () {
var fakeFlash = {error: sinon.stub()}; var auth = authFactory();
return auth.service(fakeFlash, fakeSession).logout().then(function () { return auth.logout().then(function () {
assert.called(fakeSession.logout); assert.called(fakeSession.logout);
}); });
}); });
......
...@@ -22,12 +22,23 @@ describe('store', function () { ...@@ -22,12 +22,23 @@ describe('store', function () {
}))); })));
}); });
beforeEach(angular.mock.module('h')); beforeEach(function () {
beforeEach(angular.mock.module(function ($provide) {
sandbox = sinon.sandbox.create(); sandbox = sinon.sandbox.create();
$provide.value('settings', {apiUrl: 'http://example.com/api'});
})); var fakeAuth = {};
angular.mock.module('h', {
auth: fakeAuth,
settings: {apiUrl: 'http://example.com/api'},
});
angular.mock.inject(function (_$q_) {
var $q = _$q_;
fakeAuth.tokenGetter = function () {
return $q.resolve('faketoken');
};
});
});
afterEach(function () { afterEach(function () {
$httpBackend.verifyNoOutstandingExpectation(); $httpBackend.verifyNoOutstandingExpectation();
...@@ -65,10 +76,12 @@ describe('store', function () { ...@@ -65,10 +76,12 @@ describe('store', function () {
$httpBackend.flush(); $httpBackend.flush();
})); }));
it('saves a new annotation', function () { it('saves a new annotation', function (done) {
store.annotation.create({}, {}).then(function (saved) { store.annotation.create({}, {}).then(function (saved) {
assert.isNotNull(saved.id); assert.isNotNull(saved.id);
done();
}); });
$httpBackend.expectPOST('http://example.com/api/annotations') $httpBackend.expectPOST('http://example.com/api/annotations')
.respond(function () { .respond(function () {
return [201, {id: 'new-id'}, {}]; return [201, {id: 'new-id'}, {}];
...@@ -76,8 +89,11 @@ describe('store', function () { ...@@ -76,8 +89,11 @@ describe('store', function () {
$httpBackend.flush(); $httpBackend.flush();
}); });
it('updates an annotation', function () { it('updates an annotation', function (done) {
store.annotation.update({id: 'an-id'}, {text: 'updated'}); store.annotation.update({id: 'an-id'}, {text: 'updated'}).then(function () {
done();
});
$httpBackend.expectPUT('http://example.com/api/annotations/an-id') $httpBackend.expectPUT('http://example.com/api/annotations/an-id')
.respond(function () { .respond(function () {
return [200, {}, {}]; return [200, {}, {}];
...@@ -85,8 +101,11 @@ describe('store', function () { ...@@ -85,8 +101,11 @@ describe('store', function () {
$httpBackend.flush(); $httpBackend.flush();
}); });
it('deletes an annotation', function () { it('deletes an annotation', function (done) {
store.annotation.delete({id: 'an-id'}, {}); store.annotation.delete({id: 'an-id'}, {}).then(function () {
done();
});
$httpBackend.expectDELETE('http://example.com/api/annotations/an-id') $httpBackend.expectDELETE('http://example.com/api/annotations/an-id')
.respond(function () { .respond(function () {
return [200, {}, {}]; return [200, {}, {}];
...@@ -94,24 +113,30 @@ describe('store', function () { ...@@ -94,24 +113,30 @@ describe('store', function () {
$httpBackend.flush(); $httpBackend.flush();
}); });
it('removes internal properties before sending data to the server', function () { it('removes internal properties before sending data to the server', function (done) {
var annotation = { var annotation = {
$highlight: true, $highlight: true,
$notme: 'nooooo!', $notme: 'nooooo!',
allowed: 123, allowed: 123,
}; };
store.annotation.create({}, annotation); store.annotation.create({}, annotation).then(function () {
done();
});
$httpBackend.expectPOST('http://example.com/api/annotations', { $httpBackend.expectPOST('http://example.com/api/annotations', {
allowed: 123, allowed: 123,
}) })
.respond(function () { return {id: 'test'}; }); .respond(function () { return [200, {id: 'test'}, {}]; });
$httpBackend.flush(); $httpBackend.flush();
}); });
// Our backend service interprets semicolons as query param delimiters, so we // Our backend service interprets semicolons as query param delimiters, so we
// must ensure to encode them in the query string. // must ensure to encode them in the query string.
it('encodes semicolons in query parameters', function () { it('encodes semicolons in query parameters', function (done) {
store.search({'uri': 'http://example.com/?foo=bar;baz=qux'}); store.search({'uri': 'http://example.com/?foo=bar;baz=qux'}).then(function () {
done();
});
$httpBackend.expectGET('http://example.com/api/search?uri=http%3A%2F%2Fexample.com%2F%3Ffoo%3Dbar%3Bbaz%3Dqux') $httpBackend.expectGET('http://example.com/api/search?uri=http%3A%2F%2Fexample.com%2F%3Ffoo%3Dbar%3Bbaz%3Dqux')
.respond(function () { return [200, {}, {}]; }); .respond(function () { return [200, {}, {}]; });
$httpBackend.flush(); $httpBackend.flush();
......
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