diff --git a/app/auth/security.py b/app/auth/security.py index 11cd303..cdc49f2 100644 --- a/app/auth/security.py +++ b/app/auth/security.py @@ -38,7 +38,10 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) - else: expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes) - to_encode.update({"exp": expire}) + to_encode.update({ + "exp": expire, + "iat": datetime.utcnow(), + }) encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) return encoded_jwt @@ -46,7 +49,12 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) - def verify_token(token: str) -> Optional[str]: """Verify JWT token and return username""" try: - payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + payload = jwt.decode( + token, + settings.secret_key, + algorithms=[settings.algorithm], + leeway=30 # allow small clock skew + ) username: str = payload.get("sub") if username is None: return None diff --git a/static/js/main.js b/static/js/main.js index c1cfffd..ef69b3e 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -6,7 +6,9 @@ const app = { token: localStorage.getItem('auth_token'), user: null, - initialized: false + initialized: false, + refreshTimerId: null, + refreshInProgress: false }; // Initialize application @@ -20,7 +22,7 @@ async function initializeApp() { window.keyboardShortcuts.initialize(); } - // Remove Bootstrap-dependent tooltips/popovers; use native title/tooltips if needed + // Tooltips/popovers handled via native browser features as needed // Add form validation classes initializeFormValidation(); @@ -34,7 +36,7 @@ async function initializeApp() { // Form validation function initializeFormValidation() { - // Native validation handling without Bootstrap classes + // Native validation handling const forms = document.querySelectorAll('form'); forms.forEach(form => { form.addEventListener('submit', function(event) { @@ -72,20 +74,50 @@ function setupAPIHelpers() { if (app.token) { window.apiHeaders['Authorization'] = `Bearer ${app.token}`; } + + // Start proactive refresh scheduling when a token is present + if (app.token) { + scheduleTokenRefresh(); + } } // API utility functions async function apiCall(url, options = {}) { + // Proactively refresh token if close to expiry + if (app.token) { + const msLeft = getMsUntilExpiry(app.token); + if (msLeft !== null && msLeft < 5 * 60 * 1000) { // < 5 minutes + try { + await refreshToken(); + } catch (e) { + logout(); + throw new Error('Authentication required'); + } + } + } + const config = { headers: { ...window.apiHeaders, ...options.headers }, ...options }; try { - const response = await fetch(url, config); + let response = await fetch(url, config); + if (response.status === 401 && app.token) { + // Attempt one refresh then retry once + try { + await refreshToken(); + const retryConfig = { + headers: { ...window.apiHeaders, ...options.headers }, + ...options + }; + response = await fetch(url, retryConfig); + } catch (_) { + // fall through to logout below + } + } if (response.status === 401) { - // Token expired or invalid logout(); throw new Error('Authentication required'); } @@ -130,9 +162,17 @@ function setAuthToken(token) { app.token = token; localStorage.setItem('auth_token', token); window.apiHeaders['Authorization'] = `Bearer ${token}`; + + // Reschedule refresh on token update + scheduleTokenRefresh(); } function logout() { + if (app.refreshTimerId) { + clearTimeout(app.refreshTimerId); + app.refreshTimerId = null; + } + app.refreshInProgress = false; app.token = null; app.user = null; localStorage.removeItem('auth_token'); @@ -363,4 +403,68 @@ window.apiPost = apiPost; window.apiPut = apiPut; window.apiDelete = apiDelete; window.formatCurrency = formatCurrency; -window.formatDate = formatDate; \ No newline at end of file +window.formatDate = formatDate; + +// JWT utilities and refresh handling +function decodeJwt(token) { + try { + const payload = token.split('.')[1]; + const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); + return JSON.parse(json); + } catch (e) { + return null; + } +} + +function getMsUntilExpiry(token) { + const decoded = decodeJwt(token); + if (!decoded || !decoded.exp) return null; + const expiryMs = decoded.exp * 1000; + return expiryMs - Date.now(); +} + +function scheduleTokenRefresh() { + if (!app.token) return; + + // Clear existing timer + if (app.refreshTimerId) { + clearTimeout(app.refreshTimerId); + app.refreshTimerId = null; + } + + const msLeft = getMsUntilExpiry(app.token); + if (msLeft === null) return; + + // Refresh 5 minutes before expiry, but not sooner than 30 seconds from now + const leadTimeMs = 5 * 60 * 1000; + const refreshInMs = Math.max(msLeft - leadTimeMs, 30 * 1000); + + app.refreshTimerId = setTimeout(() => { + // Fire and forget; apiCall path also guards per-request + refreshToken().catch(() => { + logout(); + }); + }, refreshInMs); +} + +async function refreshToken() { + if (!app.token) throw new Error('No token to refresh'); + if (app.refreshInProgress) return; // Avoid parallel refreshes + app.refreshInProgress = true; + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { ...window.apiHeaders } + }); + if (!response.ok) { + throw new Error('Refresh failed'); + } + const data = await response.json(); + if (!data || !data.access_token) { + throw new Error('Invalid refresh response'); + } + setAuthToken(data.access_token); + } finally { + app.refreshInProgress = false; + } +} \ No newline at end of file