Last active
May 28, 2016 09:09
-
-
Save rhyek/55e75d6dc45eaa65bceac510196c2a30 to your computer and use it in GitHub Desktop.
OAuth2 authenticator for use with Ember Simple Auth with several features added
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
/** @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