Skip to content

Commit 769db96

Browse files
committed
feat: session timeout optimization - 24h absolute + 30min idle + 401 auto-logout
- Backend: change SESSION_TTL from 30 days to 24 hours (env overrideable via WEBSOFT9_SESSION_TTL_HOURS) - Backend: sync cookie max_age to SESSION_TTL_HOURS - Frontend: add useIdleTimeout hook (30min inactivity auto-logout) - Frontend: add global 401 interception with auto-redirect to /auth/login - Frontend: integrate idle timeout into app-shell
1 parent d9d175d commit 769db96

8 files changed

Lines changed: 708 additions & 177 deletions

File tree

.github/ISSUE_TEMPLATE/release-testing-checklist.md

Lines changed: 441 additions & 117 deletions
Large diffs are not rendered by default.

apphub/src/api/v1/routers/auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
ProductAuthUpdateUserRequest,
1717
ProductAuthUsersResponse,
1818
)
19-
from src.services.product_auth import PRODUCT_AUTH_COOKIE_NAME, ProductAuthService
19+
from src.services.product_auth import PRODUCT_AUTH_COOKIE_NAME, SESSION_TTL_HOURS, ProductAuthService
2020

2121
router = APIRouter()
2222

@@ -28,7 +28,7 @@ def _set_session_cookie(request: Request, response: Response, session_token: str
2828
httponly=True,
2929
samesite="lax",
3030
path="/",
31-
max_age=60 * 60 * 24 * 30,
31+
max_age=60 * 60 * SESSION_TTL_HOURS,
3232
secure=(request.headers.get("x-forwarded-proto") or request.url.scheme) == "https",
3333
)
3434

apphub/src/services/product_auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def _resolve_product_auth_cookie_name() -> str:
3838

3939

4040
PRODUCT_AUTH_COOKIE_NAME = _resolve_product_auth_cookie_name()
41-
SESSION_TTL_DAYS = 30
41+
SESSION_TTL_HOURS = int(os.getenv("WEBSOFT9_SESSION_TTL_HOURS", "24"))
4242
PASSWORD_HASH_ITERATIONS = 310_000
4343
DOCKER_BOOTSTRAP_ACTOR = "docker-bootstrap"
4444
PENDING_MIGRATED_FAVORITES_KEY = "migrated_favorite_apps_pending"
@@ -832,7 +832,7 @@ def _create_session(self, operator_id: str) -> str:
832832
"token_hash": self._hash_session_token(session_token),
833833
"created_at": self._iso(now),
834834
"last_seen_at": self._iso(now),
835-
"expires_at": self._iso(now + timedelta(days=SESSION_TTL_DAYS)),
835+
"expires_at": self._iso(now + timedelta(hours=SESSION_TTL_HOURS)),
836836
"invalidated_at": None,
837837
}
838838
)

console/src/app/shell/app-shell.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next'
1717
import { useAppColorMode } from '../providers/color-mode'
1818
import { useProductAuth } from '../../features/product-auth/product-auth-provider'
1919
import { normalizeSupportedLocale } from '../../shared/i18n/i18n'
20+
import { useIdleTimeout } from '../../shared/hooks/useIdleTimeout'
2021
import { PersistentIntegrationWorkspaces } from '../../features/integrations/integration-workspace-page'
2122
import { useIntegrationSessionPrewarm } from '../../features/integrations/integration-session-bootstrap'
2223
import { getRememberedMyAppsDetailRoute, rememberMyAppsDetailRoute } from '../../features/my-apps/my-app-detail-overlay-intent'
@@ -524,6 +525,21 @@ export function AppShell() {
524525
void persistCurrentUserLocale(nextNormalizedLocale)
525526
}
526527

