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

@@ -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
}
})
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
};
});