Keep sessions alive: proactive token refresh + longer access TTL
All checks were successful
Deploy GSM / deploy (push) Successful in 27s
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:
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,7 +22,7 @@ function clearTokensAndRedirect() {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
export async function refreshAccessToken() {
|
||||
const refreshToken = getStoredRefreshToken()
|
||||
if (!refreshToken) return null
|
||||
|
||||
|
||||
Reference in New Issue
Block a user