Last active
November 7, 2016 07:52
-
-
Save jenyayel/2dc0b36f93910b487fb20f8e38c8ba79 to your computer and use it in GitHub Desktop.
OAuth JWT autorenew demo
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
node_modules | |
bower_components | |
.idea | |
.DS_STORE |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
bower_components | |
.idea |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"name": "OAuth.JWT.Autorenew", | |
"authors": [ | |
"jenyay@gmail.com <jenyay@gmail.com>" | |
], | |
"description": "", | |
"main": "", | |
"moduleType": [], | |
"license": "MIT", | |
"homepage": "", | |
"ignore": [ | |
"**/.*", | |
"node_modules", | |
"bower_components", | |
"test", | |
"tests" | |
], | |
"dependencies": { | |
"client-oauth2": "~1.0.0", | |
"knockout": "~3.4.0", | |
"jwt-decode": "~1.4.0", | |
"bootstrap-css-only": "~3.3.5" | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<title></title> | |
<meta charset="utf-8" /> | |
<title>OAuth2 playground</title> | |
<link href="/bower_components/bootstrap-css-only/css/bootstrap.min.css" rel="stylesheet" /> | |
<script src="/bower_components/popsicle/popsicle.js"></script> | |
<script src="/bower_components/client-oauth2/client-oauth2.js"></script> | |
<script src="/bower_components/jwt-decode/build/jwt-decode.min.js"></script> | |
<script src="/bower_components/knockout/dist/knockout.debug.js"></script> | |
</head> | |
<body style="padding-top: 80px"> | |
<nav class="navbar navbar-inverse navbar-fixed-top"> | |
<div class="container"> | |
<div class="navbar-header"> | |
<a class="navbar-brand" href="#">OAuth2 playground</a> | |
</div> | |
</div> | |
</nav> | |
<div class="container"> | |
<div class="row"> | |
<div class="col-md-6"> | |
<div class="panel panel-default"> | |
<div class="panel-heading"> | |
<h3 class="panel-title">Authenticate with user/pass <small>(watch console for errors)</small></h3> | |
</div> | |
<div class="panel-body"> | |
<form class="form-horizontal" data-bind="submit: authenticate"> | |
<div class="form-group"> | |
<label for="inputUser" class="col-sm-2 control-label">User</label> | |
<div class="col-sm-10"> | |
<input type="text" class="form-control" id="inputUser" placeholder="User" autocomplete="off" required data-bind="value: user, disable: isWorking"> | |
</div> | |
</div> | |
<div class="form-group"> | |
<label for="inputPassword" class="col-sm-2 control-label">Password</label> | |
<div class="col-sm-10"> | |
<input type="password" class="form-control" id="inputPassword" placeholder="Password" required data-bind="value: password, disable: isWorking"> | |
</div> | |
</div> | |
<div class="form-group"> | |
<div class="col-sm-offset-2 col-sm-10"> | |
<button type="submit" class="btn btn-primary" data-bind="disable: isWorking">Authenticate</button> | |
<button type="button" class="btn btn-default" data-bind="disable: isWorking() || isAnonymous(), click: signOut">Sign out</button> | |
</div> | |
</div> | |
</form> | |
</div> | |
</div> | |
</div> | |
<div class="col-md-6"> | |
<div class="panel panel-default"> | |
<div class="panel-heading"> | |
<h3 class="panel-title">Tokens info <small>(watch console for errors)</small></h3> | |
</div> | |
<div class="panel-body"> | |
<form class="form-horizontal"> | |
<div class="form-group"> | |
<label class="col-sm-4 control-label">Refresh token</label> | |
<div class="col-sm-8"> | |
<input type="text" class="form-control" data-bind="value: token.refreshToken" readonly> | |
</div> | |
</div> | |
<div class="form-group"> | |
<label class="col-sm-4 control-label">Access token</label> | |
<div class="col-sm-8"> | |
<div class="input-group"> | |
<input type="text" class="form-control" data-bind="value: token.accessToken" readonly> | |
<span class="input-group-btn"> | |
<button class="btn btn-default" type="button" data-bind="disable: isAnonymous, click: decodeJwt">Decode</button> | |
</span> | |
</div> | |
</div> | |
</div> | |
<div class="form-group"> | |
<label class="col-sm-4 control-label"> | |
Expires on | |
<span data-bind="visible: expiresIn, html: expiresIn"></span> | |
</label> | |
<div class="col-sm-8"> | |
<div class="input-group"> | |
<input type="text" class="form-control" data-bind="value: token.expires" readonly> | |
<span class="input-group-btn"> | |
<button class="btn btn-default" type="button" data-bind="disable: isAnonymous, click: refreshAccessToken">Refresh</button> | |
</span> | |
</div> | |
</div> | |
</div> | |
</form> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="row"> | |
<div class="col-md-4"> | |
<div class="panel panel-default"> | |
<div class="panel-heading"> | |
<h3 class="panel-title"> | |
API calls | |
<div class="btn-group pull-right" role="group" aria-label="..."> | |
<button type="button" class="btn btn-default btn-xs" data-bind="disable: isWorking, click: anonymousApiRequest">Public API</button> | |
<button type="button" class="btn btn-default btn-xs" data-bind="disable: isWorking, click: authenticatedApiRequest">Private API</button> | |
<button type="button" class="btn btn-default btn-xs" data-bind="disable: isWorking() || isAnonymous(), click: goToThirdParty">Go to 3rd party</button> | |
</div> | |
</h3> | |
</div> | |
<div class="panel-body"> | |
<pre style="border: 0; background-color: transparent; padding: 0" data-bind="html: apiResultText"></pre> | |
</div> | |
</div> | |
</div> | |
<div class="col-md-4"> | |
<div class="panel panel-default"> | |
<div class="panel-heading"> | |
<h3 class="panel-title"> | |
Admin tasks | |
<div class="btn-group pull-right" role="group" aria-label="..."> | |
<button type="button" class="btn btn-default btn-xs" data-bind="click: getRefreshToken, disable: isWorking() || isAnonymous()">Get refresh</button> | |
<button type="button" class="btn btn-default btn-xs" data-bind="click: deleteRefreshToken, disable: isWorking() || isAnonymous()">Invalidate refresh</button> | |
</div> | |
</h3> | |
</div> | |
<div class="panel-body"> | |
<pre style="border: 0; background-color: transparent; padding: 0" data-bind=" html: adminResultText"></pre> | |
</div> | |
</div> | |
</div> | |
<div class="col-md-4"> | |
<div class="panel panel-default"> | |
<div class="panel-heading"> | |
<h3 class="panel-title">JWT payload</h3> | |
</div> | |
<div class="panel-body"> | |
<pre style="border: 0; background-color: transparent; padding: 0" data-bind="html: claimsText"></pre> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<script src="/index.js"></script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// <reference path="bower_components/client-oauth2/client-oauth2.js" /> | |
/// <reference path="bower_components/jwt-decode/build/jwt-decode.js" /> | |
/// <reference path="bower_components/knockout/dist/knockout.debug.js" /> | |
/// <reference path="bower_components/popsicle/popsicle.js" /> | |
(function () { | |
/** | |
* Represents a persistent and observable model of token with internal event system | |
* | |
* @param {Object} client | |
* @param {Object} storageProvider | |
* @return {Function} | |
*/ | |
function TokenModel(storageProvider) { | |
var _callBacks = { | |
tokenExpiring: [], | |
tokenUpdated: [], | |
tokenRemoving: [] | |
}; | |
this.expires = ko.observable(null); | |
this.accessToken = ko.observable('') | |
this.refreshToken = ko.observable(''); | |
// tries to retrieve an item from data store and update model | |
this.tryLoad = () => { | |
var _dataItem = storageProvider.getItem('auth.token'); | |
if (_dataItem) { | |
var _dataStructure = JSON.parse(_dataItem); | |
this.expires = ko.observable(new Date(_dataStructure.expires)); | |
this.accessToken = ko.observable(_dataStructure.accessToken); | |
this.refreshToken = ko.observable(_dataStructure.refreshToken); | |
notifySubscribers('tokenUpdated'); | |
return true; | |
} | |
else | |
return false; | |
}; | |
// Re-initialize the data model with specified identity and persist it in storage | |
this.persist = (identity) => { | |
this.expires(identity.expires); | |
this.accessToken(identity.accessToken); | |
this.refreshToken(identity.refreshToken); | |
notifySubscribers('tokenUpdated'); | |
storageProvider.setItem('auth.token', JSON.stringify({ | |
expires: this.expires(), | |
accessToken: this.accessToken(), | |
refreshToken: this.refreshToken() | |
})); | |
}; | |
// Clears the model to initial state and deletes it from storage | |
this.clear = () => { | |
notifySubscribers('tokenRemoving'); | |
this.expires(null); | |
this.accessToken(''); | |
this.refreshToken(''); | |
storageProvider.removeItem('auth.token'); | |
}; | |
// adds subscription to notify when the access token is about to expire | |
this.subscribeTokenExpiring = (callback) => { | |
if (typeof callback === 'function') | |
_callBacks.tokenExpiring.push(callback); | |
}; | |
// adds subscription to notify when the access token is about to expire | |
this.subscribeTokenUpdated = (callback) => { | |
if (typeof callback === 'function') | |
_callBacks.tokenUpdated.push(callback); | |
}; | |
// adds subscription to notify when the access token is about to expire | |
this.subscribeTokenRemoving = (callback) => { | |
if (typeof callback === 'function') | |
_callBacks.tokenRemoving.push(callback); | |
}; | |
// notifies all subscribes about token expiration | |
function notifySubscribers(subscriptionType) { | |
var subscription = _callBacks[subscriptionType]; | |
for (var i = 0; i < subscription.length; i++) { | |
subscription[i](); | |
} | |
} | |
// configures event system for expiration notifications | |
function configureTokenExpiring(tokenModel) { | |
function callback() { | |
handle = null; | |
notifySubscribers('tokenExpiring'); | |
} | |
var handle = null; | |
function cancel() { | |
if (handle) { | |
window.clearTimeout(handle); | |
handle = null; | |
} | |
} | |
function setup(duration) { | |
handle = window.setTimeout(callback, duration * 1000); | |
} | |
function configure() { | |
cancel(); | |
if (tokenModel.expires() && (tokenModel.expires() > new Date())) { | |
// TODO: in this implementation, once the access_token expired we won't auto-renew it, which means: | |
// * either explicitly renew it (for instance, when coming back from 3rd party site) | |
// * change the condition above and try renew with some 'retry policy' | |
var duration = parseInt((new Date(tokenModel.expires()) - new Date()) / 1000); | |
setup(duration > 60 ? duration - 60 : 0); | |
} | |
} | |
configure(); | |
tokenModel.subscribeTokenUpdated(configure); | |
tokenModel.subscribeTokenRemoving(cancel); | |
} | |
// delay this so consumers can register for callbacks first | |
window.setTimeout(() => { | |
configureTokenExpiring(this); | |
}, 0); | |
} | |
/** | |
* Binds a realtime counter of expiration to UI | |
* | |
*/ | |
function setupRealtimeCounter(tokenModel, bindable) { | |
var timer; | |
function clear() { | |
clearInterval(timer); | |
} | |
function setup() { | |
clear(); | |
timer = setInterval(() => { | |
if (!tokenModel.expires()) | |
bindable(null); | |
else { | |
var period = parseInt((new Date(tokenModel.expires()) - new Date()) / 1000); | |
bindable(`<span class="label label-${period < 60 ? "danger" : "default"}">${period < 1 ? "expired" : "in " + period}</span>`); | |
} | |
}, 1000); | |
} | |
tokenModel.subscribeTokenUpdated(setup); | |
tokenModel.subscribeTokenRemoving(clear); | |
setup(); | |
} | |
/** | |
* Represents a viewmodel for UI markup | |
* | |
* @return {Function} | |
*/ | |
function ViewModel() { | |
// TODO: retrieve from configuration | |
var client = new ClientOAuth2({ | |
clientId: 'js_client', | |
accessTokenUri: 'http://localhost:53289/token', | |
scopes: ['all'] | |
}); | |
var identity = null; | |
this.token = new TokenModel(window.localStorage); | |
this.user = ko.observable(); | |
this.password = ko.observable(); | |
this.isWorking = ko.observable(false); | |
this.apiResultText = ko.observable(''); | |
this.adminResultText = ko.observable(''); | |
this.claimsText = ko.observable(''); | |
this.isAnonymous = ko.pureComputed(function () { | |
return this.refreshToken() === ''; | |
}, this.token); | |
this.expiresIn = ko.observable(''); | |
// performs authentication using user and password | |
this.authenticate = () => { | |
this.isWorking(true); | |
client.owner | |
.getToken(this.user(), this.password()) | |
.then((newIdentity) => { | |
console.log('Authenticated', newIdentity); | |
identity = newIdentity; | |
this.token.persist(newIdentity); | |
}) | |
.catch((error) => { | |
console.error('Failed to authenticate', error); | |
}) | |
.then(() => { | |
this.user(''); | |
this.password(''); | |
this.isWorking(false) | |
}); | |
}; | |
// retrieves a new access_token by using the refresh_token | |
this.refreshAccessToken = () => { | |
identity.refresh() | |
.then((newIdentity) => { | |
console.log('Token refreshed', newIdentity); | |
this.token.persist(newIdentity); | |
}) | |
.catch((error) => { | |
console.error('Failed to refresh access token', error); | |
}) | |
.then(() => this.isWorking(false)); | |
}; | |
// decodes claims from access_token | |
this.decodeJwt = () => { | |
var decodedObj = jwt_decode(this.token.accessToken()); | |
this.claimsText(JSON.stringify(decodedObj, undefined, 2)); | |
}; | |
// anonymize the user | |
this.signOut = () => { | |
this.token.clear(); | |
identity = null; | |
// TODO: besides clearing on client, we should also remove the token from | |
// persisted storage on server AND to delete a cookie used for 3rd party keep alive | |
this.apiResultText(''); | |
this.adminResultText(''); | |
this.claimsText(''); | |
this.expiresIn(null); | |
}; | |
this.anonymousApiRequest = () => { | |
this.apiResultText(''); | |
this.isWorking(true); | |
popsicle('/test/anon') | |
.then((res) => { | |
if (res.status != 200) throw res; | |
console.log('Successful anonymous API request', res); | |
if (this.token.identity != null && !this.token.identity.expired()) | |
this.apiResultText(`<strong>Although the user is authenticated we managed to get response as anonymous user</strong> ${JSON.stringify(res.body)}`); | |
else | |
this.apiResultText(`${JSON.stringify(res.body)}`); | |
}) | |
.catch((res) => { | |
console.error('Failed anonymous API request', res); | |
this.apiResultText(`Failed with status [${res.status}]`); | |
}) | |
.then(() => this.isWorking(false)); | |
}; | |
this.authenticatedApiRequest = () => { | |
this.apiResultText(''); | |
this.isWorking(true); | |
// 'ClientOAuth2' auto-adds authentication info to requests, thus use it if user is authenticated | |
var handler = identity ? identity.request.bind(identity) : popsicle; | |
handler({ url: '/test/auth' }) | |
.then((res) => { | |
if (res.status != 200) throw res; | |
console.log('Successful authenticated API request', res); | |
this.apiResultText(`${JSON.stringify(res.body, undefined, 2)}`); | |
}) | |
.catch((res) => { | |
console.error('Failed authenticated API request', res); | |
this.apiResultText(`Failed with status [${res.status}]`); | |
}) | |
.then(() => this.isWorking(false)); | |
}; | |
this.getRefreshToken = () => { | |
this.adminResultText(''); | |
this.isWorking(true); | |
identity.request({ | |
url: '/admin/refresh_token', | |
query: { token: this.token.refreshToken() } | |
}).then((res) => { | |
if (res.status != 200) throw res; | |
this.adminResultText(JSON.stringify(res.body, undefined, 2)); | |
}) | |
.catch((res) => console.error('Failed to get refresh token details', res)) | |
.then(() => this.isWorking(false)) | |
}; | |
this.deleteRefreshToken = () => { | |
this.adminResultText(''); | |
this.isWorking(true); | |
identity.request({ | |
url: '/admin/refresh_token', | |
method: 'DELETE', | |
query: { token: this.token.refreshToken() } | |
}).then((res) => { | |
if (res.status != 200) throw res; | |
this.adminResultText('Token deleted. Now try to refresh access token :)'); | |
}) | |
.catch((res) => console.error('Failed to delete refresh token', res)) | |
.then(() => this.isWorking(false)) | |
}; | |
this.goToThirdParty = () => { | |
var loc = window.location; | |
var pingUrl = escape(`${loc.protocol}//${loc.host}/keepalive`); | |
var returnUrl = escape(loc.href); | |
// the way to ping from 3rd party can be alos via IRFAME, then add to URI below '&strategy=iframe' | |
loc.href = `//keepalive.azurewebsites.net?pingUrl=${pingUrl}&returnUrl=${returnUrl}&frequency=5`; | |
}; | |
// setup auto-renew | |
this.token.subscribeTokenExpiring(this.refreshAccessToken); | |
if (this.token.tryLoad() === true) { | |
// initialize identity from client's persisted storage | |
identity = client.createToken( | |
this.token.accessToken(), | |
this.token.refreshToken(), | |
'bearer', | |
{ | |
expires_in: parseInt((new Date(this.token.expires()) - new Date()) / 1000) | |
}); | |
console.log('Loaded identity from storage', identity); | |
} | |
else | |
console.log('No identity in store => anonymous user'); | |
setupRealtimeCounter(this.token, this.expiresIn); | |
} | |
ko.applyBindings(new ViewModel()); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment