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:
parent
7e1d728322
commit
4ae54abb26
@ -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())
|
||||
@ -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) => {
|
||||
|
||||
@ -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<T>(path: string, options: RequestOptions): Promise<T> {
|
||||
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<T>(path: string, options: RequestOptions): Promise<T> {
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
@ -47,11 +47,10 @@ async function request<T>(
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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);
|
||||
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<string | null> {
|
||||
export async function refreshSession(options?: { force?: boolean }): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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<AuthConfig> {
|
||||
|
||||
71
scripts/apply_keycloak_token_lifespans_prod.py
Normal file
71
scripts/apply_keycloak_token_lifespans_prod.py
Normal 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())
|
||||
Loading…
Reference in New Issue
Block a user