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:
commit
12e0aa4fd4
@ -3,8 +3,20 @@
|
||||
import { 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 AuthInitializer({ children }: { children: React.ReactNode }) {
|
||||
const { refreshAuthToken, setAuthReady } = useAuthStore()
|
||||
const { refreshAuthToken, setAuthReady, accessToken } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
@ -22,5 +34,39 @@ export default function AuthInitializer({ children }: { children: React.ReactNod
|
||||
initializeAuth()
|
||||
}, [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}</>
|
||||
}
|
||||
@ -203,6 +203,14 @@ const useAuthStore = create<AuthStore>((set, get) => ({
|
||||
log("✅ Zustand: Refresh succeeded, setting in-memory token and user");
|
||||
get().setAccessToken(body.accessToken);
|
||||
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;
|
||||
} else {
|
||||
log("❌ Zustand: Refresh failed (no accessToken or non-ok). Clearing auth state");
|
||||
|
||||
128
src/app/test-refresh/page.tsx
Normal file
128
src/app/test-refresh/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -46,17 +46,18 @@ interface CustomRequestInit extends RequestInit {
|
||||
|
||||
// Main authFetch function
|
||||
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;
|
||||
|
||||
log("🌐 authFetch: Making API call to:", url);
|
||||
log("🔑 authFetch: Using token:", accessToken ? `${accessToken.substring(0, 20)}...` : "No token");
|
||||
|
||||
// Add Authorization header if accessToken exists
|
||||
const headers: Record<string, string> = {
|
||||
const buildHeaders = (token: string | null): Record<string, string> => ({
|
||||
...(init.headers || {}),
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
};
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
});
|
||||
|
||||
// Always send credentials so refresh cookie is included when server-side refresh is needed
|
||||
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;
|
||||
try {
|
||||
res = await fetchWithAuth(headers);
|
||||
res = await fetchWithAuth(buildHeaders(accessToken));
|
||||
} catch (err) {
|
||||
log("❌ authFetch: Network/error calling API:", err);
|
||||
throw err;
|
||||
@ -82,17 +83,12 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
|
||||
log("🔄 authFetch: store.refreshAuthToken() result:", refreshOk);
|
||||
|
||||
if (refreshOk) {
|
||||
// get new token from memory (store already set it)
|
||||
// get FRESH token from memory (store already set it)
|
||||
const newToken = getAccessToken();
|
||||
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)");
|
||||
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);
|
||||
} else {
|
||||
log("❌ authFetch: Refresh failed. Calling logout to revoke server cookie and clear client state");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user