Files
hermes-agent/hermes_cli/web_routers/oauth.py
T

767 lines
34 KiB
Python

"""OAuth provider dashboard routes: catalog/status, disconnect, and in-browser device-code login flows.
Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch on
``web_server`` stay there and are late-bound (cycle-safe).
"""
import logging
import asyncio
import sys
import time
from fastapi import APIRouter
from hermes_cli.web_deps import LateState, late
from hermes_cli.web_routers._common import scoped_to_thread
from fastapi import HTTPException, Request
from hermes_cli.web_models import OAuthSubmitBody
from typing import Any, Callable, Dict, Optional
import threading
import os
import secrets
_log = logging.getLogger("hermes_cli.web_server")
router = APIRouter()
# web_server helpers/state, late-bound so monkeypatch.setattr(web_server, ...) stays authoritative.
_external_process_cli_command = late("_external_process_cli_command")
_oauth_profile_name = late("_oauth_profile_name")
_profile_scope = late("_profile_scope")
_require_token = late("_require_token")
_resolve_profile_dir = late("_resolve_profile_dir")
_minimax_poller = late("_minimax_poller")
_nous_poller = late("_nous_poller")
_truncate_token = late("_truncate_token")
_xai_device_poller = late("_xai_device_poller")
_oauth_sessions = LateState("_oauth_sessions")
_oauth_sessions_lock = LateState("_oauth_sessions_lock")
_OAUTH_PROVIDER_CATALOG = LateState("_OAUTH_PROVIDER_CATALOG")
def _http_response_error_detail(resp: Any) -> str:
"""Best-effort extraction of a short provider error detail."""
try:
payload = resp.json()
except Exception:
payload = None
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict):
parts = [
str(error.get(key, "")).strip()
for key in ("message", "error_description", "code", "type")
if str(error.get(key, "")).strip()
]
if parts:
return ": ".join(parts)
if isinstance(error, str) and error.strip():
return error.strip()
for key in ("detail", "message", "error_description"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
text = str(getattr(resp, "text", "") or "").strip()
return text[:500]
def _codex_device_code_start_error(resp: Any) -> str:
"""Dashboard-facing OpenAI Codex device-code start failure."""
status = getattr(resp, "status_code", "unknown")
detail = _http_response_error_detail(resp)
lower = detail.lower()
if "device" in lower and ("authori" in lower or "enable" in lower):
message = (
"OpenAI rejected the device-code login request. Your OpenAI "
"account may need device-code authorization enabled before Hermes "
"can start this dashboard login. Enable device-code authorization "
"in OpenAI, then return here and click Login again."
)
else:
message = (
"OpenAI rejected the device-code login request. Please try Login "
"again from the dashboard after checking your OpenAI account settings."
)
if detail:
return f"{message} (HTTP {status}: {detail})"
return f"{message} (HTTP {status})"
def _new_oauth_session(
provider_id: str, flow: str, profile: Optional[str] = None,
) -> tuple[str, Dict[str, Any]]:
"""Create + register a new OAuth session, return (session_id, session_dict)."""
sid = secrets.token_urlsafe(16)
sess = {
"session_id": sid,
"provider": provider_id,
"flow": flow,
"profile": _oauth_profile_name(profile),
"created_at": time.time(),
"status": "pending", # pending | approved | denied | expired | error
"error_message": None,
}
with _oauth_sessions_lock:
_oauth_sessions[sid] = sess
return sid, sess
def _start_poller(target, sid: str) -> None:
threading.Thread(target=target, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}").start()
def _codex_full_login_worker(session_id: str) -> None:
"""Run the complete OpenAI Codex device-code flow.
Codex has its own ``/api/accounts/deviceauth/usercode`` (returns
``device_auth_id``) and ``/api/accounts/deviceauth/token`` (polled until
200) endpoints; success yields ``authorization_code`` + ``code_verifier``
exchanged at CODEX_OAUTH_TOKEN_URL. Replicated inline rather than calling
``_codex_device_code_login`` because that helper prints/blocks/polls in one
function — the dashboard needs the user_code before polling completes.
"""
try:
import httpx
from hermes_cli.auth import CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL
issuer = "https://auth.openai.com"
# Step 1: request device code
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
resp = client.post(
f"{issuer}/api/accounts/deviceauth/usercode",
json={"client_id": CODEX_OAUTH_CLIENT_ID},
headers={"Content-Type": "application/json"},
)
if resp.status_code != 200:
raise RuntimeError(_codex_device_code_start_error(resp))
device_data = resp.json()
user_code = device_data.get("user_code", "")
device_auth_id = device_data.get("device_auth_id", "")
poll_interval = max(3, int(device_data.get("interval", "5")))
if not user_code or not device_auth_id:
raise RuntimeError("device-code response missing user_code or device_auth_id")
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
if not sess:
return
sess["user_code"] = user_code
sess["verification_url"] = f"{issuer}/codex/device"
sess["device_auth_id"] = device_auth_id
sess["interval"] = poll_interval
sess["expires_in"] = 15 * 60 # OpenAI's effective limit
sess["expires_at"] = time.time() + sess["expires_in"]
# Captured now (not re-derived after cancel pops the session) so a
# cancelled session can never fall back to the caller's current
# profile scope at save time.
session_profile = sess.get("profile")
# Step 2: poll until authorized
deadline = time.monotonic() + sess["expires_in"]
code_resp = None
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
while time.monotonic() < deadline:
if sess.get("cancelled"):
_log.info("oauth/device: openai-codex login cancelled (session=%s)", session_id)
return
time.sleep(poll_interval)
if sess.get("cancelled"):
_log.info("oauth/device: openai-codex login cancelled (session=%s)", session_id)
return
poll = client.post(
f"{issuer}/api/accounts/deviceauth/token",
json={"device_auth_id": device_auth_id, "user_code": user_code},
headers={"Content-Type": "application/json"},
)
if poll.status_code == 200:
code_resp = poll.json()
break
if poll.status_code in {403, 404}:
continue # user hasn't authorized yet
raise RuntimeError(f"deviceauth/token poll returned {poll.status_code}")
if code_resp is None:
with _oauth_sessions_lock:
sess["status"] = "expired"
sess["error_message"] = "Device code expired before approval"
return
if sess.get("cancelled"):
_log.info("oauth/device: openai-codex login cancelled before token exchange (session=%s)", session_id)
return
# Step 3: exchange authorization_code for tokens
authorization_code = code_resp.get("authorization_code", "")
code_verifier = code_resp.get("code_verifier", "")
if not authorization_code or not code_verifier:
raise RuntimeError("device-auth response missing authorization_code/code_verifier")
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
token_resp = client.post(
CODEX_OAUTH_TOKEN_URL,
data={
"grant_type": "authorization_code", "code": authorization_code,
"redirect_uri": f"{issuer}/deviceauth/callback",
"client_id": CODEX_OAUTH_CLIENT_ID, "code_verifier": code_verifier,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if token_resp.status_code != 200:
raise RuntimeError(f"token exchange returned {token_resp.status_code}")
tokens = token_resp.json()
access_token = tokens.get("access_token", "")
refresh_token = tokens.get("refresh_token", "")
if not access_token:
raise RuntimeError("token exchange did not return access_token")
from hermes_cli.auth import _save_codex_tokens
# The cancellation check and the save are one atomic critical section
# under the lock cancel_oauth_session() uses; otherwise DELETE could
# flip "cancelled" between the check and the save and tokens would be
# persisted after the user believed the login was aborted.
with _oauth_sessions_lock:
if sess.get("cancelled"):
_log.info("oauth/device: openai-codex login cancelled before token save (session=%s)", session_id)
return
with _profile_scope(session_profile):
_save_codex_tokens({"access_token": access_token, "refresh_token": refresh_token})
sess["status"] = "approved"
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
except Exception as e:
_log.warning("codex device-code worker failed (session=%s): %s", session_id, e)
with _oauth_sessions_lock:
s = _oauth_sessions.get(session_id)
if s:
s["status"] = "error"
s["error_message"] = str(e)
# Hand-written status card shapes per provider id: (hauth getter name, shaper).
# Providers absent here fall through to the slug-driven ``get_auth_status``.
def _nous_status(raw):
# Refresh-free snapshot so listing providers never performs an OAuth refresh.
return {
"logged_in": bool(raw.get("logged_in")), "source": "nous_portal",
"source_label": raw.get("portal_base_url") or "Nous Portal",
"token_preview": _truncate_token(raw.get("access_token")),
"expires_at": raw.get("access_expires_at"),
"has_refresh_token": bool(raw.get("has_refresh_token")),
}
def _codex_status(raw):
return {
"logged_in": bool(raw.get("logged_in")), "source": raw.get("source") or "openai_codex",
"source_label": raw.get("auth_mode") or "OpenAI Codex",
"token_preview": _truncate_token(raw.get("api_key")), "expires_at": None,
"has_refresh_token": False, "last_refresh": raw.get("last_refresh"),
}
def _qwen_status(raw):
return {
"logged_in": bool(raw.get("logged_in")), "source": "qwen_cli",
"source_label": raw.get("auth_store_path") or "Qwen CLI",
"token_preview": _truncate_token(raw.get("access_token")),
"expires_at": raw.get("expires_at"),
"has_refresh_token": bool(raw.get("has_refresh_token")),
}
def _minimax_status(raw):
return {
"logged_in": bool(raw.get("logged_in")), "source": "minimax_oauth",
"source_label": f"MiniMax ({raw.get('region', 'global')})", "token_preview": None,
"expires_at": raw.get("expires_at"), "has_refresh_token": True,
}
def _xai_status(raw):
# source_label is a human-readable origin (auth-store path / credential
# source), not the internal auth_mode string ("oauth_pkce").
return {
"logged_in": bool(raw.get("logged_in")), "source": raw.get("source") or "xai_oauth",
"source_label": raw.get("auth_store") or raw.get("source") or "xAI Grok OAuth",
"token_preview": _truncate_token(raw.get("api_key")), "expires_at": None,
"has_refresh_token": True, "last_refresh": raw.get("last_refresh"),
}
_PROVIDER_STATUS: Dict[str, tuple[str, Callable[[dict], dict]]] = {
"nous": ("get_nous_auth_status_local", _nous_status),
"openai-codex": ("get_codex_auth_status", _codex_status),
"qwen-oauth": ("get_qwen_auth_status", _qwen_status),
"minimax-oauth": ("get_minimax_oauth_auth_status", _minimax_status),
"xai-oauth": ("get_xai_oauth_auth_status", _xai_status),
}
def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
"""Dispatch to the right status helper for an OAuth provider entry."""
if status_fn is not None:
try:
return status_fn()
except Exception as e:
return {"logged_in": False, "error": str(e)}
try:
from hermes_cli import auth as hauth
entry = _PROVIDER_STATUS.get(provider_id)
if entry is not None:
getter, shape = entry
return shape(getattr(hauth, getter)())
# Catalog-derived providers (status_fn=None, no hand-written card) still
# reflect real login state via the canonical slug-driven dispatcher, so
# a new OAuth/account provider plugin never renders permanently logged-out.
raw = hauth.get_auth_status(provider_id)
if isinstance(raw, dict) and "logged_in" in raw:
return {
"logged_in": bool(raw.get("logged_in")),
"source": raw.get("source") or raw.get("provider") or provider_id,
"source_label": (
raw.get("source_label")
or raw.get("auth_store")
or raw.get("auth_store_path")
or raw.get("base_url")
or raw.get("name")
or ""
),
"token_preview": _truncate_token(raw.get("access_token") or raw.get("api_key")),
"expires_at": raw.get("expires_at") or raw.get("access_expires_at"),
"has_refresh_token": bool(raw.get("has_refresh_token")),
}
except Exception as e:
return {"logged_in": False, "error": str(e)}
return {"logged_in": False}
async def _start_nous_device_code(profile: Optional[str]) -> Dict[str, Any]:
from hermes_cli.auth import _request_device_code, PROVIDER_REGISTRY
import httpx
pconfig = PROVIDER_REGISTRY["nous"]
portal_base_url = (
os.getenv("HERMES_PORTAL_BASE_URL")
or os.getenv("NOUS_PORTAL_BASE_URL")
or pconfig.portal_base_url
).rstrip("/")
client_id = pconfig.client_id
scope = pconfig.scope
def _do_nous_device_request():
with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client:
return (
_request_device_code(
client=client, portal_base_url=portal_base_url, client_id=client_id, scope=scope
),
scope,
)
device_data, effective_scope = await asyncio.get_running_loop().run_in_executor(None, _do_nous_device_request)
sid, sess = _new_oauth_session("nous", "device_code", profile=profile)
sess.update(
device_code=str(device_data["device_code"]), interval=int(device_data["interval"]),
expires_at=time.time() + int(device_data["expires_in"]), portal_base_url=portal_base_url,
client_id=client_id, scope=effective_scope,
)
_start_poller(_nous_poller, sid)
return {
"session_id": sid, "flow": "device_code", "user_code": str(device_data["user_code"]),
"verification_url": str(device_data["verification_uri_complete"]),
"expires_in": int(device_data["expires_in"]), "poll_interval": int(device_data["interval"]),
}
async def _start_codex_device_code(profile: Optional[str]) -> Dict[str, Any]:
# The full Codex helper polls inline, so it runs in a worker thread and
# proxies user_code + verification_url back via the session dict.
sid, _ = _new_oauth_session("openai-codex", "device_code", profile=profile)
threading.Thread(target=_codex_full_login_worker, args=(sid,), daemon=True, name=f"oauth-codex-{sid[:6]}").start()
# Block briefly until the worker has populated the user_code, OR error.
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
with _oauth_sessions_lock:
s = _oauth_sessions.get(sid)
if s and (s.get("user_code") or s["status"] != "pending"):
break
await asyncio.sleep(0.1)
with _oauth_sessions_lock:
s = _oauth_sessions.get(sid, {})
if s.get("status") == "error":
raise HTTPException(status_code=500, detail=s.get("error_message") or "device-auth failed")
if not s.get("user_code"):
raise HTTPException(status_code=504, detail="device-auth timed out before returning a user code")
return {
"session_id": sid, "flow": "device_code", "user_code": s["user_code"],
"verification_url": s["verification_url"], "expires_in": int(s.get("expires_in") or 900),
"poll_interval": int(s.get("interval") or 5),
}
async def _start_minimax_device_code(profile: Optional[str]) -> Dict[str, Any]:
# Device-code flow with a PKCE extension: verifier + challenge from
# _minimax_pkce_pair bind the token exchange to the original session.
from hermes_cli.auth import (
_minimax_pkce_pair, _minimax_request_user_code, MINIMAX_OAUTH_CLIENT_ID, MINIMAX_OAUTH_GLOBAL_BASE,
)
import httpx
verifier, challenge, state = _minimax_pkce_pair()
portal_base_url = (os.getenv("MINIMAX_PORTAL_BASE_URL") or MINIMAX_OAUTH_GLOBAL_BASE).rstrip("/")
def _do_minimax_request():
with httpx.Client(
timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}, follow_redirects=True
) as client:
return _minimax_request_user_code(
client=client, portal_base_url=portal_base_url, client_id=MINIMAX_OAUTH_CLIENT_ID,
code_challenge=challenge, state=state,
)
device_data = await asyncio.get_event_loop().run_in_executor(None, _do_minimax_request)
sid, sess = _new_oauth_session("minimax-oauth", "device_code", profile=profile)
# MiniMax's `interval` is in milliseconds (defensive default 2000ms in _minimax_poll_token).
interval_raw = device_data.get("interval")
sess.update(
interval_ms=int(interval_raw) if interval_raw is not None else None,
user_code=str(device_data["user_code"]), code_verifier=verifier, state=state,
portal_base_url=portal_base_url, client_id=MINIMAX_OAUTH_CLIENT_ID, region="global",
)
# `expired_in` is overloaded — a unix-ms timestamp OR seconds-from-now.
# Mirror the heuristic in _minimax_poll_token; keep the raw value for the
# poller and derive expires_at + UI-friendly expires_in seconds.
expired_in_raw = int(device_data["expired_in"])
sess["expired_in_raw"] = expired_in_raw
if expired_in_raw > 1_000_000_000_000: # likely unix-ms
expires_at_ts = expired_in_raw / 1000.0
expires_in_seconds = max(0, int(expires_at_ts - time.time()))
else:
expires_at_ts = time.time() + expired_in_raw
expires_in_seconds = expired_in_raw
sess["expires_at"] = expires_at_ts
_start_poller(_minimax_poller, sid)
return {
"session_id": sid, "flow": "device_code", "user_code": str(device_data["user_code"]),
"verification_url": str(device_data["verification_uri"]), "expires_in": expires_in_seconds,
"poll_interval": max(2, (sess["interval_ms"] or 2000) // 1000),
}
async def _start_xai_device_code(profile: Optional[str]) -> Dict[str, Any]:
from hermes_cli.auth import _xai_oauth_request_device_code
import httpx
def _do_xai_device_request():
with httpx.Client(timeout=httpx.Timeout(20.0), headers={"Accept": "application/json"}) as client:
return _xai_oauth_request_device_code(client)
device_data = await asyncio.get_running_loop().run_in_executor(None, _do_xai_device_request)
sid, sess = _new_oauth_session("xai-oauth", "device_code", profile=profile)
sess.update(
device_code=str(device_data["device_code"]), interval=int(device_data["interval"]),
expires_at=time.time() + int(device_data["expires_in"]),
)
_start_poller(_xai_device_poller, sid)
return {
"session_id": sid, "flow": "device_code", "user_code": str(device_data["user_code"]),
"verification_url": str(device_data.get("verification_uri_complete") or device_data["verification_uri"]),
"expires_in": int(device_data["expires_in"]), "poll_interval": int(device_data["interval"]),
}
_DEVICE_CODE_STARTERS = {
"nous": _start_nous_device_code, "openai-codex": _start_codex_device_code,
"minimax-oauth": _start_minimax_device_code, "xai-oauth": _start_xai_device_code,
}
async def _start_device_code_flow(
provider_id: str, profile: Optional[str] = None,
) -> Dict[str, Any]:
"""Initiate a device-code flow (Nous, OpenAI Codex, MiniMax, or xAI).
Calls the provider's device-auth endpoint via the CLI helpers, then spawns
a background poller. Returns the user-facing display fields (verification
link + user code).
"""
starter = _DEVICE_CODE_STARTERS.get(provider_id)
if starter is None:
raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow")
return await starter(profile)
def _oauth_provider_disconnect_command(provider: Dict[str, Any]) -> Optional[str]:
"""Shell command that clears an external provider's credentials.
External providers store credentials outside Hermes, so the disconnect API
refuses them (never silently delete files another CLI owns); instead the
GUI gets a command to run in the embedded terminal, so the user sees
exactly what executes. Claude Code has no scriptable logout, so we remove
the credential the way logout does: the macOS Keychain entry
(``Claude Code-credentials``) and/or ``~/.claude/.credentials.json`` — the
two sources ``read_claude_code_credentials()`` consults. None for providers
we can't safely clear (the GUI shows a manual hint).
"""
if provider.get("flow") != "external":
return None
if provider.get("id") == "claude-code":
rm_file = "rm -f ~/.claude/.credentials.json"
if sys.platform == "darwin":
return f'security delete-generic-password -s "Claude Code-credentials" 2>/dev/null; {rm_file}'
return rm_file
return None
def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str, Any]) -> Optional[str]:
"""Return the manual disconnect path when the API cannot clear this provider."""
# "anthropic" is flow == "external" (no in-dashboard login) but Hermes still
# OWNS its credential (the PKCE file ~/.hermes/.anthropic_oauth.json and its
# credential-pool entry, written by `hermes auth add anthropic`), so it is
# excluded from the "external providers can't be auto-disconnected" rule.
if provider.get("flow") == "external" and provider.get("id") != "anthropic":
if _oauth_provider_disconnect_command(provider):
# Fallback wording for surfaces without the one-click "run in terminal" path.
return "Managed outside Hermes — run the disconnect command to remove it."
return "Managed by that provider's CLI; remove it there."
if status.get("source") == "env_var":
return "Remove the API key from Settings → Keys instead."
return None
def _build_oauth_catalog() -> list[Dict[str, Any]]:
"""Build the Accounts-tab provider list.
Membership is the union of ``_OAUTH_PROVIDER_CATALOG`` (hand-tuned cards
with bespoke flow / status_fn / cli_command, incl. the Anthropic PKCE card
and the synthetic claude-code row) and every accounts-tab provider in the
unified ``provider_catalog()``, so a plugin-added OAuth/external provider
appears automatically. Explicit cards win on metadata and come first in
curated order; catalog-only providers follow in ``hermes model`` order.
"""
rows: list[Dict[str, Any]] = []
seen: set[str] = set()
for entry in _OAUTH_PROVIDER_CATALOG:
if entry["id"] in seen:
continue
seen.add(entry["id"])
rows.append(dict(entry))
try:
from hermes_cli.provider_catalog import provider_catalog
for d in provider_catalog():
if d.tab != "accounts" or d.slug in seen:
continue
seen.add(d.slug)
rows.append({
"id": d.slug, "name": d.label, "flow": "external",
"cli_command": f"hermes auth add {d.slug}", "docs_url": d.signup_url or "",
"status_fn": None,
})
except Exception:
pass
return rows
@router.get("/api/providers/oauth")
async def list_oauth_providers(profile: Optional[str] = None):
"""Enumerate every OAuth-capable LLM provider with current status.
Per provider: id (used in the DELETE path), name, flow
("device_code" | "external"), cli_command (manual fallback),
disconnect_command (shell command for external providers, else null),
docs_url, and status {logged_in, source slug, source_label, token_preview
(last N chars, never the full token), expires_at, has_refresh_token}.
"""
def _run():
providers = []
for p in _build_oauth_catalog():
status = _resolve_provider_status(p["id"], p.get("status_fn"))
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
providers.append({
"id": p["id"], "name": p["name"], "flow": p["flow"],
"cli_command": _external_process_cli_command(p["id"], p["cli_command"]),
"docs_url": p["docs_url"], "disconnect_hint": disconnect_hint,
"disconnect_command": _oauth_provider_disconnect_command(p),
"disconnectable": disconnect_hint is None, "status": status,
})
return {"providers": providers}
return await scoped_to_thread(profile, _run)
def _reject_if_not_disconnectable(provider: Dict[str, Any], status: Dict[str, Any]) -> None:
disconnect_hint = _oauth_provider_disconnect_hint(provider, status)
if disconnect_hint:
raise HTTPException(
status_code=400,
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
)
@router.delete("/api/providers/oauth/{provider_id}")
async def disconnect_oauth_provider(
provider_id: str, request: Request, profile: Optional[str] = None,
):
"""Disconnect an OAuth provider. Token-protected (matches /env/reveal)."""
_require_token(request)
def _run():
catalog_by_id = {p["id"]: p for p in _build_oauth_catalog()}
provider = catalog_by_id.get(provider_id)
if provider is None:
raise HTTPException(
status_code=400,
detail=f"Unknown provider: {provider_id}. "
f"Available: {', '.join(sorted(catalog_by_id))}",
)
_reject_if_not_disconnectable(provider, {})
_reject_if_not_disconnectable(
provider, _resolve_provider_status(provider_id, provider.get("status_fn"))
)
# Anthropic clears only the Hermes-managed PKCE file and auth-store
# entry; the external claude-code row was rejected above so we never
# pretend to remove ~/.claude/* credentials owned by the CLI.
if provider_id == "anthropic":
cleared = False
try:
from agent.anthropic_adapter import _get_hermes_oauth_file
oauth_file = _get_hermes_oauth_file()
if oauth_file.exists():
oauth_file.unlink()
cleared = True
except Exception:
pass
try:
from hermes_cli.auth import clear_provider_auth
cleared = clear_provider_auth("anthropic") or cleared
except Exception:
pass
_log.info("oauth/disconnect: %s", provider_id)
return {"ok": bool(cleared), "provider": provider_id}
try:
from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache
cleared = clear_provider_auth(provider_id)
if provider_id == "nous":
invalidate_nous_auth_status_cache()
_log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared)
return {"ok": bool(cleared), "provider": provider_id}
except Exception as e:
_log.exception("disconnect %s failed", provider_id)
raise HTTPException(status_code=500, detail=str(e))
return await scoped_to_thread(profile, _run)
# In-browser device-code flows (Nous, OpenAI Codex, MiniMax, xAI):
# 1. POST /api/providers/oauth/{provider}/start hits the provider's
# device-auth endpoint, spawns a poller thread that polls the token
# endpoint every `interval` seconds, and returns
# {session_id, flow, user_code, verification_url, expires_in, poll_interval}.
# 2. UI opens verification_url and shows user_code, then polls
# GET /api/providers/oauth/{provider}/poll/{session_id} until status != "pending".
# 3. On "approved" the poller has already saved creds; UI refreshes the list.
# Anthropic has NO dashboard PKCE flow: an unattended endpoint minting Claude
# subscription tokens outside Anthropic's own client violates its OAuth usage
# policy; that card is flow == "external" (`hermes auth add anthropic`).
# Sessions are in-memory (single-process) and expire after 15 minutes; /start
# GCs expired ones so the dict is bounded.
_OAUTH_SESSION_TTL_SECONDS = 15 * 60
def _gc_oauth_sessions() -> None:
"""Drop expired sessions. Called opportunistically on /start."""
cutoff = time.time() - _OAUTH_SESSION_TTL_SECONDS
with _oauth_sessions_lock:
stale = [sid for sid, sess in _oauth_sessions.items() if sess["created_at"] < cutoff]
for sid in stale:
_oauth_sessions.pop(sid, None)
def _validate_oauth_profile(profile: Optional[str]) -> None:
profile_name = _oauth_profile_name(profile)
if profile_name:
_resolve_profile_dir(profile_name)
@router.post("/api/providers/oauth/{provider_id}/start")
async def start_oauth_login(
provider_id: str, request: Request, profile: Optional[str] = None,
):
"""Initiate an OAuth login flow. Token-protected."""
_require_token(request)
_gc_oauth_sessions()
_validate_oauth_profile(profile)
catalog_entry = next((p for p in _OAUTH_PROVIDER_CATALOG if p["id"] == provider_id), None)
if catalog_entry is None:
raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}")
if catalog_entry["flow"] == "external":
raise HTTPException(
status_code=400,
detail=f"{provider_id} uses an external CLI; run `{catalog_entry['cli_command']}` manually",
)
try:
if catalog_entry["flow"] == "device_code":
return await _start_device_code_flow(provider_id, profile=profile)
except HTTPException:
raise
except Exception as e:
_log.exception("oauth/start %s failed", provider_id)
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=400, detail="Unsupported flow")
@router.post("/api/providers/oauth/{provider_id}/submit")
async def submit_oauth_code(
provider_id: str, body: OAuthSubmitBody, request: Request, profile: Optional[str] = None,
):
"""Submit the auth code for PKCE flows. Token-protected."""
_require_token(request)
raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}")
@router.get("/api/providers/oauth/{provider_id}/poll/{session_id}")
async def poll_oauth_session(
provider_id: str, session_id: str, profile: Optional[str] = None,
):
"""Poll a session's status (no auth — read-only state). One endpoint serves
every device-code flow: all report progress via the worker-updated ``status``."""
_validate_oauth_profile(profile)
requested_profile = _oauth_profile_name(profile)
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
if not sess:
raise HTTPException(status_code=404, detail="Session not found or expired")
if sess["provider"] != provider_id:
raise HTTPException(status_code=400, detail="Provider mismatch for session")
if sess.get("profile") != requested_profile:
raise HTTPException(status_code=400, detail="OAuth session profile mismatch")
return {
"session_id": session_id, "status": sess["status"],
"error_message": sess.get("error_message"), "expires_at": sess.get("expires_at"),
}
@router.delete("/api/providers/oauth/sessions/{session_id}")
async def cancel_oauth_session(
session_id: str, request: Request, profile: Optional[str] = None,
):
"""Cancel a pending OAuth session. Token-protected.
Marks the session dict ``cancelled`` before popping it so a background
worker still holding that dict (e.g. the Codex poller) stops
polling/exchanging/saving instead of completing the login after the user
believed it was aborted.
"""
_require_token(request)
_validate_oauth_profile(profile)
requested_profile = _oauth_profile_name(profile)
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
if sess is not None:
if sess.get("profile") != requested_profile:
raise HTTPException(status_code=400, detail="OAuth session profile mismatch")
sess["cancelled"] = True
_oauth_sessions.pop(session_id, None)
if sess is None:
return {"ok": False, "message": "session not found"}
return {"ok": True, "session_id": session_id}