Skip to content

Instantly share code, notes, and snippets.

@rhyek
Last active May 28, 2016 09:09
Show Gist options
  • Save rhyek/55e75d6dc45eaa65bceac510196c2a30 to your computer and use it in GitHub Desktop.
Save rhyek/55e75d6dc45eaa65bceac510196c2a30 to your computer and use it in GitHub Desktop.
OAuth2 authenticator for use with Ember Simple Auth with several features added
/** @module app/authenticators/application */
import Ember from 'ember';
import OAuth2PasswordGrant from 'ember-simple-auth/authenticators/oauth2-password-grant';
const { isEmpty, RSVP, run, assign } = Ember;
/** need ember-browserify */
import UUID from 'npm:node-uuid';
const refreshTokenTabKey = 'refresh-token-tab';
const shouldInvalidateRefreshTokenTabKey = 'should-invalidate-refresh-token-tab';
const uuid = UUID.v4();
/**
* Authenticator that extends ESA's OAuth2 implementation and adds several features
* such as:
* -Ensure only one tab/window is handling token refreshes to avoid 400/401 errors
* due to concurrent requests and lowering bandwidth used.
* -If a token refresh is rejected with 400/401 http status codes, invalidate the
* session.
* -Attempt token refreshes when 90% of the access token's lifetime has transpired,
* leaving the remaining 10% as an opportunity to retry the refresh in case of
* connection failures.
* -If a token refresh fails due to a connection error, keep trying every 5 seconds
* until the current access token's lifetime has expired, in which case, the
* session will be invalidated.
*/
export default OAuth2PasswordGrant.extend({
session: Ember.inject.service(),
clientId: 'ember-app',
init() {
this._super(...arguments);
Ember.Logger.debug(`uuid for ${refreshTokenTabKey}:`, uuid);
window.addEventListener('beforeunload', () => {
if (this.get('isRefreshTokenTab')) {
localStorage[refreshTokenTabKey] = '';
Ember.Logger.debug('Unregistered as refresh token tab.');
}
});
window.addEventListener('storage', e => {
if (e.key === shouldInvalidateRefreshTokenTabKey) {
let contesting = eval(e.newValue);
if (contesting && this.get('isRefreshTokenTab')) {
localStorage[shouldInvalidateRefreshTokenTabKey] = false;
Ember.Logger.debug('Avoided invalidation of refresh tab.');
} else if (!contesting && !this.get('isRefreshTokenTab')) {
if (this.contestTimeout !== null) {
clearTimeout(this.contestTimeout);
this.contestTimeout = null;
Ember.Logger.debug('Refresh token tab contest aborted.');
}
}
}
});
},
isRefreshTokenTab: Ember.computed(function() {
return localStorage[refreshTokenTabKey] === uuid;
}).volatile(),
tryRegisterAsRefreshTokenTab() {
let success = false;
if (!this.get('isRefreshTokenTab')) {
if (Ember.isEmpty(localStorage[refreshTokenTabKey])) {
localStorage[refreshTokenTabKey] = uuid;
localStorage[shouldInvalidateRefreshTokenTabKey] = false;
success = true;
}
Ember.Logger.debug(`Trying to register as refresh token tab... ${success ? 'SUCCESS' : 'FAILED'}.`);
} else {
success = true;
}
return success;
},
contestTimeout: null,
restore(data) {
return new RSVP.Promise((resolve, reject) => {
const now = (new Date()).getTime();
const refreshAccessTokens = this.get('refreshAccessTokens');
if (!isEmpty(data['expires_at']) && data['expires_at'] < now) {
if (refreshAccessTokens) {
this._refreshAccessToken(data['expires_in'], data['refresh_token'], true).then(resolve, reject);
} else {
reject();
}
} else {
if (isEmpty(data['access_token'])) {
reject();
} else {
this._scheduleAccessTokenRefresh(data['expires_in'], data['expires_at'], data['refresh_token']);
resolve(data);
}
}
});
},
invalidate(data) {
const serverTokenRevocationEndpoint = this.get('serverTokenRevocationEndpoint');
function success(resolve) {
run.cancel(this._refreshTokenTimeout);
delete this._refreshTokenTimeout;
clearTimeout(this.contestTimeout);
this.contestTimeout = null;
resolve();
}
return new RSVP.Promise((resolve) => {
if (isEmpty(serverTokenRevocationEndpoint)) {
success.apply(this, [resolve]);
} else {
const requests = [];
Ember.A(['access_token', 'refresh_token']).forEach((tokenType) => {
const token = data[tokenType];
if (!isEmpty(token)) {
requests.push(this.makeRequest(serverTokenRevocationEndpoint, {
'token_type_hint': tokenType, token
}));
}
});
const succeed = () => {
success.apply(this, [resolve]);
};
RSVP.all(requests).then(succeed, succeed);
}
});
},
tokenRefreshDelay(expiresIn, expiresAt) {
const grace = expiresIn * .1 * 1000;
const now = new Date().getTime();
const delta = expiresAt - now;
let delay = expiresAt - now - grace;
if (delta < expiresIn - grace) {
delay = 5 * 1000;
}
Ember.Logger.debug(`tokenRefreshDelay: ${delay / 1000}s`);
return delay;
},
_scheduleAccessTokenRefresh(expiresIn, expiresAt, refreshToken) {
const refreshAccessTokens = this.get('refreshAccessTokens');
if (refreshAccessTokens) {
const now = (new Date()).getTime();
if (isEmpty(expiresAt) && !isEmpty(expiresIn)) {
expiresAt = new Date(now + expiresIn * 1000).getTime();
}
const delay = this.tokenRefreshDelay(expiresIn, expiresAt);
if (!isEmpty(refreshToken) && !isEmpty(expiresAt)) {
run.cancel(this._refreshTokenTimeout);
delete this._refreshTokenTimeout;
if (expiresAt > now + delay) {
if (!Ember.testing) {
this._refreshTokenTimeout = run.later(this, this._refreshAccessToken, expiresIn, refreshToken, delay);
}
} else if (this.get('session.isAuthenticated') && this.get('isRefreshTokenTab')) {
Ember.Logger.debug('Access token expired. Invalidating session.');
this.get('session').invalidate();
}
}
}
},
_refreshAccessToken(expiresIn, refreshToken, restoring = false) {
const data = { 'grant_type': 'refresh_token', 'refresh_token': refreshToken };
const serverTokenEndpoint = this.get('serverTokenEndpoint');
return new RSVP.Promise((resolve, reject) => {
if (this.tryRegisterAsRefreshTokenTab()) {
this.makeRequest(serverTokenEndpoint, data).then((response) => {
run(() => {
expiresIn = response['expires_in'] || expiresIn;
refreshToken = response['refresh_token'] || refreshToken;
const expiresAt = this._absolutizeExpirationTime(expiresIn);
const data = assign(response, { 'expires_in': expiresIn, 'expires_at': expiresAt, 'refresh_token': refreshToken });
this._scheduleAccessTokenRefresh(expiresIn, null, refreshToken);
this.trigger('sessionDataUpdated', data);
resolve(data);
});
}, (xhr, status, error) => {
Ember.Logger.warn(`Access token could not be refreshed - server responded with ${error}.`);
if (!restoring) {
if([400, 401].contains(xhr.status)) {
if (this.get('session.isAuthenticated')) {
this.get('session').invalidate();
}
} else {
Ember.Logger.warn(`Refresh token failed due to a connection error. Retrying.`);
this._scheduleAccessTokenRefresh(expiresIn, this.get('session.data.authenticated.expires_at'), refreshToken);
}
} else {
reject();
}
});
} else {
Ember.Logger.debug('Contesting refresh tab...');
this.contestTimeout = setTimeout(() => {
let done = false;
let shouldInvalidate = eval(localStorage[shouldInvalidateRefreshTokenTabKey]);
if (shouldInvalidate) {
localStorage[refreshTokenTabKey] = '';
localStorage[shouldInvalidateRefreshTokenTabKey] = false;
Ember.Logger.debug('Will invalidate.');
if (this.tryRegisterAsRefreshTokenTab()) {
this._refreshAccessToken(expiresIn, refreshToken).then(resolve, reject);
done = true;
}
} else {
Ember.Logger.debug('Will not invalidate.');
}
if (!done) {
reject();
}
}, 500);
localStorage[shouldInvalidateRefreshTokenTabKey] = true;
}
});
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment