From 23ab1cc85dd680a26be95ce5ba402cc554c3dded Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Fri, 9 May 2025 01:53:44 -0400 Subject: [PATCH] fix(auth): handles expired tokens correctly --- frontend/src/internal_time_ago.js | 21 ++ frontend/src/plugins/api.js | 87 ++++-- frontend/src/stores/authStore.js | 432 ++++++++++++++++-------------- 3 files changed, 305 insertions(+), 235 deletions(-) diff --git a/frontend/src/internal_time_ago.js b/frontend/src/internal_time_ago.js index 2ad15b1..b8c640e 100644 --- a/frontend/src/internal_time_ago.js +++ b/frontend/src/internal_time_ago.js @@ -66,3 +66,24 @@ function internal_time_ago(time) { } return time; } + + +// Function to format time duration similar to C#'s TimeSpan +export function formatDuration(milliseconds) { + // Ensure we're working with a positive number + const ms = Math.abs(milliseconds); + + const days = Math.floor(ms / (24 * 60 * 60 * 1000)); + const hours = Math.floor((ms % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)); + const minutes = Math.floor((ms % (60 * 60 * 1000)) / (60 * 1000)); + const seconds = Math.floor((ms % (60 * 1000)) / 1000); + + // Format the duration like "1d 2h 3m 4s" or just the relevant parts + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0 || days > 0) parts.push(`${hours}h`); + if (minutes > 0 || hours > 0 || days > 0) parts.push(`${minutes}m`); + parts.push(`${seconds}s`); + + return parts.join(' '); +} diff --git a/frontend/src/plugins/api.js b/frontend/src/plugins/api.js index 23f21d7..2f9dcef 100644 --- a/frontend/src/plugins/api.js +++ b/frontend/src/plugins/api.js @@ -1,69 +1,102 @@ -import axios from "axios" -import {useAuthStore} from "@/stores/authStore.js" +import axios from 'axios'; +import {useAuthStore} from '@/stores/authStore.js'; export function useClient() { - if (!import.meta.env.VITE_API_URL) throw new Error("VITE_API_URL is not provided") + if (!import.meta.env.VITE_API_URL) { + throw new Error('VITE_API_URL is not provided'); + } - const authStore = useAuthStore() const client = axios.create({ baseURL: import.meta.env.VITE_API_URL, headers: { - 'Content-Type': 'application/json', - }, + 'Content-Type': 'application/json' + } }); + const authStore = useAuthStore(); + // Request interceptor client.interceptors.request.use(async (config) => { - // Proactively check and refresh token if needed - if (authStore.isAuthenticated) { + console.log(`Request interceptor triggered for: ${config.method?.toUpperCase()} ${config.url}`); + + // Check if this is the refresh token endpoint + const isRefreshEndpoint = config.url?.includes('api/users/refresh'); + + // Check if we need to refresh the token + if (authStore.isAuthenticated && !isRefreshEndpoint) { try { - await authStore.ensureValidToken(); + console.log(`User is authenticated, checking token for: ${config.method?.toUpperCase()} ${config.url}`); + + // If token is expiring soon, start a refresh and WAIT for it to complete + if (authStore.isTokenExpiringSoon(authStore.accessToken)) { + console.log(`Token is expiring soon, waiting for refresh to complete before continuing request: ${config.method?.toUpperCase()} ${config.url}`); + await authStore.refresh(); + console.log(`Token refresh completed, proceeding with request: ${config.method?.toUpperCase()} ${config.url}`); + } } catch (error) { - console.error('Failed to ensure valid token:', error); + console.error(`Failed to refresh token for: ${config.method?.toUpperCase()} ${config.url}`, error); + throw error; // This will cancel the request } } - // Add authorization header if authenticated - if (authStore.isAuthenticated) { + if (authStore.isAuthenticated && !isRefreshEndpoint) { + console.log(`Setting Authorization header for: ${config.method?.toUpperCase()} ${config.url}`); config.headers.Authorization = `Bearer ${authStore.accessToken}`; + } else if (isRefreshEndpoint) { + console.log(`Skipping Authorization header for refresh endpoint: ${config.method?.toUpperCase()} ${config.url}`); } - - // Don't override Content-Type for FormData requests + if (config.data instanceof FormData) { - // Let the browser set the correct Content-Type with boundary + console.log(`Data is FormData, removing explicit Content-Type header for: ${config.method?.toUpperCase()} ${config.url}`); delete config.headers['Content-Type']; } - + return config; }); + // Response interceptor client.interceptors.response.use( - (response) => response, + (response) => { + console.log(`Response received successfully for: ${response.config.method?.toUpperCase()} ${response.config.url}`); + return response; + }, async (error) => { const originalRequest = error.config; + console.error(`Response interceptor caught an error for: ${originalRequest.method?.toUpperCase()} ${originalRequest.url}`, error); - // If the error is 401 and we haven't tried to refresh the token yet - if (error.response?.status === 401 && !originalRequest._retry) { - console.log('Received 401 error, attempting token refresh...'); + // Prevent retry loops by checking if this is already a retry or a refresh request + if (error.response?.status === 401 && !originalRequest._retry && !originalRequest.url.includes('api/users/refresh')) { + console.log(`Received 401 error for: ${originalRequest.method?.toUpperCase()} ${originalRequest.url}, attempting token refresh...`); originalRequest._retry = true; try { - await authStore.refresh(); - console.log('Token refresh successful, retrying original request...'); - // Retry the original request with the new token + // Use a timeout to prevent hanging indefinitely + const refreshTimeout = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Token refresh timeout')), 10000); // 10s timeout + }); + + // Race the refresh against the timeout + await Promise.race([authStore.refresh(), refreshTimeout]); + + console.log(`Token refresh successful, retrying original request: ${originalRequest.method?.toUpperCase()} ${originalRequest.url}`); return client(originalRequest); } catch (refreshError) { - console.error('Token refresh failed, logging out user:', refreshError); - await authStore.logout('/login'); + console.error(`Token refresh failed for: ${originalRequest.method?.toUpperCase()} ${originalRequest.url}, logging out user:`, refreshError); + // Let the authStore handle the navigation with returnUrl throw refreshError; } } + // If it's a refresh request that failed, or a retry that still failed, give up + if (originalRequest.url.includes('api/users/refresh') || originalRequest._retry) { + console.log(`Request permanently failed: ${originalRequest.method?.toUpperCase()} ${originalRequest.url}`); + // Don't do anything here, let the refresh error handling work + } + return Promise.reject(error); } ); return client; -} - +} \ No newline at end of file diff --git a/frontend/src/stores/authStore.js b/frontend/src/stores/authStore.js index ead2470..6204e0c 100644 --- a/frontend/src/stores/authStore.js +++ b/frontend/src/stores/authStore.js @@ -1,185 +1,160 @@ import {defineStore} from 'pinia'; -import {computed, ref} from "vue"; -import {useRouter} from "vue-router"; -import {useClient} from "@/plugins/api.js"; -import {useSessionStorage} from "@vueuse/core"; -import {jwtDecode} from "jwt-decode"; +import {computed, ref} from 'vue'; +import {useRouter} from 'vue-router'; +import {useClient} from '@/plugins/api.js'; +import {useSessionStorage} from '@vueuse/core'; +import {jwtDecode} from 'jwt-decode'; +import {formatDuration} from "@/internal_time_ago.js"; -function getClaimsFromToken(token) { - if (!token) return null; - try { - return jwtDecode(token); - } catch (error) { - console.error('Failed to decode token:', error); - return null; +export const useAuthStore = defineStore('auth', () => { + const clientApi = useClient(); + const router = useRouter(); + + const isRefreshing = ref(false); + let refreshPromise = null; + + const accessToken = useSessionStorage('auth-accessToken', undefined); + const refreshToken = useSessionStorage('auth-refreshToken', undefined); + const tokenClaims = useSessionStorage('auth-tokenClaims', null, { + serializer: { + read: (v) => (v ? JSON.parse(v) : null), + write: (v) => (v ? JSON.stringify(v) : null) + } + }); + + const isAuthenticated = computed(() => !!accessToken.value); + const userId = computed(() => tokenClaims.value?.sub); + + function updateTokens(data) { + console.log('updateTokens called with response data:', data); + if (!data?.accessToken || !data?.refreshToken) { + throw new Error('Invalid token data'); + } + accessToken.value = data.accessToken; + refreshToken.value = data.refreshToken; + const claims = getClaimsFromToken(data.accessToken); + tokenClaims.value = claims; + console.log('Tokens updated, user ID:', claims?.sub); } -} -function isTokenExpiringSoon(token) { - if (!token) return true; - const claims = getClaimsFromToken(token); - if (!claims || !claims.exp) return true; - - // Check if token will expire in the next 5 minutes - const expirationTime = claims.exp * 1000; // Convert to milliseconds - const currentTime = Date.now(); - const fiveMinutesInMs = 5 * 60 * 1000; - - // Return true if token is expired or will expire in the next 5 minutes - return currentTime >= expirationTime - fiveMinutesInMs; -} + function cleanTokens() { + console.log('cleanTokens called - clearing stored tokens'); + accessToken.value = undefined; + refreshToken.value = undefined; + tokenClaims.value = null; + } -export const useAuthStore = defineStore( - 'auth', - () => { - const clientApi = useClient() - const router = useRouter() + async function logout(redirectTo = '/landing') { + console.log('logout called, redirecting to:', redirectTo); + try { + // Optionally call logout endpoint if you have one + // await clientApi.post('api/users/logout'); + } catch (error) { + console.error('Logout failed:', error); + } finally { + cleanTokens(); + await router.push(redirectTo); + } + } + + async function login(email, password) { + console.log('login called with email:', email); + if (!email || !password) { + throw new Error('Email and password are required'); + } + + try { + const response = await clientApi.post('api/users/login', { + email: email.trim(), + password: password + }); + + if (!response.data?.accessToken || !response.data?.refreshToken) { + throw new Error('Invalid login response'); + } + + updateTokens(response.data); + console.log('login successful'); + return true; + } catch (error) { + console.error('Login failed:', error); + cleanTokens(); + throw error; + } + } + + async function loginWithGoogle(accessTokenParam) { + console.log('loginWithGoogle called'); + if (!accessTokenParam) { + throw new Error('Google access token is required'); + } + + try { + const response = await clientApi.post('api/users/login-with-google', { + token: accessTokenParam + }); + + if (!response.data?.accessToken || !response.data?.refreshToken) { + throw new Error('Invalid Google login response'); + } + + updateTokens(response.data); + console.log('Google login successful'); + return true; + } catch (error) { + console.error('Google login failed:', error); + cleanTokens(); + throw error; + } + } + + async function loginWithFacebook(authResponse) { + console.log('loginWithFacebook called'); + if (!authResponse?.accessToken) { + throw new Error('Facebook access token is required'); + } + + try { + const response = await clientApi.post('api/users/login-with-facebook', { + token: authResponse.accessToken + }); + + if (!response.data?.accessToken || !response.data?.refreshToken) { + throw new Error('Invalid Facebook login response'); + } + + updateTokens(response.data); + console.log('Facebook login successful'); + return true; + } catch (error) { + console.error('Facebook login failed:', error); + cleanTokens(); + throw error; + } + } + + async function refresh() { + console.log('refresh called'); - // Flag to track if we're currently refreshing the token - const isRefreshing = ref(false) - // Store the refresh promise to avoid multiple concurrent refreshes - let refreshPromise = null - - const accessToken = useSessionStorage('auth-accessToken', undefined) - const refreshToken = useSessionStorage('auth-refreshToken', undefined) - // Cache for decoded claims using session storage with proper serialization - const tokenClaims = useSessionStorage('auth-tokenClaims', null, { - serializer: { - read: (v) => v ? JSON.parse(v) : null, - write: (v) => v ? JSON.stringify(v) : null - } - }) - - const isAuthenticated = computed(() => !!accessToken.value) - - const userId = computed(() => tokenClaims.value?.sub) - - function updateTokens(data) { - if (!data?.accessToken || !data?.refreshToken) { - throw new Error('Invalid token data'); - } - accessToken.value = data.accessToken; - refreshToken.value = data.refreshToken; - // Update claims cache when we get new tokens - const claims = getClaimsFromToken(data.accessToken); - tokenClaims.value = claims; + if (!refreshToken.value) { + cleanTokens(); // Clear tokens first + throw new Error('No refresh token available'); } - function cleanTokens() { - accessToken.value = undefined; - refreshToken.value = undefined; - tokenClaims.value = null; - // Clear any other auth-related data if needed + if (isRefreshing.value && refreshPromise) { + console.log('Already refreshing, returning existing refreshPromise'); + return refreshPromise; } - async function logout(redirectTo = '/landing') { - try { - // Optionally call logout endpoint if you have one - // await clientApi.post('api/users/logout'); - } catch (error) { - console.error('Logout failed:', error); - } finally { - cleanTokens(); - await router.push(redirectTo); - } - } - - async function login(email, password) { - if (!email || !password) { - throw new Error('Email and password are required'); - } - - try { - const response = await clientApi.post( - 'api/users/login', - { - email: email.trim(), - password: password - }); - - if (!response.data?.accessToken || !response.data?.refreshToken) { - throw new Error('Invalid login response'); - } - - updateTokens(response.data); - return true; - } catch (error) { - console.error('Login failed:', error); - cleanTokens(); - throw error; - } - } - - async function loginWithGoogle(accessToken) { - if (!accessToken) { - throw new Error('Google access token is required'); - } - - try { - const response = await clientApi.post( - 'api/users/login-with-google', - { - token: accessToken - }); - - if (!response.data?.accessToken || !response.data?.refreshToken) { - throw new Error('Invalid Google login response'); - } - - updateTokens(response.data); - return true; - } catch (error) { - console.error('Google login failed:', error); - cleanTokens(); - throw error; - } - } - - async function loginWithFacebook(authResponse) { - if (!authResponse?.accessToken) { - throw new Error('Facebook access token is required'); - } - - try { - const response = await clientApi.post( - 'api/users/login-with-facebook', - { - token: authResponse.accessToken - }); - - if (!response.data?.accessToken || !response.data?.refreshToken) { - throw new Error('Invalid Facebook login response'); - } - - updateTokens(response.data); - return true; - } catch (error) { - console.error('Facebook login failed:', error); - cleanTokens(); - throw error; - } - } - - async function refresh() { - if (!refreshToken.value) { - throw new Error('No refresh token available'); - } - - // If we're already refreshing, return the existing promise - if (isRefreshing.value && refreshPromise) { - return refreshPromise; - } - - // Create a new refresh promise + try { + isRefreshing.value = true; refreshPromise = (async () => { try { - isRefreshing.value = true; - - const response = await clientApi.post( - 'api/users/refresh', - { - refreshToken: refreshToken.value - }); + console.log('Sending refresh request...'); + + const response = await clientApi.post('api/users/refresh', { + refreshToken: refreshToken.value + }); if (!response.data?.accessToken || !response.data?.refreshToken) { throw new Error('Invalid refresh response'); @@ -189,56 +164,97 @@ export const useAuthStore = defineStore( accessToken: response.data.accessToken, refreshToken: response.data.refreshToken }); - - isRefreshing.value = false; - refreshPromise = null; + + console.log('Token refresh successful'); return true; } catch (error) { console.error('Token refresh failed:', error); - isRefreshing.value = false; - refreshPromise = null; - - // Only clear tokens and session storage after a failed refresh attempt cleanTokens(); - - // Get the current route to use as returnUrl + const currentRoute = router.currentRoute.value; const returnUrl = currentRoute.fullPath; - - // Force a redirect to the login page with returnUrl - await router.push({ + + // Handle navigation + router.push({ name: 'login', - query: { returnUrl } + query: {returnUrl} + }).catch(navError => { + console.error('Navigation error after token refresh failure:', navError); }); - - throw error; + + throw error; // Re-throw to notify callers } })(); - return refreshPromise; + return await refreshPromise; + } catch (error) { + throw error; + } finally { + // Ensure these are always reset, even if an error is thrown + isRefreshing.value = false; + refreshPromise = null; + } + } + + function getClaimsFromToken(token) { + if (!token) return null; + try { + return jwtDecode(token); + } catch (error) { + console.error('Failed to decode token:', error); + return null; + } + } + + function isTokenExpiringSoon(token) { + console.log('isTokenExpiringSoon called'); + + if (!token) { + console.log('No token provided, considered expiring soon'); + return true; } - // Function to check if token needs refresh - async function ensureValidToken() { - if (isTokenExpiringSoon(accessToken.value)) { - // Start the refresh process without waiting for it to complete - refresh().catch(error => { - console.error('Error during token refresh:', error); - }); - } + const claims = getClaimsFromToken(token); + if (!claims || !claims.exp) { + console.log('No valid claims found, considered expiring soon'); + return true; } - return { - accessToken, - refreshToken, - isAuthenticated, - userId, - isRefreshing, - login, - loginWithGoogle, - loginWithFacebook, - logout, - refresh, - ensureValidToken - } - }) \ No newline at end of file + const expirationTime = claims.exp * 1000; // Convert to milliseconds + const currentTime = Date.now(); + const fiveMinutesInMs = 2 * 60 * 1000; // 2 minutes for demonstration + + // Calculate time remaining (can be negative if already expired) + const timeRemainingMs = expirationTime - currentTime; + + // Token is expiring soon if less than 2 minutes remaining or already expired + const isExpiring = timeRemainingMs < fiveMinutesInMs; + + // Determine the sign for display purposes + const formattedTimeRemaining = timeRemainingMs < 0 + ? `-${formatDuration(Math.abs(timeRemainingMs))}` + : formatDuration(timeRemainingMs); + + console.log(`Token expiration check; is token expired: ${isExpiring}`, { + expirationTime: new Date(expirationTime).toLocaleString(), + currentTime: new Date(currentTime).toLocaleString(), + timeRemaining: formattedTimeRemaining + }); + + return isExpiring; + } + + return { + accessToken, + refreshToken, + isAuthenticated, + userId, + isRefreshing, + login, + loginWithGoogle, + loginWithFacebook, + logout, + refresh, + isTokenExpiringSoon + }; +}); \ No newline at end of file