Skip to content

Commit

Permalink
Provide 'silent' token-renewal support
Browse files Browse the repository at this point in the history
Silent renewal of tokens is achieved by the dynamic construction of a
child iframe that attempts to re-authenticate the page  with the same
original arguments as the 'parent' page, but with an additional prompt=none
argument.  If this successfully responds with a token we update the token
we're sending for API calls to this renewed value, otherwise we sign-out.

The specific details of how this is achieved within this plugin are mildly
complex so i'll describe them here.

Parent lifetime:
  If the parent receives an 'oauth2:authSuccess' event and the directive has
  been configured to support silenttokenrenewal (by specifying the
  silentTokenRedirectUrl parameter on the directive)  then it will call the
  'renewTokenSilently' method on the endpoint.  This code then sets a timeout
  to fire a minute before the token is expected to expire.  When the timeout
  is triggered some code executes that constructs a hidden iframe that calls
  out to the Identity Server's authorization endpoint with a prompt=none
  argument set, and an associated 5 second timeout to cleanup if the IDP does
  not respond within that timeframe. It also registers a listener for
  postMessage calls.  If the listener is triggered with some data of the
  string 'oauth2.silentRenewFailure' then it will broadcast the event
  'oauth2:authExpired' otherwise it will attempt to use the passed data
  as the location hash that would've been returned from the IDP, so the normal
  event broadcasing occurs (this should re-emit an 'oauth2:authSuccess' and restart
  the scheduled silent renewal again.)

