Skip to content
This repository was archived by the owner on Jan 22, 2019. It is now read-only.

Commit ff3e8e6

Browse files
author
ciaranj
committed
Provide 'silent' token-renewal support
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.
1 parent 909d86c commit ff3e8e6

File tree

1 file changed

+132
-34
lines changed

1 file changed

+132
-34
lines changed

dist/angularJsOAuth2.js

Lines changed: 132 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
function getSessionToken($window) {
1616
var tokenString = $window.sessionStorage.getItem('token');
1717
var token = null;
18-
if (tokenString) {
18+
if (tokenString && tokenString !== "null" ) {
1919
token = JSON.parse(tokenString);
20+
token.expires_at= new Date(token.expires_at);
2021
}
2122
return token;
2223
}
@@ -67,13 +68,19 @@
6768
service.get = function() {
6869
return this.token;
6970
};
70-
service.set = function() {
71+
service.set = function(trustedTokenHash) {
7172
// Get and scrub the session stored state
7273
var parsedFromHash = false;
7374
var previousState = $window.sessionStorage.getItem('verifyState');
7475
$window.sessionStorage.setItem('verifyState', null);
7576

76-
if ($location.$$html5) {
77+
if(trustedTokenHash) {
78+
// We 'trust' this hash as it was already 'parsed' by the child iframe before we got it as the parent
79+
// and then handed it back (not just reverifying as the sessionStorage was blanked by the child frame, so
80+
// we can't :(
81+
service.token = setTokenFromHashParams(trustedTokenHash);
82+
}
83+
else if ($location.$$html5) {
7784
if ($location.path().length > 1) {
7885
var values = $location.path().substring(1);
7986
service.token = setTokenFromHashParams(values);
@@ -128,7 +135,7 @@
128135
};
129136
service.destroy = function() {
130137
$window.sessionStorage.setItem('token', null);
131-
service.token = null;
138+
service.token = null;
132139
};
133140

134141
return service;
@@ -182,16 +189,79 @@
182189
}]);
183190

184191
// Endpoint wrapper
185-
angular.module('oauth2.endpoint', []).factory('Endpoint', ['AccessToken', '$window', function(accessToken, $window) {
192+
angular.module('oauth2.endpoint', ['angular-md5']).factory('Endpoint', ['AccessToken', '$window', 'md5', '$rootScope', function(accessToken, $window, md5, $rootScope) {
186193
var service = {
187194
authorize: function() {
188195
accessToken.destroy();
189196
$window.sessionStorage.setItem('verifyState', service.state);
190-
window.location.replace(service.url);
197+
window.location.replace(getAuthorizationUrl());
191198
},
192199
appendSignoutToken: false
193200
};
194201

202+
function getAuthorizationUrl(performSilently) {
203+
var url= service.authorizationUrl + '?' +
204+
'client_id=' + encodeURIComponent(service.clientId) + '&' +
205+
'redirect_uri=' + encodeURIComponent(performSilently?service.silentTokenRedirectUrl:service.redirectUrl) + '&' +
206+
'response_type=' + encodeURIComponent(service.responseType) + '&' +
207+
'scope=' + encodeURIComponent(service.scope);
208+
if (service.nonce) {
209+
url += '&nonce=' + encodeURIComponent(service.nonce);
210+
}
211+
url += '&state=' + encodeURIComponent(service.state);
212+
213+
if( performSilently ) {
214+
url = url + "&prompt=none";
215+
}
216+
return url;
217+
}
218+
219+
service.renewTokenSilently= function() {
220+
function setupTokenSilentRenewInTheFuture() {
221+
var frame= $window.document.createElement("iframe");
222+
frame.style.display = "none";
223+
$window.sessionStorage.setItem('verifyState', service.state);
224+
frame.src= getAuthorizationUrl(true);
225+
function cleanup() {
226+
$window.removeEventListener("message", message, false);
227+
if( handle) {
228+
window.clearTimeout(handle);
229+
}
230+
handle= null;
231+
$window.setTimeout(function() {
232+
// Complete this on another tick of the eventloop to allow angular (in the child frame) to complete nicely.
233+
$window.document.body.removeChild(frame);
234+
}, 0);
235+
}
236+
237+
function message(e) {
238+
if (handle && e.origin === location.protocol + "//" + location.host && e.source == frame.contentWindow) {
239+
cleanup();
240+
if( e.data === "oauth2.silentRenewFailure" ) {
241+
$rootScope.$broadcast('oauth2:authExpired');
242+
}
243+
else {
244+
accessToken.set(e.data);
245+
}
246+
}
247+
}
248+
249+
var handle= window.setTimeout(function() {
250+
cleanup();
251+
}, 5000);
252+
$window.addEventListener("message", message, false);
253+
$window.document.body.appendChild(frame);
254+
};
255+
256+
var now= new Date();
257+
// 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
258+
// 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
259+
260+
var renewTokenAt= new Date( accessToken.get().expires_at.getTime() - 60000 );
261+
var renewTokenIn= renewTokenAt - new Date();
262+
window.setTimeout(setupTokenSilentRenewInTheFuture, renewTokenIn);
263+
};
264+
195265
service.signOut = function(token) {
196266
if (service.signOutUrl && service.signOutUrl.length > 0) {
197267
var url = service.signOutUrl;
@@ -207,18 +277,24 @@
207277
};
208278

209279
service.init = function(params) {
210-
service.url = params.authorizationUrl + '?' +
211-
'client_id=' + encodeURIComponent(params.clientId) + '&' +
212-
'redirect_uri=' + encodeURIComponent(params.redirectUrl) + '&' +
213-
'response_type=' + encodeURIComponent(params.responseType) + '&' +
214-
'scope=' + encodeURIComponent(params.scope) + '&';
215-
if (params.nonce) {
216-
service.url += 'nonce=' + encodeURIComponent(params.nonce) + '&';
280+
function generateState() {
281+
var text = ((Date.now() + Math.random()) * Math.random()).toString().replace(".","");
282+
return md5.createHash(text);
283+
}
284+
285+
if (!params.nonce && params.autoGenerateNonce) {
286+
params.nonce = generateState();
217287
}
218-
service.url += 'state=' + encodeURIComponent(params.state);
288+
service.nonce = params.nonce;
289+
service.clientId= params.clientId;
290+
service.redirectUrl= params.redirectUrl;
291+
service.scope= params.scope;
292+
service.responseType= params.responseType;
293+
service.authorizationUrl= params.authorizationUrl;
219294
service.signOutUrl = params.signOutUrl;
295+
service.silentTokenRedirectUrl= params.silentTokenRedirectUrl;
220296
service.signOutRedirectUrl = params.signOutRedirectUrl;
221-
service.state = params.state;
297+
service.state = params.state || generateState();
222298
if (params.signOutAppendToken == 'true') {
223299
service.appendSignoutToken = true;
224300
}
@@ -228,11 +304,18 @@
228304
}]);
229305

230306
// Open ID directive
231-
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) {
232-
var definition = {
233-
restrict: 'E',
234-
replace: true,
235-
scope: {
307+
angular.module('oauth2.directive', [])
308+
.config(['$routeProvider', function ($routeProvider) {
309+
$routeProvider
310+
.when('/silent-renew', {
311+
template: ""
312+
})
313+
}])
314+
.directive('oauth2', ['$rootScope', '$http', '$window', '$location', '$templateCache', '$compile', 'AccessToken', 'Endpoint', function($rootScope, $http, $window, $location, $templateCache, $compile, accessToken, endpoint) {
315+
var definition = {
316+
restrict: 'E',
317+
replace: true,
318+
scope: {
236319
authorizationUrl: '@', // authorization server url
237320
clientId: '@', // client ID
238321
redirectUrl: '@', // uri th auth server should redirect to (cannot contain #)
@@ -246,10 +329,11 @@
246329
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
247330
signOutAppendToken: '@', // defaults to 'false', set to 'true' to append the token to the sign out url
248331
signOutRedirectUrl: '@', // url to redirect to after sign out on the STS has completed
332+
silentTokenRedirectUrl: '@', // url to use for silently renewing access tokens, default behaviour is not to do
249333
nonce: '@?', // nonce value, optional. If unspecified or an empty string and autoGenerateNonce is true then a nonce will be auto-generated
250334
autoGenerateNonce: '=?' // Should a nonce be autogenerated if not supplied. Optional and defaults to true.
251-
}
252-
};
335+
}
336+
};
253337

254338
definition.link = function(scope, element, attrs) {
255339
function compile() {
@@ -275,10 +359,6 @@
275359
}
276360
};
277361

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

283363
function init() {
284364
scope.buttonClass = scope.buttonClass || 'btn btn-primary';
@@ -288,30 +368,48 @@
288368
scope.signOutUrl = scope.signOutUrl || '';
289369
scope.signOutRedirectUrl = scope.signOutRedirectUrl || '';
290370
scope.unauthorizedAccessUrl = scope.unauthorizedAccessUrl || '';
291-
scope.state = scope.state || generateState();
371+
scope.silentTokenRedirectUrl = scope.silentTokenRedirectUrl || '';
292372
if (scope.autoGenerateNonce === undefined) {
293373
scope.autoGenerateNonce = true;
294374
}
295-
if (!scope.nonce && scope.autoGenerateNonce) {
296-
scope.nonce = generateState();
297-
}
298-
299375
compile();
300376

301377
endpoint.init(scope);
302-
scope.signedIn = accessToken.set() !== null;
303378
scope.$on('oauth2:authRequired', function() {
304379
endpoint.authorize();
305380
});
381+
scope.$on('oauth2:authSuccess', function() {
382+
if (scope.silentTokenRedirectUrl.length > 0) {
383+
if( $location.path().indexOf("/silent-renew") == 0 ) {
384+
// A 'child' frame has successfully authorised an access token.
385+
if (window.top && window.parent && window !== window.top) {
386+
var hash = hash || window.location.hash;
387+
if (hash) {
388+
window.parent.postMessage(hash, location.protocol + "//" + location.host);
389+
}
390+
}
391+
} else {
392+
// An 'owning' frame has successfully authorised an access token.
393+
endpoint.renewTokenSilently();
394+
}
395+
}
396+
});
306397
scope.$on('oauth2:authError', function() {
307-
if (scope.unauthorizedAccessUrl.length > 0) {
308-
$location.path(scope.unauthorizedAccessUrl);
398+
if( $location.path().indexOf("/silent-renew") == 0 && window.top && window.parent && window !== window.top) {
399+
// A 'child' frame failed to authorize.
400+
window.parent.postMessage("oauth2.silentRenewFailure", location.protocol + "//" + location.host);
401+
}
402+
else {
403+
if (scope.unauthorizedAccessUrl.length > 0) {
404+
$location.path(scope.unauthorizedAccessUrl);
405+
}
309406
}
310407
});
311408
scope.$on('oauth2:authExpired', function() {
312409
scope.signedIn = false;
313410
accessToken.destroy();
314411
});
412+
scope.signedIn = accessToken.set() !== null;
315413
$rootScope.$on('$routeChangeStart', routeChangeHandler);
316414
}
317415

0 commit comments

Comments
 (0)