Commit b14047de authored by Robert Knight's avatar Robert Knight

Use OAuth for first party login if enabled.

If the service provides an OAuth client ID and sets the "oauthEnabled"
flag in the app configuration, use OAuth for first party login.

This initial implementation does not persist the token for future use,
so the user has to login every time they use the app.
parent 7e9fe98e
...@@ -109,8 +109,15 @@ function processAppOpts() { ...@@ -109,8 +109,15 @@ function processAppOpts() {
} }
} }
function shouldUseOAuth() {
if (serviceConfig(settings)) {
return true;
}
return settings.oauthClientId && settings.oauthEnabled;
}
var authService; var authService;
if (serviceConfig(settings)) { if (shouldUseOAuth()) {
authService = require('./oauth-auth'); authService = require('./oauth-auth');
} else { } else {
authService = require('./auth'); authService = require('./auth');
...@@ -211,6 +218,7 @@ module.exports = angular.module('h', [ ...@@ -211,6 +218,7 @@ module.exports = angular.module('h', [
.value('Discovery', require('../shared/discovery')) .value('Discovery', require('../shared/discovery'))
.value('ExcerptOverflowMonitor', require('./util/excerpt-overflow-monitor')) .value('ExcerptOverflowMonitor', require('./util/excerpt-overflow-monitor'))
.value('VirtualThreadList', require('./virtual-thread-list')) .value('VirtualThreadList', require('./virtual-thread-list'))
.value('random', require('./util/random'))
.value('raven', require('./raven')) .value('raven', require('./raven'))
.value('serviceConfig', serviceConfig) .value('serviceConfig', serviceConfig)
.value('settings', settings) .value('settings', settings)
......
...@@ -25,8 +25,8 @@ function authStateFromUserID(userid) { ...@@ -25,8 +25,8 @@ function authStateFromUserID(userid) {
// @ngInject // @ngInject
function HypothesisAppController( function HypothesisAppController(
$document, $location, $rootScope, $route, $scope, $document, $location, $rootScope, $route, $scope,
$window, analytics, annotationUI, auth, bridge, drafts, features, frameSync, groups, $window, analytics, annotationUI, auth, bridge, drafts, features,
serviceUrl, session, settings, streamer flash, frameSync, groups, serviceUrl, session, settings, streamer
) { ) {
var self = this; var self = this;
...@@ -90,16 +90,32 @@ function HypothesisAppController( ...@@ -90,16 +90,32 @@ function HypothesisAppController(
}, 0); }, 0);
} }
// Start the login flow. This will present the user with the login dialog. /**
* Start the login flow. This will present the user with the login dialog.
*
* @return {Promise<void>} - A Promise that resolves when the login flow
* completes. For non-OAuth logins, always resolves immediately.
*/
this.login = function () { this.login = function () {
if (serviceConfig(settings)) { if (serviceConfig(settings)) {
// Let the host page handle the login request // Let the host page handle the login request
bridge.call(bridgeEvents.LOGIN_REQUESTED); bridge.call(bridgeEvents.LOGIN_REQUESTED);
return; return Promise.resolve();
} }
self.accountDialog.visible = true; if (auth.login) {
scrollToView('login-form'); // OAuth-based login 😀
return auth.login().then(() => {
session.reload();
}).catch((err) => {
flash.error(err.message);
});
} else {
// Legacy cookie-based login 😔.
self.accountDialog.visible = true;
scrollToView('login-form');
return Promise.resolve();
}
}; };
this.signUp = function(){ this.signUp = function(){
......
...@@ -18,6 +18,7 @@ describe('hypothesisApp', function () { ...@@ -18,6 +18,7 @@ describe('hypothesisApp', function () {
var fakeBridge = null; var fakeBridge = null;
var fakeDrafts = null; var fakeDrafts = null;
var fakeFeatures = null; var fakeFeatures = null;
var fakeFlash = null;
var fakeFrameSync = null; var fakeFrameSync = null;
var fakeLocation = null; var fakeLocation = null;
var fakeParams = null; var fakeParams = null;
...@@ -88,6 +89,10 @@ describe('hypothesisApp', function () { ...@@ -88,6 +89,10 @@ describe('hypothesisApp', function () {
flagEnabled: sandbox.stub().returns(false), flagEnabled: sandbox.stub().returns(false),
}; };
fakeFlash = {
error: sandbox.stub(),
};
fakeFrameSync = { fakeFrameSync = {
connect: sandbox.spy(), connect: sandbox.spy(),
}; };
...@@ -101,6 +106,7 @@ describe('hypothesisApp', function () { ...@@ -101,6 +106,7 @@ describe('hypothesisApp', function () {
fakeSession = { fakeSession = {
load: sandbox.stub().returns(Promise.resolve({userid: null})), load: sandbox.stub().returns(Promise.resolve({userid: null})),
logout: sandbox.stub(), logout: sandbox.stub(),
reload: sandbox.stub().returns(Promise.resolve({userid: null})),
}; };
fakeGroups = {focus: sandbox.spy()}; fakeGroups = {focus: sandbox.spy()};
...@@ -128,6 +134,7 @@ describe('hypothesisApp', function () { ...@@ -128,6 +134,7 @@ describe('hypothesisApp', function () {
$provide.value('analytics', fakeAnalytics); $provide.value('analytics', fakeAnalytics);
$provide.value('drafts', fakeDrafts); $provide.value('drafts', fakeDrafts);
$provide.value('features', fakeFeatures); $provide.value('features', fakeFeatures);
$provide.value('flash', fakeFlash);
$provide.value('frameSync', fakeFrameSync); $provide.value('frameSync', fakeFrameSync);
$provide.value('serviceUrl', fakeServiceUrl); $provide.value('serviceUrl', fakeServiceUrl);
$provide.value('session', fakeSession); $provide.value('session', fakeSession);
...@@ -354,12 +361,49 @@ describe('hypothesisApp', function () { ...@@ -354,12 +361,49 @@ describe('hypothesisApp', function () {
}); });
describe('#login()', function () { describe('#login()', function () {
it('shows the login dialog if not using a third-party service', function () { context('when using cookie auth', () => {
// If no third-party annotation service is in use then it should show the it('shows the login dialog if not using a third-party service', function () {
// built-in login dialog. // If no third-party annotation service is in use then it should show the
var ctrl = createController(); // built-in login dialog.
ctrl.login(); var ctrl = createController();
assert.equal(ctrl.accountDialog.visible, true); ctrl.login();
assert.equal(ctrl.accountDialog.visible, true);
});
});
context('when using OAuth', () => {
beforeEach(() => {
fakeAuth.login = sinon.stub().returns(Promise.resolve());
});
it('does not show the login dialog', () => {
var ctrl = createController();
ctrl.login();
assert.equal(ctrl.accountDialog.visible, false);
});
it('initiates the OAuth login flow', () => {
var ctrl = createController();
ctrl.login();
assert.called(fakeAuth.login);
});
it('reloads the session when login completes', () => {
var ctrl = createController();
return ctrl.login().then(() => {
assert.called(fakeSession.reload);
});
});
it('reports an error if login fails', () => {
fakeAuth.login.returns(Promise.reject(new Error('Login failed')));
var ctrl = createController();
return ctrl.login().then(null, () => {
assert.called(fakeFlash.error);
});
});
}); });
it('sends LOGIN_REQUESTED if a third-party service is in use', function () { it('sends LOGIN_REQUESTED if a third-party service is in use', function () {
......
...@@ -39,7 +39,12 @@ function hostPageConfig(window) { ...@@ -39,7 +39,12 @@ function hostPageConfig(window) {
return Object.keys(config).reduce(function (result, key) { return Object.keys(config).reduce(function (result, key) {
if (paramWhiteList.indexOf(key) !== -1) { if (paramWhiteList.indexOf(key) !== -1) {
result[key] = config[key]; // Ignore `null` values as these indicate a default value.
// In this case the config value set in the sidebar app HTML config is
// used.
if (config[key] !== null) {
result[key] = config[key];
}
} }
return result; return result;
}, {}); }, {});
......
...@@ -6,14 +6,24 @@ var resolve = require('./util/url-util').resolve; ...@@ -6,14 +6,24 @@ var resolve = require('./util/url-util').resolve;
var serviceConfig = require('./service-config'); var serviceConfig = require('./service-config');
/** /**
* OAuth-based authentication service used for publisher accounts. * OAuth-based authorization service.
* *
* A grant token embedded on the page by the publisher is exchanged for * A grant token embedded on the page by the publisher is exchanged for
* an opaque access token. * an opaque access token.
*/ */
// @ngInject // @ngInject
function auth($http, flash, settings) { function auth($http, $window, flash, random, settings) {
/**
* Authorization code from auth popup window.
* @type {string}
*/
var authCode;
/**
* Access token retrieved via `POST /token` endpoint.
* @type {Promise<string>}
*/
var accessTokenPromise; var accessTokenPromise;
var tokenUrl = resolve('token', settings.apiUrl); var tokenUrl = resolve('token', settings.apiUrl);
...@@ -80,6 +90,9 @@ function auth($http, flash, settings) { ...@@ -80,6 +90,9 @@ function auth($http, flash, settings) {
// See https://tools.ietf.org/html/rfc7523#section-4 // See https://tools.ietf.org/html/rfc7523#section-4
function exchangeToken(grantToken) { function exchangeToken(grantToken) {
var data = { var data = {
// FIXME: This should be set to the appropriate grant type if we are
// exchanging an authorization code for a grant token, which
// is the case for first-party accounts.
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: grantToken, assertion: grantToken,
}; };
...@@ -128,7 +141,7 @@ function auth($http, flash, settings) { ...@@ -128,7 +141,7 @@ function auth($http, flash, settings) {
function tokenGetter() { function tokenGetter() {
if (!accessTokenPromise) { if (!accessTokenPromise) {
var grantToken = (serviceConfig(settings) || {}).grantToken; var grantToken = (serviceConfig(settings) || {}).grantToken || authCode;
if (grantToken) { if (grantToken) {
accessTokenPromise = exchangeToken(grantToken).then(function (tokenInfo) { accessTokenPromise = exchangeToken(grantToken).then(function (tokenInfo) {
...@@ -154,9 +167,76 @@ function auth($http, flash, settings) { ...@@ -154,9 +167,76 @@ function auth($http, flash, settings) {
function clearCache() { function clearCache() {
} }
/**
* Login to the annotation service using OAuth.
*
* This displays a popup window which allows the user to login to the service
* (if necessary) and then responds with an auth code which the client can
* then exchange for access and refresh tokens.
*/
function login() {
// Random state string used to check that auth messages came from the popup
// window that we opened.
var state = random.hexString(16);
// Promise which resolves or rejects when the user accepts or closes the
// auth popup.
var authResponse = new Promise((resolve, reject) => {
function authRespListener(event) {
if (typeof event.data !== 'object') {
return;
}
if (event.data.state !== state) {
// This message came from a different popup window.
return;
}
if (event.data.type === 'authorization_response') {
resolve(event.data);
}
if (event.data.type === 'authorization_canceled') {
reject(new Error('Authorization window was closed'));
}
$window.removeEventListener('message', authRespListener);
}
$window.addEventListener('message', authRespListener);
});
// Authorize user and retrieve grant token
var width = 400;
var height = 400;
var left = $window.screen.width / 2 - width / 2;
var top = $window.screen.height /2 - height / 2;
var authUrl = settings.oauthAuthorizeUrl;
authUrl += '?' + queryString.stringify({
client_id: settings.oauthClientId,
origin: $window.location.origin,
response_mode: 'web_message',
response_type: 'code',
state: state,
});
var authWindowSettings = queryString.stringify({
left: left,
top: top,
width: width,
height: height,
}).replace(/&/g, ',');
$window.open(authUrl, 'Login to Hypothesis', authWindowSettings);
return authResponse.then((resp) => {
// Save the auth code. It will be exchanged for an access token when the
// next API request is made.
authCode = resp.code;
accessTokenPromise = null;
});
}
return { return {
clearCache: clearCache, clearCache,
tokenGetter: tokenGetter, login,
tokenGetter,
}; };
} }
......
...@@ -91,8 +91,12 @@ function session($http, $q, $resource, $rootScope, analytics, annotationUI, auth ...@@ -91,8 +91,12 @@ function session($http, $q, $resource, $rootScope, analytics, annotationUI, auth
lastLoadTime = Date.now(); lastLoadTime = Date.now();
lastLoad = retryUtil.retryPromiseOperation(function () { lastLoad = retryUtil.retryPromiseOperation(function () {
var authority = getAuthority(); var authority = getAuthority();
if (authority) { if (auth.login || authority) {
return store.profile.read({authority: authority}).then(update); var opts = {};
if (authority) {
opts.authority = authority;
}
return store.profile.read(opts).then(update);
} else { } else {
return resource._load().$promise; return resource._load().$promise;
} }
...@@ -216,12 +220,23 @@ function session($http, $q, $resource, $rootScope, analytics, annotationUI, auth ...@@ -216,12 +220,23 @@ function session($http, $q, $resource, $rootScope, analytics, annotationUI, auth
}); });
} }
/**
* Clear the cached profile information and re-fetch it from the server.
*
* This can be used to refresh the user's profile state after logging in.
*/
function reload() {
lastLoad = null;
lastLoadTime = null;
return resource.load();
}
return { return {
dismissSidebarTutorial: dismissSidebarTutorial, dismissSidebarTutorial: dismissSidebarTutorial,
load: resource.load, load: resource.load,
login: resource.login, login: resource.login,
logout: logout, logout: logout,
reload: reload,
// For the moment, we continue to expose the session state as a property on // For the moment, we continue to expose the session state as a property on
// this service. In future, other services which access the session state // this service. In future, other services which access the session state
......
...@@ -44,4 +44,12 @@ describe('hostPageConfig', function () { ...@@ -44,4 +44,12 @@ describe('hostPageConfig', function () {
assert.deepEqual(hostPageConfig(window_), {}); assert.deepEqual(hostPageConfig(window_), {});
}); });
it('ignores `null` values in config', function () {
var window_ = fakeWindow({
oauthEnabled: null,
});
assert.deepEqual(hostPageConfig(window_), {});
});
}); });
'use strict'; 'use strict';
var { stringify } = require('query-string');
var authService = require('../oauth-auth'); var authService = require('../oauth-auth');
var DEFAULT_TOKEN_EXPIRES_IN_SECS = 1000; var DEFAULT_TOKEN_EXPIRES_IN_SECS = 1000;
describe('oauth auth', function () { class FakeWindow {
constructor() {
this.callbacks = [];
this.location = {
origin: 'client.hypothes.is',
};
this.screen = {
width: 1024,
height: 768,
};
this.open = sinon.stub();
}
addEventListener(event, callback) {
this.callbacks.push({event, callback});
}
removeEventListener(event, callback) {
this.callbacks = this.callbacks.filter((cb) =>
cb.event === event && cb.callback === callback
);
}
sendMessage(data) {
var evt = new MessageEvent('message', { data });
this.callbacks.forEach(({event, callback}) => {
if (event === 'message') {
callback(evt);
}
});
}
}
describe('sidebar.oauth-auth', function () {
var auth; var auth;
var nowStub; var nowStub;
var fakeHttp; var fakeHttp;
var fakeFlash; var fakeFlash;
var fakeRandom;
var fakeWindow;
var fakeSettings; var fakeSettings;
var clock; var clock;
var successfulFirstAccessTokenPromise; var successfulFirstAccessTokenPromise;
...@@ -35,15 +75,29 @@ describe('oauth auth', function () { ...@@ -35,15 +75,29 @@ describe('oauth auth', function () {
error: sinon.stub(), error: sinon.stub(),
}; };
fakeRandom = {
hexString: sinon.stub().returns('notrandom'),
};
fakeSettings = { fakeSettings = {
apiUrl: 'https://hypothes.is/api/', apiUrl: 'https://hypothes.is/api/',
oauthAuthorizeUrl: 'https://hypothes.is/oauth/authorize/',
oauthClientId: 'the-client-id',
services: [{ services: [{
authority: 'publisher.org', authority: 'publisher.org',
grantToken: 'a.jwt.token', grantToken: 'a.jwt.token',
}], }],
}; };
auth = authService(fakeHttp, fakeFlash, fakeSettings); fakeWindow = new FakeWindow();
auth = authService(
fakeHttp,
fakeWindow,
fakeFlash,
fakeRandom,
fakeSettings
);
clock = sinon.useFakeTimers(); clock = sinon.useFakeTimers();
}); });
...@@ -131,9 +185,7 @@ describe('oauth auth', function () { ...@@ -131,9 +185,7 @@ describe('oauth auth', function () {
}); });
it('should return null if no grant token was provided', function () { it('should return null if no grant token was provided', function () {
var auth = authService(fakeHttp, fakeFlash, { fakeSettings.services = [{ authority: 'publisher.org' }];
services: [{authority: 'publisher.org'}],
});
return auth.tokenGetter().then(function (token) { return auth.tokenGetter().then(function (token) {
assert.notCalled(fakeHttp.post); assert.notCalled(fakeHttp.post);
assert.equal(token, null); assert.equal(token, null);
...@@ -244,6 +296,91 @@ describe('oauth auth', function () { ...@@ -244,6 +296,91 @@ describe('oauth auth', function () {
}); });
}); });
describe('#login', () => {
beforeEach(() => {
// login() is only currently used when using the public
// Hypothesis service.
fakeSettings.services = [];
});
it('opens the auth endpoint in a popup window', () => {
auth.login();
var params = {
client_id: fakeSettings.oauthClientId,
origin: 'client.hypothes.is',
response_mode: 'web_message',
response_type: 'code',
state: 'notrandom',
};
var expectedAuthUrl = `${fakeSettings.oauthAuthorizeUrl}?${stringify(params)}`;
assert.calledWith(
fakeWindow.open,
expectedAuthUrl,
'Login to Hypothesis',
'height=400,left=312,top=184,width=400'
);
});
it('ignores auth responses if the state does not match', () => {
var loggedIn = false;
auth.login().then(() => {
loggedIn = true;
});
fakeWindow.sendMessage({
// Successful response with wrong state
type: 'authorization_response',
code: 'acode',
state: 'wrongstate',
});
return Promise.resolve().then(() => {
assert.isFalse(loggedIn);
});
});
it('resolves when auth completes successfully', () => {
var loggedIn = auth.login();
fakeWindow.sendMessage({
// Successful response
type: 'authorization_response',
code: 'acode',
state: 'notrandom',
});
// 1. Verify that login completes.
return loggedIn.then(() => {
return auth.tokenGetter();
}).then(() => {
// 2. Verify that auth code is exchanged for access & refresh tokens.
var expectedBody =
'assertion=acode' +
'&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'},
});
});
});
it('rejects when auth is canceled', () => {
var loggedIn = auth.login();
fakeWindow.sendMessage({
// Error response
type: 'authorization_canceled',
state: 'notrandom',
});
return loggedIn.catch((err) => {
assert.equal(err.message, 'Authorization window was closed');
});
});
});
// Advance time forward so that any current access tokens will have expired. // Advance time forward so that any current access tokens will have expired.
function expireAccessToken () { function expireAccessToken () {
clock.tick(DEFAULT_TOKEN_EXPIRES_IN_SECS * 1000); clock.tick(DEFAULT_TOKEN_EXPIRES_IN_SECS * 1000);
......
...@@ -43,6 +43,7 @@ describe('session', function () { ...@@ -43,6 +43,7 @@ describe('session', function () {
}; };
fakeAuth = { fakeAuth = {
clearCache: sandbox.spy(), clearCache: sandbox.spy(),
login: null, // Use cookie-based auth
}; };
fakeFlash = {error: sandbox.spy()}; fakeFlash = {error: sandbox.spy()};
fakeRaven = { fakeRaven = {
...@@ -198,6 +199,27 @@ describe('session', function () { ...@@ -198,6 +199,27 @@ describe('session', function () {
}); });
}); });
context('when using OAuth for first-party accounts', () => {
beforeEach(() => {
fakeAuth.login = sinon.stub().returns(Promise.resolve());
fakeStore.profile.read.returns(Promise.resolve({
userid: 'acct:user@hypothes.is',
}));
});
it('should fetch profile data from the API', () => {
return session.load().then(() => {
assert.calledWith(fakeStore.profile.read);
});
});
it('should update the session with the profile data from the API', () => {
return session.load().then(function () {
assert.equal(session.state.userid, 'acct:user@hypothes.is');
});
});
});
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();
...@@ -304,6 +326,29 @@ describe('session', function () { ...@@ -304,6 +326,29 @@ describe('session', function () {
}); });
}); });
describe('#reload', () => {
beforeEach(() => {
// Use OAuth
fakeAuth.login = sinon.stub().returns(Promise.resolve());
// Load the initial profile data, as the client will do on startup.
fakeStore.profile.read.returns(Promise.resolve({
userid: 'acct:user_a@hypothes.is',
}));
return session.load();
});
it('should clear cached data and reload', () => {
fakeStore.profile.read.returns(Promise.resolve({
userid: 'acct:user_b@hypothes.is',
}));
return session.reload().then(() => {
assert.equal(session.state.userid, 'acct:user_b@hypothes.is');
});
});
});
describe('#logout()', function () { describe('#logout()', function () {
var postExpectation; var postExpectation;
beforeEach(function () { beforeEach(function () {
......
'use strict';
/* global Uint8Array */
function byteToHex(val) {
var str = val.toString(16);
return str.length === 1 ? '0' + str : str;
}
/**
* Generate a random hex string of `len` chars.
*
* @param {number} - An even-numbered length string to generate.
* @return {string}
*/
function hexString(len) {
var bytes = new Uint8Array(len / 2);
crypto.getRandomValues(bytes);
return Array.from(bytes).map(byteToHex).join('');
}
module.exports = {
hexString,
};
'use strict';
var random = require('../random');
describe('sidebar.util.random', () => {
describe('#hexString', () => {
[2,4,8,16].forEach((len) => {
it(`returns a ${len} digit hex string`, () => {
var re = new RegExp(`^[0-9a-fA-F]{${len}}$`);
var str = random.hexString(len);
assert.isTrue(re.test(str));
});
});
});
});
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