UNPKG

besper-frontend-site-dev-main

Version:

Professional B-esper Frontend Site - Site-wide integration toolkit for full website bot deployment

818 lines (718 loc) 25.5 kB
/** * Smart Token Authentication Service * Handles PowerPages authentication tokens with intelligent generation, storage, and cross-page persistence * Implements foreign key-based authorization using Microsoft security system * * Features: * - Smart token sharing: Multiple functions share the same token generation process * - Cross-page persistence: Tokens stored in browser storage for navigation * - Intelligent refresh: Automatic token validation and refresh * - Central coordination: Prevents duplicate token generation requests * * PowerPages Integration Pattern: * {% if user %} * {% assign bsp_token = user.bsp_token %} * {% assign contact_id = user.contactid %} * {% endif %} * * Pass the token directly to b_esper_site functions */ import { getRootApiEndpoint } from './centralizedApi.js'; class TokenAuthService { constructor() { this.token = null; this.contactId = null; this.isAuthenticated = false; this.userPermissions = new Map(); // Smart token management this.tokenGenerationPromise = null; // Shared promise for concurrent requests this.tokenExpiry = null; this.storageKeys = { token: 'bsp_auth_token', contactId: 'bsp_contact_id', userPermissions: 'bsp_user_permissions', tokenExpiry: 'bsp_token_expiry', authenticated: 'bsp_authenticated', }; // Initialize from stored data first, then PowerPages this.initializeFromStorage(); this.initializeFromLiquidData(); } /** * Initialize from browser storage for cross-page persistence * Check if valid token exists from previous page visits */ initializeFromStorage() { if (typeof window === 'undefined' || typeof localStorage === 'undefined') return; try { const storedToken = localStorage.getItem(this.storageKeys.token); const storedExpiry = localStorage.getItem(this.storageKeys.tokenExpiry); const storedAuthenticated = localStorage.getItem( this.storageKeys.authenticated ); if (storedToken && storedExpiry && storedAuthenticated === 'true') { const expiryTime = parseInt(storedExpiry, 10); const currentTime = Date.now(); // Check if token is still valid (not expired) if (currentTime < expiryTime) { const storedContactId = localStorage.getItem( this.storageKeys.contactId ); const storedPermissions = localStorage.getItem( this.storageKeys.userPermissions ); this.token = storedToken; this.contactId = storedContactId; this.tokenExpiry = expiryTime; this.isAuthenticated = true; // Restore user permissions if (storedPermissions) { try { const permissions = JSON.parse(storedPermissions); this.userPermissions = new Map(Object.entries(permissions)); } catch (e) { console.warn('[Token] Failed to parse stored permissions:', e); } } console.log('[Token] ♻️ Authentication restored from storage'); return; } else { // Token expired, clear storage this.clearStoredAuthentication(); console.log('[Token] 🕐 Stored token expired, cleared from storage'); } } } catch (error) { console.warn('[Token] Failed to restore from storage:', error); this.clearStoredAuthentication(); } } /** * Initialize authentication from PowerPages liquid template data * PowerPages provides token directly via liquid template: {{ user.bsp_token }} * Smart system - stores for cross-page use */ initializeFromLiquidData() { if (typeof window === 'undefined') return; // Skip if already authenticated from storage if (this.isAuthenticated && this.token) { console.log( '[Token] [SUCCESS] Already authenticated from storage, skipping PowerPages init' ); return; } // Use original token generation from PowerPages liquid template if (window.PowerPagesAuth && window.PowerPagesAuth.token) { this.setAuthenticationDataWithStorage(window.PowerPagesAuth); return; } // Original global variables from liquid template if (window.bsp_token) { this.setAuthenticationDataWithStorage({ token: window.bsp_token, contactId: window.contact_id || window.user_contactid, userId: window.user_id, workspaceId: window.workspace_id, accountId: window.account_id, }); return; } console.log('[Token] No authentication token available from PowerPages'); } /** * Set authentication data with smart storage for cross-page persistence * Called when page provides pre-fetched authentication data */ setAuthenticationDataWithStorage(authData, options = {}) { if (!authData || !authData.token) { this.isAuthenticated = false; this.token = null; console.log('[Token] No authentication data provided'); return; } this.setAuthenticationData(authData); // Store in browser storage for cross-page persistence this.storeAuthentication(options.tokenLifetime); } /** * Set authentication data (internal method) */ setAuthenticationData(authData) { if (!authData || !authData.token) { this.isAuthenticated = false; this.token = null; return; } this.token = authData.token; this.contactId = authData.contactId; this.isAuthenticated = true; // Store user permissions for authorization checks this.userPermissions.set('contactId', authData.contactId); this.userPermissions.set('userId', authData.userId); this.userPermissions.set('workspaceId', authData.workspaceId); this.userPermissions.set('accountId', authData.accountId); this.userPermissions.set('subscriptionId', authData.subscriptionId); console.log('[Token] 🔐 Authentication data set successfully'); } /** * Store authentication data in browser storage for cross-page persistence */ storeAuthentication(tokenLifetimeMs = 30 * 60 * 1000) { // Default: 30 minutes if (typeof window === 'undefined' || typeof localStorage === 'undefined') return; try { const expiryTime = Date.now() + tokenLifetimeMs; localStorage.setItem(this.storageKeys.token, this.token); localStorage.setItem(this.storageKeys.contactId, this.contactId || ''); localStorage.setItem(this.storageKeys.authenticated, 'true'); localStorage.setItem(this.storageKeys.tokenExpiry, expiryTime.toString()); // Store user permissions as JSON const permissionsObj = Object.fromEntries(this.userPermissions); localStorage.setItem( this.storageKeys.userPermissions, JSON.stringify(permissionsObj) ); this.tokenExpiry = expiryTime; console.log('[Token] 💾 Authentication stored for cross-page use'); } catch (error) { console.warn('[Token] Failed to store authentication:', error); } } /** * Clear stored authentication data */ clearStoredAuthentication() { if (typeof window === 'undefined' || typeof localStorage === 'undefined') return; try { Object.values(this.storageKeys).forEach(key => { localStorage.removeItem(key); }); console.log('[Token] 🗑️ Stored authentication cleared'); } catch (error) { console.warn('[Token] Failed to clear stored authentication:', error); } } /** * Initialize page with authentication data from b_esper_site function call * Smart system - uses stored token if available, otherwise stores new token */ initializeFromPageData(pageData) { // If already authenticated from storage and no new page data, keep existing if (this.isAuthenticated && this.token && !pageData) { console.log('[Token] Using existing stored authentication'); return; } if (pageData && pageData.authData) { this.setAuthenticationDataWithStorage(pageData.authData); } else if (pageData && pageData.token) { // Direct token provided this.setAuthenticationDataWithStorage({ token: pageData.token, contactId: pageData.contactId, userId: pageData.userId, workspaceId: pageData.workspaceId, accountId: pageData.accountId, }); } else if (!this.isAuthenticated) { // No auth data provided and no stored auth console.log('[Token] No authentication data available'); this.isAuthenticated = false; this.token = null; } } /** * Verify contact access - checks if the current user can access their own contact record * Used for authorization on entities like support tickets that don't have dataverse equivalent */ async verifyContactAccess() { if (!this.isAuthenticated || !this.contactId) { console.log('[Token] Cannot verify contact access - not authenticated'); return false; } try { // Verify the contact can access their own contact record (read permission) return await this.canAccessEntity('contact', this.contactId, ['read']); } catch (error) { console.error('[Token] Contact verification failed:', error); return false; } } /** * Smart token validation - checks storage and expiry */ isTokenValid() { if (!this.isAuthenticated || !this.token) { return false; } // Check if token is expired if (this.tokenExpiry && Date.now() >= this.tokenExpiry) { console.log('[Token] Token expired, clearing authentication'); this.clearStoredAuthentication(); this.isAuthenticated = false; this.token = null; return false; } return true; } /** * Smart token generation with concurrent request coordination * Multiple functions calling this will share the same generation process */ async getTokenSmart() { // If token is valid, return it immediately if (this.isTokenValid()) { console.log('[Token] ⚡ Using existing valid token'); return this.token; } // If token generation is already in progress, wait for it if (this.tokenGenerationPromise) { console.log('[Token] [LOADING] Token generation in progress, waiting...'); try { const result = await this.tokenGenerationPromise; return result.token; } catch (error) { console.error('[Token] Shared token generation failed:', error); return null; } } // Start new token generation process console.log('[Token] [INIT] Starting new token generation'); this.tokenGenerationPromise = this.generateTokenFromPowerPages(); try { const result = await this.tokenGenerationPromise; this.tokenGenerationPromise = null; // Clear promise after completion return result.token; } catch (error) { this.tokenGenerationPromise = null; // Clear promise on error console.error('[Token] Token generation failed:', error); return null; } } /** * Generate token from PowerPages authentication endpoint */ async generateTokenFromPowerPages() { try { if (typeof window === 'undefined') { throw new Error('Window not available'); } // Try to get token from PowerPages /_services/auth/token endpoint const response = await fetch('/_services/auth/token', { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', // Include cookies for PowerPages auth }); if (!response.ok) { throw new Error(`Token generation failed: ${response.status}`); } const tokenData = await response.json(); if (tokenData.token) { // Store the generated token this.setAuthenticationDataWithStorage({ token: tokenData.token, contactId: tokenData.contactId || tokenData.contact_id, userId: tokenData.userId || tokenData.user_id, workspaceId: tokenData.workspaceId || tokenData.workspace_id, accountId: tokenData.accountId || tokenData.account_id, }); console.log('[Token] [SUCCESS] Token generated from PowerPages'); return { success: true, token: tokenData.token, source: 'powerpages_endpoint', }; } else { throw new Error('No token in response'); } } catch (error) { console.error('[Token] PowerPages token generation failed:', error); // Fallback: try to use existing global variables if (window.bsp_token) { this.setAuthenticationDataWithStorage({ token: window.bsp_token, contactId: window.contact_id || window.user_contactid, userId: window.user_id, workspaceId: window.workspace_id, accountId: window.account_id, }); console.log( '[Token] [SUCCESS] Using existing global token as fallback' ); return { success: true, token: window.bsp_token, source: 'global_fallback', }; } throw new Error('No authentication available'); } } /** * Check if user can access a specific entity * Uses foreign key relationships to workspace/account and subscription/cost pool */ async canAccessEntity(entityType, entityId, requiredPermissions = []) { if (!this.isAuthenticated || !this.token) { return false; } try { // For entities with direct dataverse equivalent, check permissions directly if (this.hasDirectDataverseEquivalent(entityType)) { return await this.checkDataversePermissions( entityType, entityId, requiredPermissions ); } // For entities like support tickets or notifications without dataverse equivalent, // check if user can access connected dataverse entities return await this.checkForeignKeyPermissions( entityType, entityId, requiredPermissions ); } catch (error) { console.error('[Token] Permission check failed:', error); return false; } } /** * Check if entity type has direct dataverse equivalent */ hasDirectDataverseEquivalent(entityType) { const dataverseEntities = [ 'contact', 'account', 'workspace', 'subscription', 'bot_config', 'user', 'organization', 'billing', ]; return dataverseEntities.includes(entityType); } /** * Check permissions for entities with direct dataverse equivalent */ async checkDataversePermissions(entityType, entityId, _requiredPermissions) { try { if (typeof window === 'undefined' || !window.$) { return false; } const response = await window.$.ajax({ url: `${getRootApiEndpoint()}/permissions/check`, method: 'POST', headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json', }, data: JSON.stringify({ entityType, entityId, permissions: _requiredPermissions, }), }); return response.hasAccess === true; } catch (error) { console.error('[Token] Dataverse permission check failed:', error); return false; } } /** * Check permissions using foreign key relationships * For entities like support tickets, notifications that reference workspace/account and subscription */ async checkForeignKeyPermissions(entityType, entityId, _requiredPermissions) { try { // Get foreign key references for the entity const foreignKeys = await this.getForeignKeyReferences( entityType, entityId ); if (!foreignKeys) { return false; } // Check if user can access the referenced workspace/account if (foreignKeys.workspaceId || foreignKeys.accountId) { const workspaceAccess = foreignKeys.workspaceId ? await this.checkDataversePermissions( 'workspace', foreignKeys.workspaceId, ['read'] ) : true; const accountAccess = foreignKeys.accountId ? await this.checkDataversePermissions( 'account', foreignKeys.accountId, ['read'] ) : true; if (!workspaceAccess || !accountAccess) { return false; } } // Check if user can access the referenced subscription/cost pool if (foreignKeys.subscriptionId) { const subscriptionAccess = await this.checkDataversePermissions( 'subscription', foreignKeys.subscriptionId, ['read'] ); if (!subscriptionAccess) { return false; } } return true; } catch (error) { console.error('[Token] Foreign key permission check failed:', error); return false; } } /** * Get foreign key references for an entity */ async getForeignKeyReferences(entityType, entityId) { try { if (typeof window === 'undefined' || !window.$) { return null; } const response = await window.$.ajax({ url: `${getRootApiEndpoint()}/entities/${entityType}/${entityId}/foreign-keys`, method: 'GET', headers: { Authorization: `Bearer ${this.token}`, }, }); return { workspaceId: response.workspace_id || response.related_workspace, accountId: response.account_id || response.related_account, subscriptionId: response.subscription_id || response.cost_pool_id, }; } catch (error) { console.error('[Token] Failed to get foreign keys:', error); return null; } } /** * Check if user is authenticated * Original token generation only - no fallbacks */ isUserAuthenticated() { // Simply return the authentication state without any fallback mechanisms // If authentication fails, it fails honestly return this.isAuthenticated && this.token; } /** * Check if user is authenticated with retry logic for token generation * Original token generation only - no fallbacks, honest about failures */ async isUserAuthenticatedWithRetry(maxWaitTime = 5000, checkInterval = 250) { const startTime = Date.now(); console.log('[Token] Checking authentication with retry logic...'); while (Date.now() - startTime < maxWaitTime) { // Check current authentication state if (this.isUserAuthenticated()) { console.log('[Token] Authentication confirmed'); return true; } // Wait for a bit before retrying await new Promise(resolve => setTimeout(resolve, checkInterval)); } // No token found after waiting - be honest about authentication failure console.log('[Token] Authentication failed - no valid token found'); return false; } /** * Generate token as a promise - core lifecycle method * Original token generation only - no fallbacks */ async generateTokenPromise(maxWaitTime = 8000, checkInterval = 500) { console.log('[Token] [LOADING] Starting token generation...'); return new Promise((resolve, reject) => { const startTime = Date.now(); let attempts = 0; const maxAttempts = Math.ceil(maxWaitTime / checkInterval); const checkForToken = () => { attempts++; const elapsed = Date.now() - startTime; // Only log to console, never show user-facing messages console.log( `[Token] Token check attempt ${attempts}/${maxAttempts} (${elapsed}ms elapsed)` ); // Check if token is available from any source if (this.isUserAuthenticated()) { console.log(`[Token] [SUCCESS] Token found after ${elapsed}ms`); resolve({ success: true, token: this.getToken(), elapsed, attempts, source: 'authenticated', }); return; } // Check if we should continue waiting if (elapsed < maxWaitTime && attempts < maxAttempts) { setTimeout(checkForToken, checkInterval); } else { // No token found after waiting - be honest about authentication failure console.log(`[Token] Authentication failed after ${elapsed}ms`); reject({ success: false, error: 'Authentication timeout - no valid token found', elapsed, attempts, }); } }; // Start checking for token checkForToken(); }); } /** * Get current token with smart generation if needed * This is the main method functions should call */ async getToken() { return await this.getTokenSmart(); } /** * Get current token synchronously (for backward compatibility) * Returns null if no token is immediately available */ getTokenSync() { if (this.isTokenValid()) { return this.token; } return null; } /** * Get complete auth data for APIM operator calls * Returns authentication data including token and user permissions */ getAuthData() { if (!this.isAuthenticated || !this.token) { throw new Error('No authentication data available'); } return { token: this.token, contactId: this.contactId, userId: this.getUserPermission('userId'), userName: this.getUserPermission('userName'), userEmail: this.getUserPermission('userEmail'), workspaceId: this.getUserPermission('workspaceId'), accountId: this.getUserPermission('accountId'), subscriptionId: this.getUserPermission('subscriptionId'), isAuthenticated: this.isAuthenticated, }; } getUserPermission(key) { return this.userPermissions.get(key); } /** * Cleanup resources and clear stored data */ destroy() { this.token = null; this.isAuthenticated = false; this.userPermissions.clear(); this.tokenGenerationPromise = null; this.tokenExpiry = null; this.clearStoredAuthentication(); } } // Global token manager functions for easy access // DEPRECATED: These are now handled by professionalCookieTokenManager.js // Keeping for backward compatibility but they delegate to professionalTokenManager // Import is handled in the global section below to avoid unused import warning const globalTokenManager = { /** * Generate token with no arguments - DEPRECATED * Use professionalTokenManager instead */ async generateToken() { console.warn( '[TokenAuth] generateToken is deprecated - use professionalTokenManager.getToken()' ); // Import professionalTokenManager and delegate if (typeof window !== 'undefined' && window.professionalTokenManager) { return window.professionalTokenManager.getToken('deprecated-tokenAuth'); } return null; // Prevent infinite loops }, /** * Get current token if available - DEPRECATED */ getCurrentToken() { console.warn( '[TokenAuth] getCurrentToken is deprecated - use professionalTokenManager.getTokenSync()' ); if (typeof window !== 'undefined' && window.professionalTokenManager) { return window.professionalTokenManager.getTokenSync(); } return tokenAuthService.getTokenSync(); }, /** * Check if user is authenticated - DEPRECATED */ isAuthenticated() { if (typeof window !== 'undefined' && window.professionalTokenManager) { return window.professionalTokenManager.isAuthenticated(); } return tokenAuthService.isTokenValid(); }, /** * Get user information - DEPRECATED */ getUserInfo() { if (typeof window !== 'undefined' && window.professionalTokenManager) { return window.professionalTokenManager.getUserInfo(); } if (!tokenAuthService.isTokenValid()) return null; return { contactId: tokenAuthService.contactId, userId: tokenAuthService.getUserPermission('userId'), workspaceId: tokenAuthService.getUserPermission('workspaceId'), accountId: tokenAuthService.getUserPermission('accountId'), subscriptionId: tokenAuthService.getUserPermission('subscriptionId'), }; }, /** * Clear all authentication data - DEPRECATED */ clearToken() { if (typeof window !== 'undefined' && window.professionalTokenManager) { return window.professionalTokenManager.clearAllTokens(); } tokenAuthService.destroy(); }, /** * Refresh token by clearing current and generating new - DEPRECATED */ async refreshToken() { console.warn( '[TokenAuth] refreshToken is deprecated - use professionalTokenManager.forceRefresh()' ); if (typeof window !== 'undefined' && window.professionalTokenManager) { return window.professionalTokenManager.forceRefresh( 'deprecated-tokenAuth' ); } return null; // Prevent infinite loops }, }; // NOTE: All token functions are now handled by professionalCookieTokenManager.js // This service only handles authentication detection and token validation // Do NOT export global token functions from here to prevent conflicts // Create singleton instance const tokenAuthService = new TokenAuthService(); // Export singleton and class for different use cases export default tokenAuthService; export { TokenAuthService, globalTokenManager };