528+
// Idle timeout: auto-logout after 30 minutes of inactivity
529+
const isAuthenticated = status?.enabled && status?.authenticated
530+
useIdleTimeout(
531+
() => {
532+
if (!isAuthenticated || isSubmitting) {
533+
return
534+
}
535+
536+
void logout().then(() => {
537+
navigate('/auth/login', { replace: true })
538+
})
539+
},
540+
{ enabled: isAuthenticated },
541+
)
542+
527543
return (
528544
<Box className={`app-shell-root app-shell-root--${colorMode} ${isNavCollapsed ? 'app-shell-root--collapsed' : ''} ${mobileNavOpen ? 'app-shell-root--mobile-nav-open' : ''}`}>
529545
<Box className="app-shell-frame">

console/src/features/product-auth/product-auth-provider.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
33
import { i18n, normalizeSupportedLocale } from '../../shared/i18n/i18n'
44

55
const PRODUCT_AUTH_STATUS_STORAGE_KEY = 'websoft9:product-auth-status'
6+
const PRODUCT_AUTH_UNAUTHORIZED_EVENT = 'websoft9:product-auth-unauthorized'
67

78
type ProductAuthUser = {
89
id: string
@@ -102,6 +103,10 @@ async function requestJson<T>(input: string, init?: RequestInit): Promise<T> {
102103
...init,
103104
})
104105

106+
if (response.status === 401) {
107+
window.dispatchEvent(new CustomEvent(PRODUCT_AUTH_UNAUTHORIZED_EVENT))
108+
}
109+
105110
const payload = (await response.json().catch(() => null)) as { details?: string; message?: string } | T | null
106111
if (!response.ok) {
107112
const errorMessage =
@@ -175,6 +180,34 @@ export function ProductAuthProvider({ children }: { children: ReactNode }) {
175180
void i18n.changeLanguage(normalizedLocale)
176181
}, [status?.current_user?.locale])
177182

183+
// Global 401 interception: auto-logout and redirect to login
184+
useEffect(() => {
185+
function handleUnauthorized() {
186+
// Avoid redirect loops — don't redirect if already on an auth page
187+
if (isAuthRoute(window.location.pathname)) {
188+
return
189+
}
190+
191+
// Clear local auth state
192+
setStatus(null)
193+
if (typeof window !== 'undefined') {
194+
try {
195+
window.sessionStorage.removeItem(PRODUCT_AUTH_STATUS_STORAGE_KEY)
196+
} catch {
197+
// ignore
198+
}
199+
}
200+
201+
// Redirect to login
202+
window.location.href = '/auth/login'
203+
}
204+
205+
window.addEventListener(PRODUCT_AUTH_UNAUTHORIZED_EVENT, handleUnauthorized)
206+
return () => {
207+
window.removeEventListener(PRODUCT_AUTH_UNAUTHORIZED_EVENT, handleUnauthorized)
208+
}
209+
}, [])
210+
178211
const initialize = useCallback(async (payload: { username: string; password: string; email: string; locale: string }) => {
179212
setIsSubmitting(true)
180213
try {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useEffect, useRef } from 'react'
2+
3+
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
4+
5+
/**
6+
* Calls `onIdle` after the user has been idle (no mouse, keyboard, touch, or scroll events)
7+
* for the specified duration. The timer resets on any user interaction.
8+
*/
9+
export function useIdleTimeout(
10+
onIdle: () => void,
11+
{ timeoutMs = DEFAULT_IDLE_TIMEOUT_MS, enabled = true }: { timeoutMs?: number; enabled?: boolean } = {},
12+
) {
13+
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
14+
const onIdleRef = useRef(onIdle)
15+
16+
// Keep the callback ref up to date without restarting the timer
17+
onIdleRef.current = onIdle
18+
19+
useEffect(() => {
20+
if (!enabled) {
21+
return
22+
}
23+
24+
function resetTimer() {
25+
if (idleTimerRef.current) {
26+
clearTimeout(idleTimerRef.current)
27+
}
28+
29+
idleTimerRef.current = setTimeout(() => {
30+
onIdleRef.current()
31+
}, timeoutMs)
32+
}
33+
34+
const events: Array<keyof WindowEventMap> = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll']
35+
36+
for (const event of events) {
37+
window.addEventListener(event, resetTimer, { passive: true })
38+
}
39+
40+
// Start the initial timer
41+
resetTimer()
42+
43+
return () => {
44+
if (idleTimerRef.current) {
45+
clearTimeout(idleTimerRef.current)
46+
}
47+
48+
for (const event of events) {
49+
window.removeEventListener(event, resetTimer)
50+
}
51+
}
52+
}, [timeoutMs, enabled])
53+
}

0 commit comments

Comments
 (0)