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() {
}
}
function shouldUseOAuth() {
if (serviceConfig(settings)) {
return true;
}
return settings.oauthClientId && settings.oauthEnabled;
}
var authService;
if (serviceConfig(settings)) {
if (shouldUseOAuth()) {
authService = require('./oauth-auth');
} else {
authService = require('./auth');
......@@ -211,6 +218,7 @@ module.exports = angular.module('h', [
.value('Discovery', require('../shared/discovery'))
.value('ExcerptOverflowMonitor', require('./util/excerpt-overflow-monitor'))
.value('VirtualThreadList', require('./virtual-thread-list'))
.value('random', require('./util/random'))
.value('raven', require('./raven'))
.value('serviceConfig', serviceConfig)
.value('settings', settings)
......
......@@ -25,8 +25,8 @@ function authStateFromUserID(userid) {
// @ngInject
function HypothesisAppController(
$document, $location, $rootScope, $route, $scope,
$window, analytics, annotationUI, auth, bridge, drafts, features, frameSync, groups,
serviceUrl, session, settings, streamer
$window, analytics, annotationUI, auth, bridge, drafts, features,
flash, frameSync, groups, serviceUrl, session, settings, streamer
) {
var self = this;
......@@ -90,16 +90,32 @@ function HypothesisAppController(
}, 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 () {
if (serviceConfig(settings)) {
// Let the host page handle the login request
bridge.call(bridgeEvents.LOGIN_REQUESTED);
return;
return Promise.resolve();
}
if (auth.login) {
// 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(){
......
......@@ -18,6 +18,7 @@ describe('hypothesisApp', function () {
var fakeBridge = null;
var fakeDrafts = null;
var fakeFeatures = null;
var fakeFlash = null;
var fakeFrameSync = null;
var fakeLocation = null;
var fakeParams = null;
......@@ -88,6 +89,10 @@ describe('hypothesisApp', function () {
flagEnabled: sandbox.stub().returns(false),
};
fakeFlash = {
error: sandbox.stub(),
};
fakeFrameSync = {
connect: sandbox.spy(),
};
......@@ -101,6 +106,7 @@ describe('hypothesisApp', function () {
fakeSession = {
load: sandbox.stub().returns(Promise.resolve({userid: null})),
logout: sandbox.stub(),
reload: sandbox.stub().returns(Promise.resolve({userid: null})),
};
fakeGroups = {focus: sandbox.spy()};
......@@ -128,6 +134,7 @@ describe('hypothesisApp', function () {
$provide.value('analytics', fakeAnalytics);
$provide.value('drafts', fakeDrafts);
$provide.value('features', fakeFeatures);
$provide.value('flash', fakeFlash);
$provide.value('frameSync', fakeFrameSync);
$provide.value('serviceUrl', fakeServiceUrl);
$provide.value('session', fakeSession);
......@@ -354,6 +361,7 @@ describe('hypothesisApp', function () {
});
describe('#login()', function () {
context('when using cookie auth', () => {
it('shows the login dialog if not using a third-party service', function () {
// If no third-party annotation service is in use then it should show the
// built-in login dialog.
......@@ -361,6 +369,42 @@ describe('hypothesisApp', function () {
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 () {
// If the client is using a third-party annotation service then clicking
......
......@@ -39,8 +39,13 @@ function hostPageConfig(window) {
return Object.keys(config).reduce(function (result, key) {
if (paramWhiteList.indexOf(key) !== -1) {
// 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;
}, {});
}
......
......@@ -6,14 +6,24 @@ var resolve = require('./util/url-util').resolve;
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
* an opaque access token.
*/
// @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 tokenUrl = resolve('token', settings.apiUrl);
......@@ -80,6 +90,9 @@ function auth($http, flash, settings) {
// See https://tools.ietf.org/html/rfc7523#section-4
function exchangeToken(grantToken) {
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',
assertion: grantToken,
};
......@@ -128,7 +141,7 @@ function auth($http, flash, settings) {
function tokenGetter() {
if (!accessTokenPromise) {
var grantToken = (serviceConfig(settings) || {}).grantToken;
var grantToken = (serviceConfig(settings) || {}).grantToken || authCode;
if (grantToken) {
accessTokenPromise = exchangeToken(grantToken).then(function (tokenInfo) {
......@@ -154,9 +167,76 @@ function auth($http, flash, settings) {
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 {
clearCache: clearCache,
tokenGetter: tokenGetter,
clearCache,
login,
tokenGetter,
};
}
......
......@@ -91,8 +91,12 @@ function session($http, $q, $resource, $rootScope, analytics, annotationUI, auth
lastLoadTime = Date.now();
lastLoad = retryUtil.retryPromiseOperation(function () {
var authority = getAuthority();
if (auth.login || authority) {
var opts = {};
if (authority) {
return store.profile.read({authority: authority}).then(update);
opts.authority = authority;
}
return store.profile.read(opts).then(update);
} else {
return resource._load().$promise;
}
......@@ -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 {
dismissSidebarTutorial: dismissSidebarTutorial,
load: resource.load,
login: resource.login,
logout: logout,
reload: reload,
// 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
......
......@@ -44,4 +44,12 @@ describe('hostPageConfig', function () {
assert.deepEqual(hostPageConfig(window_), {});
});
it('ignores `null` values in config', function () {
var window_ = fakeWindow({
oauthEnabled: null,
});
assert.deepEqual(hostPageConfig(window_), {});
});
});
'use strict';
var { stringify } = require('query-string');
var authService = require('../oauth-auth');
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 nowStub;
var fakeHttp;
var fakeFlash;
var fakeRandom;
var fakeWindow;
var fakeSettings;
var clock;
var successfulFirstAccessTokenPromise;
......@@ -35,15 +75,29 @@ describe('oauth auth', function () {
error: sinon.stub(),
};
fakeRandom = {
hexString: sinon.stub().returns('notrandom'),
};
fakeSettings = {
apiUrl: 'https://hypothes.is/api/',
oauthAuthorizeUrl: 'https://hypothes.is/oauth/authorize/',
oauthClientId: 'the-client-id',
services: [{
authority: 'publisher.org',
grantToken: 'a.jwt.token',
}],
};
auth = authService(fakeHttp, fakeFlash, fakeSettings);
fakeWindow = new FakeWindow();
auth = authService(
fakeHttp,
fakeWindow,
fakeFlash,
fakeRandom,
fakeSettings
);
clock = sinon.useFakeTimers();
});
......@@ -131,9 +185,7 @@ describe('oauth auth', function () {
});
it('should return null if no grant token was provided', function () {
var auth = authService(fakeHttp, fakeFlash, {
services: [{authority: 'publisher.org'}],
});
fakeSettings.services = [{ authority: 'publisher.org' }];
return auth.tokenGetter().then(function (token) {
assert.notCalled(fakeHttp.post);
assert.equal(token, null);
......@@ -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.
function expireAccessToken () {
clock.tick(DEFAULT_TOKEN_EXPIRES_IN_SECS * 1000);
......
......@@ -43,6 +43,7 @@ describe('session', function () {
};
fakeAuth = {
clearCache: sandbox.spy(),
login: null, // Use cookie-based auth
};
fakeFlash = {error: sandbox.spy()};
fakeRaven = {
......@@ -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 () {
$httpBackend.expectGET(url).respond({});
session.load();
......@@ -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 () {
var postExpectation;
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