Access tokens are now refreshed in preflight promises.

This patch modifies the access token header to return a promise, which
will resolve only if it has performed a best-effort attempt to provide
a valid OAuth token.

This provides several benefits:
- It removes the need for a timer inside the refresh manager. In fact,
  the entire refresh manager is now obsolete.
- No HTTP request is sent to the API without a token.
- Significant reduction of 401 errors being raised to the user.
- Validating a token now simply requires a single HTTP request, after
  which the AccessToken provider can be assumed to be current.
- Makes 401 error handling mostly irrelevant. The remaining edge case is
  when a refresh token expires.

Change-Id: I52168485c8236f93a85d3d2b6033a01293e7b747
This commit is contained in:
Michael Krotscheck 2016-02-24 10:50:19 -08:00
parent a3abee9058
commit 61d33605c4
6 changed files with 158 additions and 155 deletions

View File

@ -1,44 +0,0 @@
/*
* Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* An HTTP request interceptor that attaches an authorization to every HTTP
* request, assuming it exists and isn't expired.
*/
angular.module('sb.auth').factory('httpAuthorizationHeader',
function (AccessToken) {
'use strict';
return {
request: function (request) {
// TODO(krotscheck): Only apply the token to requests to
// storyboardApiBase.
var token = AccessToken.getAccessToken();
var type = AccessToken.getTokenType();
if (!!token) {
request.headers.Authorization = type + ' ' + token;
}
return request;
}
};
})
// Attach the HTTP interceptor.
.config(function ($httpProvider) {
'use strict';
$httpProvider.interceptors.push('httpAuthorizationHeader');
});

View File

@ -0,0 +1,148 @@
/*
* Copyright (c) 2016 Hewlett Packard Enterprise Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* This $http interceptor ensures that the OAuth Token is always valid, fresh,
* and reissued when needed.
*/
angular.module('sb.auth').factory('httpOAuthTokenInterceptor',
function (AccessToken, $injector, $q, $log) {
'use strict';
/**
* Promise deferral for when we're in-flight of a refresh token request.
*
* @type {Promise}
*/
var refreshPromise = null;
/**
* Returns the current access token, if available.
* @returns {*} The access token.
*/
function getCurrentToken() {
return $q.when({
type: AccessToken.getTokenType() || null,
value: AccessToken.getAccessToken() || null,
expired: AccessToken.isExpired() || AccessToken.expiresSoon(),
refresh: AccessToken.getRefreshToken() || null
});
}
/**
* This method checks a token (see above) to see whether it needs to
* be refreshed. If yes, it will refresh that token, and return a
* promise that will update the token in the promise chain.
* Otherwise, it will simply pass the token along to the header
* decorator.
*
* @param {{}} token The token to check.
* @returns {Promise}
*/
function refreshIfNeeded(token) {
// If it's not expired, just pass it on.
if (!token.expired) {
return $q.when(token);
}
// If we don't have a refresh token, pass it on.
if (!token.refresh) {
return $q.when(token);
}
// If the refresh promise is already in flight, return that. We
// don't want to get caught in a situation where we try to refresh
// more than once.
if (refreshPromise) {
$log.debug('Returning in-flight refresh promise.');
return refreshPromise;
}
// We have to refresh.
$log.debug('Attempting to refresh auth token.');
var deferred = $q.defer();
refreshPromise = deferred.promise;
// Issue a refresh token request. We have to manually grab the
// service from the injector here, because else we'd have a
// circular injection dependency.
var OpenId = $injector.get('OpenId');
OpenId.token({
grant_type: 'refresh_token',
refresh_token: token.refresh
}).then(
function (data) {
AccessToken.setToken(data);
},
function () {
AccessToken.clear();
}
).finally(function () {
// Inject the token, whether or not it exists, back into the
// promise chain.
deferred.resolve(getCurrentToken());
// Clear the promise;
refreshPromise = null;
});
return deferred.promise;
}
return {
/**
* The request interceptor ensures that the OAuth token, if
* available, is attached to the HTTP request.
*
* @param request The request.
* @returns {*} A promise that will resolve once we're sure we
* have a good token.
*/
request: function (httpConfig) {
/**
* Decorate the request with the header token, IFF it's
* available.
*
* @param token The token received from the promise chain.
* @returns {Promise} A promise that resolves the httpconfig
*/
function decorateHeader(token) {
if (token.type && token.value) {
httpConfig.headers.Authorization =
token.type + ' ' + token.value;
}
return $q.when(httpConfig);
}
// Are we an OpenID request? These get skipped.
if(httpConfig.url.indexOf('/openid/') > -1) {
return $q.when(httpConfig);
}
// Build the interceptor promise chain for each request.
return $q.when(getCurrentToken())
.then(refreshIfNeeded)
.then(decorateHeader);
}
};
})
// Attach the HTTP interceptor.
.config(function ($httpProvider) {
'use strict';
$httpProvider.interceptors.unshift('httpOAuthTokenInterceptor');
});

