Auth: add proactive JWT refresh in frontend and clock-skew leeway in backend; improve 401 handling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user