qik.auth.js

import { EventDispatcher } from './qik.utils.js';
import { createStorageAdapter } from './qik.storage.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;

    ///////////////////////////////////////////////////

    // Create storage adapter based on configuration
    const storageAdapter = createStorageAdapter({
        useHttpOnlyCookies: qik.useHttpOnlyCookies,
        cookieConfig: qik.cookieConfig
    });

    const tokenBufferSeconds = 10;

    ///////////////////////////////////////////////////

    const service = {
        debug: false,
        storageAdapter: storageAdapter,
    }

    // Backwards compatibility - keep store reference
    Object.defineProperty(service, 'store', {
        get: function() {
            return { user: storageAdapter.getUser() };
        },
        enumerable: true
    });

    //Create a new dispatcher
    const dispatcher = new EventDispatcher();
    dispatcher.bootstrap(service);

    ///////////////////////////////////////////////////

    function dispatch(parameters) {

        //Get the current user
        var user = storageAdapter.getUser();

        //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) {

        const currentUser = storageAdapter.getUser();
        if (JSON.stringify(currentUser) != JSON.stringify(user)) {
            storageAdapter.setUser(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() {
        storageAdapter.clearUser();
        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: 'Login requires an email and password',
                })
            }

            if (!credentials.email || !credentials.email.length) {
                return reject({
                    message: 'An email address is required to log in',
                })
            }

            if (!credentials.password || !credentials.password.length) {
                return reject({
                    message: 'A password is required to log in',
                })
            }

            /////////////////////////////////////////////

            var postOptions = {
                bypassInterceptor: true
            }

            // Check if we're in cookie mode
            const isCookieMode = storageAdapter.isCookieMode && storageAdapter.isCookieMode();
            
            if (isCookieMode) {
                // Signal to backend that we want cookie-based auth
                credentials.useCookies = true;
                postOptions.withCredentials = 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: 'Signup details are required',
                })
            }

            if (!credentials.firstName || !credentials.firstName.length) {
                return reject({
                    message: 'Please provide a first name',
                })
            }

            if (!credentials.lastName || !credentials.lastName.length) {
                return reject({
                    message: 'Please provide a last name',
                })
            }

            if (!credentials.email || !credentials.email.length) {
                return reject({
                    message: 'An email address is required to sign up',
                })
            }


            if (!credentials.password || !credentials.password.length) {
                return reject({
                    message: 'A password is required to sign up',
                })
            }

            if (!credentials.confirmPassword || !credentials.confirmPassword.length) {
                return reject({
                    message: 'Please confirm your password',
                })
            }

            if (credentials.confirmPassword != credentials.password) {
                return reject({
                    message: 'Password and confirmation do not match',
                })
            }

            /////////////////////////////////////////////

            var postOptions = {
                bypassInterceptor: true
            }

            // Check if we're in cookie mode
            const isCookieMode = storageAdapter.isCookieMode && storageAdapter.isCookieMode();
            
            if (isCookieMode) {
                // Signal to backend that we want cookie-based auth
                credentials.useCookies = true;
                postOptions.withCredentials = 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: 'Please provide your account details to reset your password',
            })
        }

        if (!details.email || !details.email.length) {
            return Promise.reject({
                message: 'An email address is required for password reset',
            })
        }

        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');
        }

        // Check if we're in cookie mode
        const isCookieMode = storageAdapter.isCookieMode && storageAdapter.isCookieMode();

        if (isCookieMode) {
            // In cookie mode, we can't check token expiry from JavaScript
            // Just attempt a refresh if forced, otherwise let the server handle it
            if (forceRefresh) {
                return await service.refreshAccessToken();
            } else {
                // Return a placeholder - server will handle token validation
                return 'cookie_mode_token';
            }
        }

        // localStorage mode - existing token validation logic
        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) {
            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 there is already a request in progress
        if (refreshContext.inflightRefreshRequest) {
            return refreshContext.inflightRefreshRequest;
        }

        /////////////////////////////////////////////////////

        // Check if we're in cookie mode
        const isCookieMode = storageAdapter.isCookieMode && storageAdapter.isCookieMode();

        //Create a refresh request
        refreshContext.inflightRefreshRequest = new Promise(function(resolve, reject) {

            let requestBody = {};
            let requestOptions = {
                bypassInterceptor: true,
                withoutToken: true,
            };

            if (isCookieMode) {
                // In cookie mode, refresh token is sent via httpOnly cookie
                // No need to include it in the request body
                requestOptions.withCredentials = true;
            } else {
                // In localStorage mode, send refresh token in request body
                if (!refreshToken) {
                    refreshContext.inflightRefreshRequest = null;
                    return reject(new Error('No refresh token provided for localStorage mode'));
                }
                requestBody.refreshToken = refreshToken;
            }

            qik.api.post(`/user/refresh`, requestBody, requestOptions)
                .then(function tokenRefreshComplete(res) {

                    //Update the user with any changes 
                    //returned back from the refresh request
                    if (!res || !res.data) {
                        refreshContext.inflightRefreshRequest = null;
                        return reject(new Error('Invalid refresh response'));
                    }

                    //Update with our new session
                    service.set(res.data);
                    dispatch();

                    // Return appropriate response based on mode
                    if (isCookieMode) {
                        // In cookie mode, just indicate success - tokens are in cookies
                        resolve('cookie_token_refreshed');
                    } else {
                        // In localStorage mode, return the actual access token
                        const newToken = res.data.token?.accessToken;
                        if (newToken) {
                            resolve(newToken);
                        } else {
                            reject(new Error('No access token in refresh response'));
                        }
                    }

                    //Remove the inflight request
                    setTimeout(function() {
                        refreshContext.inflightRefreshRequest = null;
                    });

                })
                .catch(function(err) {
                    setTimeout(function() {
                        refreshContext.inflightRefreshRequest = null;
                    });
                    reject(err);
                });
        });

        //Return the refresh request
        return refreshContext.inflightRefreshRequest;
    }

    ///////////////////////////////////////////////////

    /**
     * @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) {
                    const currentUser = storageAdapter.getUser();
                    if (currentUser) {
                        const updatedUser = { ...currentUser };
                        if (updatedUser.session) {
                            Object.assign(updatedUser.session, res.data);
                        } else {
                            updatedUser.session = res.data;
                        }
                        service.set(updatedUser);
                    } else {
                        service.set(res.data);
                    }
                } else {
                    service.set(null);
                }

                dispatch();
            })
            .catch(function(err) {
                service.set(null);
                dispatch();
            });
    }

    /////////////////////////////////////////////////////

    /**
     * @alias auth.getCurrentUser
     * @description Retrieves the current user session
     * @example
     *
     * const me = sdk.auth.getCurrentUser();
     */
    service.getCurrentUser = function() {
        return storageAdapter.getUser();
    }


    /**
     * @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() {

        // First try to get token from storage adapter
        var accessToken = storageAdapter.getAccessToken();
        if (accessToken) {
            return accessToken;
        }

        // For localStorage mode, check user object for token
        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;

            //////////////////////////////

            // Check if we're in cookie mode
            const isCookieMode = storageAdapter.isCookieMode && storageAdapter.isCookieMode();

            if (isCookieMode) {
                // In cookie mode, browser automatically includes httpOnly cookies
                // We don't need to manually add Authorization headers or handle token refresh
                // Just ensure withCredentials is set for cross-origin requests
                if (!originalRequest.withCredentials) {
                    originalRequest.withCredentials = true;
                }
                return originalRequest;
            }

            // localStorage mode - existing logic for manual token management
            var userDetails = 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;