UNPKG

@underpostnet/underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

354 lines (316 loc) 12 kB
/** * Utility class for authentication state management and session lifecycle control. * This class is designed to be used as a singleton instance (exported as 'Auth'). * @module src/client/components/core/Auth.js * @namespace AuthClient */ import { UserMock, UserService } from '../../services/user/user.service.js'; import { Account } from './Account.js'; import { loggerFactory } from './Logger.js'; import { LogIn } from './LogIn.js'; import { LogOut } from './LogOut.js'; import { NotificationManager } from './NotificationManager.js'; import { SearchBox } from './SearchBox.js'; import { Translate } from './Translate.js'; import { s } from './VanillaJs.js'; import { GuestService } from '../../services/user/guest.service.js'; const logger = loggerFactory(import.meta, { trace: true }); /** * Manages user authentication state, tokens, and session lifecycle. * @memberof AuthClient */ class Auth { /** * Timeout ID for the token refresh schedule. * @type {number | undefined} * @method */ #refreshTimeout; /** * Creates an instance of Auth. */ constructor() { // Private fields are initialized above. } // --- Token Management --- /** * Sets the user's access token. * @memberof AuthClient.Auth * @param {string} [value=''] - The JWT token value. * @returns {string} The set token value. */ setToken(value = '') { return GuestService.setUserToken(value); } /** * Clears the user's access token. * @memberof AuthClient.Auth * @returns {string} An empty string. */ deleteToken() { return GuestService.clearUserToken(); } /** * Gets the user's access token. * @memberof AuthClient.Auth * @returns {string} The JWT token. */ getToken() { return GuestService.getUserToken(); } /** * Sets the anonymous guest token. * @memberof AuthClient.Auth * @param {string} [value=''] - The guest token value. * @returns {string} The set guest token value. */ setGuestToken(value = '') { return GuestService.setGuestToken(value); } /** * Clears the anonymous guest token. * @memberof AuthClient.Auth * @returns {string} An empty string. */ deleteGuestToken() { return GuestService.clearGuestToken(); } /** * Gets the anonymous guest token. * @memberof AuthClient.Auth * @returns {string} The guest token. */ getGuestToken() { return GuestService.getGuestToken(); } /** * Generates the JWT header string (e.g., "Bearer [token]") using the active token (user or guest). * @memberof AuthClient.Auth * @returns {string} The Bearer token string or an empty string. */ getJWT() { return GuestService.getAuthorizationHeader(); } /** * Decodes the payload section of a JWT token. * @static * @memberof AuthClient.Auth * @param {string} token - The JWT string. * @returns {object | null} The decoded JWT payload object, or null on failure. */ static decodeJwt(token) { try { // Uses atob for base64 decoding of the middle part of the JWT return JSON.parse(atob(token.split('.')[1])); } catch (e) { logger.error('Failed to decode JWT:', e); return null; } } // --- Session Management --- /** * Schedules the access token to be refreshed shortly before it expires. * Clears any existing refresh timeout before setting a new one. * @memberof AuthClient.Auth * @returns {void} */ scheduleTokenRefresh() { if (this.#refreshTimeout) { clearTimeout(this.#refreshTimeout); this.#refreshTimeout = undefined; } const currentToken = this.getToken(); if (!currentToken) return; const payload = Auth.decodeJwt(currentToken); if (!payload || !payload.refreshExpiresAt) return; // Requires refreshExpiresAt in milliseconds const expiresIn = payload.refreshExpiresAt - Date.now(); const refreshBuffer = 2 * 60 * 1000; // 2 minutes buffer before expiry const refreshIn = expiresIn - refreshBuffer; logger.info(`Token refresh scheduled in ${refreshIn / (1000 * 60)} minutes`); if (refreshIn <= 0) { logger.warn('Token already expired or too close to expiry, skipping refresh schedule.'); return; } this.#refreshTimeout = setTimeout(async () => { const { data, status } = await UserService.get({ id: 'auth' }); // API call to get a fresh token if (status === 'success' && data?.token) { logger.info('Successfully refreshed access token.'); this.setToken(data.token); localStorage.setItem('jwt', data.token); this.scheduleTokenRefresh(); // Schedule the next refresh } else { logger.warn('Token refresh failed, attempting session out.'); this.sessionOut(); } }, refreshIn); } /** * Establishes a user session (logged-in) or falls back to a guest session. * It attempts to use the provided token or a token from localStorage ('jwt'). * @memberof AuthClient.Auth * @param {object} [userServicePayload] - Payload from a successful login/signup call to UserService. * @returns {Promise<{user: object}>} A promise resolving to the current user object. */ async sessionIn(userServicePayload) { try { let token = userServicePayload?.data?.token || GuestService.getUserToken(); if (token) { this.setToken(token); const result = userServicePayload ? userServicePayload // From login/signup : await UserService.get({ id: 'auth' }); // Verify token with backend const { status, data, message } = result; if (status === 'success' && data.token) { // A valid user token was found/refreshed this.setToken(data.token); GuestService.clearGuestToken(); await GuestService.setMeta('session', { role: data.user?.role, userId: data.user?._id, }); // Clear cached profile image to ensure fresh load for new user LogIn.Scope.user.main.model.user = {}; this.renderSessionUI(); await LogIn.Trigger({ user: data.user }); await Account.updateForm(data.user); this.scheduleTokenRefresh(); return { user: data.user }; } else if (message && message.match('expired')) { logger.warn('User session token expired.'); // Redirect to login modal and push notification setTimeout(() => { s(`.main-btn-log-in`).click(); NotificationManager.Push({ html: Translate.instance(`expired-session`), status: 'warning', }); }); } } // Cleanup failed user session attempt this.deleteToken(); // Anon guest session attempt let guestToken = GuestService.getGuestToken(); if (guestToken) { this.setGuestToken(guestToken); let { data, status, message } = await UserService.get({ id: 'auth' }); // Verify guest token if (status === 'success' && data.token) { // Guest token is valid and refreshed this.setGuestToken(data.token); await GuestService.setMeta('guest-session', { role: data.user?.role, userId: data.user?._id, }); // Clear cached profile image for fresh guest session LogIn.Scope.user.main.model.user = {}; await LogIn.Trigger(data); await Account.updateForm(data.user); return data; } else { logger.error(`Guest token validation failed: ${message}`); // Fall through to full sessionOut to re-create guest session } } // If all attempts fail, create a new guest session (which calls sessionIn recursively) return await this.sessionOut(); } catch (error) { logger.error('Error during sessionIn process:', error); // Fallback to a mock user object return { user: UserMock.default }; } } /** * Ends the current user session (logout) and initiates a new anonymous guest session. * @memberof AuthClient.Auth * @returns {Promise<object>} A promise resolving to the newly created guest session data. */ async sessionOut() { // 1. End User Session — skip the network call when there is no active user session // (avoids a wasted DELETE round-trip for guests / new visitors) const hasUserSession = !!GuestService.getUserToken(); try { const result = hasUserSession ? await UserService.delete({ id: 'logout' }) : null; SearchBox.RecentResults.clear(); this.deleteToken(); if (this.#refreshTimeout) { clearTimeout(this.#refreshTimeout); this.#refreshTimeout = undefined; } this.renderGuestUi(); // Reset user data in the LogIn state/model LogIn.Scope.user.main.model.user = {}; if (result) await LogOut.Trigger(result); } catch (error) { logger.error('Error during user logout:', error); } // 2. Start Guest Session try { this.deleteGuestToken(); const result = await UserService.post({ id: 'guest' }); // Request a new guest token if (result.status === 'success' && result.data.token) { this.setGuestToken(result.data.token); // Use the POST response data directly — avoids a redundant GET /user/auth // round-trip that would otherwise re-verify the token we just received. await GuestService.setMeta('guest-session', { role: result.data.user?.role, userId: result.data.user?._id, }); LogIn.Scope.user.main.model.user = {}; await LogIn.Trigger(result.data); await Account.updateForm(result.data.user); return result.data; } else { logger.error('Failed to get a new guest token.'); return { user: UserMock.default }; } } catch (error) { logger.error('Error during guest session creation:', error); return { user: UserMock.default }; } } // --- UI Rendering --- /** * Renders the UI for a logged-in user (hides Log In/Sign Up, shows Log Out/Account). * Also closes any active login/signup modals. * @memberof AuthClient.Auth * @returns {void} */ renderSessionUI() { s(`.main-btn-log-in`).style.display = 'none'; s(`.main-btn-sign-up`).style.display = 'none'; s(`.main-btn-log-out`).style.display = null; s(`.main-btn-account`).style.display = null; if (s(`.main-btn-public-profile`)) s(`.main-btn-public-profile`).style.display = null; setTimeout(() => { // Close any open login/signup modals if (s(`.modal-log-in`)) s(`.btn-close-modal-log-in`).click(); if (s(`.modal-sign-up`)) s(`.btn-close-modal-sign-up`).click(); if (!s(`.main-body-btn-ui-open`).classList.contains('hide')) s(`.main-body-btn-ui-open`).click(); if (!s(`.main-body-btn-ui-bar-custom-open`).classList.contains('hide')) { SearchBox.Data.skipOpen = true; s(`.main-body-btn-ui-bar-custom-open`).click(); } }); } /** * Renders the UI for a guest user (shows Log In/Sign Up, hides Log Out/Account). * Also closes any active logout/account modals. * @memberof AuthClient.Auth * @returns {void} */ renderGuestUi() { s(`.main-btn-log-in`).style.display = null; s(`.main-btn-sign-up`).style.display = null; s(`.main-btn-log-out`).style.display = 'none'; s(`.main-btn-account`).style.display = 'none'; if (s(`.main-btn-public-profile`)) s(`.main-btn-public-profile`).style.display = 'none'; setTimeout(() => { // Close any open logout/account modals if (s(`.modal-log-out`)) s(`.btn-close-modal-log-out`).click(); if (s(`.modal-account`)) s(`.btn-close-modal-account`).click(); }); } } // Export a singleton instance of the Auth class to maintain the original utility object access pattern. const AuthSingleton = new Auth(); export { AuthSingleton as Auth };