Skip to content

Instantly share code, notes, and snippets.

@jenyayel
Last active November 7, 2016 07:52
Show Gist options
  • Save jenyayel/2dc0b36f93910b487fb20f8e38c8ba79 to your computer and use it in GitHub Desktop.
Save jenyayel/2dc0b36f93910b487fb20f8e38c8ba79 to your computer and use it in GitHub Desktop.
OAuth JWT autorenew demo
node_modules
bower_components
.idea
.DS_STORE
bower_components
.idea
{
"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"
}
}
<!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>
/// <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