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(() => {
|
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) => {
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
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