Child iframe lifetime:
  Firstly this directive adds a new route 'silent-renew' to the route provider
  which has no template or controller, but as it is within the same template
  as the original oauth directive was nested we get to re-leverage the existing
  oauth configuration.  This means when setting up your OIDC client in your IP
  you will need to support at least two redirects, one for the outer-page (the
  existing routes) and a new one for the hidden inner frame
  (probably <scheme>://<host>/#/silent-renew).

  When requests come back in to the 'silent-renew' page they are treated as normal
  'post authentication' requests and the location is parsed as usual, if an
  'oauth2:authSuccess' event is emitted and capture by the child iframe then
  it will use 'postMessage' to send the silently-renewed token hash back to the
  parent frame, otherwise if an 'oauth2:authError' is captured by the child iframe
  then it will use 'postMessage' to send the string 'oauth2.silentRenewFailure'
  to the parent window.
  • Loading branch information
ciaranj committed Jan 13, 2016
1 parent 909d86c commit ff3e8e6
Showing 1 changed file with 132 additions and 34 deletions.
166 changes: 132 additions & 34 deletions dist/angularJsOAuth2.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
function getSessionToken($window) {
var tokenString = $window.sessionStorage.getItem('token');
var token = null;
if (tokenString) {
if (tokenString && tokenString !== "null" ) {
token = JSON.parse(tokenString);
token.expires_at= new Date(token.expires_at);
}
return token;
}
Expand Down Expand Up @@ -67,13 +68,19 @@
service.get = function() {
return this.token;
};
service.set = function() {
service.set = function(trustedTokenHash) {
// Get and scrub the session stored state
var parsedFromHash = false;
var previousState = $window.sessionStorage.getItem('verifyState');
$window.sessionStorage.setItem('verifyState', null);

if ($location.$$html5) {
if(trustedTokenHash) {
// We 'trust' this hash as it was already 'parsed' by the child iframe before we got it as the parent
// and then handed it back (not just reverifying as the sessionStorage was blanked by the child frame, so
// we can't :(
service.token = setTokenFromHashParams(trustedTokenHash);
}
else if ($location.$$html5) {
if ($location.path().length > 1) {
var values = $location.path().substring(1);
service.token = setTokenFromHashParams(values);
Expand Down Expand Up @@ -128,7 +135,7 @@
};
service.destroy = function() {
$window.sessionStorage.setItem('token', null);
service.token = null;
service.token = null;
};

return service;
Expand Down Expand Up @@ -182,16 +189,79 @@
}]);

// Endpoint wrapper
angular.module('oauth2.endpoint', []).factory('Endpoint', ['AccessToken', '$window', function(accessToken, $window) {
angular.module('oauth2.endpoint', ['angular-md5']).factory('Endpoint', ['AccessToken', '$window', 'md5', '$rootScope', function(accessToken, $window, md5, $rootScope) {
var service = {
authorize: function() {
accessToken.destroy();
$window.sessionStorage.setItem('verifyState', service.state);
window.location.replace(service.url);
window.location.replace(getAuthorizationUrl());
},
appendSignoutToken: false
};

function getAuthorizationUrl(performSilently) {
var url= service.authorizationUrl + '?' +
'client_id=' + encodeURIComponent(service.clientId) + '&' +
'redirect_uri=' + encodeURIComponent(performSilently?service.silentTokenRedirectUrl:service.redirectUrl) + '&' +
'response_type=' + encodeURIComponent(service.responseType) + '&' +
'scope=' + encodeURIComponent(service.scope);
if (service.nonce) {
url += '&nonce=' + encodeURIComponent(service.nonce);
}
url += '&state=' + encodeURIComponent(service.state);

if( performSilently ) {
url = url + "&prompt=none";
}
return url;
}

service.renewTokenSilently= function() {
function setupTokenSilentRenewInTheFuture() {
var frame= $window.document.createElement("iframe");
frame.style.display = "none";
$window.sessionStorage.setItem('verifyState', service.state);
frame.src= getAuthorizationUrl(true);
function cleanup() {
$window.removeEventListener("message", message, false);
if( handle) {
window.clearTimeout(handle);
}
handle= null;
$window.setTimeout(function() {
// Complete this on another tick of the eventloop to allow angular (in the child frame) to complete nicely.
$window.document.body.removeChild(frame);
}, 0);
}

function message(e) {
if (handle && e.origin === location.protocol + "//" + location.host && e.source == frame.contentWindow) {
cleanup();
if( e.data === "oauth2.silentRenewFailure" ) {
$rootScope.$broadcast('oauth2:authExpired');
}
else {
accessToken.set(e.data);
}
}
}

var handle= window.setTimeout(function() {
cleanup();
}, 5000);
$window.addEventListener("message", message, false);
$window.document.body.appendChild(frame);
};

var now= new Date();
// Renew the token 1 minute before we expect it to expire. N.B. This code elsewhere sets the expires_at to be 60s less than the server-decided expiry time
// this has the effect of reducing access token lifetimes by a mininum of 2 minutes, and restricts you to producing access tokens that are at *least* this long lived

var renewTokenAt= new Date( accessToken.get().expires_at.getTime() - 60000 );
var renewTokenIn= renewTokenAt - new Date();
window.setTimeout(setupTokenSilentRenewInTheFuture, renewTokenIn);
};

service.signOut = function(token) {
if (service.signOutUrl && service.signOutUrl.length > 0) {
var url = service.signOutUrl;
Expand All @@ -207,18 +277,24 @@
};

service.init = function(params) {
service.url = params.authorizationUrl + '?' +
'client_id=' + encodeURIComponent(params.clientId) + '&' +
'redirect_uri=' + encodeURIComponent(params.redirectUrl) + '&' +
'response_type=' + encodeURIComponent(params.responseType) + '&' +
'scope=' + encodeURIComponent(params.scope) + '&';
if (params.nonce) {
service.url += 'nonce=' + encodeURIComponent(params.nonce) + '&';
function generateState() {
var text = ((Date.now() + Math.random()) * Math.random()).toString().replace(".","");
return md5.createHash(text);
}

if (!params.nonce && params.autoGenerateNonce) {
params.nonce = generateState();
}
service.url += 'state=' + encodeURIComponent(params.state);
service.nonce = params.nonce;
service.clientId= params.clientId;
service.redirectUrl= params.redirectUrl;
service.scope= params.scope;
service.responseType= params.responseType;
service.authorizationUrl= params.authorizationUrl;
service.signOutUrl = params.signOutUrl;
service.silentTokenRedirectUrl= params.silentTokenRedirectUrl;
service.signOutRedirectUrl = params.signOutRedirectUrl;
service.state = params.state;
service.state = params.state || generateState();
if (params.signOutAppendToken == 'true') {
service.appendSignoutToken = true;
}
Expand All @@ -228,11 +304,18 @@
}]);

// Open ID directive
angular.module('oauth2.directive', ['angular-md5']).directive('oauth2', ['$rootScope', '$http', '$window', '$location', '$templateCache', '$compile', 'AccessToken', 'Endpoint', 'md5', function($rootScope, $http, $window, $location, $templateCache, $compile, accessToken, endpoint, md5) {
var definition = {
restrict: 'E',
replace: true,
scope: {
angular.module('oauth2.directive', [])
.config(['$routeProvider', function ($routeProvider) {
$routeProvider
.when('/silent-renew', {
template: ""
})
}])
.directive('oauth2', ['$rootScope', '$http', '$window', '$location', '$templateCache', '$compile', 'AccessToken', 'Endpoint', function($rootScope, $http, $window, $location, $templateCache, $compile, accessToken, endpoint) {
var definition = {
restrict: 'E',
replace: true,
scope: {
authorizationUrl: '@', // authorization server url
clientId: '@', // client ID
redirectUrl: '@', // uri th auth server should redirect to (cannot contain #)
Expand All @@ -246,10 +329,11 @@
signOutUrl: '@', // url on the authorization server for logging out. Local token is deleted even if no URL is given but that will leave user logged in against STS
signOutAppendToken: '@', // defaults to 'false', set to 'true' to append the token to the sign out url
signOutRedirectUrl: '@', // url to redirect to after sign out on the STS has completed
silentTokenRedirectUrl: '@', // url to use for silently renewing access tokens, default behaviour is not to do
nonce: '@?', // nonce value, optional. If unspecified or an empty string and autoGenerateNonce is true then a nonce will be auto-generated
autoGenerateNonce: '=?' // Should a nonce be autogenerated if not supplied. Optional and defaults to true.
}
};
}
};

definition.link = function(scope, element, attrs) {
function compile() {
Expand All @@ -275,10 +359,6 @@
}
};

function generateState() {
var text = ((Date.now() + Math.random()) * Math.random()).toString().replace(".","");
return md5.createHash(text);
}

function init() {
scope.buttonClass = scope.buttonClass || 'btn btn-primary';
Expand All @@ -288,30 +368,48 @@
scope.signOutUrl = scope.signOutUrl || '';
scope.signOutRedirectUrl = scope.signOutRedirectUrl || '';
scope.unauthorizedAccessUrl = scope.unauthorizedAccessUrl || '';
scope.state = scope.state || generateState();
scope.silentTokenRedirectUrl = scope.silentTokenRedirectUrl || '';
if (scope.autoGenerateNonce === undefined) {
scope.autoGenerateNonce = true;
}
if (!scope.nonce && scope.autoGenerateNonce) {
scope.nonce = generateState();
}

compile();

endpoint.init(scope);
scope.signedIn = accessToken.set() !== null;
scope.$on('oauth2:authRequired', function() {
endpoint.authorize();
});
scope.$on('oauth2:authSuccess', function() {
if (scope.silentTokenRedirectUrl.length > 0) {
if( $location.path().indexOf("/silent-renew") == 0 ) {
// A 'child' frame has successfully authorised an access token.
if (window.top && window.parent && window !== window.top) {
var hash = hash || window.location.hash;
if (hash) {
window.parent.postMessage(hash, location.protocol + "//" + location.host);
}
}
} else {
// An 'owning' frame has successfully authorised an access token.
endpoint.renewTokenSilently();
}
}
});
scope.$on('oauth2:authError', function() {
if (scope.unauthorizedAccessUrl.length > 0) {
$location.path(scope.unauthorizedAccessUrl);
if( $location.path().indexOf("/silent-renew") == 0 && window.top && window.parent && window !== window.top) {
// A 'child' frame failed to authorize.
window.parent.postMessage("oauth2.silentRenewFailure", location.protocol + "//" + location.host);
}
else {
if (scope.unauthorizedAccessUrl.length > 0) {
$location.path(scope.unauthorizedAccessUrl);
}
}
});
scope.$on('oauth2:authExpired', function() {
scope.signedIn = false;
accessToken.destroy();
});
scope.signedIn = accessToken.set() !== null;
$rootScope.$on('$routeChangeStart', routeChangeHandler);
}

Expand Down

0 comments on commit ff3e8e6

Please sign in to comment.