diff --git a/src/main.js b/src/main.js index ba6a9a6..3c00cba 100644 --- a/src/main.js +++ b/src/main.js @@ -7,7 +7,8 @@ import { createPinia } from 'pinia'; import { createApp } from 'vue'; import App from './App.vue'; import router from './router'; - +import { AuthService } from './service/AuthService.js'; +import { TokenRefreshManager } from './service/TokenRefreshManager.js'; import '@/assets/styles.scss'; import '@/assets/tailwind.css'; @@ -20,16 +21,16 @@ import ConfirmationService from 'primevue/confirmationservice'; import ToastService from 'primevue/toastservice'; import { LoadingStore } from './stores/LoadingStore.js'; - - - +// Declare variables that will be used globally +let authInstance = null; +let tokenRefreshManager = null; config({ editorConfig: { renderDelay: 0, zIndex: 200000000 } - }); +}); var auth = createAuth({ plugins: { http: axios, @@ -40,23 +41,20 @@ var auth = createAuth({ auth: driverAuthBearer, router: driverRouterVueRouter }, - options:{ + options: { notFoundRedirect: '/', authRedirect: '/', - authRedirect: true, // niente redirect automatici + authRedirect: true, // niente redirect automatici notFoundRedirect: false, - loginData: {url: '/api/auth/login', method: 'POST', redirect: '/home'}, - logoutData: {url: '/api/auth/logout', redirect: '/auth/login'}, - fetchData: {url: '/api/auth/fetch-user', method: 'GET', enabled: false}, - refreshData: {url: '/api/auth/refresh-token', method: 'GET', enabled: true} + loginData: { url: '/api/auth/login', method: 'POST', redirect: '/home' }, + logoutData: { url: '/api/auth/logout', redirect: '/auth/login' }, + fetchData: { url: '/api/auth/fetch-user', method: 'GET', enabled: false }, + refreshData: { url: '/api/auth/refresh-token', method: 'GET', enabled: true } } }); axios.defaults.baseURL = import.meta.env.VITE_BACKEND_URL; -//axios.defaults.baseURL ='http://localhost:8081'; - - - +//axios.defaults.baseURL = 'http://localhost:8081'; console.log(import.meta.env.VITE_BACKEND_URL); @@ -95,7 +93,7 @@ const preset = definePreset(Nora, { }, formField: { hoverBorderColor: '{primary.color}', - borderColor: '{primary.color}', + borderColor: '{primary.color}' } }, dark: { @@ -115,7 +113,7 @@ const preset = definePreset(Nora, { }, formField: { hoverBorderColor: '{primary.color}', - borderColor: '{primary.color}', + borderColor: '{primary.color}' } } }, @@ -137,7 +135,7 @@ app.use(PrimeVue, { prefix: 'p', darkModeSelector: '.app-dark', cssLayer: false - }, + } } }); app.use(ToastService); @@ -148,20 +146,108 @@ app.component('BlockViewer', BlockViewer); app.mount('#app'); +// Store auth instance for interceptor and token refresh manager after app is mounted +authInstance = app.config.globalProperties.$auth; +tokenRefreshManager = new TokenRefreshManager(authInstance); + +// Start token refresh timer when user is authenticated +if (authInstance.check()) { + tokenRefreshManager.startRefreshTimer(); +} + +// Listen for successful login to start token refresh timer +window.addEventListener('auth-login-success', () => { + console.log('[Main] Login success event received, starting token refresh timer'); + if (tokenRefreshManager) { + tokenRefreshManager.startRefreshTimer(); + } +}); + +// Stop token refresh timer on logout +window.addEventListener('auth-logout', () => { + console.log('[Main] Logout event received, stopping token refresh timer'); + if (tokenRefreshManager) { + tokenRefreshManager.stopRefreshTimer(); + } +}); + const loadingStore = LoadingStore(); -axios.interceptors.request.use(function (config) { - loadingStore.another_loading = true; - return config -}, function (error) { - return Promise.reject(error); - }); - -axios.interceptors.response.use(function (response) { - loadingStore.another_loading = false; +axios.interceptors.request.use( + function (config) { + loadingStore.another_loading = true; + return config; + }, + function (error) { + return Promise.reject(error); + } +); - return response; - }, function (error) { - return Promise.reject(error); - }); - \ No newline at end of file +axios.interceptors.response.use( + function (response) { + loadingStore.another_loading = false; + return response; + }, + async function (error) { + loadingStore.another_loading = false; + + // Don't handle auth-related URLs to prevent infinite loops + const isAuthUrl = error.config && (error.config.url.includes('/api/auth/') || error.config.url.includes('/msauth/')); + + // Handle 403 errors by attempting token refresh (but not for auth URLs) + if (error.response && error.response.status === 403 && !isAuthUrl) { + console.log('[Interceptor] 403 error detected, attempting token refresh...'); + + // Check if we have an authenticated user and authInstance is available + if (authInstance && authInstance.check && authInstance.check()) { + try { + // Use our custom AuthService to refresh tokens (both MSAL and classic) + const refreshResult = await AuthService.refreshToken(authInstance); + + if (refreshResult.success && refreshResult.token) { + console.log('[Interceptor] Token refresh successful, updating auth and retrying request...'); + + // Update the auth token + authInstance.token(null, refreshResult.token, false); + + // Update the original request with new token + error.config.headers['Authorization'] = `Bearer ${refreshResult.token}`; + + // Retry the original request + return axios.request(error.config); + } else { + console.error('[Interceptor] Token refresh failed:', refreshResult.error); + throw new Error(refreshResult.error); + } + } catch (refreshError) { + console.error('[Interceptor] Token refresh process failed:', refreshError); + + // If refresh fails, logout and redirect to login + try { + // Stop token refresh timer + window.dispatchEvent(new CustomEvent('auth-logout')); + + await AuthService.logout(); + if (authInstance && authInstance.logout) { + authInstance.logout({ + redirect: '/auth/login' + }); + } else { + window.location.href = '/auth/login'; + } + } catch (logoutError) { + console.error('[Interceptor] Logout failed:', logoutError); + // Force redirect to login + window.location.href = '/auth/login'; + } + + return Promise.reject(refreshError); + } + } else { + console.log('[Interceptor] No authenticated user for non-auth 403, ignoring...'); + } + } + + return Promise.reject(error); + } +); diff --git a/src/router/index.js b/src/router/index.js index 8081a9d..e99d3ae 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -17,63 +17,68 @@ const router = createRouter({ auth: true }, children: [ - { - path: 'scenario', - children: [ { - path: '/projects', - name: 'projects-list', - component: () => import('@/views/pages/ProjectList.vue') - }, - { - path: '/home', - name: 'scenario-list', - component: () => import('@/views/pages/ScenarioList.vue') - }, - { - path: 'exec/:id', - name: 'scenario-exec', - component: () => import('@/views/pages/ScenarioExec.vue') - }, - { - path: 'exec-history', - name: 'scenario-exec-history', - component: () => import('@/views/pages/OldScenarioExec.vue') - }, - { - path: '/dashboard', - name: 'dashboard', - component: () => import('@/views/pages/DashExecution.vue') - }, - { - path: '/executions/:name', - name: 'executions', - component: () => import('@/views/pages/ScenarioExecList.vue'), - props: true // Facoltativo: consente di passare il parametro come prop al componente - }, - { - path: '/canvas', - name: 'canvas', - component: () => import('@/views/pages/canvas/Canvas.vue') - }, - { - path: '/app-browser', - name: 'app-browser', - component: () => import('@/views/pages/ApplicationBrowser.vue') - }, - { - path: '/mdcanvas', - name: 'mdcanvas', - component: () => import('@/views/pages/canvas/MdCanvas.vue') - }, - - { - path: '/chat', - name: 'chat', - component: () => import('@/views/pages/chat/ChatPage.vue') + path: 'scenario', + children: [ + { + path: '/projects', + name: 'projects-list', + component: () => import('@/views/pages/ProjectList.vue') + }, + { + path: '/home', + name: 'scenario-list', + component: () => import('@/views/pages/ScenarioList.vue') + }, + { + path: 'exec/:id', + name: 'scenario-exec', + component: () => import('@/views/pages/ScenarioExec.vue') + }, + { + path: 'exec-history', + name: 'scenario-exec-history', + component: () => import('@/views/pages/OldScenarioExec.vue') + }, + { + path: '/dashboard', + name: 'dashboard', + component: () => import('@/views/pages/DashExecution.vue') + }, + { + path: '/executions/:name', + name: 'executions', + component: () => import('@/views/pages/ScenarioExecList.vue'), + props: true // Facoltativo: consente di passare il parametro come prop al componente + }, + { + path: '/canvas', + name: 'canvas', + component: () => import('@/views/pages/canvas/Canvas.vue') + }, + { + path: '/app-browser', + name: 'app-browser', + component: () => import('@/views/pages/ApplicationBrowser.vue') + }, + // { + // path: '/filesystem-browser', + // name: 'filesystem-browser', + // component: () => import('@/views/pages/FileSystemBrowser.vue') + // }, + { + path: '/mdcanvas', + name: 'mdcanvas', + component: () => import('@/views/pages/canvas/MdCanvas.vue') + }, + + { + path: '/chat', + name: 'chat', + component: () => import('@/views/pages/chat/ChatPage.vue') + } + ] } - ] - } ] }, { @@ -95,4 +100,17 @@ const router = createRouter({ ] }); +// Navigation guard to handle logout when going to login page +router.beforeEach((to, from, next) => { + // Only trigger logout event when coming from an authenticated route to login + // and not on initial page load or when already on login/callback pages + if (to.name === 'login' && from.name && from.name !== 'login' && from.name !== 'test' && from.meta?.auth === true) { + // User is navigating to login page from an authenticated route + // This indicates a logout or session expiry + console.log('[Router] Navigating from authenticated route to login, triggering logout event'); + window.dispatchEvent(new CustomEvent('auth-logout')); + } + next(); +}); + export default router; diff --git a/src/service/AuthService.js b/src/service/AuthService.js new file mode 100644 index 0000000..0dbb23d --- /dev/null +++ b/src/service/AuthService.js @@ -0,0 +1,215 @@ +import { msalInstance, msalrequest } from '@/views/pages/auth/MsalConfig'; +import axios from 'axios'; + +export class AuthService { + /** + * Attempts to refresh tokens based on the authentication type + * @param {Object} authInstance - The vue-auth instance + * @returns {Promise<{success: boolean, token?: string, error?: string}>} + */ + static async refreshToken(authInstance = null) { + try { + console.log('[AuthService] Starting token refresh process...'); + + // Check if we have MSAL accounts first + await msalInstance.initialize(); + const accounts = msalInstance.getAllAccounts(); + + if (accounts.length > 0) { + // User logged in with MSAL - use MSAL refresh + console.log('[AuthService] Using MSAL token refresh...'); + return await this.refreshMsalToken(); + } else if (authInstance && authInstance.check()) { + // User logged in with classic authentication - use vue-auth refresh + console.log('[AuthService] Using classic authentication token refresh...'); + return await this.refreshClassicToken(authInstance); + } else { + console.warn('[AuthService] No valid authentication method found'); + return { success: false, error: 'No valid authentication method found' }; + } + } catch (error) { + console.error('[AuthService] Token refresh failed:', error); + return { success: false, error: error.message }; + } + } + + /** + * Refreshes token using classic username/password authentication + * @param {Object} authInstance - The vue-auth instance + * @returns {Promise<{success: boolean, token?: string, error?: string}>} + */ + static async refreshClassicToken(authInstance) { + try { + console.log('[AuthService] Attempting classic token refresh...'); + + const refreshResponse = await authInstance.refresh(); + console.log('[AuthService] Classic refresh response:', refreshResponse); + + // Check multiple possible response structures + let token = null; + + if (refreshResponse && refreshResponse.data) { + // Try different token field names + token = refreshResponse.data.token || refreshResponse.data.accessToken || refreshResponse.data.access_token; + + // If still no token, check if the token is directly in data + if (!token && typeof refreshResponse.data === 'string') { + token = refreshResponse.data; + } + } + + // Also check if token is in headers (as backend sets it there) + if (!token && refreshResponse && refreshResponse.headers) { + token = refreshResponse.headers.authorization || refreshResponse.headers.Authorization; + } + + if (token) { + console.log('[AuthService] Classic token refresh successful'); + return { + success: true, + token: token, + response: refreshResponse + }; + } else { + console.error('[AuthService] No token found in classic refresh response:', refreshResponse); + return { success: false, error: 'No token in refresh response' }; + } + } catch (error) { + console.error('[AuthService] Classic token refresh failed:', error); + return { success: false, error: 'Classic token refresh failed' }; + } + } + + /** + * Attempts to refresh the MSAL token and exchange it for a new backend token + * @returns {Promise<{success: boolean, token?: string, error?: string}>} + */ + static async refreshMsalToken() { + try { + console.log('[AuthService] Starting MSAL token refresh process...'); + + // Get all accounts from MSAL + await msalInstance.initialize(); + const accounts = msalInstance.getAllAccounts(); + + if (accounts.length === 0) { + console.warn('[AuthService] No MSAL accounts found for MSAL refresh'); + return { success: false, error: 'No MSAL accounts found' }; + } + + const account = accounts[0]; + console.log('[AuthService] Using account:', account.username); + + // Try to acquire token silently + let tokenResponse; + try { + tokenResponse = await msalInstance.acquireTokenSilent({ + scopes: msalrequest.scopes, + account: account + }); + console.log('[AuthService] MSAL token acquired silently'); + } catch (silentError) { + console.warn('[AuthService] Silent token acquisition failed, trying interactive...'); + + // If silent fails, try interactive + try { + tokenResponse = await msalInstance.acquireTokenPopup({ + scopes: msalrequest.scopes, + account: account + }); + console.log('[AuthService] MSAL token acquired interactively'); + } catch (interactiveError) { + console.error('[AuthService] Interactive token acquisition failed:', interactiveError); + return { success: false, error: 'Token acquisition failed' }; + } + } + + if (!tokenResponse || !tokenResponse.accessToken) { + console.error('[AuthService] No access token received'); + return { success: false, error: 'No access token received' }; + } + + // Exchange the MSAL token for a backend token + try { + const exchangeResponse = await axios.post( + '/msauth/exchange', + {}, + { + headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } + } + ); + + if (exchangeResponse.data && exchangeResponse.data.token) { + console.log('[AuthService] MSAL token refresh successful'); + return { + success: true, + token: exchangeResponse.data.token, + response: exchangeResponse + }; + } else { + console.error('[AuthService] No token in MSAL exchange response'); + return { success: false, error: 'No token in exchange response' }; + } + } catch (exchangeError) { + console.error('[AuthService] MSAL token exchange failed:', exchangeError); + return { success: false, error: 'Token exchange failed' }; + } + } catch (error) { + console.error('[AuthService] MSAL token refresh failed:', error); + return { success: false, error: error.message }; + } + } + + /** + * Checks if the current MSAL session is still valid + * @returns {Promise} + */ + static async isSessionValid() { + try { + await msalInstance.initialize(); + const accounts = msalInstance.getAllAccounts(); + + if (accounts.length === 0) { + return false; + } + + // Try to acquire token silently to check if session is valid + try { + await msalInstance.acquireTokenSilent({ + scopes: msalrequest.scopes, + account: accounts[0] + }); + return true; + } catch (error) { + console.warn('[AuthService] Session validation failed:', error); + return false; + } + } catch (error) { + console.error('[AuthService] Session validation error:', error); + return false; + } + } + + /** + * Logs out from MSAL + * @returns {Promise} + */ + static async logout() { + try { + await msalInstance.initialize(); + const accounts = msalInstance.getAllAccounts(); + + if (accounts.length > 0) { + const logoutRequest = { + account: accounts[0], + mainWindowRedirectUri: window.location.origin + '/auth/login' + }; + + await msalInstance.logoutPopup(logoutRequest); + console.log('[AuthService] MSAL logout completed'); + } + } catch (error) { + console.error('[AuthService] Logout error:', error); + } + } +} diff --git a/src/service/TokenRefreshManager.js b/src/service/TokenRefreshManager.js new file mode 100644 index 0000000..1788908 --- /dev/null +++ b/src/service/TokenRefreshManager.js @@ -0,0 +1,106 @@ +import { AuthService } from './AuthService.js'; + +export class TokenRefreshManager { + constructor(authInstance) { + this.authInstance = authInstance; + this.refreshInterval = null; + this.isRefreshing = false; + // Refresh every 50 minutes (tokens expire after 1 hour) + this.REFRESH_INTERVAL_MS = 50 * 60 * 1000; + } + + /** + * Starts the automatic token refresh timer + */ + startRefreshTimer() { + console.log('[TokenRefreshManager] Starting automatic token refresh timer...'); + + // Clear any existing timer + this.stopRefreshTimer(); + + this.refreshInterval = setInterval(async () => { + await this.performRefresh(); + }, this.REFRESH_INTERVAL_MS); + + console.log(`[TokenRefreshManager] Timer set to refresh every ${this.REFRESH_INTERVAL_MS / 60000} minutes`); + } + + /** + * Stops the automatic token refresh timer + */ + stopRefreshTimer() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + console.log('[TokenRefreshManager] Token refresh timer stopped'); + } + } + + /** + * Performs a token refresh + */ + async performRefresh() { + if (this.isRefreshing) { + console.log('[TokenRefreshManager] Refresh already in progress, skipping...'); + return; + } + + // Only refresh if user is authenticated + if (!this.authInstance || !this.authInstance.check()) { + console.log('[TokenRefreshManager] User not authenticated, stopping refresh timer'); + this.stopRefreshTimer(); + return; + } + + this.isRefreshing = true; + + try { + console.log('[TokenRefreshManager] Performing scheduled token refresh...'); + + const refreshResult = await AuthService.refreshToken(this.authInstance); + + if (refreshResult.success && refreshResult.token) { + // Update the auth token silently + this.authInstance.token(null, refreshResult.token, false); + console.log('[TokenRefreshManager] Token refreshed successfully'); + } else { + console.error('[TokenRefreshManager] Scheduled refresh failed:', refreshResult.error); + + // Stop the timer and logout on failure + this.stopRefreshTimer(); + + try { + // Dispatch logout event to stop other timers + window.dispatchEvent(new CustomEvent('auth-logout')); + + await AuthService.logout(); + this.authInstance.logout({ + redirect: '/auth/login' + }); + } catch (logoutError) { + console.error('[TokenRefreshManager] Logout failed:', logoutError); + window.location.href = '/auth/login'; + } + } + } catch (error) { + console.error('[TokenRefreshManager] Refresh error:', error); + this.stopRefreshTimer(); + } finally { + this.isRefreshing = false; + } + } + + /** + * Manually triggers a token refresh + * @returns {Promise} Success status + */ + async manualRefresh() { + if (this.isRefreshing) { + console.log('[TokenRefreshManager] Manual refresh requested but already in progress'); + return false; + } + + await this.performRefresh(); + return true; + } +} diff --git a/src/views/pages/auth/Callback.vue b/src/views/pages/auth/Callback.vue index 1028871..1c0747a 100644 --- a/src/views/pages/auth/Callback.vue +++ b/src/views/pages/auth/Callback.vue @@ -18,7 +18,7 @@ onMounted(async () => { console.log('[Callback] After initialize on callback'); } catch (e) { console.error('[Callback] Error during MSAL initialization:', e); - message.value = "Error during MSAL initialization."; + message.value = 'Error during MSAL initialization.'; visible.value = true; return; } @@ -45,7 +45,7 @@ onMounted(async () => { } // Wait 2 second to avoid race condition - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); message.value = 'Logging in to the application...'; // Token exchange function with retry @@ -63,7 +63,7 @@ onMounted(async () => { } catch (err) { if (!retry) { console.warn('[Callback] First attempt failed, waiting 1500ms and retrying...'); - await new Promise(resolve => setTimeout(resolve, 1500)); + await new Promise((resolve) => setTimeout(resolve, 1500)); return tryTokenExchange(true); } else { throw err; @@ -85,6 +85,9 @@ onMounted(async () => { const userResponse = await auth.fetch(); console.log('[Callback] User fetch response:', userResponse); + // Start token refresh timer after successful authentication + window.dispatchEvent(new CustomEvent('auth-login-success')); + const userData = userResponse.data?.data; console.log('[Callback] userResponse.data.data:', userData); @@ -116,7 +119,6 @@ onMounted(async () => { visible.value = true; } }); -