UNPKG

@qikdev/sdk

Version:

Promise based Javascript SDK

938 lines (676 loc) 27.1 kB
import axios from 'axios'; import { EventDispatcher } from './qik.utils.js'; /////////////////////////////////////////////////// /** * Creates a new instance of QikAuth a module of the SDK * that contains all helper functions to do with authentication and user session management * @alias auth * @constructor * @hideconstructor * @param {QikAPI} qik A reference to the parent instance of the QikCore module. This module is usually created by a QikCore instance that passes itself in as the first argument. */ var QikAuth = function(qik) { if (!qik.api) { throw new Error(`Please ensure that QikAPI exists before QikAuth`); } //Keep track of any refresh requests var inflightRefreshRequest; /////////////////////////////////////////////////// var sessionStorage = {}; var store = sessionStorage; const tokenBufferSeconds = 10; /////////////////////////////////////////////////// const service = { debug: false, } Object.defineProperty(service, 'store', { value: store, writable: false, }); //Create a new dispatcher const dispatcher = new EventDispatcher(); dispatcher.bootstrap(service); /////////////////////////////////////////////////// function dispatch(parameters) { //Get the current user var user = store.user; //Dispatch the change to the listeners if (service.onChange) { service.onChange(user); } //Dispatch the change event dispatcher.dispatch('change', user, parameters); } /////////////////////////////////////////////////// /** * @alias auth.set * @description Manually set current user session * @param {Object} user The user session object to set as the current user session * @param {Object} parameters Additional parameters to dispatch * @param {Boolean} stopDispatch Whether to supress dispatching a 'change' event. * @example * * const userSession = {_id:'61eca4746971e75c1fc670cf', firstName:'Daffy', lastName:'Duck' ...}; * sdk.auth.set(userSession); */ service.set = function(user, parameters, stopDispatch) { if (JSON.stringify(store.user) != JSON.stringify(user)) { store.user = user; if (!stopDispatch) { return dispatch(parameters) } } } /////////////////////////////////////////////////// /** * @alias auth.logout * @description Clear the current user session from memory and erase all caches * @example * sdk.auth.logout(); */ service.logout = function() { delete store.user; qik.cache.reset(); return dispatch() } /////////////////////////////////////////////////// /** * @alias auth.changeOrganisation * @description Manually set current user session * @param {(String|Object)} organisation The id of the organisation to switch into * @param {Object} options Additional options * @param {Boolean} options.disableAutoAuthentication By default when switching organisation, the current user session * will be updated to reflect a session in the new organisation, you can use this option to disable that behavior and instead return the * user session without dispatching any events * @example * * sdk.auth.changeOrganisation('61eca4746971e75c1fc670cf'); * // Current user session will be automatically updated * * const newSession = await sdk.auth.changeOrganisation('61eca4746971e75c1fc670cf', {disableAutoAuthentication:true}); * // Current user session will not be updated * sdk.auth.set(newSession); */ service.changeOrganisation = function(organisationID, options) { //Ensure we just have the ID organisationID = qik.utils.id(organisationID); ////////////////////////// if (!options) { options = {}; } ////////////////////////// //Change the users current tokens straight away var autoAuthenticate = true; if (options.disableAutoAuthentication) { autoAuthenticate = false; } ////////////////////////// return new Promise(function(resolve, reject) { qik.api.post(`/user/switch/${organisationID}`) .then(function(response) { if (autoAuthenticate) { qik.cache.reset(); service.set(response.data); } resolve(response.data); }) .catch(reject) }) } /////////////////////////////////////////////////// /** * @alias auth.impersonate * @description Impersonate another user within your organisation * @param {(String|Object)} persona The id of the user persona you want to impersonate * @param {Object} options Additional options * @param {Boolean} options.disableAutoAuthentication By default when impersonating a user, the current user session * will be updated automatically to reflect the new session, you can use this option to disable that behavior and instead return the * new impersonation user session without dispatching any events * @example * * sdk.auth.impersonate('61eca4746971e75c1fc670cf'); * // Current user session will be automatically updated * * const newSession = await sdk.auth.impersonate('61eca4746971e75c1fc670cf', {disableAutoAuthentication:true}); * // Current user session will not be updated * sdk.auth.set(newSession); */ service.impersonate = function(personaID, options) { //Ensure we just have the ID personaID = qik.utils.id(personaID); ////////////////////////// if (!options) { options = {}; } ////////////////////////// //Change the users current tokens straight away var autoAuthenticate = true; if (options.disableAutoAuthentication) { autoAuthenticate = false; } ////////////////////////// var promise = qik.api.post(`/user/impersonate/${personaID}`) promise.then(function(res) { if (autoAuthenticate) { qik.cache.reset(); service.set(res.data); } }, function(err) {}); return promise; } /////////////////////////////////////////////////// /** * @alias auth.login * @description Login and authenticate as a user * @param {Object} credentials The credentials used to login * @param {String} credentials.email The email address to login to * @param {String} credentials.password The password to login with * @param {String} credentials.mfa The MFA (Multi Factor Authentication) code * @param {Object} options Additional options * @param {Boolean} options.disableAutoAuthentication By default when logging in the current user session * will be updated automatically to reflect the new session, you can use this option to disable that behavior and instead return the * session that was logged in to without dispatching any events * @example * * const credentials = { * email:'me@email.com', * password:'******', * mfa:'1234', * } * * sdk.auth.login(credentials); * // Current user session will be automatically updated * * const newSession = await sdk.auth.login(credentials, {disableAutoAuthentication:true}); * // Current user session will not be updated * sdk.auth.set(newSession); */ service.login = async function(credentials, options) { if (!options) { options = {}; } ////////////////////////// //Change the users current tokens straight away var autoAuthenticate = true; if (options.disableAutoAuthentication) { autoAuthenticate = false; } ////////////////////////////////////// var promise = new Promise(loginCheck) function loginCheck(resolve, reject) { if (!credentials) { return reject({ message: 'Missing credentials!', }) } if (!credentials.email || !credentials.email.length) { return reject({ message: 'Username was not provided', }) } if (!credentials.password || !credentials.password.length) { return reject({ message: 'Password was not provided', }) } ///////////////////////////////////////////// var postOptions = { bypassInterceptor: true } ///////////////////////////////////////////// var url = `${qik.apiURL}/user/login`; ///////////////////////////////////////////// //If we have a specified url if (options.url) { url = options.url; } ///////////////////////////////////////////// qik.api.post(url, credentials, postOptions).then(function(res) { if (autoAuthenticate) { service.set(res.data); } resolve(res); }, reject); } ////////////////////////////////////// return promise; } /////////////////////////////////////////////////// service.signup = async function(credentials, options) { if (!options) { options = {}; } ////////////////////////// //Change the users current tokens straight away var autoAuthenticate = true; if (options.disableAutoAuthentication) { autoAuthenticate = false; } ////////////////////////////////////// var promise = new Promise(signupCheck) function signupCheck(resolve, reject) { if (!credentials) { return reject({ message: 'No details provided', }) } if (!credentials.firstName || !credentials.firstName.length) { return reject({ message: 'First Name was not provided', }) } if (!credentials.lastName || !credentials.lastName.length) { return reject({ message: 'Last Name was not provided', }) } if (!credentials.email || !credentials.email.length) { return reject({ message: 'Email/Username was not provided', }) } if (!credentials.password || !credentials.password.length) { return reject({ message: 'Password was not provided', }) } if (!credentials.confirmPassword || !credentials.confirmPassword.length) { return reject({ message: 'Confirm Password was not provided', }) } if (credentials.confirmPassword != credentials.password) { return reject({ message: 'Your passwords do not match', }) } ///////////////////////////////////////////// var postOptions = { bypassInterceptor: true } ///////////////////////////////////////////// var url = `${qik.apiURL}/user/signup`; ///////////////////////////////////////////// //If we are authenticating as an application if (options.application) { //The url is relative to the domain url = `${qik.domain || ''}/qik/application/signup`; } //If we have a specified url if (options.url) { url = options.url; } ///////////////////////////////////////////// qik.api.post(url, credentials, postOptions).then(function(res) { if (autoAuthenticate) { service.set(res.data); } resolve(res); }, reject); } ////////////////////////////////////// return promise; } /////////////////////////////////////////////////// /** * @alias auth.retrieveUserFromResetToken * @description Retrieve user session through use of a valid reset token, * Reset tokens are short lived tokens that can be generated when a user has forgotten their password or their * password has been reset by an administrator * @param {String} resetToken The token to use to authenticate * @param {Object} options Additional options for the request * @example * * const resetToken = 'XXX-324623-$$...'; * * // Retrieve the user session by providing a reset token * const user = await sdk.auth.retrieveUserFromResetToken(resetToken); */ service.retrieveUserFromResetToken = async function(resetToken, options) { if (!options) { options = {}; } var postOptions = { bypassInterceptor: true } return new Promise(function(resolve, reject) { qik.api.get(options.url || `${qik.apiURL}/user/reset/${resetToken}`, postOptions).then(function(res) { return resolve(res.data); }, reject); }); } /////////////////////////////////////////////////// /** * @alias auth.updateUserWithToken * @description Update a user's credentials through use of a reset token * @param {String} resetToken The token to use to authenticate * @param {Object} body Updates to be made to the user * @param {Object} options Additional options for the request * @param {Boolean} options.disableAutoAuthentication By default the current user session * will be updated automatically to reflect the new updated session, you can use this option to disable that behavior * and instead return the result without dispatching any events * @example * * const resetToken = 'XXX-324623-$$...'; * * // Retrieve the user session by providing a reset token * const user = await sdk.auth.retrieveUserFromResetToken(resetToken); */ service.updateUserWithToken = async function(resetToken, body, options) { if (!options) { options = {}; } ////////////////////////// //Change the users current tokens straight away var autoAuthenticate = true; if (options.disableAutoAuthentication) { autoAuthenticate = false; } ////////////////////////////////////// return new Promise(function(resolve, reject) { var postOptions = { bypassInterceptor: true } qik.api.post(options.url || `${qik.apiURL}/user/reset/${resetToken}`, body, postOptions) .then(function(res) { //If we should automatically authenticate //once the request is successful //Then clear caches and update the session if (autoAuthenticate) { qik.cache.reset(); service.set(res.data); } return resolve(res.data); }, reject); }); } /** * @alias auth.sendResetPasswordRequest * @description This function allows a reset token to be generated and emailed to the requesting user * allowing them to modify their user details * @param {Object} body Details for the reset request * @param {String} body.email The email of the user to generate a token for * @example * * const resetToken = 'XXX-324623-$$...'; * * // Retrieve the user session by providing a reset token * const user = await sdk.auth.retrieveUserFromResetToken(resetToken); */ service.sendResetPasswordRequest = function(details, options) { if (!options) { options = {}; } if (!details) { return Promise.reject({ message: 'No details were provided for password reset request', }) } if (!details.email || !details.email.length) { return Promise.reject({ message: 'Email is required but was not provided', }) } return new Promise(function(resolve, reject) { var postOptions = { bypassInterceptor: true } qik.api.post(options.url || `${qik.apiURL}/user/forgot`, details, postOptions).then(resolve, reject); }) } /////////////////////////////////////////////////// /** * @alias auth.ensureValidToken * @description This function forces a check to ensure that the current access token has not expired. * If the token has expired, the user session will be refreshed with a new token. * @param {Boolean} forceRefresh Whether to force the current token to be refreshed, even if it has not yet expired. * @example * * // Check to ensure that the current access token is valid * sdk.auth.ensureValidToken(); * * // Force the token to be refreshed, even if the current token in use has not yet expired. * sdk.auth.ensureValidToken(true); */ service.ensureValidToken = async function(forceRefresh) { var currentUser = service.getCurrentUser(); if (!currentUser) { return Promise.reject('No user'); } var { token } = currentUser; if (!token) { return Promise.reject('No token'); } ///////////////////////////////////////////////////// //Check our date var now = new Date(); //Give us a bit of buffer so that the backend doesn't beat us to //retiring the token now.setSeconds(now.getSeconds() + tokenBufferSeconds); ///////////////////////////////////////////////////// var expires = new Date(token.expires); if (forceRefresh) { console.log('force refresh valid token', token.refreshToken) return await service.refreshAccessToken(token.refreshToken); } //If the token is still fresh if (now < expires) { return token; } else { return await service.refreshAccessToken(token.refreshToken); } } /////////////////////////////////////////////////// const refreshContext = {}; service.refreshAccessToken = async function(refreshToken) { // ///////////////////////////////////////////// // if (appContext) { // } else { // } // ///////////////////////////////////////////// //If there is already a request in progress if (refreshContext.inflightRefreshRequest) { return refreshContext.inflightRefreshRequest; } ///////////////////////////////////////////////////// //Create an refresh request refreshContext.inflightRefreshRequest = new Promise(function(resolve, reject) { //Bypass the interceptor on all token refresh calls //Because we don't need to add the access token etc onto it qik.api.post(`/user/refresh`, { refreshToken: refreshToken }, { bypassInterceptor: true, withoutToken: true, }) .then(function tokenRefreshComplete(res) { //Update the user with any changes //returned back from the refresh request if (!res) { refreshContext.inflightRefreshRequest = null; return reject(); } else { //Update with our new session service.set(res.data); dispatch(); } //Resolve with the new token resolve(res.data.token); //Remove the inflight request setTimeout(function() { refreshContext.inflightRefreshRequest = null; }) }) .catch(function(err) { //TODO Check if invalid_refresh_token setTimeout(function() { refreshContext.inflightRefreshRequest = null; }); reject(err); }); }); //Return the refresh request return refreshContext.inflightRefreshRequest; } /////////////////////////////////////////////////// var retryCount = 0; /** * @alias auth.sync * @description A useful function to sync the current user session with the server. * @example * * // Makes request to the API and updates the current user session to match the response * sdk.auth.sync(); */ service.sync = function() { return qik.api.get('/user') .then(function(res) { if (res.data) { if (store.user) { Object.assign(store.user.session, res.data); } } else { service.set(null); } retryCount = 0; dispatch(); }) .catch(function(err) { // if (retryCount > 2) { service.set(null); retryCount = 0; dispatch(); // } else { // retryCount++; // service.sync(); // } }); } ///////////////////////////////////////////////////// /** * @alias auth.getCurrentUser * @description Retrieves the current user session * @example * * const me = sdk.auth.getCurrentUser(); */ service.getCurrentUser = function() { return store.user; } /** * @alias auth.getCurrentToken * @description Retrieves the current access token. If the user is authenticated * the response will be the current user's access token, otherwise will fall back to the * applications token. * @example * const currentAccessToken = sdk.auth.getCurrentToken(); */ service.getCurrentToken = function() { var user = service.getCurrentUser(); //User is not logged in if (!user) { //But there is an application token if (qik.applicationToken) { //use that instead return qik.applicationToken; } //No token return; } var { token } = user; if (!token) { return; } return token.accessToken; } ///////////////////////////////////////////////////// qik.api.interceptors.request.use(async function(config) { //If we want to bypass the interceptor //then just return the request if (config.bypassInterceptor) { return config; } ////////////////////////////// //Get the original request var originalRequest = config; ////////////////////////////// var userDetails = await service.getCurrentUser(); var accessToken; var refreshToken; var expiryDate; if (userDetails) { var { token } = userDetails; if (token) { accessToken = token.accessToken; refreshToken = token.refreshToken; expiryDate = token.expires; } } ////////////////////////////// //If there is a user token if (accessToken) { //Set the token of the request as the user's access token originalRequest.headers['Authorization'] = `Bearer ${accessToken}`; } else { //Return the original request without a token return originalRequest; } ///////////////////////////////////////////////////// //If no refresh token if (!refreshToken) { //Continue with the original request return originalRequest; } ///////////////////////////////////////////////////// //We have a refresh token so we need to check //whether our access token is stale and needs to be refreshed var now = new Date(); //Give us a bit of buffer so that the backend doesn't beat us to //retiring the token now.setSeconds(now.getSeconds() + tokenBufferSeconds); ///////////////////////////////////////////////////// var expires = new Date(expiryDate); //If the token is still fresh if (now < expires) { //Return the original request return originalRequest; } ///////////////////////////////////////////////////// return new Promise(async function(resolve, reject) { //Refresh the token await service.refreshAccessToken(refreshToken) .then(function(newToken) { //Update the original request with our new token originalRequest.headers['Authorization'] = `Bearer ${newToken}`; //And continue onward return resolve(originalRequest); }) .catch(function(err) { return reject(err); }); }); }, function(error) { return Promise.reject(error); }) ///////////////////////////////////////////////////// qik.api.interceptors.response.use(function(response) { return response; }, function(err) { ////////////////////////////// //Get the response status var status = (err && err.response && err.response.status) || err.status; switch (status) { case 401: service.logout(); break; default: //Some other error break; } ///////////////////////////////////////////////////// return Promise.reject(err); }) return service; } export default QikAuth;