Keep sessions alive: proactive token refresh + longer access TTL
All checks were successful
Deploy GSM / deploy (push) Successful in 27s

The 15m access token only refreshed on a 401, but the dashboard uses optionalAuth and returns 200 even with an expired token, so the live page never noticed expiry until a manual reload.

Backend: ACCESS_TOKEN_TTL 15m -> 1h. Frontend: export refreshAccessToken; App.jsx schedules a refresh ~1min before the token expires (and on tab focus/return), and logs out cleanly (redirect to /login) if the refresh token is gone — no reload needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 19:28:50 +02:00
parent c842513116
commit 2f6fc841b2
3 changed files with 49 additions and 4 deletions

View File

@@ -7,8 +7,9 @@ import { createRefreshToken, verifyRefreshToken, revokeRefreshToken, revokeRefre
const router = Router();
// Access token lifetime (short-lived; client refreshes automatically)
const ACCESS_TOKEN_TTL = '15m';
// Access token lifetime. The client refreshes proactively before this elapses
// (and on any 401), so the session stays alive transparently via the refresh token.
const ACCESS_TOKEN_TTL = '1h';
// Initialize Discord + refresh token tables
initDiscordUsers();

View File

@@ -5,7 +5,7 @@ import Dashboard from './pages/Dashboard'
import ServerDetail from './pages/ServerDetail'
import LoginPage from './pages/LoginPage'
import AuthCallback from './pages/AuthCallback'
import { storeTokens, logout as apiLogout } from './api'
import { storeTokens, logout as apiLogout, refreshAccessToken } from './api'
function ProtectedServerDetail({ onLogout }) {
const { isGuest, loading } = useUser()
@@ -36,6 +36,50 @@ export default function App() {
}
}, [])
// Proactively refresh the access token shortly before it expires so the session
// stays alive while the tab is open — no page reload needed to notice expiry.
useEffect(() => {
if (!token) return
const getExpiryMs = (jwt) => {
try {
const payload = JSON.parse(atob(jwt.split('.')[1]))
return payload?.exp ? payload.exp * 1000 : 0
} catch {
return 0
}
}
const doRefresh = async () => {
const newToken = await refreshAccessToken()
if (!newToken) {
// Refresh token gone/expired -> end the session cleanly (redirects to /login)
apiLogout()
setToken(null)
}
// On success, api.js fires 'gsm_token_refreshed' -> setToken -> this effect
// re-runs and reschedules from the new token's expiry.
}
const REFRESH_BUFFER_MS = 60 * 1000
const expMs = getExpiryMs(token)
const delay = expMs ? Math.max(0, expMs - Date.now() - REFRESH_BUFFER_MS) : 5 * 60 * 1000
const timer = setTimeout(doRefresh, delay)
// Background tabs throttle timers; refresh on return if the token is (near) expired.
const onVisible = () => {
if (document.visibilityState !== 'visible') return
const exp = getExpiryMs(token)
if (!exp || exp - Date.now() < REFRESH_BUFFER_MS) doRefresh()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearTimeout(timer)
document.removeEventListener('visibilitychange', onVisible)
}
}, [token])
const handleLogin = (newToken, refreshToken) => {
storeTokens(newToken, refreshToken)
setToken(newToken)

View File

@@ -22,7 +22,7 @@ function clearTokensAndRedirect() {
window.location.href = '/login'
}
async function refreshAccessToken() {
export async function refreshAccessToken() {
const refreshToken = getStoredRefreshToken()
if (!refreshToken) return null