Auth: add proactive JWT refresh in frontend and clock-skew leeway in backend; improve 401 handling

This commit is contained in:
HotSwapp
2025-08-10 19:49:07 -05:00
parent 350af60db3
commit 14ee479edc
2 changed files with 120 additions and 8 deletions

View File

@@ -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

View File

@@ -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');
@@ -364,3 +404,67 @@ window.apiPut = apiPut;
window.apiDelete = apiDelete;
window.formatCurrency = formatCurrency;
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;
}
}