Commit 229616ce authored by Nick Stenning's avatar Nick Stenning

Merge pull request #3084 from hypothesis/simplify-client-auth

Simplify API authentication in the client and fix #3083, #2924
parents 9f1eb5ef e5af8365
...@@ -6,11 +6,25 @@ var annotationMetadata = require('./annotation-metadata'); ...@@ -6,11 +6,25 @@ var annotationMetadata = require('./annotation-metadata');
var events = require('./events'); var events = require('./events');
var parseAccountID = require('./filter/persona').parseAccountID; var parseAccountID = require('./filter/persona').parseAccountID;
function authStateFromUserID(userid) {
if (userid) {
var parsed = parseAccountID(userid);
return {
status: 'signed-in',
userid: userid,
username: parsed.username,
provider: parsed.provider,
};
} else {
return {status: 'signed-out'};
}
}
// @ngInject // @ngInject
module.exports = function AppController( module.exports = function AppController(
$controller, $document, $location, $rootScope, $route, $scope, $controller, $document, $location, $rootScope, $route, $scope,
$window, annotationUI, auth, drafts, features, groups, $window, annotationUI, auth, drafts, features, groups,
identity, session session
) { ) {
$controller('AnnotationUIController', {$scope: $scope}); $controller('AnnotationUIController', {$scope: $scope});
...@@ -46,37 +60,21 @@ module.exports = function AppController( ...@@ -46,37 +60,21 @@ module.exports = function AppController(
// Reload the view when the user switches accounts // Reload the view when the user switches accounts
$scope.$on(events.USER_CHANGED, function (event, data) { $scope.$on(events.USER_CHANGED, function (event, data) {
$scope.auth = authStateFromUserID(data.userid);
$scope.accountDialog.visible = false;
if (!data || !data.initialLoad) { if (!data || !data.initialLoad) {
$route.reload(); $route.reload();
} }
}); });
identity.watch({ session.load().then(function (state) {
onlogin: function (identity) { // When the authentication status of the user is known,
// Hide the account dialog // update the auth info in the top bar and show the login form
$scope.accountDialog.visible = false; // after first install of the extension.
// Update the current logged-in user information $scope.auth = authStateFromUserID(state.userid);
var userid = auth.userid(identity); if (!state.userid && isFirstRun) {
var parsed = parseAccountID(userid); $scope.login();
angular.copy({
status: 'signed-in',
userid: userid,
username: parsed.username,
provider: parsed.provider,
}, $scope.auth);
},
onlogout: function () {
angular.copy({status: 'signed-out'}, $scope.auth);
},
onready: function () {
// If their status is still 'unknown', then `onlogin` wasn't called and
// we know the current user isn't signed in.
if ($scope.auth.status === 'unknown') {
angular.copy({status: 'signed-out'}, $scope.auth);
if (isFirstRun) {
$scope.login();
}
}
} }
}); });
...@@ -106,9 +104,6 @@ module.exports = function AppController( ...@@ -106,9 +104,6 @@ module.exports = function AppController(
// 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.
$scope.login = function () { $scope.login = function () {
$scope.accountDialog.visible = true; $scope.accountDialog.visible = true;
return identity.request({
oncancel: function () { $scope.accountDialog.visible = false; }
});
}; };
// Prompt to discard any unsaved drafts. // Prompt to discard any unsaved drafts.
...@@ -127,16 +122,17 @@ module.exports = function AppController( ...@@ -127,16 +122,17 @@ module.exports = function AppController(
// Log the user out. // Log the user out.
$scope.logout = function () { $scope.logout = function () {
if (promptToLogout()) { if (!promptToLogout()) {
var iterable = drafts.unsaved(); return;
for (var i = 0, draft; i < iterable.length; i++) { }
draft = iterable[i]; var iterable = drafts.unsaved();
$rootScope.$emit("annotationDeleted", draft); for (var i = 0, draft; i < iterable.length; i++) {
} draft = iterable[i];
drafts.discard(); $rootScope.$emit("annotationDeleted", draft);
$scope.accountDialog.visible = false;
return identity.logout();
} }
drafts.discard();
$scope.accountDialog.visible = false;
return auth.logout();
}; };
$scope.clearSelection = function () { $scope.clearSelection = function () {
......
...@@ -90,6 +90,15 @@ function setupCrossFrame(crossframe) { ...@@ -90,6 +90,15 @@ function setupCrossFrame(crossframe) {
return crossframe.connect(); return crossframe.connect();
} }
// @ngInject
function configureHttp($httpProvider, jwtInterceptorProvider) {
// Use the Pyramid XSRF header name
$httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token';
// Setup JWT tokens for API requests
$httpProvider.interceptors.push('jwtInterceptor');
jwtInterceptorProvider.tokenGetter = require('./auth').tokenGetter;
}
// @ngInject // @ngInject
function setupHttp($http) { function setupHttp($http) {
$http.defaults.headers.common['X-Client-Id'] = streamer.clientId; $http.defaults.headers.common['X-Client-Id'] = streamer.clientId;
...@@ -147,11 +156,9 @@ module.exports = angular.module('h', [ ...@@ -147,11 +156,9 @@ module.exports = angular.module('h', [
.filter('converter', require('./filter/converter')) .filter('converter', require('./filter/converter'))
.provider('identity', require('./identity'))
.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('auth', require('./auth').service)
.service('bridge', require('./bridge')) .service('bridge', require('./bridge'))
.service('crossframe', require('./cross-frame')) .service('crossframe', require('./cross-frame'))
.service('drafts', require('./drafts')) .service('drafts', require('./drafts'))
...@@ -181,10 +188,9 @@ module.exports = angular.module('h', [ ...@@ -181,10 +188,9 @@ module.exports = angular.module('h', [
.value('settings', settings) .value('settings', settings)
.value('time', require('./time')) .value('time', require('./time'))
.config(configureHttp)
.config(configureLocation) .config(configureLocation)
.config(configureRoutes) .config(configureRoutes)
.run(setupCrossFrame) .run(setupCrossFrame)
.run(setupHttp); .run(setupHttp);
require('./config/module');
'use strict';
/**
* Provides functions for retrieving and caching API tokens required by
* API requests and signing out of the API.
*/
var queryString = require('query-string');
var INITIAL_TOKEN = {
// The user ID which the current cached token is valid for
userid: undefined,
// Promise for the API token for 'userid'.
// This is initialized when fetchOrReuseToken() is called and
// reset when signing out via logout()
token: undefined,
};
var cachedToken = INITIAL_TOKEN;
/**
* Fetches a new API token for the current logged-in user.
*
* @return {Promise} - A promise for a new JWT token.
*/
// @ngInject
function fetchToken($http, session, settings) {
var tokenUrl = new URL('token', settings.apiUrl).href;
var config = {
params: {
assertion: session.state.csrf,
},
// Skip JWT authorization for the token request itself.
skipAuthorization: true,
transformRequest: function (data) {
return queryString.stringify(data);
}
};
return $http.get(tokenUrl, config).then(function (response) {
return response.data;
});
}
/** /**
* @ngdoc service * Fetches or returns a cached JWT API token for the current user.
* @name auth
* *
* @description * @return {Promise} - A promise for a JWT API token for the current
* The 'auth' service exposes authorization helpers for other components. * user.
*/ */
// @ngInject // @ngInject
function Auth(jwtHelper) { function fetchOrReuseToken($http, jwtHelper, session, settings) {
this.userid = function userid(identity) { function refreshToken() {
try { return fetchToken($http, session, settings).then(function (token) {
if (jwtHelper.isTokenExpired(identity)) { return token;
return null; });
}
var userid;
return session.load()
.then(function (data) {
userid = data.userid;
if (userid === cachedToken.userid && cachedToken.token) {
return cachedToken.token;
} else { } else {
var payload = jwtHelper.decodeToken(identity); cachedToken = {
return payload.sub || null; userid: userid,
token: refreshToken(),
};
return cachedToken.token;
} }
} catch (error) { })
return null; .then(function (token) {
} if (jwtHelper.isTokenExpired(token)) {
cachedToken = {
userid: userid,
token: refreshToken(),
};
return cachedToken.token;
} else {
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() {
cachedToken = INITIAL_TOKEN;
}
// @ngInject
function authService(flash, session) {
/**
* Sign out from the API and clear any cached tokens.
*
* @return {Promise<void>} - A promise for when signout has completed.
*/
function logout() {
return session.logout({}).$promise
.then(function() {
clearCache();
})
.catch(function(err) {
flash.error('Sign out failed!');
throw err;
});
}
return {
logout: logout,
}; };
} }
module.exports = Auth; module.exports = {
tokenGetter: tokenGetter,
clearCache: clearCache,
service: authService,
};
// @ngInject
function configureHttp($httpProvider) {
// Use the Pyramid XSRF header name
$httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token';
// Use the JWT request interceptor
$httpProvider.interceptors.push('jwtInterceptor');
}
module.exports = configureHttp;
var querystring = require('querystring');
var authPromise = null;
var tokenPromise = null;
var currentToken = null;
// @ngInject
function fetchToken($http, $q, jwtHelper, session, settings) {
var tokenUrl = new URL('token', settings.apiUrl).href;
if (currentToken === null || jwtHelper.isTokenExpired(currentToken)) {
if (tokenPromise === null) {
// Set up the token request data.
var data = {
assertion: session.state.csrf
};
// Skip JWT authorization for the token request itself.
var config = {
params: data,
skipAuthorization: true,
transformRequest: function (data) {
return querystring.stringify(data);
}
};
// Make the request.
var request = $http.get(tokenUrl, config);
// Extract and save the response data.
tokenPromise = request.then(function (response) {
tokenPromise = null;
currentToken = response.data;
return currentToken;
});
}
// Return a promise of the access token.
return tokenPromise;
} else {
// The token is available and not expired.
return $q.when(currentToken);
}
}
// @ngInject
function tokenGetter($injector, config, settings) {
var requestUrl = config.url;
// Only send the token on requests to the annotation storage service
// and only if it is not the token request itself.
if (requestUrl !== settings.apiUrl) {
if (requestUrl.slice(0, settings.apiUrl.length) === settings.apiUrl) {
return authPromise
.then(function () {
return $injector.invoke(fetchToken);
})
.catch(function () {
return null;
});
}
}
return null;
}
// @ngInject
function checkAuthentication($injector, $q, session) {
if (authPromise === null) {
var deferred = $q.defer();
authPromise = deferred.promise;
session.load()
.then(function (data) {
if (data.userid) {
$injector.invoke(fetchToken).then(function (token) {
deferred.resolve(token);
});
} else {
deferred.reject('no session');
}
})
.catch(function () {
deferred.reject('request failure');
});
}
return authPromise;
}
// @ngInject
function forgetAuthentication($q, flash, session) {
return session.logout({}).$promise
.then(function() {
authPromise = $q.reject('no session');
tokenPromise = null;
currentToken = null;
return null;
})
.catch(function(err) {
flash.error('Sign out failed!');
throw err;
});
}
// @ngInject
function requestAuthentication($injector, $q, $rootScope) {
return authPromise.catch(function () {
var deferred = $q.defer();
$rootScope.$on('auth', function(event, err, data) {
if (err) {
deferred.reject(err);
} else {
$injector.invoke(fetchToken).then(function (token) {
authPromise = deferred.promise;
deferred.resolve(token);
});
}
});
return deferred.promise;
});
}
// @ngInject
function configureIdentity(identityProvider, jwtInterceptorProvider) {
// Configure the identity provider.
identityProvider.checkAuthentication = checkAuthentication;
identityProvider.forgetAuthentication = forgetAuthentication;
identityProvider.requestAuthentication = requestAuthentication;
// Provide tokens from the token service to the JWT request interceptor.
jwtInterceptorProvider.tokenGetter = tokenGetter;
}
module.exports = configureIdentity;
var angular = require('angular');
var configureHttp = require('./http');
var configureIdentity = require('./identity');
module.exports = angular.module('h')
.config(configureHttp)
.config(configureIdentity)
;
'use strict'; 'use strict';
var angular = require('angular');
// @ngInject // @ngInject
function Controller($scope, $timeout, flash, session, formRespond, settings) { function Controller($scope, $timeout, flash, session, formRespond, settings) {
var pendingTimeout = null; var pendingTimeout = null;
...@@ -11,10 +13,10 @@ function Controller($scope, $timeout, flash, session, formRespond, settings) { ...@@ -11,10 +13,10 @@ function Controller($scope, $timeout, flash, session, formRespond, settings) {
angular.copy({}, $scope.model); angular.copy({}, $scope.model);
if ($scope.form != null) { if ($scope.form) {
$scope.form.$setPristine() $scope.form.$setPristine();
} }
}; }
function failure(form, response) { function failure(form, response) {
var errors, reason; var errors, reason;
...@@ -28,12 +30,12 @@ function Controller($scope, $timeout, flash, session, formRespond, settings) { ...@@ -28,12 +30,12 @@ function Controller($scope, $timeout, flash, session, formRespond, settings) {
} }
return formRespond(form, errors, reason); return formRespond(form, errors, reason);
}; }
function timeout() { function timeout() {
angular.copy({}, $scope.model); angular.copy({}, $scope.model);
if ($scope.form != null) { if ($scope.form) {
$scope.form.$setPristine(); $scope.form.$setPristine();
} }
...@@ -42,7 +44,7 @@ function Controller($scope, $timeout, flash, session, formRespond, settings) { ...@@ -42,7 +44,7 @@ function Controller($scope, $timeout, flash, session, formRespond, settings) {
} }
function cancelTimeout() { function cancelTimeout() {
if (pendingTimeout == null) { if (!pendingTimeout) {
return; return;
} }
$timeout.cancel(pendingTimeout); $timeout.cancel(pendingTimeout);
...@@ -68,7 +70,7 @@ function Controller($scope, $timeout, flash, session, formRespond, settings) { ...@@ -68,7 +70,7 @@ function Controller($scope, $timeout, flash, session, formRespond, settings) {
}); });
}; };
if ($scope.model == null) { if (!$scope.model) {
$scope.model = {}; $scope.model = {};
} }
......
###*
# @ngdoc provider
# @name identityProvider
# @property {function} checkAuthentication A function to check for an
# authenticated user. This function should return an authorization certificate,
# or a promise of the same, if the user has authorized signing in to the
# application. Its arguments are injected.
#
# @property {function} forgetAuthentication A function to forget the current
# authentication. The return value, if any, will be resolved as a promise
# before the identity service fires logout callbacks. This function should
# ensure any active session is invalidated. Its arguments are injected.
#
# @property {function} requestAuthentication A function to request that the
# the user begin authenticating. This function should start a flow that
# authenticates the user before asking the user grant authorization to the
# application and then returning an authorization certificate or a promise
# of the same. Its arguments are injected.
#
# @description
# The `identityProvider` is used to configure functions that fulfill
# identity authorization state management requests. It allows applications
# that perform authentication to export their authentication responding to
# identity authorization requests from clients consuming the
# {@link h.identity:identity identity} service.
#
# An application wishing to export an identity provider should override all
# of the public methods of this provider.
###
module.exports = ->
checkAuthentication: ['$q', ($q) ->
$q.reject 'Not implemented idenityProvider#checkAuthentication.'
]
forgetAuthentication: ['$q', ($q) ->
$q.reject 'Not implemented idenityProvider#forgetAuthentication.'
]
requestAuthentication: ['$q', ($q) ->
$q.reject 'Not implemented idenityProvider#requestAuthentication.'
]
###*
# @ngdoc service
# @name identity
# @description
# This service is used by a client application to request authorization for
# the user identity (login), relinquish authorization (logout), and set
# callbacks to observe identity changes.
#
# See https://developer.mozilla.org/en-US/docs/Web/API/navigator.id
###
$get: [
'$injector', '$q',
($injector, $q) ->
provider = this
onlogin = null
onlogout = null
###*
# @ngdoc method
# @name identity#logout
# @description
# https://developer.mozilla.org/en-US/docs/Web/API/navigator.id.logout
###
logout: ->
result = $injector.invoke(provider.forgetAuthentication, provider)
$q.when(result).then(onlogout)
###*
# @ngdoc method
# @name identity#request
# @description
# https://developer.mozilla.org/en-US/docs/Web/API/navigator.id.request
###
request: (options={}) ->
{oncancel} = options
result = $injector.invoke(provider.requestAuthentication, provider)
$q.when(result).then(onlogin, oncancel)
###*
# @ngdoc method
# @name identity#watch
# @description
# https://developer.mozilla.org/en-US/docs/Web/API/navigator.id.watch
###
watch: (options={}) ->
{onlogin, onlogout, onready} = options
for key, fn of options when key.match /^on/
unless angular.isFunction fn
throw new Error 'argument "' + key + '" must be a function'
unless onlogin then throw new Error 'argument "onlogin" is required'
result = $injector.invoke(provider.checkAuthentication, provider)
$q.when(result).then(onlogin).finally(-> onready?())
]
...@@ -96,7 +96,7 @@ function session($http, $resource, $rootScope, flash, raven, settings) { ...@@ -96,7 +96,7 @@ function session($http, $resource, $rootScope, flash, raven, settings) {
}); });
} }
return lastLoad; return lastLoad;
} };
/** /**
* @name session.update() * @name session.update()
...@@ -131,13 +131,14 @@ function session($http, $resource, $rootScope, flash, raven, settings) { ...@@ -131,13 +131,14 @@ function session($http, $resource, $rootScope, flash, raven, settings) {
if (userChanged) { if (userChanged) {
$rootScope.$broadcast(events.USER_CHANGED, { $rootScope.$broadcast(events.USER_CHANGED, {
initialLoad: isInitialLoad, initialLoad: isInitialLoad,
userid: model.userid,
}); });
// associate error reports with the current user in Sentry // associate error reports with the current user in Sentry
if (resource.state.userid) { if (resource.state.userid) {
raven.setUserInfo({ raven.setUserInfo({
id: resource.state.userid, id: resource.state.userid,
}) });
} else { } else {
raven.setUserInfo(undefined); raven.setUserInfo(undefined);
} }
...@@ -153,7 +154,7 @@ function session($http, $resource, $rootScope, flash, raven, settings) { ...@@ -153,7 +154,7 @@ function session($http, $resource, $rootScope, flash, raven, settings) {
return model; return model;
}; };
function process(data, headersGetter) { function process(data) {
// Parse as json // Parse as json
data = angular.fromJson(data); data = angular.fromJson(data);
......
...@@ -12,12 +12,12 @@ describe('AppController', function () { ...@@ -12,12 +12,12 @@ describe('AppController', function () {
var fakeAuth = null; var fakeAuth = null;
var fakeDrafts = null; var fakeDrafts = null;
var fakeFeatures = null; var fakeFeatures = null;
var fakeIdentity = null;
var fakeLocation = null; var fakeLocation = null;
var fakeParams = null; var fakeParams = null;
var fakeSession = null; var fakeSession = null;
var fakeGroups = null; var fakeGroups = null;
var fakeRoute = null; var fakeRoute = null;
var fakeSettings = null;
var fakeWindow = null; var fakeWindow = null;
var sandbox = null; var sandbox = null;
...@@ -45,7 +45,7 @@ describe('AppController', function () { ...@@ -45,7 +45,7 @@ describe('AppController', function () {
}; };
fakeAuth = { fakeAuth = {
userid: sandbox.stub() logout: sandbox.stub().returns(Promise.resolve()),
}; };
fakeDrafts = { fakeDrafts = {
...@@ -62,19 +62,15 @@ describe('AppController', function () { ...@@ -62,19 +62,15 @@ describe('AppController', function () {
flagEnabled: sandbox.stub().returns(false) flagEnabled: sandbox.stub().returns(false)
}; };
fakeIdentity = {
watch: sandbox.spy(),
request: sandbox.spy(),
logout: sandbox.stub()
};
fakeLocation = { fakeLocation = {
search: sandbox.stub().returns({}) search: sandbox.stub().returns({})
}; };
fakeParams = {id: 'test'}; fakeParams = {id: 'test'};
fakeSession = {}; fakeSession = {
load: sandbox.stub().returns(Promise.resolve({userid: null})),
};
fakeGroups = {focus: sandbox.spy()}; fakeGroups = {focus: sandbox.spy()};
...@@ -85,12 +81,16 @@ describe('AppController', function () { ...@@ -85,12 +81,16 @@ describe('AppController', function () {
confirm: sandbox.stub() confirm: sandbox.stub()
}; };
fakeSettings = {
firstRun: false,
};
$provide.value('annotationUI', fakeAnnotationUI); $provide.value('annotationUI', fakeAnnotationUI);
$provide.value('auth', fakeAuth); $provide.value('auth', fakeAuth);
$provide.value('drafts', fakeDrafts); $provide.value('drafts', fakeDrafts);
$provide.value('features', fakeFeatures); $provide.value('features', fakeFeatures);
$provide.value('identity', fakeIdentity);
$provide.value('session', fakeSession); $provide.value('session', fakeSession);
$provide.value('settings', fakeSettings);
$provide.value('groups', fakeGroups); $provide.value('groups', fakeGroups);
$provide.value('$route', fakeRoute); $provide.value('$route', fakeRoute);
$provide.value('$location', fakeLocation); $provide.value('$location', fakeLocation);
...@@ -123,46 +123,54 @@ describe('AppController', function () { ...@@ -123,46 +123,54 @@ describe('AppController', function () {
}); });
}); });
it('watches the identity service for identity change events', function () {
createController();
assert.calledOnce(fakeIdentity.watch);
});
it('auth.status is "unknown" on startup', function () { it('auth.status is "unknown" on startup', function () {
createController(); createController();
assert.equal($scope.auth.status, 'unknown'); assert.equal($scope.auth.status, 'unknown');
}); });
it('sets auth.status to "signed-out" when the identity has been checked but the user is not authenticated', function () { it('sets auth.status to "signed-out" if userid is null', function () {
createController(); createController();
var identityCallbackArgs = fakeIdentity.watch.args[0][0]; return fakeSession.load().then(function () {
identityCallbackArgs.onready(); assert.equal($scope.auth.status, 'signed-out');
assert.equal($scope.auth.status, 'signed-out'); });
}); });
it('sets auth.status to "signed-in" when the identity has been checked and the user is authenticated', function () { it('sets auth.status to "signed-in" if userid is non-null', function () {
fakeSession.load = function () {
return Promise.resolve({userid: 'acct:jim@hypothes.is'});
};
createController(); createController();
fakeAuth.userid.withArgs('test-assertion').returns('acct:hey@joe'); return fakeSession.load().then(function () {
var identityCallbackArgs = fakeIdentity.watch.args[0][0]; assert.equal($scope.auth.status, 'signed-in');
identityCallbackArgs.onlogin('test-assertion'); });
assert.equal($scope.auth.status, 'signed-in');
}); });
it('sets userid, username, and provider properties at login', function () { it('sets userid, username, and provider properties at login', function () {
fakeSession.load = function () {
return Promise.resolve({userid: 'acct:jim@hypothes.is'});
};
createController(); createController();
fakeAuth.userid.withArgs('test-assertion').returns('acct:hey@joe'); return fakeSession.load().then(function () {
var identityCallbackArgs = fakeIdentity.watch.args[0][0]; assert.equal($scope.auth.userid, 'acct:jim@hypothes.is');
identityCallbackArgs.onlogin('test-assertion'); assert.equal($scope.auth.username, 'jim');
assert.equal($scope.auth.userid, 'acct:hey@joe'); assert.equal($scope.auth.provider, 'hypothes.is');
assert.equal($scope.auth.username, 'hey'); });
assert.equal($scope.auth.provider, 'joe');
}); });
it('sets auth.status to "signed-out" at logout', function () { it('updates auth when the logged-in user changes', function () {
createController(); createController();
var identityCallbackArgs = fakeIdentity.watch.args[0][0]; return fakeSession.load().then(function () {
identityCallbackArgs.onlogout(); $scope.$broadcast(events.USER_CHANGED, {
assert.equal($scope.auth.status, "signed-out"); initialLoad: false,
userid: 'acct:john@hypothes.is',
});
assert.deepEqual($scope.auth, {
status: 'signed-in',
userid: 'acct:john@hypothes.is',
username: 'john',
provider: 'hypothes.is',
});
});
}); });
it('does not show login form for logged in users', function () { it('does not show login form for logged in users', function () {
......
var inject = angular.mock.inject; 'use strict';
var module = angular.mock.module;
describe('h', function () { var auth = require('../auth');
var auth = null;
var fakeJwtHelper = null;
var sandbox = null;
before(function () { describe('auth', function () {
angular.module('h', []) var fakeHttp;
.service('auth', require('../auth')); var fakeJwtHelper;
}); var fakeSettings;
var fakeSession;
var fakeTokens = ['token-one', 'token-two'];
var fakeTokenIndex;
beforeEach(function () { beforeEach(function () {
module('h'); fakeTokenIndex = 0;
}); fakeHttp = {
get: sinon.spy(function (url, config) {
beforeEach(module(function ($provide) { assert.equal(config.skipAuthorization, true);
sandbox = sinon.sandbox.create(); assert.equal(url, 'https://test.hypothes.is/api/token');
assert.equal(config.params.assertion, fakeSession.state.csrf);
fakeJwtHelper = { var result = {status: 200, data: fakeTokens[fakeTokenIndex]};
decodeToken: sandbox.stub(), ++fakeTokenIndex;
isTokenExpired: sandbox.stub() return Promise.resolve(result);
}),
}; };
fakeJwtHelper = {isTokenExpired: sinon.stub()};
$provide.value('jwtHelper', fakeJwtHelper); fakeSession = {
})); load: sinon.spy(function () {
return Promise.resolve(fakeSession.state);
beforeEach(inject(function (_auth_) { }),
auth = _auth_ logout: sinon.spy(function () {
})); return {$promise: Promise.resolve()};
}),
state: {
csrf: 'fake-csrf-token',
},
};
fakeSettings = {
apiUrl: 'https://test.hypothes.is/api/',
};
});
afterEach(function () { afterEach(function () {
sandbox.restore() auth.clearCache();
}); });
it('returns the subject of a valid jwt', function () { describe('tokenGetter', function () {
var identity = 'fake-identity'; function tokenGetter() {
fakeJwtHelper.isTokenExpired.withArgs(identity).returns(false); var config = {url:'https://test.hypothes.is/api/search'};
fakeJwtHelper.decodeToken.withArgs(identity).returns({sub: 'pandora'}); return auth.tokenGetter(fakeHttp, config, fakeJwtHelper,
var userid = auth.userid(identity); fakeSession, fakeSettings);
assert.equal(userid, 'pandora'); }
});
it('should fetch and return a new token', function () {
return tokenGetter().then(function (token) {
assert.called(fakeHttp.get);
assert.equal(token, fakeTokens[0]);
});
});
it('should cache tokens for future use', function () {
return tokenGetter().then(function () {
return tokenGetter();
}).then(function (token) {
assert.calledOnce(fakeHttp.get);
assert.equal(token, fakeTokens[0]);
});
});
it('should refresh expired tokens', function () {
return tokenGetter().then(function () {
fakeJwtHelper.isTokenExpired = function () {
return true;
};
return tokenGetter();
}).then(function (token) {
assert.calledTwice(fakeHttp.get);
assert.equal(token, fakeTokens[1]);
});
});
it('returns null for an expired jwt', function () { it('should fetch a new token if the userid changes', function () {
var identity = 'fake-identity'; return tokenGetter().then(function () {
fakeJwtHelper.isTokenExpired.withArgs(identity).returns(true); fakeSession.state.userid = 'new-user-id';
var userid = auth.userid(identity); return tokenGetter();
assert.isNull(userid); }).then(function (token) {
assert.calledTwice(fakeHttp.get);
assert.equal(token, fakeTokens[1]);
});
});
}); });
it('returns null for an invalid jwt', function () { describe('.logout', function () {
var identity = 'fake-identity'; it('should call session.logout', function () {
fakeJwtHelper.decodeToken.withArgs(identity).throws('Error'); var fakeFlash = {error: sinon.stub()};
var userid = auth.userid(identity); return auth.service(fakeFlash, fakeSession).logout().then(function () {
assert.isNull(userid); assert.called(fakeSession.logout);
});
});
}); });
}); });
{module, inject} = angular.mock
sandbox = sinon.sandbox.create()
describe 'identityProvider', ->
provider = null
mockInjectable = {}
before ->
angular.module('h')
.provider('identity', require('../identity'))
beforeEach module('h')
beforeEach module ($provide, identityProvider) ->
$provide.value('foo', mockInjectable)
provider = identityProvider
return
afterEach ->
sandbox.restore()
describe 'identity', ->
scope = null
service = null
beforeEach inject ($rootScope, identity) ->
scope = $rootScope
service = identity
injects = (name, cb) ->
it 'invokes identityProvider##{name} with injection', ->
provider[name] = ['foo', sinon.spy((foo) ->)]
cb()
assert.calledWith provider[name][1], mockInjectable
describe '#logout()', ->
onlogin = angular.noop
onlogout = null
beforeEach ->
onlogout = sandbox.spy()
injects 'forgetAuthentication', -> service.logout()
it 'invokes onlogout on success', ->
onlogout = sandbox.spy()
provider.forgetAuthentication = angular.noop
service.watch({onlogin, onlogout})
service.logout()
scope.$digest()
assert.called onlogout
it 'does not invoke onlogout on failure', ->
provider.forgetAuthentication = ($q) -> $q.reject()
service.watch({onlogin, onlogout})
service.logout()
scope.$digest()
assert.notCalled onlogout
describe '#request()', ->
onlogin = null
beforeEach ->
onlogin = sandbox.spy()
injects 'requestAuthentication', -> service.request()
it 'invokes onlogin with the authorization result on success', ->
provider.requestAuthentication = -> userid: 'alice'
service.watch({onlogin})
service.request()
scope.$digest()
assert.calledWith onlogin, sinon.match(userid: 'alice')
it 'invokes oncancel on failure', ->
oncancel = sandbox.spy()
provider.requestAuthentication = ($q) -> $q.reject('canceled')
service.watch({onlogin})
service.request({oncancel})
scope.$digest()
assert.called oncancel
it 'does not invoke onlogin on failure', ->
provider.requestAuthentication = ($q) -> $q.reject('canceled')
service.watch({onlogin})
scope.$digest()
assert.notCalled onlogin
describe '#watch()', ->
onlogin = null
beforeEach ->
onlogin = sandbox.spy()
injects 'checkAuthentication', -> service.watch(onlogin: angular.noop)
it 'requires an onlogin option', ->
assert.throws (-> service.watch())
it 'requires callback options to be functions', ->
assert.throws (-> service.watch(onlogin: angular.noop, onlogout: 'foo'))
assert.throws (-> service.watch(onlogin: 'foo', onlogout: angular.noop))
it 'invokes onlogin with the authorization result on success', ->
provider.checkAuthentication = -> userid: 'alice'
service.watch({onlogin})
scope.$digest()
assert.calledWith onlogin, sinon.match(userid: 'alice')
it 'does not invoke onlogin on failure', ->
provider.checkAuthentication = ($q) -> $q.reject('canceled')
service.watch({onlogin})
scope.$digest()
assert.notCalled onlogin
it 'invokes onready after onlogin on success', ->
onready = sandbox.spy -> assert.called onlogin
provider.checkAuthentication = angular.noop
service.watch({onlogin, onready})
scope.$digest()
assert.called onready
it 'invokes onready on failure', ->
onready = sandbox.spy()
provider.checkAuthentication = ($q) -> $q.reject('canceled')
service.watch({onlogin: angular.noop, onready})
scope.$digest()
assert.called onready
...@@ -50,6 +50,7 @@ ...@@ -50,6 +50,7 @@
"node-uuid": "^1.4.3", "node-uuid": "^1.4.3",
"page": "^1.6.4", "page": "^1.6.4",
"postcss": "^5.0.6", "postcss": "^5.0.6",
"query-string": "^3.0.1",
"raf": "^3.1.0", "raf": "^3.1.0",
"raven-js": "^2.0.2", "raven-js": "^2.0.2",
"retry": "^0.8.0", "retry": "^0.8.0",
......
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