From 8487645224614b2bfa9e094b2215d421d7a5a165 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Wed, 25 Mar 2026 23:22:18 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20claude=20auth=20expiry=20warning=20?= =?UTF-8?q?=E2=80=94=20correct=20field=20name=20and=20smarter=20threshold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 'undefined' in auth banner: read access_token_hours_remaining (not hours_remaining) - Fix false-positive warning on fresh tokens: when refresh token present, only warn within 1 hour of expiry (not 24h) since the CLI should auto-rotate but sometimes misses - Emit claude_auth_expired SSE event on 401 so UI shows inline red banner immediately - app.js: handle claude_auth_expired SSE event with persistent top banner + dismiss button Co-Authored-By: Claude Sonnet 4.6 --- cortex/llm_client.py | 4 ++++ cortex/routers/auth.py | 15 +++++++++------ cortex/static/app.js | 16 +++++++++++++++- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/cortex/llm_client.py b/cortex/llm_client.py index 6f0cc4d..dfd39a6 100644 --- a/cortex/llm_client.py +++ b/cortex/llm_client.py @@ -4,6 +4,7 @@ import os import signal import subprocess from config import settings +import event_bus logger = logging.getLogger(__name__) @@ -48,7 +49,10 @@ async def complete( response = await _dispatch(primary, system_prompt, messages, model) return response, primary except Exception as e: + err_str = str(e) logger.warning("%s failed (%s) — falling back to %s", primary, e, fallback) + if primary == "claude" and any(k in err_str for k in ("401", "authenticate", "expired", "OAuth")): + await event_bus.publish({"type": "claude_auth_expired"}) response = await _dispatch(fallback, system_prompt, messages, None) return response, fallback diff --git a/cortex/routers/auth.py b/cortex/routers/auth.py index b234453..b8f2e11 100644 --- a/cortex/routers/auth.py +++ b/cortex/routers/auth.py @@ -20,7 +20,8 @@ router = APIRouter(prefix="/auth") CLAUDE_CREDS = Path.home() / ".claude" / ".credentials.json" GEMINI_CREDS = Path.home() / ".gemini" / "oauth_creds.json" GEMINI_ACCTS = Path.home() / ".gemini" / "google_accounts.json" -WARN_HOURS = 24 +WARN_HOURS = 24 # no refresh token — warn a day ahead +WARN_HOURS_REFRESH = 1 # refresh token present — only warn if CLI hasn't rotated in time def _claude_status() -> dict: @@ -31,11 +32,13 @@ def _claude_status() -> dict: expires_dt = datetime.fromtimestamp(oauth["expiresAt"] / 1000, tz=timezone.utc) now = datetime.now(tz=timezone.utc) hours_remaining = (expires_dt - now).total_seconds() / 3600 - # If a refresh token is present the session is long-lived (~1 year). - # expiresAt only reflects the current access token window (~8 h) and - # rotates automatically — do not warn based on it when a refresh token exists. - warning = not has_refresh and hours_remaining < WARN_HOURS - expired = hours_remaining <= 0 and not has_refresh + # When a refresh token is present the CLI *should* auto-rotate the access + # token, but sometimes it doesn't. Use a tight 1-hour window so a fresh + # 8-hour token doesn't immediately trigger a warning, but a stale token + # that the CLI missed will still surface before it expires. + expired = hours_remaining <= 0 + threshold = WARN_HOURS_REFRESH if has_refresh else WARN_HOURS + warning = expired or hours_remaining < threshold return { "ok": True, "has_refresh_token": has_refresh, diff --git a/cortex/static/app.js b/cortex/static/app.js index 2fadc7f..08b67d1 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -975,6 +975,20 @@ let data; try { data = JSON.parse(e.data); } catch { return; } if (data.type === 'keepalive') return; + + if (data.type === 'claude_auth_expired') { + let banner = document.getElementById('claude-auth-banner'); + if (!banner) { + banner = document.createElement('div'); + banner.id = 'claude-auth-banner'; + banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#7c2d12;color:#fef2f2;padding:0.6rem 1rem;font-size:0.85rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;'; + banner.innerHTML = '⚠️ Claude authentication expired — run claude in your terminal to re-authenticate, then reload.' + + ''; + document.body.prepend(banner); + } + return; + } + if (data.type !== 'nct_message' && data.type !== 'nct_response') return; if (sessionId === data.session_id) { @@ -1188,7 +1202,7 @@ warnings.push('✕ Claude CLI token has expired'); anyExpired = true; } else { - warnings.push(`⚠ Claude CLI token expires in ${d.claude.hours_remaining}h`); + warnings.push(`⚠ Claude CLI token expires in ${d.claude.access_token_hours_remaining}h`); } fixes.push('claude'); }