diff --git a/src/components/examples/TokenRefreshExample.tsx b/src/components/examples/TokenRefreshExample.tsx new file mode 100644 index 0000000..de6cdca --- /dev/null +++ b/src/components/examples/TokenRefreshExample.tsx @@ -0,0 +1,143 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useTokenRefresh } from '@/hooks/useTokenRefresh'; +import { useAppSelector } from '@/store'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { RefreshCw, AlertCircle, CheckCircle } from 'lucide-react'; + +export function TokenRefreshExample() { + const { refreshToken, refreshTokenIfNeeded, isRefreshing, refreshError, isTokenExpired } = useTokenRefresh(); + const { token, isAuthenticated } = useAppSelector((state) => state.auth); + const [lastRefreshTime, setLastRefreshTime] = useState(null); + + useEffect(() => { + if (token) { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + setLastRefreshTime(new Date(payload.iat * 1000)); + } catch (error) { + console.error('Error parsing token:', error); + } + } + }, [token]); + + const handleManualRefresh = async () => { + const success = await refreshToken(); + if (success) { + setLastRefreshTime(new Date()); + } + }; + + const handleAutoRefresh = async () => { + const success = await refreshTokenIfNeeded(); + if (success) { + setLastRefreshTime(new Date()); + } + }; + + const getTokenStatus = () => { + if (!token) return { status: 'No Token', color: 'destructive' }; + if (isTokenExpired()) return { status: 'Expired', color: 'destructive' }; + return { status: 'Valid', color: 'default' }; + }; + + const tokenStatus = getTokenStatus(); + + return ( + + + + + JWT Token Auto-Refresh + + + Demonstrates automatic JWT token refresh functionality + + + + {/* Authentication Status */} +
+ Authentication: + + {isAuthenticated ? 'Authenticated' : 'Not Authenticated'} + +
+ + {/* Token Status */} +
+ Token Status: + + {tokenStatus.status} + +
+ + {/* Refresh Status */} + {isRefreshing && ( +
+ + Refreshing token... +
+ )} + + {/* Error Display */} + {refreshError && ( +
+ + {refreshError} +
+ )} + + {/* Success Message */} + {lastRefreshTime && !refreshError && !isRefreshing && ( +
+ + Last refreshed: {lastRefreshTime.toLocaleTimeString()} +
+ )} + + {/* Token Info */} + {token && ( +
+
+ Token Preview: +
+ {token.substring(0, 50)}... +
+
+
+ )} + + {/* Action Buttons */} +
+ + + +
+ + {/* Instructions */} +
+

Manual Refresh: Forces a token refresh

+

Auto Refresh: Only refreshes if token is expired

+

• The API service automatically refreshes tokens on 401 errors

+
+
+
+ ); +} diff --git a/src/hooks/useTokenRefresh.ts b/src/hooks/useTokenRefresh.ts new file mode 100644 index 0000000..7ad6a55 --- /dev/null +++ b/src/hooks/useTokenRefresh.ts @@ -0,0 +1,49 @@ +import { useDispatch } from 'react-redux'; +import { useCallback } from 'react'; +import { refreshToken } from '@/store/slices/authSlice'; +import { useAppSelector } from '@/store'; +import type { AppDispatch } from '@/store'; + +export function useTokenRefresh() { + const dispatch = useDispatch(); + const { isLoading, error, token } = useAppSelector((state) => state.auth); + + const refreshAccessToken = useCallback(async () => { + try { + const result = await dispatch(refreshToken()).unwrap(); + return result; + } catch (error) { + console.error('Token refresh failed:', error); + return false; + } + }, [dispatch]); + + const isTokenExpired = useCallback(() => { + if (!token) return true; + + try { + // Decode JWT token to check expiration + const payload = JSON.parse(atob(token.split('.')[1])); + const currentTime = Date.now() / 1000; + return payload.exp < currentTime; + } catch (error) { + console.error('Error decoding token:', error); + return true; + } + }, [token]); + + const refreshTokenIfNeeded = useCallback(async () => { + if (isTokenExpired()) { + return await refreshAccessToken(); + } + return true; + }, [isTokenExpired, refreshAccessToken]); + + return { + refreshToken: refreshAccessToken, + refreshTokenIfNeeded, + isRefreshing: isLoading, + refreshError: error, + isTokenExpired, + }; +} diff --git a/src/services/api.ts b/src/services/api.ts index 8bc72b7..f38e9c3 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -16,6 +16,7 @@ import { Conversation } from '@/types'; import agentService from './agentService'; +import { tokenRefreshService } from './tokenRefreshService'; class ApiService { private api: AxiosInstance; @@ -43,18 +44,48 @@ class ApiService { } ); - // Response interceptor for error handling + // Response interceptor for automatic token refresh this.api.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401) { + async (error) => { + const originalRequest = error.config; + + // If error is not 401 or original request already tried refresh, reject + if (error.response?.status !== 401 || originalRequest._retry) { + // Handle 401 by clearing token and redirecting to login + if (error.response?.status === 401) { + this.clearToken(); + if (typeof window !== 'undefined') { + window.location.href = '/auth/login'; + } + } + return Promise.reject(error); + } + + // Mark that we're retrying + originalRequest._retry = true; + + try { + // Attempt to refresh the token + const response = await tokenRefreshService.refreshToken(this.api.defaults.baseURL as string); + const newToken = response.token; + + // Update the token in the service and original request + this.setToken(newToken); + originalRequest.headers.Authorization = `Bearer ${newToken}`; + + // Retry the original request + return this.api(originalRequest); + } catch (refreshError) { + // Refresh failed, clear token and redirect to login this.clearToken(); - // Redirect to login or dispatch logout action + if (typeof window !== 'undefined') { window.location.href = '/auth/login'; } + + return Promise.reject(refreshError); } - return Promise.reject(error); } ); } @@ -169,6 +200,25 @@ class ApiService { } } + async refreshToken(): Promise<{ token: string }> { + // Use direct axios call to avoid interceptor recursion + const response = await axios.post<{ token: string }>( + `${this.api.defaults.baseURL}/auth/refresh`, + {}, + { + headers: { + 'Content-Type': 'application/json', + // Don't send Authorization header for refresh to avoid circular dependency + }, + } + ); + + if (response.data?.token) { + this.setToken(response.data.token); + } + return response.data; + } + // Protected endpoints async getProfile(): Promise> { const response = await this.api.get>('/auth/profile'); diff --git a/src/services/tokenRefreshService.ts b/src/services/tokenRefreshService.ts new file mode 100644 index 0000000..5d765bb --- /dev/null +++ b/src/services/tokenRefreshService.ts @@ -0,0 +1,65 @@ +import axios from 'axios'; + +export class TokenRefreshService { + private static instance: TokenRefreshService; + private isRefreshing = false; + private failedQueue: Array<{ + resolve: (value?: any) => void; + reject: (reason?: any) => void; + }> = []; + + static getInstance(): TokenRefreshService { + if (!TokenRefreshService.instance) { + TokenRefreshService.instance = new TokenRefreshService(); + } + return TokenRefreshService.instance; + } + + private processQueue = (error: any, token: string | null = null) => { + this.failedQueue.forEach(({ resolve, reject }) => { + if (error) { + reject(error); + } else { + resolve(token); + } + }); + + this.failedQueue = []; + }; + + async refreshToken(apiBaseUrl: string): Promise<{ token: string }> { + if (this.isRefreshing) { + return new Promise((resolve, reject) => { + this.failedQueue.push({ resolve, reject }); + }); + } + + this.isRefreshing = true; + + try { + const response = await axios.post<{ token: string }>( + `${apiBaseUrl}/auth/refresh`, + {}, + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + this.processQueue(null, response.data.token); + return response.data; + } catch (error) { + this.processQueue(error, null); + throw error; + } finally { + this.isRefreshing = false; + } + } + + isTokenRefreshing(): boolean { + return this.isRefreshing; + } +} + +export const tokenRefreshService = TokenRefreshService.getInstance(); diff --git a/src/store/slices/authSlice.ts b/src/store/slices/authSlice.ts index c5a327a..3002b3e 100644 --- a/src/store/slices/authSlice.ts +++ b/src/store/slices/authSlice.ts @@ -152,6 +152,19 @@ export const googleAuth = createAsyncThunk( } ); +export const refreshToken = createAsyncThunk( + 'auth/refreshToken', + async (_, { rejectWithValue }) => { + try { + // Call the API service refresh token method + const response = await apiService.refreshToken(); + return { token: response.token }; + } catch (error: any) { + return rejectWithValue(error.message || 'Token refresh failed'); + } + } +); + const authSlice = createSlice({ name: 'auth', initialState, @@ -306,10 +319,36 @@ const authSlice = createSlice({ state.isLoading = false; state.error = action.payload as string; state.isAuthenticated = false; + }) + // Refresh Token + .addCase(refreshToken.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase(refreshToken.fulfilled, (state, action) => { + state.isLoading = false; + state.token = action.payload.token; + state.isAuthenticated = true; + state.error = null; + // Update localStorage + if (typeof window !== 'undefined') { + localStorage.setItem('auth_token', action.payload.token); + } + }) + .addCase(refreshToken.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + state.isAuthenticated = false; + state.token = null; + // Clear localStorage on refresh failure + if (typeof window !== 'undefined') { + localStorage.removeItem('auth_token'); + localStorage.removeItem('user_data'); + } }); }, }); export const { clearError, setToken, clearAuth, initializeAuth } = authSlice.actions; -export { login, register, logout, loadUser, updateProfile, changePassword, googleAuth }; +export { login, register, logout, loadUser, updateProfile, changePassword, googleAuth, refreshToken }; export default authSlice.reducer;