Commit 921cad75 authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #476 from hypothesis/oauth-first-party-login

Use OAuth for first party login if enabled.
parents 85225124 dd18727d
......@@ -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.generateHexString(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 = {
generateHexString: 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 generateHexString(len) {
var bytes = new Uint8Array(len / 2);
crypto.getRandomValues(bytes);
return Array.from(bytes).map(byteToHex).join('');
}
module.exports = {
generateHexString,
};
'use strict';
var random = require('../random');
describe('sidebar.util.random', () => {
describe('#generateHexString', () => {
[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.generateHexString(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