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 <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-24 16:29:36 +03:30
parent 7e1d728322
commit 4ae54abb26
6 changed files with 233 additions and 29 deletions

View File

@ -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())

View File

@ -71,11 +71,23 @@ export function useAuth() {
useEffect(() => { useEffect(() => {
load(); load();
// Keep session alive while tab is open. // Keep session alive while tab is open (proactive refresh).
const id = window.setInterval(() => { const id = window.setInterval(() => {
void getValidAccessToken(); void getValidAccessToken();
}, 60_000); }, 45_000);
return () => window.clearInterval(id); 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]); }, [load]);
const login = async (redirectPath?: string) => { const login = async (redirectPath?: string) => {

View File

@ -4,7 +4,7 @@
* Always sends Authorization + X-Tenant-ID. * Always sends Authorization + X-Tenant-ID.
*/ */
import { getStoredToken, getValidAccessToken, refreshSession } from "@/lib/auth"; import { getValidAccessToken, refreshSession } from "@/lib/auth";
const ACCOUNTING_BASE = const ACCOUNTING_BASE =
typeof window !== "undefined" typeof window !== "undefined"
@ -32,7 +32,8 @@ async function request<T>(path: string, options: RequestOptions): Promise<T> {
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
headers.set("X-Tenant-ID", tenantId); headers.set("X-Tenant-ID", tenantId);
if (auth) { 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}`); if (token) headers.set("Authorization", `Bearer ${token}`);
} }
@ -51,7 +52,7 @@ async function request<T>(path: string, options: RequestOptions): Promise<T> {
} }
if (res.status === 401 && auth) { if (res.status === 401 && auth) {
const refreshed = await refreshSession(); const refreshed = await refreshSession({ force: true });
if (refreshed) { if (refreshed) {
headers.set("Authorization", `Bearer ${refreshed}`); headers.set("Authorization", `Bearer ${refreshed}`);
res = await fetch(url, { ...init, headers }); res = await fetch(url, { ...init, headers });

View File

@ -47,11 +47,10 @@ async function request<T>(
} }
if (res.status === 401 && auth) { if (res.status === 401 && auth) {
const { refreshSession, getStoredToken } = await import("@/lib/auth"); const { refreshSession } = await import("@/lib/auth");
const refreshed = await refreshSession(); const refreshed = await refreshSession({ force: true });
if (refreshed || getStoredToken()) { if (refreshed) {
const token = refreshed || getStoredToken(); headers.set("Authorization", `Bearer ${refreshed}`);
if (token) headers.set("Authorization", `Bearer ${token}`);
res = await fetch(`${API_BASE}${path}`, { ...init, headers }); res = await fetch(`${API_BASE}${path}`, { ...init, headers });
} }
} }

View File

@ -3,7 +3,7 @@
* توکنها روی دامنهٔ پایه (.torbatyar.ir) در cookie ذخیره میشوند * توکنها روی دامنهٔ پایه (.torbatyar.ir) در cookie ذخیره میشوند
* تا بین apex و سابدامین tenant مشترک باشند؛ روی localhost از sessionStorage. * تا بین apex و سابدامین tenant مشترک باشند؛ روی localhost از sessionStorage.
* *
* Access token کوتاهعمر است؛ refresh_token نشست را تمدید میکند. * Access token توسط Keycloak صادر میشود؛ refresh_token نشست را تا سقف SSO تمدید میکند.
*/ */
const IDENTITY_API = const IDENTITY_API =
@ -13,10 +13,11 @@ const TOKEN_KEY = "superapp_access_token";
const REFRESH_KEY = "superapp_refresh_token"; const REFRESH_KEY = "superapp_refresh_token";
const EXPIRES_AT_KEY = "superapp_access_expires_at"; const EXPIRES_AT_KEY = "superapp_access_expires_at";
const POST_LOGIN_REDIRECT_KEY = "superapp_post_login_redirect"; const POST_LOGIN_REDIRECT_KEY = "superapp_post_login_redirect";
/** Cookie lifetime for access token storage (refresh keeps JWT valid). */ /** Cookie lifetime for access token storage (value is replaced on each refresh). */
const ACCESS_COOKIE_MAX_AGE = 60 * 60 * 12; // 12h 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_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 { export interface AuthConfig {
issuer: string; issuer: string;
@ -110,8 +111,9 @@ export function getStoredRefreshToken(): string | null {
} }
export function storeTokens(tokens: TokenBundle): void { export function storeTokens(tokens: TokenBundle): void {
const jwtExp = readJwtExpMs(tokens.access_token);
const expiresAt = 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(TOKEN_KEY, tokens.access_token);
sessionStorage.setItem(EXPIRES_AT_KEY, String(expiresAt)); sessionStorage.setItem(EXPIRES_AT_KEY, String(expiresAt));
// Cookie must outlive JWT so we can refresh after access token expires. // Cookie must outlive JWT so we can refresh after access token expires.
@ -134,27 +136,38 @@ export function clearTokens(): void {
let refreshInFlight: Promise<string | null> | null = null; let refreshInFlight: Promise<string | null> | 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); const raw = getCookie(EXPIRES_AT_KEY) || sessionStorage.getItem(EXPIRES_AT_KEY);
if (raw) { if (raw) {
const n = Number(raw); const n = Number(raw);
if (!Number.isNaN(n)) return n; if (!Number.isNaN(n)) return n;
} }
const token = getStoredToken(); return null;
return token ? readJwtExpMs(token) : 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 { function needsRefresh(token: string | null): boolean {
if (!token) return true; return isExpired(token, REFRESH_SKEW_MS);
const exp = accessExpiresAt() ?? readJwtExpMs(token);
if (!exp) return false;
return Date.now() >= exp - REFRESH_SKEW_MS;
} }
/** Refresh access token via Identity BFF. Returns new access token or null. */ /** Refresh access token via Identity BFF. Returns new access token or null. */
export async function refreshSession(): Promise<string | null> { export async function refreshSession(options?: { force?: boolean }): Promise<string | null> {
const refresh = getStoredRefreshToken(); const refresh = getStoredRefreshToken();
if (!refresh) return null; if (!refresh) return null;
if (!options?.force) {
const current = getStoredToken();
if (current && !needsRefresh(current)) return current;
}
if (refreshInFlight) return refreshInFlight; if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => { refreshInFlight = (async () => {
@ -164,7 +177,13 @@ export async function refreshSession(): Promise<string | null> {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }), 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; const bundle = (await res.json()) as TokenBundle;
storeTokens(bundle); storeTokens(bundle);
return bundle.access_token; return bundle.access_token;
@ -180,14 +199,20 @@ export async function refreshSession(): Promise<string | null> {
/** /**
* Return a usable access token, refreshing when near/after expiry. * 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<string | null> { export async function getValidAccessToken(): Promise<string | null> {
const token = getStoredToken(); const token = getStoredToken();
if (token && !needsRefresh(token)) return token; if (token && !needsRefresh(token)) return token;
if (!getStoredRefreshToken()) return token;
const refreshed = await refreshSession(); if (getStoredRefreshToken()) {
return refreshed || getStoredToken(); 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<AuthConfig> { export async function fetchAuthConfig(): Promise<AuthConfig> {

View File

@ -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())