Skip to content

Instantly share code, notes, and snippets.

@pauln
Last active January 25, 2023 18:29
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pauln/913e8b87b66bb0f8fc437b25a7a27382 to your computer and use it in GitHub Desktop.
Save pauln/913e8b87b66bb0f8fc437b25a7a27382 to your computer and use it in GitHub Desktop.
ember-simple-auth oauth2 implicit grant authenticator

oauth2 Implicit Grant authenticator for ember-simple-auth

This is a sample ember-simple-auth authenticator implementation for the oauth2 Implicit Grant which implements "silent reauthentication" (fetching a new token from the IDP via the prompt=none flow). It also uses ember-master-tab to run the refresh process in only a single tab (if the application is open in multiple tabs); at time of writing, it's necessary to use the master branch rather than the version published to npm as it makes use of a recent change to try to recover from the master tab crashing (as opposed to being closed cleanly).

This implementation also expects the token to be a JWT; you may need to adjust the token-related parts if you're not using JWTs.

So how do I use this?

This gist is not a fully developed, drop-in, ready-to-use implementation. It's intended as a starting point for your own implementation, so you'll need to do some work yourself to use it - including (but not necessarily limited to):

  • Add ember-master-tab to your Ember project, if you're not already using it
    • Switch to the master branch if you're running v1.0.0 or earlier
  • Add the files in this gist (excluding this readme) to the indicated locations in your Ember app
    • Strip out the comment from the top of silent-callback.html - it's just there to tell you where to put the file
    • You may also want to remove the filename comments from the other files, but they're not critical
  • Configure the relevant parameters (oauth endpoint, client ID, requested scope)
    • client ID is currently pulled from Ember environment config (oauthClientID) - either set it there or adjust to suit
  • Send users to the authorize endpoint, with a redirect URI pointing to the callback route, in order to authenticate
  • Implement a login-failed route which displays errors (based on the error query parameter, if desired)
