/* global WP_DEFINE_DEVELOPMENT */
import debugLib from 'debug';
import EventEmitter from 'eventemitter3';
import loadOidcClient from 'bundle-loader?lazy!oidc-client';
import mockedWindow from 'storage-mock';
import serviceConfig from '../../../app/config/service.configdefinitions';
import * as AuthConstants from '../../../app/data/AuthConstants';
const debug = debugLib('SlimmingWorld:ClientAuthenticationManager');
/**
* Wrapper class for the oidc-client.js library that handles client-side implicit authentication.
*/
class ClientAuthenticationManager extends EventEmitter {
/**
* When a login is executed or has completed executing, this will be set to a promise
* that resolves when the login is complete. If there is no login and we're not currently
* in the process of logging in, this will be set to null.
* @member executingSignInPromise
* @type {Promise}
* @private
*/
executingSignInPromise = null;
/**
* If true, will initiate a redirect callback
* @member enableRedirectOnSilentFail
* @type {boolean}
* @private
*/
enableRedirectOnSilentFail = false;
/**
* Constructor for authentication manager
* @constructor
* @param oidcConfig The oidc section of configuration object as parsed by the 'config' module
* from the configuration jsons
*
* @fires authtoken
*/
constructor(oidcConfig) {
super();
window.ClientAuthenticationManager = this;
this.firstRenderComplete = new Promise(resolve => {
this.setFirstRenderComplete = resolve;
});
this.initialized = this.setupUserManager(oidcConfig).then(() => {
this.userManager.clearStaleState();
this.userManager.events.addSilentRenewError(() => {
debug('Error during silent renew. Redirecting to login page...');
this.executeRedirectSignin();
});
this.userManager.events.addAccessTokenExpired(() => {
this.executingSignInPromise = null;
});
this.onSigninSuccess = (user, log) => {
this.enableRedirectOnSilentFail = false;
debug(log);
// add callback for userLoaded. This is for when the user session is renewed
this.userManager.events.addUserLoaded(this.handleUserLoaded);
// for signin, we call setAuthToken ourselves because we need to return the promise
// this.setAuthTokensCallback(user.id_token, user.access_token);
this.emit('authtoken', { idToken: user.id_token, accessToken: user.access_token });
return user;
};
});
}
handleUserLoaded = user => {
this.executingSignInPromise = Promise.resolve(user);
// this.setAuthTokensCallback(user.id_token, user.access_token);
this.emit('authtoken', { idToken: user.id_token, accessToken: user.access_token });
};
/**
* Initializes the UserManager from the oidc-client module.
* @param oidcConfig The oidc section of configuration object as parsed by the 'config' module
* from the configuration jsons
* @return {Promise} A Promise that resolves when setup has completed.
*/
setupUserManager(oidcConfig) {
/* eslint-disable */
return new Promise(resolve => {
loadOidcClient(({ Log, UserManager, WebStorageStateStore }) => {
Log.logger = console;
Log.level = WP_DEFINE_DEVELOPMENT ? Log.WARN : Log.ERROR;
const webroot = `${window.location.protocol}//${window.location.host}`;
const oidcSettings = {
authority: `${oidcConfig.authority}/.well-known/openid-configuration?${document.location.hostname}`,
client_id: oidcConfig.implicit[serviceConfig.webHost].client_id,
redirect_uri: `${webroot}${AuthConstants.IMPLICIT_ROUTE_CALLBACK}`,
response_type: 'id_token token',
post_logout_redirect_uri: webroot,
scope: ['openid'].concat(AuthConstants.OID_CLIENT_SCOPES).join(' '),
silent_redirect_uri: `${webroot}${AuthConstants.IMPLICIT_ROUTE_SILENT_RENEW}`,
automaticSilentRenew: true,
loadUserInfo: false,
...(serviceConfig.persistClientAuthentication
? {}
: { userStore: new WebStorageStateStore({ store: mockedWindow.sessionStorage }) }),
metadata: {
issuer: oidcConfig.authority,
userinfo_endpoint: `${oidcConfig.authority}/connect/userinfo`,
jwks_uri: `${oidcConfig.authority}/.well-known/openid-configuration/jwks?${document.location.hostname}`,
end_session_endpoint: `${oidcConfig.authority}/connect/endsession`,
authorization_endpoint: `${oidcConfig.authority}/connect/authorize`,
},
};
this.userManager = new UserManager(oidcSettings);
resolve();
});
});
/* eslint-enable */
}
/**
* Logout the user from Node.JS, the browser and the single sign-on account server
*
* The argument is used in the oidc-client->signoutRedirect
*/
logout(postLogoutRedirectUri) {
this.initialized.then(() => {
invalidateServerAuthToken();
this.userManager.signoutRedirect({
post_logout_redirect_uri: postLogoutRedirectUri,
});
});
}
/**
* Attempts to login the user using a silent sign-in. This method should only be called
* if another login is not already in process.
* @return {Promise} A Promise that resolves when signin has completed.
* https://raw.githubusercontent.com/IdentityModel/oidc-client-js/dev/lib/oidc-client.js
*/
executeSilentSignin() {
if (this.executingSignInPromise !== null) {
debug('Executing silent sign-in while sign-in is already in progress');
}
this.executingSignInPromise = this.initialized
.then(() => this.userManager.getUser())
// This will fix the difference between the client and server at the first render
.then(() => this.firstRenderComplete)
.then(userInSession => {
if (userInSession && !userInSession.expired) {
return this.onSigninSuccess(userInSession, 'user already authenticated');
}
// Temporarily remove the event listener for when the user had loaded
this.userManager.events.removeUserLoaded(this.handleUserLoaded);
return this.userManager
.signinSilent()
.then(user => this.onSigninSuccess(user, 'silent signin completed'))
.catch(() => {
if (this.enableRedirectOnSilentFail) {
debug('Error during silent signin. Redirecting to login page...');
return this.executeRedirectSignin();
}
// Silent signin failed but not required. Set promise to null so we trigger
// silent signin again on the next route change
this.executingSignInPromise = null;
return null;
});
});
return this.executingSignInPromise;
}
/**
* Callback that should be called whenever the route changes. Should also be called
* on the first page render. Parses the login information from the url fragment, if
* present. Otherwise, triggers a silent sign-in if that is not done yet.
* @param requiresAuth A boolean that indicates if authentication is required for this
* page.
*/
onRouteChange = (requiresAuth = true) => {
if (requiresAuth) {
this.enableRedirectOnSilentFail = true;
}
if (window.location.hash && window.location.hash.indexOf('access_token') >= 0) {
this.executingSignInPromise = this.initialized.then(() => {
this.userManager.events.removeUserLoaded(this.handleUserLoaded);
return this.userManager
.signinRedirectCallback()
.then(user => this.onSigninSuccess(user, 'signin redirect callback received'))
.catch(e => {
debug(`Error processing redirect callback: ${e}`);
this.executingSignInPromise = null;
this.executeSilentSignin();
});
});
} else if (this.executingSignInPromise === null) {
const route = window.location.pathname.split('/');
const blacklist = ['login', 'logout', 'logged-out'];
const isBlackList = blacklist.some(key => route.includes(key));
if (!isBlackList) {
this.executeSilentSignin();
}
}
};
/**
* Executes a sign-in using a redirect rather than silently via an iframe. This will be
* called when sign-in is required but the silent method failed.
* @returns {Promise} A Promise that resolves when the sign-in has completed
*/
executeRedirectSignin = () => {
/* eslint-disable dot-notation */
if (window['sessionStorage']) {
try {
window.sessionStorage[
AuthConstants.LOGIN_RETURN_SESSION_PROP
] = `${window.location.pathname}${window.location.search}`;
} catch (e) {
debug(`skipping executeRedirectSignin error: ${e}`);
}
}
/* eslint-enable */
return this.initialized.then(() => this.userManager.signinRedirect());
};
/**
* Executes a silent sign-in if it is not already running. Returns a Promise that resolves
* with the logged in user.
* @returns {Promise}
*/
getUser = async () => {
await this.executingSignInPromise;
if (this.executingSignInPromise === null) {
this.executeSilentSignin();
}
return this.executingSignInPromise.then(user => {
if (user) {
if (Date.now() > user.expires_at * 1000) {
debug('Token expired. Executing silent signin...');
this.executingSignInPromise = null;
this.executeSilentSignin();
return this.executingSignInPromise;
}
}
return user;
});
};
/**
* Clears any current authentication and executes a new silent sign-in.
*/
refreshLogin = () => {
const signInExecuting = this.executingSignInPromise || this.initialized.then(() => null);
return signInExecuting
.then(user => {
invalidateServerAuthToken();
if (user) {
return this.userManager.removeUser();
}
return null;
})
.then(() => {
this.executingSignInPromise = null;
this.enableRedirectOnSilentFail = true;
return this.executeSilentSignin();
});
};
removeUserState = () => this.userManager.removeUser();
}
export default ClientAuthenticationManager;
/**
* Logs the user out from Node.JS by removing the cookie that contains the authentication
* token. This will not logout the user from the account server.
* @private
*/
function invalidateServerAuthToken() {
[
AuthConstants.STATE_COOKIE,
AuthConstants.INITIAL_USER_STATE_COOKIE,
AuthConstants.TOKEN_COOKIE,
].forEach(cookieName => {
document.cookie = `${cookieName}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/`;
});
}
/**
* Will be emitted whenever new auth tokens are set.
*
* @event authtoken
* @type {object}
* @property {object} idToken The new id token
* @property {object} accessToken The new access token
*/