Merge pull request 'feat: implement automatic token refresh and add token expiry logging' (#5) from sz/token-refresh into dev

Reviewed-on: #5
This commit is contained in:
DK404 2025-10-22 17:42:13 +00:00
commit 12e0aa4fd4
4 changed files with 191 additions and 13 deletions

View File

@ -3,8 +3,20 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import useAuthStore from '../store/authStore' import useAuthStore from '../store/authStore'
// Helper to decode JWT and get expiry
function getTokenExpiry(token: string | null): Date | null {
if (!token) return null;
try {
const [, payload] = token.split(".");
const { exp } = JSON.parse(atob(payload));
return exp ? new Date(exp * 1000) : null;
} catch {
return null;
}
}
export default function AuthInitializer({ children }: { children: React.ReactNode }) { export default function AuthInitializer({ children }: { children: React.ReactNode }) {
const { refreshAuthToken, setAuthReady } = useAuthStore() const { refreshAuthToken, setAuthReady, accessToken } = useAuthStore()
useEffect(() => { useEffect(() => {
const initializeAuth = async () => { const initializeAuth = async () => {
@ -22,5 +34,39 @@ export default function AuthInitializer({ children }: { children: React.ReactNod
initializeAuth() initializeAuth()
}, [refreshAuthToken, setAuthReady]) }, [refreshAuthToken, setAuthReady])
// Automatic token refresh - check every minute
useEffect(() => {
const interval = setInterval(async () => {
const currentToken = useAuthStore.getState().accessToken;
if (currentToken) {
const expiry = getTokenExpiry(currentToken);
if (expiry) {
const timeUntilExpiry = expiry.getTime() - Date.now();
const threeMinutes = 3 * 60 * 1000; // 3 minutes in milliseconds
// If token expires within 3 minutes, refresh it
if (timeUntilExpiry <= threeMinutes && timeUntilExpiry > 0) {
console.log('🔄 Token expires soon, auto-refreshing...', {
expiresIn: Math.round(timeUntilExpiry / 1000),
expiresAt: expiry.toLocaleTimeString()
});
try {
const success = await refreshAuthToken();
if (success) {
console.log('✅ Token auto-refresh successful');
} else {
console.log('❌ Token auto-refresh failed');
}
} catch (error) {
console.log('❌ Token auto-refresh error:', error);
}
}
}
}
}, 60000); // Check every minute
return () => clearInterval(interval);
}, [refreshAuthToken])
return <>{children}</> return <>{children}</>
} }

View File

@ -203,6 +203,14 @@ const useAuthStore = create<AuthStore>((set, get) => ({
log("✅ Zustand: Refresh succeeded, setting in-memory token and user"); log("✅ Zustand: Refresh succeeded, setting in-memory token and user");
get().setAccessToken(body.accessToken); get().setAccessToken(body.accessToken);
if (body.user) get().setUser(body.user); if (body.user) get().setUser(body.user);
// Log token expiry for debugging
const newExpiry = getTokenExpiry(body.accessToken);
if (newExpiry) {
log("⏰ Zustand: New token expires at:", newExpiry.toLocaleString());
log("⏰ Zustand: Time until expiry:", Math.round((newExpiry.getTime() - Date.now()) / 60000), "minutes");
}
return true; return true;
} else { } else {
log("❌ Zustand: Refresh failed (no accessToken or non-ok). Clearing auth state"); log("❌ Zustand: Refresh failed (no accessToken or non-ok). Clearing auth state");

View File

@ -0,0 +1,128 @@
'use client'
import { useState, useEffect } from 'react'
import useAuthStore from '../store/authStore'
// Helper to decode JWT and get expiry
function getTokenExpiry(token: string | null): Date | null {
if (!token) return null;
try {
const [, payload] = token.split(".");
const { exp } = JSON.parse(atob(payload));
return exp ? new Date(exp * 1000) : null;
} catch {
return null;
}
}
export default function TestRefreshPage() {
const { accessToken, refreshAuthToken, user } = useAuthStore()
const [tokenInfo, setTokenInfo] = useState<any>({})
const [refreshStatus, setRefreshStatus] = useState<string>('')
// Update token info every second
useEffect(() => {
const interval = setInterval(() => {
if (accessToken) {
const expiry = getTokenExpiry(accessToken)
const now = Date.now()
const timeLeft = expiry ? expiry.getTime() - now : 0
setTokenInfo({
hasToken: !!accessToken,
tokenPrefix: accessToken ? `${accessToken.substring(0, 20)}...` : null,
expiresAt: expiry ? expiry.toLocaleTimeString() : 'Unknown',
timeLeftMs: timeLeft,
timeLeftMin: Math.round(timeLeft / 60000),
timeLeftSec: Math.round(timeLeft / 1000),
isExpired: timeLeft <= 0
})
} else {
setTokenInfo({ hasToken: false })
}
}, 1000)
return () => clearInterval(interval)
}, [accessToken])
const handleManualRefresh = async () => {
setRefreshStatus('Refreshing...')
try {
const success = await refreshAuthToken()
setRefreshStatus(success ? '✅ Success' : '❌ Failed')
} catch (error) {
setRefreshStatus(`❌ Error: ${error}`)
}
setTimeout(() => setRefreshStatus(''), 3000)
}
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8">🧪 Token Refresh Test</h1>
<div className="grid gap-6">
{/* Token Status */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">🔑 Token Status</h2>
<div className="space-y-2 font-mono text-sm">
<div>Has Token: <span className={tokenInfo.hasToken ? 'text-green-600' : 'text-red-600'}>
{tokenInfo.hasToken ? '✅ Yes' : '❌ No'}
</span></div>
{tokenInfo.hasToken && (
<>
<div>Token Preview: <span className="text-blue-600">{tokenInfo.tokenPrefix}</span></div>
<div>Expires At: <span className="text-purple-600">{tokenInfo.expiresAt}</span></div>
<div>Time Left: <span className={tokenInfo.timeLeftSec <= 180 ? 'text-red-600' : 'text-green-600'}>
{tokenInfo.timeLeftMin}m {tokenInfo.timeLeftSec % 60}s
</span></div>
<div>Status: <span className={tokenInfo.isExpired ? 'text-red-600' : 'text-green-600'}>
{tokenInfo.isExpired ? '💀 Expired' : '✅ Valid'}
</span></div>
</>
)}
</div>
</div>
{/* User Info */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">👤 User Info</h2>
<pre className="text-sm bg-gray-100 p-4 rounded overflow-auto">
{JSON.stringify(user, null, 2)}
</pre>
</div>
{/* Manual Controls */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">🔧 Manual Controls</h2>
<div className="space-y-4">
<button
onClick={handleManualRefresh}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
🔄 Manual Refresh Token
</button>
{refreshStatus && (
<div className="text-sm font-mono">{refreshStatus}</div>
)}
</div>
</div>
{/* Instructions */}
<div className="bg-yellow-50 border border-yellow-200 p-6 rounded-lg">
<h2 className="text-xl font-semibold mb-4">📋 Testing Instructions</h2>
<ol className="list-decimal list-inside space-y-2 text-sm">
<li>Make sure JWT_EXPIRES_IN=2m in backend .env for fast testing</li>
<li>Login and watch the countdown timer</li>
<li>When time left 3 minutes, auto-refresh should trigger</li>
<li>Check browser console for detailed logs</li>
<li>Check Network tab for /api/refresh requests</li>
<li>Token should automatically renew without user action</li>
</ol>
</div>
</div>
</div>
</div>
)
}

View File

@ -46,17 +46,18 @@ interface CustomRequestInit extends RequestInit {
// Main authFetch function // Main authFetch function
export async function authFetch(input: RequestInfo | URL, init: CustomRequestInit = {}): Promise<Response> { export async function authFetch(input: RequestInfo | URL, init: CustomRequestInit = {}): Promise<Response> {
const accessToken = getAccessToken(); // Always get the fresh token from store at call time
let accessToken = getAccessToken();
const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url;
log("🌐 authFetch: Making API call to:", url); log("🌐 authFetch: Making API call to:", url);
log("🔑 authFetch: Using token:", accessToken ? `${accessToken.substring(0, 20)}...` : "No token"); log("🔑 authFetch: Using token:", accessToken ? `${accessToken.substring(0, 20)}...` : "No token");
// Add Authorization header if accessToken exists // Add Authorization header if accessToken exists
const headers: Record<string, string> = { const buildHeaders = (token: string | null): Record<string, string> => ({
...(init.headers || {}), ...(init.headers || {}),
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}; });
// Always send credentials so refresh cookie is included when server-side refresh is needed // Always send credentials so refresh cookie is included when server-side refresh is needed
const fetchWithAuth = async (hdrs: Record<string, string>): Promise<Response> => { const fetchWithAuth = async (hdrs: Record<string, string>): Promise<Response> => {
@ -66,7 +67,7 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
let res: Response; let res: Response;
try { try {
res = await fetchWithAuth(headers); res = await fetchWithAuth(buildHeaders(accessToken));
} catch (err) { } catch (err) {
log("❌ authFetch: Network/error calling API:", err); log("❌ authFetch: Network/error calling API:", err);
throw err; throw err;
@ -82,17 +83,12 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
log("🔄 authFetch: store.refreshAuthToken() result:", refreshOk); log("🔄 authFetch: store.refreshAuthToken() result:", refreshOk);
if (refreshOk) { if (refreshOk) {
// get new token from memory (store already set it) // get FRESH token from memory (store already set it)
const newToken = getAccessToken(); const newToken = getAccessToken();
log("🔁 authFetch: Retrieved new token from store after refresh:", newToken ? `${newToken.substring(0,20)}...` : null); log("🔁 authFetch: Retrieved new token from store after refresh:", newToken ? `${newToken.substring(0,20)}...` : null);
const retryHeaders = {
...headers,
...(newToken ? { Authorization: `Bearer ${newToken}` } : {}),
};
log("🔄 authFetch: Retrying original request with refreshed token (if available)"); log("🔄 authFetch: Retrying original request with refreshed token (if available)");
res = await fetch(url, { ...init, headers: retryHeaders, credentials: "include" }); res = await fetch(url, { ...init, headers: buildHeaders(newToken), credentials: "include" });
log("📡 authFetch: Retry response status:", res.status); log("📡 authFetch: Retry response status:", res.status);
} else { } else {
log("❌ authFetch: Refresh failed. Calling logout to revoke server cookie and clear client state"); log("❌ authFetch: Refresh failed. Calling logout to revoke server cookie and clear client state");