// app/routes/callback.js
import Ember from 'ember';
import OAuth2ImplicitGrantCallbackRouteMixin from 'ember-simple-auth/mixins/oauth2-implicit-grant-callback-route-mixin';
const { Route } = Ember;
export default Route.extend(OAuth2ImplicitGrantCallbackRouteMixin, {
authenticator: 'authenticator:oauth2-implicit-grant',
handleError: Ember.observer('error', function() {
let err = this.get('error');
if (err && err.length) {
this.transitionTo(`/login-failed?error=${err}`);
}
})
});
// app/authenticators/oauth2-implicit-grant.js
import Ember from 'ember';
import OAuth2ImplicitGrant from 'ember-simple-auth/authenticators/oauth2-implicit-grant';
import OAuth2ImplicitGrantMixin from 'ember-simple-auth/mixins/oauth2-implicit-grant-callback-route-mixin';
import config from '../config/environment';
const { inject: { service }, isEmpty, isPresent } = Ember;
export default OAuth2ImplicitGrant.extend(OAuth2ImplicitGrantMixin, {
masterTab: service(),
session: service(),
routing: service('-routing'),
tokenPropertyName: 'access_token',
tokenExpireName: 'exp',
refreshAccessTokens: true,
refreshLeeway: 240,
refreshFrame: null,
backupTimer: null,
authorizeUrl: 'http://localhost:8080/oauth/authorize',
requestedScope: 'global',
init() {
// Set up callback for hidden iframe refresh flow
window._ESA_Oauth2_Process_Implicit_Refresh = (hash) => {
let frame = this.get('refreshFrame');
let args = this._parseResponse(hash);
let backupTimer = this.get('backupTimer');
let router = this.get('routing');
// Authenticate using provided hash data
this.get('session').authenticate('authenticator:OAuth2ImplicitGrant', args).catch((err) => {
// All the routers!
router.router.router.transitionTo('login-failed', {queryParams: {error: 'session_timeout'}});
});
// Remove iframe / reset reference
if (!!frame) {
frame.remove();
this.set('refreshFrame', null);
}
// Remove backup schedule
if (!!backupTimer) {
Ember.run.cancel(backupTimer);
this.set('backupTimer', null);
}
}
},
authenticate(hash) {
// Use parent authenticator, then set up for token refresh as appropriate
return this._super(...arguments).then((data) => {
return this.initTokenRefresh(data)
});
},
restore(data) {
return this.initTokenRefresh(data);
},
initTokenRefresh(data) {
const dataObject = Ember.Object.create(data);
return new Ember.RSVP.Promise((resolve, reject) => {
const now = this.getCurrentTime();
const token = dataObject.get(this.tokenPropertyName);
let expiresAt = dataObject.get(this.tokenExpireName);
if (isEmpty(token)) {
return reject(new Error('empty token'));
}
if (isEmpty(expiresAt)) {
// Fetch the expire time from the token data since `expiresAt`
// wasn't included in the data object that was passed in.
const tokenData = this.getTokenData(token);
expiresAt = tokenData[this.tokenExpireName];
if (isEmpty(expiresAt)) {
return resolve(data);
}
}
if (expiresAt > now) {
const wait = expiresAt - now - this.refreshLeeway;
if (wait > 0) {
if (this.refreshAccessTokens) {
Ember.run.once(this, 'scheduleAccessTokenRefresh', wait);
}
return resolve(data);
} else if (this.refreshAccessTokens) {
// Token is still valid, but due to be refreshed.
// Try getting a new one - session will be updated if successful.
Ember.run.once(this, 'refreshAccessToken');
return resolve(data);
} else {
return reject(new Error('unable to refresh token'));
}
} else if (isPresent(token)) {
// Fetch the expire time from the auth token data.
const tokenData = this.getTokenData(token);
let tokenExpiresAt = tokenData[this.tokenExpireName];
if (isPresent(tokenExpiresAt)) {
if (tokenExpiresAt > now && this.refreshAccessTokens) {
// Token is still valid, but due to be refreshed.
// Try getting a new one - session will be updated if successful.
Ember.run.once(this, 'refreshAccessToken');
return resolve(data);
} else {
return reject(new Error('Token is expired'));
}
}
} else {
return reject(new Error('token is expired'));
}
});
},
/**
Returns the current time as a timestamp in seconds
@method getCurrentTime
@return {Integer} timestamp
*/
getCurrentTime() {
return Math.floor((new Date()).getTime() / 1000);
},
/**
Returns the decoded token with accessible returned values.
@method getTokenData
@return {object} An object with properties for the session.
*/
getTokenData(token) {
const payload = token.split('.')[1];
const tokenData = decodeURIComponent(window.escape(atob(payload)));
try {
return JSON.parse(tokenData);
} catch (e) {
return tokenData;
}
},
/**
* Triggers a token refresh, if this is the master tab.
* Otherwise, triggers a master tab contest in case the master tab has died.
*/
refreshAccessToken() {
const masterTab = this.get('masterTab');
masterTab.run(() => {
this.doRefreshAccessToken();
}).else(() => {
masterTab.contestMasterTab();
})
},
/**
Attempts to silently fetch a new access token using prompt=none
@method refreshAccessToken
@private
*/
doRefreshAccessToken() {
let clientId = config.oauthClientID;
let redirectURI = `${window.location.origin}/silent-callback.html`;
let responseType = `token`;// `token id_token`
let scope = this.get('requestedScope');
let authorizeUrl = this.get('authorizeUrl');
let refreshUrl = `${authorizeUrl}?`
+ `client_id=${clientId}`
+ `&redirect_uri=${redirectURI}`
+ `&response_type=${responseType}`
+ `&scope=${scope}&state=foobar123`
+ `&prompt=none`
;
let frame = this.get('refreshFrame');
if (!!frame) {
// Refresh already in progress?
return;
}
frame = Ember.$(`<iframe style="display:none" src="${refreshUrl}"></iframe>`);
this.set('refreshFrame', frame);
Ember.$('body').append(frame);
},
scheduleAccessTokenRefresh(delay) {
let leeway = this.get('refreshLeeway');
let additionalDelay = Math.floor(0.5 * leeway);
// Schedule main refresh attempt
Ember.run.later(this, 'refreshAccessToken', delay * 1000);
// Schedule backup refresh attempt (in case master tab fails / is replaced)
let timer = Ember.run.later(this, 'refreshAccessToken', (delay + additionalDelay) * 1000);
this.set('backupTimer', timer);
}
});
<!-- public/silent-callback.html -->
<!doctype html>
<html>
<head>
<title>Silent callback</title>
<script type="text/javascript">
window.parent._ESA_Oauth2_Process_Implicit_Refresh(window.location.hash);
</script>
</head>
<body>
<h1>Silent callback</h1>
</body>
</html>
@WillEngler
Copy link

// All the routers!
router.router.router.transitionTo('login-failed', {queryParams: {error: 'session_timeout'}});

I got a kick out of that

@fredguth
Copy link

What does (!! frame) mean? I have never seen !! operator.

@davidgodzsak
Copy link

What does (!! frame) mean? I have never seen !! operator.

I think it is a nasty way of making sure you have a boolean there. It is double negation, the first one "casts" the variable to a boolean but the negated value, so you negate again...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment