feat: add Gemini auth check to token warning banner

/auth/status now returns per-backend status: Claude warns on <24h expiry,
Gemini warns only when oauth_creds.json is missing or has no refresh_token
(access token rotates automatically so expiry_date is not a useful signal).
Banner shows warnings for both backends when needed, and the hint text
names the specific CLI commands to run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-17 23:29:25 -04:00
parent 1127610752
commit fe6561bf6a
3 changed files with 73 additions and 17 deletions

View File

@@ -1,7 +1,12 @@
""" """
Claude CLI OAuth token status. CLI auth status for both Claude and Gemini backends.
GET /auth/status — returns expiry info; warns when < WARN_HOURS remain GET /auth/status — returns per-backend auth info and warning flags
Claude: warns when OAuth token is < WARN_HOURS from expiry (requires
user to re-run `claude` to refresh via browser flow).
Gemini: warns only when oauth_creds.json is missing or has no
refresh_token (access token rotates automatically every ~1h).
""" """
import json import json
import logging import logging
@@ -12,17 +17,17 @@ from fastapi import APIRouter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth") router = APIRouter(prefix="/auth")
CREDENTIALS_PATH = Path.home() / ".claude" / ".credentials.json" CLAUDE_CREDS = Path.home() / ".claude" / ".credentials.json"
WARN_HOURS = 24 # show warning banner when fewer than this many hours remain GEMINI_CREDS = Path.home() / ".gemini" / "oauth_creds.json"
GEMINI_ACCTS = Path.home() / ".gemini" / "google_accounts.json"
WARN_HOURS = 24
@router.get("/status") def _claude_status() -> dict:
async def auth_status() -> dict:
try: try:
data = json.loads(CREDENTIALS_PATH.read_text()) data = json.loads(CLAUDE_CREDS.read_text())
oauth = data["claudeAiOauth"] oauth = data["claudeAiOauth"]
expires_at_ms = oauth["expiresAt"] expires_dt = datetime.fromtimestamp(oauth["expiresAt"] / 1000, tz=timezone.utc)
expires_dt = datetime.fromtimestamp(expires_at_ms / 1000, tz=timezone.utc)
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
hours_remaining = (expires_dt - now).total_seconds() / 3600 hours_remaining = (expires_dt - now).total_seconds() / 3600
return { return {
@@ -33,5 +38,32 @@ async def auth_status() -> dict:
"expired": hours_remaining <= 0, "expired": hours_remaining <= 0,
} }
except Exception as e: except Exception as e:
logger.warning("auth status check failed: %s", e) logger.warning("claude auth check failed: %s", e)
return {"ok": False, "error": str(e), "warning": True, "expired": False} return {"ok": False, "error": str(e), "warning": True, "expired": False}
def _gemini_status() -> dict:
try:
creds = json.loads(GEMINI_CREDS.read_text())
if not creds.get("refresh_token"):
return {"ok": True, "authenticated": False, "warning": True, "account": None}
account = None
try:
accts = json.loads(GEMINI_ACCTS.read_text())
account = accts.get("active")
except Exception:
pass
return {"ok": True, "authenticated": True, "warning": False, "account": account}
except FileNotFoundError:
return {"ok": True, "authenticated": False, "warning": True, "account": None}
except Exception as e:
logger.warning("gemini auth check failed: %s", e)
return {"ok": False, "error": str(e), "warning": True, "authenticated": False}
@router.get("/status")
async def auth_status() -> dict:
return {
"claude": _claude_status(),
"gemini": _gemini_status(),
}

View File

@@ -967,6 +967,7 @@
// ── Auth token warning banner ───────────────────────────── // ── Auth token warning banner ─────────────────────────────
const authBanner = document.getElementById('auth-banner'); const authBanner = document.getElementById('auth-banner');
const authBannerMsg = document.getElementById('auth-banner-msg'); const authBannerMsg = document.getElementById('auth-banner-msg');
const authBannerHint = document.getElementById('auth-banner-hint');
const authBannerClose = document.getElementById('auth-banner-close'); const authBannerClose = document.getElementById('auth-banner-close');
async function checkAuthStatus() { async function checkAuthStatus() {
@@ -974,13 +975,36 @@
const res = await fetch('/auth/status'); const res = await fetch('/auth/status');
if (!res.ok) return; if (!res.ok) return;
const d = await res.json(); const d = await res.json();
if (!d.warning) return;
const msg = d.expired const warnings = [];
? '✕ Claude CLI token has expired' const fixes = [];
: `⚠ Claude CLI token expires in ${d.hours_remaining}h`; let anyExpired = false;
authBannerMsg.textContent = msg;
authBanner.classList.toggle('expired', !!d.expired); if (d.claude?.warning) {
if (d.claude.expired) {
warnings.push('✕ Claude CLI token has expired');
anyExpired = true;
} else {
warnings.push(`⚠ Claude CLI token expires in ${d.claude.hours_remaining}h`);
}
fixes.push('<code>claude</code>');
}
if (d.gemini?.warning) {
warnings.push('⚠ Gemini CLI not authenticated');
fixes.push('<code>gemini</code>');
}
if (!warnings.length) {
authBanner.classList.remove('show');
return;
}
authBannerMsg.innerHTML = warnings.join('<br>');
authBannerHint.innerHTML =
`To fix: SSH into the Cortex host and run ${fixes.join(' and/or ')}`
+ 'follow the login prompt, then restart Cortex.';
authBanner.classList.toggle('expired', anyExpired);
authBanner.classList.add('show'); authBanner.classList.add('show');
} catch { /* silently ignore — don't break the UI */ } } catch { /* silently ignore — don't break the UI */ }
} }

View File

@@ -113,7 +113,7 @@
<div id="auth-banner"> <div id="auth-banner">
<div id="auth-banner-text"> <div id="auth-banner-text">
<span id="auth-banner-msg"></span> <span id="auth-banner-msg"></span>
<span id="auth-banner-hint">To fix: SSH into the Cortex host and run <code>claude</code> — follow the login prompt, then restart Cortex.</span> <span id="auth-banner-hint"></span>
</div> </div>
<button id="auth-banner-close" title="Dismiss"></button> <button id="auth-banner-close" title="Dismiss"></button>
</div> </div>