Commit c604c0c3 authored by Robert Knight's avatar Robert Knight

Implement API token and profile fetching for OAuth clients

Implement access token and profile retrieval for embedders of the client
that provide an OAuth grant token as part of the client's configuration.

For a page embedding Hypothesis configured to use a 3rd-party account,
the start up flow for the client is:

 1. Read service configuration from 'services' array in settings

 2. Exchange grant token from service config for an access token
    using the `POST /api/token` endpoint

 3. Fetch profile data using `GET /api/profile` endpoint

On startup, the app reads the service config and then switches between
either the cookie-based auth implementation or the OAuth-based auth
implementation.

In future, the cookie-based auth implementation will be removed in favor
of OAuth-based auth for first-party accounts as well.
parent 444482ec
......@@ -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', [
// Angular addons which export the Angular module name
// via module.exports
......@@ -163,7 +170,7 @@ module.exports = angular.module('h', [
.service('analytics', require('./analytics'))
.service('annotationMapper', require('./annotation-mapper'))
.service('annotationUI', require('./annotation-ui'))
.service('auth', require('./auth'))
.service('auth', authService)
.service('bridge', require('../shared/bridge'))
.service('drafts', require('./drafts'))
.service('features', require('./features'))
......
'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() {
if (cachedToken && cachedToken.expiresAt > Date.now()) {
return Promise.resolve(cachedToken.token);
} else if (grantToken) {
return exchangeToken(grantToken).then(function (tokenInfo) {
cachedToken = {
token: tokenInfo.access_token,
expiresAt: Date.now() + 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) {
* @ngInject
*/
function session($http, $resource, $rootScope, annotationUI, auth,
flash, raven, settings) {
flash, raven, settings, store) {
// Headers sent by every request made by the session service.
var headers = {};
var actions = sessionActions({
......@@ -84,7 +84,11 @@ function session($http, $resource, $rootScope, annotationUI, auth,
// the /app endpoint.
lastLoadTime = Date.now();
lastLoad = retryUtil.retryPromiseOperation(function () {
return resource._load().$promise;
if (Array.isArray(settings.services)) {
return store.profile().then(update);
} else {
return resource._load().$promise;
}
}).then(function (session) {
lastLoadTime = Date.now();
return session;
......
......@@ -143,6 +143,7 @@ function store($http, $q, auth, settings) {
get: apiCall('annotation.read'),
update: apiCall('annotation.update'),
},
profile: apiCall('profile'),
};
}
......
......@@ -13,6 +13,8 @@ describe('session', function () {
var fakeAuth;
var fakeFlash;
var fakeRaven;
var fakeSettings;
var fakeStore;
var sandbox;
var session;
......@@ -40,15 +42,20 @@ describe('session', function () {
fakeRaven = {
setUserInfo: sandbox.spy(),
};
fakeStore = {
profile: sandbox.stub(),
};
fakeSettings = {
serviceUrl: 'https://test.hypothes.is/root/',
};
mock.module('h', {
annotationUI: fakeAnnotationUI,
auth: fakeAuth,
flash: fakeFlash,
raven: fakeRaven,
settings: {
serviceUrl: 'https://test.hypothes.is/root/',
},
settings: fakeSettings,
store: fakeStore,
});
});
......@@ -155,6 +162,24 @@ describe('session', function () {
$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.equal(session.state.userid, 'acct:user@publisher.org');
});
});
});
it('should cache the session data', function () {
$httpBackend.expectGET(url).respond({});
session.load();
......
......@@ -71,6 +71,10 @@ describe('store', function () {
method: 'GET',
url: 'http://example.com/api/search',
},
profile: {
method: 'GET',
url: 'http://example.com/api/profile',
},
},
});
$httpBackend.flush();
......@@ -141,4 +145,15 @@ describe('store', function () {
.respond(function () { return [200, {}, {}]; });
$httpBackend.flush();
});
it("fetches the user's profile", function (done) {
var profile = {userid: 'acct:user@publisher.org'};
store.profile().then(function (profile_) {
assert.deepEqual(profile_, profile);
done();
});
$httpBackend.expectGET('http://example.com/api/profile')
.respond(function () { return [200, profile, {}]; });
$httpBackend.flush();
});
});
......@@ -25,6 +25,17 @@ function replaceURLParams(url, params) {
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 = {
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