diff --git a/cortex/routers/auth.py b/cortex/routers/auth.py
index 8a06b19..9f51380 100644
--- a/cortex/routers/auth.py
+++ b/cortex/routers/auth.py
@@ -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 logging
@@ -12,17 +17,17 @@ from fastapi import APIRouter
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth")
-CREDENTIALS_PATH = Path.home() / ".claude" / ".credentials.json"
-WARN_HOURS = 24 # show warning banner when fewer than this many hours remain
+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
-@router.get("/status")
-async def auth_status() -> dict:
+def _claude_status() -> dict:
try:
- data = json.loads(CREDENTIALS_PATH.read_text())
+ data = json.loads(CLAUDE_CREDS.read_text())
oauth = data["claudeAiOauth"]
- expires_at_ms = oauth["expiresAt"]
- expires_dt = datetime.fromtimestamp(expires_at_ms / 1000, tz=timezone.utc)
+ expires_dt = datetime.fromtimestamp(oauth["expiresAt"] / 1000, tz=timezone.utc)
now = datetime.now(tz=timezone.utc)
hours_remaining = (expires_dt - now).total_seconds() / 3600
return {
@@ -33,5 +38,32 @@ async def auth_status() -> dict:
"expired": hours_remaining <= 0,
}
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}
+
+
+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(),
+ }
diff --git a/cortex/static/app.js b/cortex/static/app.js
index 14d17eb..1fa49af 100644
--- a/cortex/static/app.js
+++ b/cortex/static/app.js
@@ -967,6 +967,7 @@
// ── Auth token warning banner ─────────────────────────────
const authBanner = document.getElementById('auth-banner');
const authBannerMsg = document.getElementById('auth-banner-msg');
+ const authBannerHint = document.getElementById('auth-banner-hint');
const authBannerClose = document.getElementById('auth-banner-close');
async function checkAuthStatus() {
@@ -974,13 +975,36 @@
const res = await fetch('/auth/status');
if (!res.ok) return;
const d = await res.json();
- if (!d.warning) return;
- const msg = d.expired
- ? '✕ Claude CLI token has expired'
- : `⚠ Claude CLI token expires in ${d.hours_remaining}h`;
- authBannerMsg.textContent = msg;
- authBanner.classList.toggle('expired', !!d.expired);
+ const warnings = [];
+ const fixes = [];
+ let anyExpired = false;
+
+ 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('claude');
+ }
+
+ if (d.gemini?.warning) {
+ warnings.push('⚠ Gemini CLI not authenticated');
+ fixes.push('gemini');
+ }
+
+ if (!warnings.length) {
+ authBanner.classList.remove('show');
+ return;
+ }
+
+ authBannerMsg.innerHTML = warnings.join('
');
+ 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');
} catch { /* silently ignore — don't break the UI */ }
}
diff --git a/cortex/static/index.html b/cortex/static/index.html
index 1e47385..8a9a8a6 100644
--- a/cortex/static/index.html
+++ b/cortex/static/index.html
@@ -113,7 +113,7 @@