Commit e5af8365 authored by Robert Knight's avatar Robert Knight

Simplify API authentication in the client

In order to make API requests, the client needs to
fetch a JWT token and then configure 'angular-jwt'
to provide it on subsequent HTTP requests to
API endpoints.

This fairly simple task was complicated by
the inclusion of an emulation of the deprecated
Mozilla IdentityManager API - see https://developer.mozilla.org/en-US/docs/Web/API/IdentityManager

This commit replaces the identity module with
a much simpler implementation that only does
what we actually need at present:

 1. Enable the 'angular-jwt' interceptor which
    adds 'Authorization: Bearer <Token>' headers
    to API HTTP requests.

 2. Provide the JWT interceptor with a function
    which fetches JWT tokens and caches them.

The new implementation fixes two bugs in the previous
implementation:

 1. Cached API tokens were not invalidated properly when
    signing out (#3083).

    (In the old code, 'authPromise' was set to a rejected promise
     after signing out, but 'checkAuthentication()' checked for
     'authPromise' being _null_ when deciding whether to retrieve
     a new token. Consequently API requests made immediately
     after signing in could end up being unauthenticated).

 2. The value of $scope.auth.username and session.state.userid
    could get out of sync (#2924).

    In the new implementation, $scope.auth.username is always
    updated whenever the USER_CHANGED event is emitted and that
    event is always emitted when session.state.userid changes.

Fixes #3083
Fixes #2924
parent e0ef6831
......@@ -6,11 +6,25 @@ var annotationMetadata = require('./annotation-metadata');
var events = require('./events');
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
module.exports = function AppController(
$controller, $document, $location, $rootScope, $route, $scope,
$window, annotationUI, auth, drafts, features, groups,
identity, session
session
) {
$controller('AnnotationUIController', {$scope: $scope});
......@@ -46,38 +60,22 @@ module.exports = function AppController(
// Reload the view when the user switches accounts
$scope.$on(events.USER_CHANGED, function (event, data) {
$scope.auth = authStateFromUserID(data.userid);
$scope.accountDialog.visible = false;
if (!data || !data.initialLoad) {
$route.reload();
}
});
identity.watch({
onlogin: function (identity) {
// Hide the account dialog
$scope.accountDialog.visible = false;
// Update the current logged-in user information
var userid = auth.userid(identity);
var parsed = parseAccountID(userid);
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) {
session.load().then(function (state) {
// When the authentication status of the user is known,
// update the auth info in the top bar and show the login form
// after first install of the extension.
$scope.auth = authStateFromUserID(state.userid);
if (!state.userid && isFirstRun) {
$scope.login();
}
}
}
});
$scope.$watch('sort.name', function (name) {
......@@ -106,9 +104,6 @@ module.exports = function AppController(
// Start the login flow. This will present the user with the login dialog.
$scope.login = function () {
$scope.accountDialog.visible = true;
return identity.request({
oncancel: function () { $scope.accountDialog.visible = false; }
});
};
// Prompt to discard any unsaved drafts.
......@@ -127,7 +122,9 @@ module.exports = function AppController(
// Log the user out.
$scope.logout = function () {
if (promptToLogout()) {
if (!promptToLogout()) {
return;
}
var iterable = drafts.unsaved();
for (var i = 0, draft; i < iterable.length; i++) {
draft = iterable[i];
......@@ -135,8 +132,7 @@ module.exports = function AppController(
}
drafts.discard();
$scope.accountDialog.visible = false;
return identity.logout();
}
return auth.logout();
};
$scope.clearSelection = function () {
......
......@@ -90,6 +90,15 @@ function setupCrossFrame(crossframe) {
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
function setupHttp($http) {
$http.defaults.headers.common['X-Client-Id'] = streamer.clientId;
......@@ -147,11 +156,9 @@ module.exports = angular.module('h', [
.filter('converter', require('./filter/converter'))
.provider('identity', require('./identity'))
.service('annotationMapper', require('./annotation-mapper'))
.service('annotationUI', require('./annotation-ui'))
.service('auth', require('./auth'))
.service('auth', require('./auth').service)
.service('bridge', require('./bridge'))
.service('crossframe', require('./cross-frame'))
.service('drafts', require('./drafts'))
......@@ -181,10 +188,9 @@ module.exports = angular.module('h', [
.value('settings', settings)
.value('time', require('./time'))
.config(configureHttp)
.config(configureLocation)
.config(configureRoutes)
.run(setupCrossFrame)
.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;
/**
* @ngdoc service
* @name auth
* Fetches a new API token for the current logged-in user.
*
* @description
* The 'auth' service exposes authorization helpers for other components.
* @return {Promise} - A promise for a new JWT token.
*/
// @ngInject
function Auth(jwtHelper) {
this.userid = function userid(identity) {
try {
if (jwtHelper.isTokenExpired(identity)) {
return null;
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;
});
}
/**
* Fetches or returns a cached JWT API token for the current user.
*
* @return {Promise} - A promise for a JWT API token for the current
* user.
*/
// @ngInject
function fetchOrReuseToken($http, jwtHelper, session, settings) {
function refreshToken() {
return fetchToken($http, session, settings).then(function (token) {
return token;
});
}
var userid;
return session.load()
.then(function (data) {
userid = data.userid;
if (userid === cachedToken.userid && cachedToken.token) {
return cachedToken.token;
} else {
var payload = jwtHelper.decodeToken(identity);
return payload.sub || null;
cachedToken = {
userid: userid,
token: refreshToken(),
};
return cachedToken.token;
}
} catch (error) {
})
.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';
var angular = require('angular');
// @ngInject
function Controller($scope, $timeout, flash, session, formRespond, settings) {
var pendingTimeout = null;
......@@ -11,10 +13,10 @@ function Controller($scope, $timeout, flash, session, formRespond, settings) {
angular.copy({}, $scope.model);
if ($scope.form != null) {
$scope.form.$setPristine()
if ($scope.form) {
$scope.form.$setPristine();
}
}
};
function failure(form, response) {
var errors, reason;
......@@ -28,12 +30,12 @@ function Controller($scope, $timeout, flash, session, formRespond, settings) {
}
return formRespond(form, errors, reason);
};
}
function timeout() {
angular.copy({}, $scope.model);
if ($scope.form != null) {
if ($scope.form) {
$scope.form.$setPristine();
}
......@@ -42,7 +44,7 @@ function Controller($scope, $timeout, flash, session, formRespond, settings) {
}
function cancelTimeout() {
if (pendingTimeout == null) {
if (!pendingTimeout) {
return;
}
$timeout.cancel(pendingTimeout);
......@@ -68,7 +70,7 @@ function Controller($scope, $timeout, flash, session, formRespond, settings) {
});
};
if ($scope.model == null) {
if (!$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) {
});
}
return lastLoad;
}
};
/**
* @name session.update()
......@@ -131,13 +131,14 @@ function session($http, $resource, $rootScope, flash, raven, settings) {
if (userChanged) {
$rootScope.$broadcast(events.USER_CHANGED, {
initialLoad: isInitialLoad,
userid: model.userid,
});
// associate error reports with the current user in Sentry
if (resource.state.userid) {
raven.setUserInfo({
id: resource.state.userid,
})
});
} else {
raven.setUserInfo(undefined);
}
......@@ -153,7 +154,7 @@ function session($http, $resource, $rootScope, flash, raven, settings) {
return model;
};
function process(data, headersGetter) {
function process(data) {
// Parse as json
data = angular.fromJson(data);
......
......@@ -12,12 +12,12 @@ describe('AppController', function () {
var fakeAuth = null;
var fakeDrafts = null;
var fakeFeatures = null;
var fakeIdentity = null;
var fakeLocation = null;
var fakeParams = null;
var fakeSession = null;
var fakeGroups = null;
var fakeRoute = null;
var fakeSettings = null;
var fakeWindow = null;
var sandbox = null;
......@@ -45,7 +45,7 @@ describe('AppController', function () {
};
fakeAuth = {
userid: sandbox.stub()
logout: sandbox.stub().returns(Promise.resolve()),
};
fakeDrafts = {
......@@ -62,19 +62,15 @@ describe('AppController', function () {
flagEnabled: sandbox.stub().returns(false)
};
fakeIdentity = {
watch: sandbox.spy(),
request: sandbox.spy(),
logout: sandbox.stub()
};
fakeLocation = {
search: sandbox.stub().returns({})
};
fakeParams = {id: 'test'};
fakeSession = {};
fakeSession = {
load: sandbox.stub().returns(Promise.resolve({userid: null})),
};
fakeGroups = {focus: sandbox.spy()};
......@@ -85,12 +81,16 @@ describe('AppController', function () {
confirm: sandbox.stub()
};
fakeSettings = {
firstRun: false,
};
$provide.value('annotationUI', fakeAnnotationUI);
$provide.value('auth', fakeAuth);
$provide.value('drafts', fakeDrafts);
$provide.value('features', fakeFeatures);
$provide.value('identity', fakeIdentity);
$provide.value('session', fakeSession);
$provide.value('settings', fakeSettings);
$provide.value('groups', fakeGroups);
$provide.value('$route', fakeRoute);
$provide.value('$location', fakeLocation);
......@@ -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 () {
createController();
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();
var identityCallbackArgs = fakeIdentity.watch.args[0][0];
identityCallbackArgs.onready();
return fakeSession.load().then(function () {
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();
fakeAuth.userid.withArgs('test-assertion').returns('acct:hey@joe');
var identityCallbackArgs = fakeIdentity.watch.args[0][0];
identityCallbackArgs.onlogin('test-assertion');
return fakeSession.load().then(function () {
assert.equal($scope.auth.status, 'signed-in');
});
});
it('sets userid, username, and provider properties at login', function () {
fakeSession.load = function () {
return Promise.resolve({userid: 'acct:jim@hypothes.is'});
};
createController();
fakeAuth.userid.withArgs('test-assertion').returns('acct:hey@joe');
var identityCallbackArgs = fakeIdentity.watch.args[0][0];
identityCallbackArgs.onlogin('test-assertion');
assert.equal($scope.auth.userid, 'acct:hey@joe');
assert.equal($scope.auth.username, 'hey');
assert.equal($scope.auth.provider, 'joe');
return fakeSession.load().then(function () {
assert.equal($scope.auth.userid, 'acct:jim@hypothes.is');
assert.equal($scope.auth.username, 'jim');
assert.equal($scope.auth.provider, 'hypothes.is');
});
});
it('sets auth.status to "signed-out" at logout', function () {
it('updates auth when the logged-in user changes', function () {
createController();
var identityCallbackArgs = fakeIdentity.watch.args[0][0];
identityCallbackArgs.onlogout();
assert.equal($scope.auth.status, "signed-out");
return fakeSession.load().then(function () {
$scope.$broadcast(events.USER_CHANGED, {
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 () {
......
var inject = angular.mock.inject;
var module = angular.mock.module;
'use strict';
describe('h', function () {
var auth = null;
var fakeJwtHelper = null;
var sandbox = null;
var auth = require('../auth');
before(function () {
angular.module('h', [])
.service('auth', require('../auth'));
});
describe('auth', function () {
var fakeHttp;
var fakeJwtHelper;
var fakeSettings;
var fakeSession;
var fakeTokens = ['token-one', 'token-two'];
var fakeTokenIndex;
beforeEach(function () {
module('h');
});
beforeEach(module(function ($provide) {
sandbox = sinon.sandbox.create();
fakeTokenIndex = 0;
fakeHttp = {
get: sinon.spy(function (url, config) {
assert.equal(config.skipAuthorization, true);
assert.equal(url, 'https://test.hypothes.is/api/token');
assert.equal(config.params.assertion, fakeSession.state.csrf);
fakeJwtHelper = {
decodeToken: sandbox.stub(),
isTokenExpired: sandbox.stub()
var result = {status: 200, data: fakeTokens[fakeTokenIndex]};
++fakeTokenIndex;
return Promise.resolve(result);
}),
};
fakeJwtHelper = {isTokenExpired: sinon.stub()};
fakeSession = {
load: sinon.spy(function () {
return Promise.resolve(fakeSession.state);
}),
logout: sinon.spy(function () {
return {$promise: Promise.resolve()};
}),
state: {
csrf: 'fake-csrf-token',
},
};
fakeSettings = {
apiUrl: 'https://test.hypothes.is/api/',
};
});
$provide.value('jwtHelper', fakeJwtHelper);
}));
afterEach(function () {
auth.clearCache();
});
beforeEach(inject(function (_auth_) {
auth = _auth_
}));
describe('tokenGetter', function () {
function tokenGetter() {
var config = {url:'https://test.hypothes.is/api/search'};
return auth.tokenGetter(fakeHttp, config, fakeJwtHelper,
fakeSession, fakeSettings);
}
afterEach(function () {
sandbox.restore()
it('should fetch and return a new token', function () {
return tokenGetter().then(function (token) {
assert.called(fakeHttp.get);
assert.equal(token, fakeTokens[0]);
});
});
it('returns the subject of a valid jwt', function () {
var identity = 'fake-identity';
fakeJwtHelper.isTokenExpired.withArgs(identity).returns(false);
fakeJwtHelper.decodeToken.withArgs(identity).returns({sub: 'pandora'});
var userid = auth.userid(identity);
assert.equal(userid, 'pandora');
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('returns null for an expired jwt', function () {
var identity = 'fake-identity';
fakeJwtHelper.isTokenExpired.withArgs(identity).returns(true);
var userid = auth.userid(identity);
assert.isNull(userid);
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 invalid jwt', function () {
var identity = 'fake-identity';
fakeJwtHelper.decodeToken.withArgs(identity).throws('Error');
var userid = auth.userid(identity);
assert.isNull(userid);
it('should fetch a new token if the userid changes', function () {
return tokenGetter().then(function () {
fakeSession.state.userid = 'new-user-id';
return tokenGetter();
}).then(function (token) {
assert.calledTwice(fakeHttp.get);
assert.equal(token, fakeTokens[1]);
});
});
});
describe('.logout', function () {
it('should call session.logout', function () {
var fakeFlash = {error: sinon.stub()};
return auth.service(fakeFlash, fakeSession).logout().then(function () {
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 @@
"node-uuid": "^1.4.3",
"page": "^1.6.4",
"postcss": "^5.0.6",
"query-string": "^3.0.1",
"raf": "^3.1.0",
"raven-js": "^2.0.2",
"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