Skip to content

Commit

Permalink
Merge pull request #3084 from hypothesis/simplify-client-auth
Browse files Browse the repository at this point in the history
Simplify API authentication in the client and fix #3083, #2924
  • Loading branch information
nickstenning committed Mar 15, 2016
2 parents 00793e3 + 082c680 commit f0ab7c2
Show file tree
Hide file tree
Showing 13 changed files with 312 additions and 532 deletions.
74 changes: 35 additions & 39 deletions h/static/scripts/app-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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});

Expand Down Expand Up @@ -46,37 +60,21 @@ 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) {
$scope.login();
}
}
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();
}
});

Expand Down Expand Up @@ -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.
Expand All @@ -127,16 +122,17 @@ module.exports = function AppController(

// Log the user out.
$scope.logout = function () {
if (promptToLogout()) {
var iterable = drafts.unsaved();
for (var i = 0, draft; i < iterable.length; i++) {
draft = iterable[i];
$rootScope.$emit("annotationDeleted", draft);
}
drafts.discard();
$scope.accountDialog.visible = false;
return identity.logout();
if (!promptToLogout()) {
return;
}
var iterable = drafts.unsaved();
for (var i = 0, draft; i < iterable.length; i++) {
draft = iterable[i];
$rootScope.$emit("annotationDeleted", draft);
}
drafts.discard();
$scope.accountDialog.visible = false;
return auth.logout();
};

$scope.clearSelection = function () {
Expand Down
16 changes: 11 additions & 5 deletions h/static/scripts/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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');
139 changes: 124 additions & 15 deletions h/static/scripts/auth.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,133 @@
'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
* @name auth
* Fetches or returns a cached JWT API token for the current user.
*
* @description
* The 'auth' service exposes authorization helpers for other components.
* @return {Promise} - A promise for a JWT API token for the current
* user.
*/
// @ngInject
function Auth(jwtHelper) {
this.userid = function userid(identity) {
try {
if (jwtHelper.isTokenExpired(identity)) {
return null;
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) {
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,
};
9 changes: 0 additions & 9 deletions h/static/scripts/config/http.js

This file was deleted.

0 comments on commit f0ab7c2

Please sign in to comment.