refactor(web): extract OAuth provider routes into web_routers/oauth.py

This commit is contained in:
Teknium
2026-09-02 10:16:29 -07:00
parent d3b768232e
commit 067028eb22
2 changed files with 410 additions and 356 deletions
+405
View File
@@ -0,0 +1,405 @@
"""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 imported lazily at call time (cycle-safe).
"""
import logging
from fastapi import APIRouter
from fastapi import HTTPException, Request
from hermes_cli.web_models import OAuthSubmitBody
from typing import Any, Dict, Optional
_log = logging.getLogger("hermes_cli.web_server")
router = APIRouter()
def _oauth_provider_disconnect_command(provider: Dict[str, Any]) -> Optional[str]:
"""Shell command that clears an external provider's credentials.
External providers store their credentials outside Hermes, so the disconnect
API deliberately refuses them (we never delete files another CLI owns on the
user's behalf via a silent API call). For the ones we know how to clear we
instead hand the GUI a command it can *run in the embedded terminal* — the
user sees exactly what executes, and Hermes then stops resolving the token.
Claude Code has no scriptable logout (only the interactive ``/logout``), so
we remove the credential the same way logout does: the macOS Keychain entry
(``Claude Code-credentials``) and/or the ``~/.claude/.credentials.json``
file — the two sources ``read_claude_code_credentials()`` consults. Returns
None for providers we can't safely clear (the GUI shows a manual hint).
"""
from hermes_cli.web_server import sys
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 OAuth login, see the
# catalog entry) but, unlike other external providers, Hermes still OWNS
# the credential it can show here: the Hermes-managed PKCE file
# (~/.hermes/.anthropic_oauth.json) and its credential-pool entry, both
# written by `hermes auth add anthropic` in the terminal. Those are ours
# to clear via the API, so this provider is excluded from the generic
# "external providers can't be auto-disconnected" rule below.
if provider.get("flow") == "external" and provider.get("id") != "anthropic":
if _oauth_provider_disconnect_command(provider):
# The GUI offers a one-click "run in terminal" path; this hint is the
# fallback wording for surfaces that only show text.
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:
1. ``_OAUTH_PROVIDER_CATALOG`` — the explicit, hand-tuned cards that carry
bespoke flow / status_fn / cli_command (including the api-key Anthropic
PKCE card and the synthetic claude-code subscription row, which are not
catalog providers), and
2. every accounts-tab provider in the unified ``provider_catalog()`` (the
``hermes model`` universe) — so any OAuth/external provider added as a
plugin appears automatically, with sensible defaults, even if no
explicit card was written for it.
The explicit catalog wins on metadata; the unified catalog guarantees we
never silently drop a provider the CLI picker offers. Order: explicit cards
first (their curated order), then any catalog-only providers appended in
``hermes model`` order.
"""
from hermes_cli.web_server import _OAUTH_PROVIDER_CATALOG
rows: list[Dict[str, Any]] = []
seen: set[str] = set()
# 1. Explicit hand-tuned cards (authoritative metadata + curated order).
for entry in _OAUTH_PROVIDER_CATALOG:
if entry["id"] in seen:
continue
seen.add(entry["id"])
rows.append(dict(entry))
# 2. Catalog accounts-providers not already covered — keeps the Accounts tab
# in lockstep with the `hermes model` universe (zero-edit for new plugins).
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.
Response shape (per provider):
id stable identifier (used in DELETE path)
name human label
flow "device_code" | "external"
cli_command fallback CLI command for users to run manually
disconnect_command shell command that clears an external provider's
creds (run in the embedded terminal), else null
docs_url external docs/portal link for the "Learn more" link
status:
logged_in bool — currently has usable creds
source short slug ("hermes_pkce", "claude_code", ...)
source_label human-readable origin (file path, env var name)
token_preview last N chars of the token, never the full token
expires_at ISO timestamp string or null
has_refresh_token bool
Membership is derived from the unified provider_catalog() so this stays in
sync with the `hermes model` picker; _OAUTH_OVERRIDES supplies per-provider
flow/status/cli metadata.
"""
from hermes_cli.web_server import (
_external_process_cli_command,
_profile_scope,
_resolve_provider_status,
asyncio,
)
def _run():
with _profile_scope(profile):
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 asyncio.to_thread(_run)
@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)."""
from hermes_cli.web_server import (
_profile_scope,
_require_token,
_resolve_provider_status,
asyncio,
)
_require_token(request)
def _run():
with _profile_scope(profile):
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))}",
)
disconnect_hint = _oauth_provider_disconnect_hint(provider, {})
if disconnect_hint:
raise HTTPException(
status_code=400,
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
)
status = _resolve_provider_status(provider_id, provider.get("status_fn"))
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}",
)
# Anthropic clears only the Hermes-managed PKCE file and auth-store entry.
# The separate claude-code catalog row is external/read-only and 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
# Also clear the credential pool entry if present.
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 asyncio.to_thread(_run)
# ---------------------------------------------------------------------------
# OAuth Phase 2 — in-browser device-code flows
# ---------------------------------------------------------------------------
#
# Anthropic previously had a dashboard-triggered PKCE flow here too (server
# generates a claude.ai/oauth/authorize URL, exchanges the code for tokens at
# the Anthropic token endpoint, persists them). It was removed: an unattended
# HTTP endpoint minting Claude Pro/Max subscription tokens outside Anthropic's
# own client sits on the wrong side of Anthropic's usage policies for OAuth
# credentials. The "anthropic" catalog entry is now flow == "external" and
# points at `hermes auth add anthropic` (terminal PKCE, unaffected) instead.
#
# Device code (Nous, OpenAI Codex):
# 1. POST /api/providers/oauth/{nous|openai-codex}/start
# → server hits provider's device-auth endpoint
# → gets { user_code, verification_url, device_code, interval, expires_in }
# → spawns background poller thread that polls the token endpoint
# every `interval` seconds until approved/expired
# → stores poll status in _oauth_sessions[session_id]
# → returns { session_id, flow: "device_code", user_code,
# verification_url, expires_in, poll_interval }
# 2. UI opens verification_url in a new tab and shows user_code.
# 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id}
# every 2s until status != "pending".
# 4. On "approved" the background thread has already saved creds; UI
# refreshes the providers list.
#
# Sessions are kept in-memory only (single-process FastAPI) and time out
# after 15 minutes. A periodic cleanup runs on each /start call to GC
# expired sessions so the dict doesn't grow without bound.
_OAUTH_SESSION_TTL_SECONDS = 15 * 60
def _gc_oauth_sessions() -> None:
"""Drop expired sessions. Called opportunistically on /start."""
from hermes_cli.web_server import _oauth_sessions, _oauth_sessions_lock, time
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:
from hermes_cli.web_server import _oauth_profile_name, _resolve_profile_dir
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."""
from hermes_cli.web_server import (
_OAUTH_PROVIDER_CATALOG,
_require_token,
_start_device_code_flow,
)
_require_token(request)
_gc_oauth_sessions()
_validate_oauth_profile(profile)
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
if provider_id not in valid:
raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}")
catalog_entry = next(p for p in _OAUTH_PROVIDER_CATALOG if p["id"] == 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."""
from hermes_cli.web_server import _require_token
_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).
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax, xAI).
Each surfaces progress through the same background-worker-updated
``status`` field, so a single poll endpoint serves them all.
"""
from hermes_cli.web_server import _oauth_profile_name, _oauth_sessions, _oauth_sessions_lock
_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 any
background worker still holding a reference to that same dict (e.g.
the Codex device-code poller) observes the cancellation and stops
polling/exchanging/saving instead of completing the login after the
user believed it was aborted.
"""
from hermes_cli.web_server import (
_oauth_profile_name,
_oauth_sessions,
_oauth_sessions_lock,
_require_token,
)
_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}
+5 -356
View File
@@ -6306,261 +6306,17 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
return {"logged_in": False}
def _oauth_provider_disconnect_command(provider: Dict[str, Any]) -> Optional[str]:
"""Shell command that clears an external provider's credentials.
from hermes_cli.web_routers import oauth as _oauth_routes # noqa: E402
External providers store their credentials outside Hermes, so the disconnect
API deliberately refuses them (we never delete files another CLI owns on the
user's behalf via a silent API call). For the ones we know how to clear we
instead hand the GUI a command it can *run in the embedded terminal* — the
user sees exactly what executes, and Hermes then stops resolving the token.
Claude Code has no scriptable logout (only the interactive ``/logout``), so
we remove the credential the same way logout does: the macOS Keychain entry
(``Claude Code-credentials``) and/or the ``~/.claude/.credentials.json``
file — the two sources ``read_claude_code_credentials()`` consults. Returns
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
app.include_router(_oauth_routes.router)
from hermes_cli.web_routers.oauth import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server.<name>
start_oauth_login,
)
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 OAuth login, see the
# catalog entry) but, unlike other external providers, Hermes still OWNS
# the credential it can show here: the Hermes-managed PKCE file
# (~/.hermes/.anthropic_oauth.json) and its credential-pool entry, both
# written by `hermes auth add anthropic` in the terminal. Those are ours
# to clear via the API, so this provider is excluded from the generic
# "external providers can't be auto-disconnected" rule below.
if provider.get("flow") == "external" and provider.get("id") != "anthropic":
if _oauth_provider_disconnect_command(provider):
# The GUI offers a one-click "run in terminal" path; this hint is the
# fallback wording for surfaces that only show text.
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:
1. ``_OAUTH_PROVIDER_CATALOG`` — the explicit, hand-tuned cards that carry
bespoke flow / status_fn / cli_command (including the api-key Anthropic
PKCE card and the synthetic claude-code subscription row, which are not
catalog providers), and
2. every accounts-tab provider in the unified ``provider_catalog()`` (the
``hermes model`` universe) — so any OAuth/external provider added as a
plugin appears automatically, with sensible defaults, even if no
explicit card was written for it.
The explicit catalog wins on metadata; the unified catalog guarantees we
never silently drop a provider the CLI picker offers. Order: explicit cards
first (their curated order), then any catalog-only providers appended in
``hermes model`` order.
"""
rows: list[Dict[str, Any]] = []
seen: set[str] = set()
# 1. Explicit hand-tuned cards (authoritative metadata + curated order).
for entry in _OAUTH_PROVIDER_CATALOG:
if entry["id"] in seen:
continue
seen.add(entry["id"])
rows.append(dict(entry))
# 2. Catalog accounts-providers not already covered — keeps the Accounts tab
# in lockstep with the `hermes model` universe (zero-edit for new plugins).
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
@app.get("/api/providers/oauth")
async def list_oauth_providers(profile: Optional[str] = None):
"""Enumerate every OAuth-capable LLM provider with current status.
Response shape (per provider):
id stable identifier (used in DELETE path)
name human label
flow "device_code" | "external"
cli_command fallback CLI command for users to run manually
disconnect_command shell command that clears an external provider's
creds (run in the embedded terminal), else null
docs_url external docs/portal link for the "Learn more" link
status:
logged_in bool — currently has usable creds
source short slug ("hermes_pkce", "claude_code", ...)
source_label human-readable origin (file path, env var name)
token_preview last N chars of the token, never the full token
expires_at ISO timestamp string or null
has_refresh_token bool
Membership is derived from the unified provider_catalog() so this stays in
sync with the `hermes model` picker; _OAUTH_OVERRIDES supplies per-provider
flow/status/cli metadata.
"""
def _run():
with _profile_scope(profile):
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 asyncio.to_thread(_run)
@app.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():
with _profile_scope(profile):
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))}",
)
disconnect_hint = _oauth_provider_disconnect_hint(provider, {})
if disconnect_hint:
raise HTTPException(
status_code=400,
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
)
status = _resolve_provider_status(provider_id, provider.get("status_fn"))
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}",
)
# Anthropic clears only the Hermes-managed PKCE file and auth-store entry.
# The separate claude-code catalog row is external/read-only and 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
# Also clear the credential pool entry if present.
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 asyncio.to_thread(_run)
# ---------------------------------------------------------------------------
# OAuth Phase 2 — in-browser device-code flows
# ---------------------------------------------------------------------------
#
# Anthropic previously had a dashboard-triggered PKCE flow here too (server
# generates a claude.ai/oauth/authorize URL, exchanges the code for tokens at
# the Anthropic token endpoint, persists them). It was removed: an unattended
# HTTP endpoint minting Claude Pro/Max subscription tokens outside Anthropic's
# own client sits on the wrong side of Anthropic's usage policies for OAuth
# credentials. The "anthropic" catalog entry is now flow == "external" and
# points at `hermes auth add anthropic` (terminal PKCE, unaffected) instead.
#
# Device code (Nous, OpenAI Codex):
# 1. POST /api/providers/oauth/{nous|openai-codex}/start
# → server hits provider's device-auth endpoint
# → gets { user_code, verification_url, device_code, interval, expires_in }
# → spawns background poller thread that polls the token endpoint
# every `interval` seconds until approved/expired
# → stores poll status in _oauth_sessions[session_id]
# → returns { session_id, flow: "device_code", user_code,
# verification_url, expires_in, poll_interval }
# 2. UI opens verification_url in a new tab and shows user_code.
# 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id}
# every 2s until status != "pending".
# 4. On "approved" the background thread has already saved creds; UI
# refreshes the providers list.
#
# Sessions are kept in-memory only (single-process FastAPI) and time out
# after 15 minutes. A periodic cleanup runs on each /start call to GC
# expired sessions so the dict doesn't grow without bound.
_OAUTH_SESSION_TTL_SECONDS = 15 * 60
_oauth_sessions: Dict[str, Dict[str, Any]] = {}
_oauth_sessions_lock = threading.Lock()
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 _oauth_profile_name(profile: Optional[str]) -> Optional[str]:
requested = (profile or "").strip()
@@ -6569,12 +6325,6 @@ def _oauth_profile_name(profile: Optional[str]) -> Optional[str]:
return requested
def _validate_oauth_profile(profile: Optional[str]) -> None:
profile_name = _oauth_profile_name(profile)
if profile_name:
_resolve_profile_dir(profile_name)
def _new_oauth_session(
provider_id: str,
flow: str,
@@ -7230,107 +6980,6 @@ def _codex_full_login_worker(session_id: str) -> None:
s["error_message"] = str(e)
@app.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)
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
if provider_id not in valid:
raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}")
catalog_entry = next(p for p in _OAUTH_PROVIDER_CATALOG if p["id"] == 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")
@app.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}")
@app.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).
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax, xAI).
Each surfaces progress through the same background-worker-updated
``status`` field, so a single poll endpoint serves them all.
"""
_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"),
}
@app.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 any
background worker still holding a reference to that same dict (e.g.
the Codex device-code poller) observes the cancellation and 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}
# ---------------------------------------------------------------------------
# Session detail endpoints
# ---------------------------------------------------------------------------