fix(auth): handles expired tokens correctly

This commit is contained in:
2025-05-09 01:53:44 -04:00
parent b8e94f6409
commit 23ab1cc85d
3 changed files with 305 additions and 235 deletions

View File

@@ -66,3 +66,24 @@ function internal_time_ago(time) {
} }
return 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(' ');
}

View File

@@ -1,69 +1,102 @@
import axios from "axios" import axios from 'axios';
import {useAuthStore} from "@/stores/authStore.js" import {useAuthStore} from '@/stores/authStore.js';
export function useClient() { 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({ const client = axios.create({
baseURL: import.meta.env.VITE_API_URL, baseURL: import.meta.env.VITE_API_URL,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json'
}, }
}); });
const authStore = useAuthStore();
// Request interceptor // Request interceptor
client.interceptors.request.use(async (config) => { client.interceptors.request.use(async (config) => {
// Proactively check and refresh token if needed console.log(`Request interceptor triggered for: ${config.method?.toUpperCase()} ${config.url}`);
if (authStore.isAuthenticated) {
// 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 { 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) { } 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 && !isRefreshEndpoint) {
if (authStore.isAuthenticated) { console.log(`Setting Authorization header for: ${config.method?.toUpperCase()} ${config.url}`);
config.headers.Authorization = `Bearer ${authStore.accessToken}`; 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) { 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']; delete config.headers['Content-Type'];
} }
return config; return config;
}); });
// Response interceptor // Response interceptor
client.interceptors.response.use( client.interceptors.response.use(
(response) => response, (response) => {
console.log(`Response received successfully for: ${response.config.method?.toUpperCase()} ${response.config.url}`);
return response;
},
async (error) => { async (error) => {
const originalRequest = error.config; 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 // Prevent retry loops by checking if this is already a retry or a refresh request
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry && !originalRequest.url.includes('api/users/refresh')) {
console.log('Received 401 error, attempting token refresh...'); console.log(`Received 401 error for: ${originalRequest.method?.toUpperCase()} ${originalRequest.url}, attempting token refresh...`);
originalRequest._retry = true; originalRequest._retry = true;
try { try {
await authStore.refresh(); // Use a timeout to prevent hanging indefinitely
console.log('Token refresh successful, retrying original request...'); const refreshTimeout = new Promise((_, reject) => {
// Retry the original request with the new token 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); return client(originalRequest);
} catch (refreshError) { } catch (refreshError) {
console.error('Token refresh failed, logging out user:', refreshError); console.error(`Token refresh failed for: ${originalRequest.method?.toUpperCase()} ${originalRequest.url}, logging out user:`, refreshError);
await authStore.logout('/login'); // Let the authStore handle the navigation with returnUrl
throw refreshError; 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 Promise.reject(error);
} }
); );
return client; return client;
} }

View File

@@ -1,78 +1,51 @@
import {defineStore} from 'pinia'; import {defineStore} from 'pinia';
import {computed, ref} from "vue"; import {computed, ref} from 'vue';
import {useRouter} from "vue-router"; import {useRouter} from 'vue-router';
import {useClient} from "@/plugins/api.js"; import {useClient} from '@/plugins/api.js';
import {useSessionStorage} from "@vueuse/core"; import {useSessionStorage} from '@vueuse/core';
import {jwtDecode} from "jwt-decode"; import {jwtDecode} from 'jwt-decode';
import {formatDuration} from "@/internal_time_ago.js";
function getClaimsFromToken(token) { export const useAuthStore = defineStore('auth', () => {
if (!token) return null; const clientApi = useClient();
try { const router = useRouter();
return jwtDecode(token);
} catch (error) {
console.error('Failed to decode token:', error);
return null;
}
}
function isTokenExpiringSoon(token) { const isRefreshing = ref(false);
if (!token) return true; let refreshPromise = null;
const claims = getClaimsFromToken(token);
if (!claims || !claims.exp) return true;
// Check if token will expire in the next 5 minutes const accessToken = useSessionStorage('auth-accessToken', undefined);
const expirationTime = claims.exp * 1000; // Convert to milliseconds const refreshToken = useSessionStorage('auth-refreshToken', undefined);
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;
}
export const useAuthStore = defineStore(
'auth',
() => {
const clientApi = useClient()
const router = useRouter()
// 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, { const tokenClaims = useSessionStorage('auth-tokenClaims', null, {
serializer: { serializer: {
read: (v) => v ? JSON.parse(v) : null, read: (v) => (v ? JSON.parse(v) : null),
write: (v) => v ? JSON.stringify(v) : null write: (v) => (v ? JSON.stringify(v) : null)
} }
}) });
const isAuthenticated = computed(() => !!accessToken.value) const isAuthenticated = computed(() => !!accessToken.value);
const userId = computed(() => tokenClaims.value?.sub);
const userId = computed(() => tokenClaims.value?.sub)
function updateTokens(data) { function updateTokens(data) {
console.log('updateTokens called with response data:', data);
if (!data?.accessToken || !data?.refreshToken) { if (!data?.accessToken || !data?.refreshToken) {
throw new Error('Invalid token data'); throw new Error('Invalid token data');
} }
accessToken.value = data.accessToken; accessToken.value = data.accessToken;
refreshToken.value = data.refreshToken; refreshToken.value = data.refreshToken;
// Update claims cache when we get new tokens
const claims = getClaimsFromToken(data.accessToken); const claims = getClaimsFromToken(data.accessToken);
tokenClaims.value = claims; tokenClaims.value = claims;
console.log('Tokens updated, user ID:', claims?.sub);
} }
function cleanTokens() { function cleanTokens() {
console.log('cleanTokens called - clearing stored tokens');
accessToken.value = undefined; accessToken.value = undefined;
refreshToken.value = undefined; refreshToken.value = undefined;
tokenClaims.value = null; tokenClaims.value = null;
// Clear any other auth-related data if needed
} }
async function logout(redirectTo = '/landing') { async function logout(redirectTo = '/landing') {
console.log('logout called, redirecting to:', redirectTo);
try { try {
// Optionally call logout endpoint if you have one // Optionally call logout endpoint if you have one
// await clientApi.post('api/users/logout'); // await clientApi.post('api/users/logout');
@@ -85,14 +58,13 @@ export const useAuthStore = defineStore(
} }
async function login(email, password) { async function login(email, password) {
console.log('login called with email:', email);
if (!email || !password) { if (!email || !password) {
throw new Error('Email and password are required'); throw new Error('Email and password are required');
} }
try { try {
const response = await clientApi.post( const response = await clientApi.post('api/users/login', {
'api/users/login',
{
email: email.trim(), email: email.trim(),
password: password password: password
}); });
@@ -102,6 +74,7 @@ export const useAuthStore = defineStore(
} }
updateTokens(response.data); updateTokens(response.data);
console.log('login successful');
return true; return true;
} catch (error) { } catch (error) {
console.error('Login failed:', error); console.error('Login failed:', error);
@@ -110,16 +83,15 @@ export const useAuthStore = defineStore(
} }
} }
async function loginWithGoogle(accessToken) { async function loginWithGoogle(accessTokenParam) {
if (!accessToken) { console.log('loginWithGoogle called');
if (!accessTokenParam) {
throw new Error('Google access token is required'); throw new Error('Google access token is required');
} }
try { try {
const response = await clientApi.post( const response = await clientApi.post('api/users/login-with-google', {
'api/users/login-with-google', token: accessTokenParam
{
token: accessToken
}); });
if (!response.data?.accessToken || !response.data?.refreshToken) { if (!response.data?.accessToken || !response.data?.refreshToken) {
@@ -127,6 +99,7 @@ export const useAuthStore = defineStore(
} }
updateTokens(response.data); updateTokens(response.data);
console.log('Google login successful');
return true; return true;
} catch (error) { } catch (error) {
console.error('Google login failed:', error); console.error('Google login failed:', error);
@@ -136,14 +109,13 @@ export const useAuthStore = defineStore(
} }
async function loginWithFacebook(authResponse) { async function loginWithFacebook(authResponse) {
console.log('loginWithFacebook called');
if (!authResponse?.accessToken) { if (!authResponse?.accessToken) {
throw new Error('Facebook access token is required'); throw new Error('Facebook access token is required');
} }
try { try {
const response = await clientApi.post( const response = await clientApi.post('api/users/login-with-facebook', {
'api/users/login-with-facebook',
{
token: authResponse.accessToken token: authResponse.accessToken
}); });
@@ -152,6 +124,7 @@ export const useAuthStore = defineStore(
} }
updateTokens(response.data); updateTokens(response.data);
console.log('Facebook login successful');
return true; return true;
} catch (error) { } catch (error) {
console.error('Facebook login failed:', error); console.error('Facebook login failed:', error);
@@ -161,23 +134,25 @@ export const useAuthStore = defineStore(
} }
async function refresh() { async function refresh() {
console.log('refresh called');
if (!refreshToken.value) { if (!refreshToken.value) {
cleanTokens(); // Clear tokens first
throw new Error('No refresh token available'); throw new Error('No refresh token available');
} }
// If we're already refreshing, return the existing promise
if (isRefreshing.value && refreshPromise) { if (isRefreshing.value && refreshPromise) {
console.log('Already refreshing, returning existing refreshPromise');
return refreshPromise; return refreshPromise;
} }
// Create a new refresh promise
refreshPromise = (async () => {
try { try {
isRefreshing.value = true; isRefreshing.value = true;
refreshPromise = (async () => {
try {
console.log('Sending refresh request...');
const response = await clientApi.post( const response = await clientApi.post('api/users/refresh', {
'api/users/refresh',
{
refreshToken: refreshToken.value refreshToken: refreshToken.value
}); });
@@ -190,44 +165,85 @@ export const useAuthStore = defineStore(
refreshToken: response.data.refreshToken refreshToken: response.data.refreshToken
}); });
isRefreshing.value = false; console.log('Token refresh successful');
refreshPromise = null;
return true; return true;
} catch (error) { } catch (error) {
console.error('Token refresh failed:', error); console.error('Token refresh failed:', error);
isRefreshing.value = false;
refreshPromise = null;
// Only clear tokens and session storage after a failed refresh attempt
cleanTokens(); cleanTokens();
// Get the current route to use as returnUrl
const currentRoute = router.currentRoute.value; const currentRoute = router.currentRoute.value;
const returnUrl = currentRoute.fullPath; const returnUrl = currentRoute.fullPath;
// Force a redirect to the login page with returnUrl // Handle navigation
await router.push({ router.push({
name: 'login', 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 to check if token needs refresh function getClaimsFromToken(token) {
async function ensureValidToken() { if (!token) return null;
if (isTokenExpiringSoon(accessToken.value)) { try {
// Start the refresh process without waiting for it to complete return jwtDecode(token);
refresh().catch(error => { } catch (error) {
console.error('Error during token refresh:', 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;
}
const claims = getClaimsFromToken(token);
if (!claims || !claims.exp) {
console.log('No valid claims found, considered expiring soon');
return true;
}
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 { return {
accessToken, accessToken,
refreshToken, refreshToken,
@@ -239,6 +255,6 @@ export const useAuthStore = defineStore(
loginWithFacebook, loginWithFacebook,
logout, logout,
refresh, refresh,
ensureValidToken isTokenExpiringSoon
} };
}) });