feat: Claude CLI OAuth token expiry warning

New GET /auth/status endpoint reads ~/.claude/.credentials.json and
returns hours remaining + warning flag. UI shows a dismissible amber
banner when < 24h remain, turning red if expired. Checked on page load
and every 30 minutes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-17 23:06:28 -04:00
parent 7b51e7cc44
commit 48a6734ec3
6 changed files with 108 additions and 2 deletions

View File

@@ -8,7 +8,7 @@ import uvicorn
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
from config import settings
from routers import chat, google_chat, nextcloud_talk, files, distill
from routers import chat, google_chat, nextcloud_talk, files, distill, auth
@asynccontextmanager
@@ -28,6 +28,7 @@ app.include_router(google_chat.router)
app.include_router(nextcloud_talk.router)
app.include_router(files.router)
app.include_router(distill.router)
app.include_router(auth.router)
app.mount("/static", StaticFiles(directory="static"), name="static")

37
cortex/routers/auth.py Normal file
View File

@@ -0,0 +1,37 @@
"""
Claude CLI OAuth token status.
GET /auth/status — returns expiry info; warns when < WARN_HOURS remain
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
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
@router.get("/status")
async def auth_status() -> dict:
try:
data = json.loads(CREDENTIALS_PATH.read_text())
oauth = data["claudeAiOauth"]
expires_at_ms = oauth["expiresAt"]
expires_dt = datetime.fromtimestamp(expires_at_ms / 1000, tz=timezone.utc)
now = datetime.now(tz=timezone.utc)
hours_remaining = (expires_dt - now).total_seconds() / 3600
return {
"ok": True,
"expires_at": expires_dt.isoformat(),
"hours_remaining": round(hours_remaining, 1),
"warning": hours_remaining < WARN_HOURS,
"expired": hours_remaining <= 0,
}
except Exception as e:
logger.warning("auth status check failed: %s", e)
return {"ok": False, "error": str(e), "warning": True, "expired": False}

View File

@@ -905,3 +905,30 @@
helpModal.addEventListener('click', (e) => {
if (e.target === helpModal) helpModal.classList.remove('open');
});
// ── Auth token warning banner ─────────────────────────────
const authBanner = document.getElementById('auth-banner');
const authBannerMsg = document.getElementById('auth-banner-msg');
const authBannerClose = document.getElementById('auth-banner-close');
async function checkAuthStatus() {
try {
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 — run `claude` in a terminal to re-authenticate'
: `⚠ Claude CLI token expires in ${d.hours_remaining}h — run \`claude\` in a terminal to re-authenticate`;
authBannerMsg.textContent = msg;
authBanner.classList.toggle('expired', !!d.expired);
authBanner.classList.add('show');
} catch { /* silently ignore — don't break the UI */ }
}
authBannerClose.addEventListener('click', () => authBanner.classList.remove('show'));
checkAuthStatus();
// Re-check every 30 minutes
setInterval(checkAuthStatus, 30 * 60 * 1000);

View File

@@ -108,6 +108,12 @@
</div>
</div>
<!-- Auth warning banner — shown when Claude CLI token is near expiry -->
<div id="auth-banner">
<span id="auth-banner-msg"></span>
<button id="auth-banner-close" title="Dismiss"></button>
</div>
<div id="messages"></div>
<div id="session-id"></div>

View File

@@ -898,6 +898,41 @@
font-weight: 600;
}
/* ── Auth warning banner ─────────────────────────────────── */
#auth-banner {
display: none;
align-items: center;
gap: 10px;
padding: 8px 20px;
background: rgba(160, 100, 0, 0.18);
border-bottom: 1px solid rgba(200, 140, 20, 0.45);
font-size: 0.82rem;
color: #c9a84c;
}
#auth-banner.show { display: flex; }
#auth-banner.expired {
background: rgba(120, 20, 20, 0.25);
border-color: rgba(200, 60, 60, 0.45);
color: var(--error-text);
}
#auth-banner-msg { flex: 1; }
#auth-banner-close {
background: none;
border: 1px solid currentColor;
border-radius: 4px;
color: inherit;
font-size: 0.7rem;
padding: 2px 7px;
cursor: pointer;
opacity: 0.7;
flex-shrink: 0;
}
#auth-banner-close:hover { opacity: 1; }
/* ── Mobile responsive ───────────────────────────────────── */
@media (max-width: 520px) {
header { padding: 8px 12px; gap: 8px; }

View File

@@ -206,7 +206,7 @@ Chat request body (`POST /chat`):
- **Auto memory distillation (long)** — short and mid run automatically; long-term integration is off by default (set `AUTO_DISTILL_LONG=true` in `.env` to enable)
- **Ollama local model backend** — direct Ollama API support (no CLI wrapper)
- **OAuth token auto-refresh notifications** — alert when Claude CLI token is near expiry
- **OAuth token auto-refresh notifications** — ✓ implemented: amber banner shown when Claude CLI token is within 24h of expiry (check `GET /auth/status`)
- **Multi-user support** — per-user identity/memory files; currently single-user (Scott)
---