View File

@ -60,7 +60,7 @@ angular.module('sb.auth', [ 'sb.services', 'sb.templates', 'ui.router',
});
})
.run(function ($rootScope, SessionState, Session, PermissionManager,
RefreshManager, Notification, Priority) {
Notification, Priority) {
'use strict';
// Initialize our permission manager.
@ -79,6 +79,4 @@ angular.module('sb.auth', [ 'sb.services', 'sb.templates', 'ui.router',
break;
}
}, Priority.LAST);
RefreshManager.scheduleRefresh();
});

View File

@ -15,18 +15,9 @@
*/
angular.module('sb.auth').run(
function($log, $modal, Notification, RefreshManager, Session, Priority) {
function($log, $modal, Notification, Session, Priority) {
'use strict';
function handle_401() {
RefreshManager.tryRefresh().then(function () {
$log.info('Token refreshed on 401');
}, function () {
$log.info('Could not refresh token. Destroying session');
Session.destroySession();
});
}
function handle_403() {
var modalInstance = $modal.open({
templateUrl: 'app/auth/template/modal/superuser_required.html',
@ -44,12 +35,6 @@ angular.module('sb.auth').run(
// intercepted before anything else happens.
Notification.intercept(function (message) {
if (message.type === 'http') {
if (message.message === 401) {
// An unauthorized error. Refreshing the access token
// might help.
handle_401();
}
if (message.message === 403) {
// Forbidden error. A user should be warned tha he is
// doing something wrong.

View File

@ -1,83 +0,0 @@
angular.module('sb.auth').service('RefreshManager',
function ($q, $log, $timeout, preExpireDelta, AccessToken, OpenId) {
'use strict';
var currentRefresh = null;
var nextRefreshPromise = null;
var scheduledForToken = null;
var self = this;
// Try to refresh the expired access_token
this.tryRefresh = function () {
if (!currentRefresh) {
// Create our promise, since we should always return one.
currentRefresh = $q.defer();
currentRefresh.promise.then(
function () {
currentRefresh = null;
},
function () {
currentRefresh = null;
}
);
var refresh_token = AccessToken.getRefreshToken();
var is_expired = AccessToken.isExpired();
var expires_soon = AccessToken.expiresSoon();
// Do we have a refresh token to try?
if (!refresh_token) {
$log.info('No refresh token found. Aborting refresh.');
currentRefresh.reject();
} else if (!is_expired && !expires_soon) {
$log.info('No refresh required for current access token.');
currentRefresh.resolve(true);
} else {
$log.info('Trying to refresh token');
OpenId.token({
grant_type: 'refresh_token',
refresh_token: refresh_token
}).then(
function (data) {
AccessToken.setToken(data);
currentRefresh.resolve(true);
self.scheduleRefresh();
},
function () {
AccessToken.clear();
currentRefresh.reject();
}
);
}
}
return currentRefresh.promise;
};
this.scheduleRefresh = function () {
if (!AccessToken.getRefreshToken() || AccessToken.isExpired()) {
$log.info('Current token does not require deferred refresh.');
return;
}
var expiresAt = AccessToken.getIssueDate() +
AccessToken.getExpiresIn();
if (!!nextRefreshPromise &&
AccessToken.getAccessToken() === scheduledForToken) {
$log.info('The refresh is already scheduled.');
return;
}
var now = Math.round((new Date()).getTime() / 1000);
var delay = (expiresAt - preExpireDelta - now) * 1000;
nextRefreshPromise = $timeout(self.tryRefresh, delay, false);
scheduledForToken = AccessToken.getAccessToken();
$log.info('Refresh scheduled to happen in ' + delay + ' ms');
};
}
);

View File

@ -20,7 +20,7 @@
*/
angular.module('sb.auth').factory('Session',
function (SessionState, AccessToken, $rootScope, $log, $q, $state,
SystemInfo, RefreshManager, Notification, Severity) {
SystemInfo, Notification, Severity) {
'use strict';
/**
@ -53,15 +53,14 @@ angular.module('sb.auth').factory('Session',
*/
var deferred = $q.defer();
RefreshManager.tryRefresh().then(function () {
SystemInfo.get({},
function (info) {
return SystemInfo.get({},
function (info) {
if (AccessToken.getAccessToken()) {
deferred.resolve(info);
}, function (error) {
deferred.reject(error);
});
});
return deferred.promise;
} else {
deferred.reject(info);
}
}).$promise;
}
/**