From 4ae54abb2686175cf2a047a075c8593ba13bf156 Mon Sep 17 00:00:00 2001 From: Mortezakoohjani Date: Fri, 24 Jul 2026 16:29:36 +0330 Subject: [PATCH] Keep accounting sessions alive with Keycloak lifespan and forced token refresh. Raise realm access/SSO timeouts and stop sending expired JWTs when refresh fails. Co-authored-by: Cursor --- .../scripts/patch_keycloak_token_lifespans.py | 96 +++++++++++++++++++ frontend/hooks/useAuth.ts | 18 +++- frontend/lib/accounting-api.ts | 7 +- frontend/lib/api.ts | 9 +- frontend/lib/auth.ts | 61 ++++++++---- .../apply_keycloak_token_lifespans_prod.py | 71 ++++++++++++++ 6 files changed, 233 insertions(+), 29 deletions(-) create mode 100644 backend/services/identity-access/scripts/patch_keycloak_token_lifespans.py create mode 100644 scripts/apply_keycloak_token_lifespans_prod.py diff --git a/backend/services/identity-access/scripts/patch_keycloak_token_lifespans.py b/backend/services/identity-access/scripts/patch_keycloak_token_lifespans.py new file mode 100644 index 0000000..9694021 --- /dev/null +++ b/backend/services/identity-access/scripts/patch_keycloak_token_lifespans.py @@ -0,0 +1,96 @@ +"""Inspect + patch Keycloak realm token lifespans for longer sessions.""" +from __future__ import annotations + +import json +import os +import sys + +import httpx + +KEYCLOAK_URL = os.getenv("KEYCLOAK_SERVER_URL", "http://127.0.0.1:8080") +REALM = os.getenv("KEYCLOAK_REALM", "superapp") +ADMIN_USER = os.getenv("KEYCLOAK_ADMIN", "admin") +ADMIN_PASS = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin") + +# Access token: 12 hours (still refreshed proactively by FE) +ACCESS_TOKEN_LIFESPAN = int(os.getenv("KC_ACCESS_TOKEN_LIFESPAN", str(12 * 3600))) +# SSO idle: 7 days — refresh keeps working while user is inactive within this +SSO_IDLE = int(os.getenv("KC_SSO_IDLE", str(7 * 24 * 3600))) +# SSO max: 14 days absolute session ceiling +SSO_MAX = int(os.getenv("KC_SSO_MAX", str(14 * 24 * 3600))) +# Client session idle/max (refresh token bound) +CLIENT_SESSION_IDLE = SSO_IDLE +CLIENT_SESSION_MAX = SSO_MAX + + +def admin_token(client: httpx.Client) -> str: + r = client.post( + f"{KEYCLOAK_URL}/realms/master/protocol/openid-connect/token", + data={ + "grant_type": "password", + "client_id": "admin-cli", + "username": ADMIN_USER, + "password": ADMIN_PASS, + }, + ) + r.raise_for_status() + return r.json()["access_token"] + + +def main() -> int: + apply = "--apply" in sys.argv + with httpx.Client(timeout=20.0) as client: + token = admin_token(client) + headers = {"Authorization": f"Bearer {token}"} + realm_url = f"{KEYCLOAK_URL}/admin/realms/{REALM}" + r = client.get(realm_url, headers=headers) + r.raise_for_status() + realm = r.json() + before = { + "accessTokenLifespan": realm.get("accessTokenLifespan"), + "ssoSessionIdleTimeout": realm.get("ssoSessionIdleTimeout"), + "ssoSessionMaxLifespan": realm.get("ssoSessionMaxLifespan"), + "clientSessionIdleTimeout": realm.get("clientSessionIdleTimeout"), + "clientSessionMaxLifespan": realm.get("clientSessionMaxLifespan"), + "offlineSessionIdleTimeout": realm.get("offlineSessionIdleTimeout"), + } + print("BEFORE:", json.dumps(before, indent=2)) + + if not apply: + print("Dry-run only. Pass --apply to update.") + return 0 + + realm["accessTokenLifespan"] = ACCESS_TOKEN_LIFESPAN + realm["ssoSessionIdleTimeout"] = SSO_IDLE + realm["ssoSessionMaxLifespan"] = SSO_MAX + realm["clientSessionIdleTimeout"] = CLIENT_SESSION_IDLE + realm["clientSessionMaxLifespan"] = CLIENT_SESSION_MAX + # Keep offline sessions usable for long-lived refresh if issued + realm["offlineSessionIdleTimeout"] = max( + int(realm.get("offlineSessionIdleTimeout") or 0), SSO_MAX + ) + + put = client.put(realm_url, headers=headers, json=realm) + put.raise_for_status() + + after = client.get(realm_url, headers=headers).json() + print( + "AFTER:", + json.dumps( + { + "accessTokenLifespan": after.get("accessTokenLifespan"), + "ssoSessionIdleTimeout": after.get("ssoSessionIdleTimeout"), + "ssoSessionMaxLifespan": after.get("ssoSessionMaxLifespan"), + "clientSessionIdleTimeout": after.get("clientSessionIdleTimeout"), + "clientSessionMaxLifespan": after.get("clientSessionMaxLifespan"), + "offlineSessionIdleTimeout": after.get("offlineSessionIdleTimeout"), + }, + indent=2, + ), + ) + print("OK realm token lifespans updated") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts index 12dbb51..6f3d808 100644 --- a/frontend/hooks/useAuth.ts +++ b/frontend/hooks/useAuth.ts @@ -71,11 +71,23 @@ export function useAuth() { useEffect(() => { load(); - // Keep session alive while tab is open. + // Keep session alive while tab is open (proactive refresh). const id = window.setInterval(() => { void getValidAccessToken(); - }, 60_000); - return () => window.clearInterval(id); + }, 45_000); + const onFocus = () => { + void getValidAccessToken(); + }; + const onVis = () => { + if (document.visibilityState === "visible") void getValidAccessToken(); + }; + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVis); + return () => { + window.clearInterval(id); + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVis); + }; }, [load]); const login = async (redirectPath?: string) => { diff --git a/frontend/lib/accounting-api.ts b/frontend/lib/accounting-api.ts index 242d155..b9118f8 100644 --- a/frontend/lib/accounting-api.ts +++ b/frontend/lib/accounting-api.ts @@ -4,7 +4,7 @@ * Always sends Authorization + X-Tenant-ID. */ -import { getStoredToken, getValidAccessToken, refreshSession } from "@/lib/auth"; +import { getValidAccessToken, refreshSession } from "@/lib/auth"; const ACCOUNTING_BASE = typeof window !== "undefined" @@ -32,7 +32,8 @@ async function request(path: string, options: RequestOptions): Promise { headers.set("Content-Type", "application/json"); headers.set("X-Tenant-ID", tenantId); if (auth) { - const token = (await getValidAccessToken()) || getStoredToken(); + // Never fall back to a raw stored token that may already be expired. + const token = await getValidAccessToken(); if (token) headers.set("Authorization", `Bearer ${token}`); } @@ -51,7 +52,7 @@ async function request(path: string, options: RequestOptions): Promise { } if (res.status === 401 && auth) { - const refreshed = await refreshSession(); + const refreshed = await refreshSession({ force: true }); if (refreshed) { headers.set("Authorization", `Bearer ${refreshed}`); res = await fetch(url, { ...init, headers }); diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index da34caf..bb7a904 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -47,11 +47,10 @@ async function request( } if (res.status === 401 && auth) { - const { refreshSession, getStoredToken } = await import("@/lib/auth"); - const refreshed = await refreshSession(); - if (refreshed || getStoredToken()) { - const token = refreshed || getStoredToken(); - if (token) headers.set("Authorization", `Bearer ${token}`); + const { refreshSession } = await import("@/lib/auth"); + const refreshed = await refreshSession({ force: true }); + if (refreshed) { + headers.set("Authorization", `Bearer ${refreshed}`); res = await fetch(`${API_BASE}${path}`, { ...init, headers }); } } diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts index 53f02a1..6a9f1c8 100644 --- a/frontend/lib/auth.ts +++ b/frontend/lib/auth.ts @@ -3,7 +3,7 @@ * توکن‌ها روی دامنهٔ پایه (.torbatyar.ir) در cookie ذخیره می‌شوند * تا بین apex و ساب‌دامین tenant مشترک باشند؛ روی localhost از sessionStorage. * - * Access token کوتاه‌عمر است؛ refresh_token نشست را تمدید می‌کند. + * Access token توسط Keycloak صادر می‌شود؛ refresh_token نشست را تا سقف SSO تمدید می‌کند. */ const IDENTITY_API = @@ -13,10 +13,11 @@ const TOKEN_KEY = "superapp_access_token"; const REFRESH_KEY = "superapp_refresh_token"; const EXPIRES_AT_KEY = "superapp_access_expires_at"; const POST_LOGIN_REDIRECT_KEY = "superapp_post_login_redirect"; -/** Cookie lifetime for access token storage (refresh keeps JWT valid). */ -const ACCESS_COOKIE_MAX_AGE = 60 * 60 * 12; // 12h +/** Cookie lifetime for access token storage (value is replaced on each refresh). */ +const ACCESS_COOKIE_MAX_AGE = 60 * 60 * 24 * 14; // 14d storage; JWT exp still enforced const REFRESH_COOKIE_MAX_AGE = 60 * 60 * 24 * 14; // 14d -const REFRESH_SKEW_MS = 60_000; // refresh 60s before exp +/** Refresh ~2 minutes before JWT exp to avoid mid-request expiry. */ +const REFRESH_SKEW_MS = 120_000; export interface AuthConfig { issuer: string; @@ -110,8 +111,9 @@ export function getStoredRefreshToken(): string | null { } export function storeTokens(tokens: TokenBundle): void { + const jwtExp = readJwtExpMs(tokens.access_token); const expiresAt = - Date.now() + Math.max(30, tokens.expires_in || 300) * 1000; + jwtExp ?? Date.now() + Math.max(30, tokens.expires_in || 300) * 1000; sessionStorage.setItem(TOKEN_KEY, tokens.access_token); sessionStorage.setItem(EXPIRES_AT_KEY, String(expiresAt)); // Cookie must outlive JWT so we can refresh after access token expires. @@ -134,27 +136,38 @@ export function clearTokens(): void { let refreshInFlight: Promise | null = null; -function accessExpiresAt(): number | null { +/** Prefer JWT `exp` claim — source of truth for Keycloak tokens. */ +function accessExpiresAt(token?: string | null): number | null { + const t = token ?? getStoredToken(); + const fromJwt = t ? readJwtExpMs(t) : null; + if (fromJwt) return fromJwt; const raw = getCookie(EXPIRES_AT_KEY) || sessionStorage.getItem(EXPIRES_AT_KEY); if (raw) { const n = Number(raw); if (!Number.isNaN(n)) return n; } - const token = getStoredToken(); - return token ? readJwtExpMs(token) : null; + return null; +} + +function isExpired(token: string | null, skewMs = 0): boolean { + if (!token) return true; + const exp = accessExpiresAt(token); + if (!exp) return false; + return Date.now() >= exp - skewMs; } function needsRefresh(token: string | null): boolean { - if (!token) return true; - const exp = accessExpiresAt() ?? readJwtExpMs(token); - if (!exp) return false; - return Date.now() >= exp - REFRESH_SKEW_MS; + return isExpired(token, REFRESH_SKEW_MS); } /** Refresh access token via Identity BFF. Returns new access token or null. */ -export async function refreshSession(): Promise { +export async function refreshSession(options?: { force?: boolean }): Promise { const refresh = getStoredRefreshToken(); if (!refresh) return null; + if (!options?.force) { + const current = getStoredToken(); + if (current && !needsRefresh(current)) return current; + } if (refreshInFlight) return refreshInFlight; refreshInFlight = (async () => { @@ -164,7 +177,13 @@ export async function refreshSession(): Promise { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refresh }), }); - if (!res.ok) return null; + if (!res.ok) { + // Refresh token itself expired / revoked — clear so UI can re-login cleanly. + if (res.status === 401 || res.status === 400) { + clearTokens(); + } + return null; + } const bundle = (await res.json()) as TokenBundle; storeTokens(bundle); return bundle.access_token; @@ -180,14 +199,20 @@ export async function refreshSession(): Promise { /** * Return a usable access token, refreshing when near/after expiry. - * Does not clear tokens on network errors. + * Never returns a JWT that is already past exp. */ export async function getValidAccessToken(): Promise { const token = getStoredToken(); if (token && !needsRefresh(token)) return token; - if (!getStoredRefreshToken()) return token; - const refreshed = await refreshSession(); - return refreshed || getStoredToken(); + + if (getStoredRefreshToken()) { + const refreshed = await refreshSession({ force: isExpired(token) }); + if (refreshed && !isExpired(refreshed)) return refreshed; + } + + // Last resort: non-expired access without refresh token + if (token && !isExpired(token)) return token; + return null; } export async function fetchAuthConfig(): Promise { diff --git a/scripts/apply_keycloak_token_lifespans_prod.py b/scripts/apply_keycloak_token_lifespans_prod.py new file mode 100644 index 0000000..dc1a26d --- /dev/null +++ b/scripts/apply_keycloak_token_lifespans_prod.py @@ -0,0 +1,71 @@ +"""Apply Keycloak token lifespan patch on production app host.""" +from __future__ import annotations + +import base64 +import sys +from pathlib import Path + +import paramiko + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "backend" / "services" / "identity-access" / "scripts" / "patch_keycloak_token_lifespans.py" +APP_HOST = "192.168.10.162" +APP_USER = "morteza" +APP_PASS = "Moli@5404" +REMOTE = "/home/morteza/torbatyar" + + +def run(client: paramiko.SSHClient, cmd: str, timeout: int = 120) -> tuple[int, str]: + print(">>>", cmd[:200]) + _i, o, e = client.exec_command(cmd, timeout=timeout, get_pty=True) + out = o.read().decode("utf-8", "replace") + e.read().decode("utf-8", "replace") + code = o.channel.recv_exit_status() + print(out[-4000:]) + return code, out + + +def main() -> int: + text = SCRIPT.read_text(encoding="utf-8") + b64 = base64.b64encode(text.encode()).decode() + + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(APP_HOST, username=APP_USER, password=APP_PASS, timeout=30, allow_agent=False, look_for_keys=False) + try: + # Upload script into identity container path via host bind mount + run( + c, + f"mkdir -p {REMOTE}/backend/services/identity-access/scripts && " + f"echo {b64} | base64 -d > {REMOTE}/backend/services/identity-access/scripts/patch_keycloak_token_lifespans.py", + ) + # Dry-run via keycloak network from identity container (KEYCLOAK on docker network) + code, _ = run( + c, + "sg docker -c \"docker compose -f /home/morteza/torbatyar/docker-compose.yml exec -T " + "-e KEYCLOAK_SERVER_URL=http://keycloak:8080 " + "-e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin " + "identity-access-service python scripts/patch_keycloak_token_lifespans.py\"", + ) + # Apply + code, out = run( + c, + "sg docker -c \"docker compose -f /home/morteza/torbatyar/docker-compose.yml exec -T " + "-e KEYCLOAK_SERVER_URL=http://keycloak:8080 " + "-e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin " + "identity-access-service python scripts/patch_keycloak_token_lifespans.py --apply\"", + ) + if code != 0: + # Fallback: run on host against published 8080 + run( + c, + f"cd {REMOTE}/backend/services/identity-access && " + "KEYCLOAK_SERVER_URL=http://127.0.0.1:8080 KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD=admin " + "python3 scripts/patch_keycloak_token_lifespans.py --apply", + ) + return 0 if "OK realm" in out or code == 0 else 1 + finally: + c.close() + + +if __name__ == "__main__": + raise SystemExit(main())