Files
hermes-webui/api/oauth.py
T
nesquena-hermes f8007d43f3 v0.50.257: 4 Opus pre-release follow-ups + CHANGELOG + test fixes for #1415
stage-257 batch (PRs #1402 + #1415):

Opus pre-release advisor caught 4 issues in stage-257:

1. MUST-FIX (security): api/oauth.py::_write_auth_json — tmp.replace()
   preserves the temp file umask (0644 default), so OAuth access/refresh
   tokens landed world-readable on shared systems. Fix: tmp.chmod(0o600)
   BEFORE rename, with try/except OSError that warns but does not abort.

2. SHOULD-FIX: _handle_cron_history and _handle_cron_run_detail accepted
   job_id as a path component without validation. Mirrors the rollback
   path-traversal vector caught in v0.50.255 (#1405). Path() / .. does NOT
   normalize. New regex ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ with explicit
   . / .. rejection.

3. SHOULD-FIX: _handle_cron_history int(offset)/int(limit) raised
   ValueError on malformed input → confusing 500. Now try/except + clamp
   to (max(0, offset), max(1, min(500, limit))).

4. NIT: same regex applied to _handle_cron_run_detail (defense-in-depth
   even though path-resolve check would catch it downstream).

PR #1415 follow-up: 8 pre-existing tests in test_issue1106 and
test_custom_provider_display_name asserted bare model IDs but #1415
changes named-custom-provider IDs to @custom:NAME:model form when active
provider differs. Tests updated to use _strip_at_prefix helper to keep
checking the same invariant in the new shape.

4 regression tests in test_v050257_opus_followups.py + 8 fixed pre-existing
tests. Full suite: 3602 passed, 0 failed.
2026-05-01 18:30:41 +00:00

188 lines
6.9 KiB
Python

"""In-app OAuth flow implementations for providers like OpenAI Codex.
Uses only stdlib (urllib.request, json, time) — no external dependencies.
Credentials are stored in ~/.hermes/auth.json under the credential_pool.
"""
import json
import logging
import time
import uuid
import urllib.request
import urllib.parse
import urllib.error
from pathlib import Path
logger = logging.getLogger(__name__)
AUTH_JSON_PATH = Path.home() / ".hermes" / "auth.json"
# ── Codex OAuth constants (from hermes_cli/auth.py) ──
CODEX_CLIENT_ID = "pdlLIX2Y72MIl2rhLhTE9VV9bN905kBh"
CODEX_AUTH_URL = "https://auth.openai.com/oauth/device/authorize"
CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token"
CODEX_SCOPE = "openid profile email offline_access"
CODEX_GRANT_TYPE_DEVICE = "urn:ietf:params:oauth:grant-type:device_code"
# ── auth.json helpers ──
def _read_auth_json():
"""Read auth.json and return parsed dict, or empty dict."""
if AUTH_JSON_PATH.exists():
try:
return json.loads(AUTH_JSON_PATH.read_text())
except json.JSONDecodeError as exc:
logger.warning("Failed to parse %s: %s", AUTH_JSON_PATH, exc)
return {}
return {}
def _write_auth_json(data):
"""Atomically write auth.json via temp-file rename.
SECURITY: auth.json contains OAuth access/refresh tokens. ``tmp.replace()``
preserves the temp file's mode (created with the process umask, typically
0644 or 0664), NOT the prior auth.json mode. Without an explicit chmod,
tokens land world-readable on shared systems. Set 0600 BEFORE the rename
so there is no window where the final file is world-readable.
(Opus pre-release advisor finding.)
"""
import os, stat
AUTH_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = AUTH_JSON_PATH.with_suffix('.tmp')
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False))
try:
tmp.chmod(0o600)
except OSError as e:
# Best-effort: if chmod fails (e.g. on a filesystem that doesn't
# support POSIX modes), don't abort. The startup permission fixer
# in api.startup will sweep auth.json on the next process start.
logger.warning("Failed to chmod 0600 on %s: %s", tmp, e)
tmp.replace(AUTH_JSON_PATH)
# ── Codex device-code flow ──
def start_codex_device_code():
"""Start Codex OAuth device-code flow.
Returns dict: { device_code, user_code, verification_uri, expires_in, interval }
Raises RuntimeError on network error.
"""
params = {
"client_id": CODEX_CLIENT_ID,
"scope": CODEX_SCOPE,
}
data = urllib.parse.urlencode(params).encode()
req = urllib.request.Request(CODEX_AUTH_URL, data=data, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode())
except Exception as e:
raise RuntimeError(f"Failed to start Codex OAuth: {e}") from e
def poll_codex_token(device_code, interval=5):
"""Poll for Codex OAuth token. Generator that yields status dicts.
Yields:
{"status": "polling", "attempt": N, "max_attempts": 40}
{"status": "success", "credentials": {...}}
{"status": "error", "error": "..."}
"""
params = {
"grant_type": CODEX_GRANT_TYPE_DEVICE,
"device_code": device_code,
"client_id": CODEX_CLIENT_ID,
}
data = urllib.parse.urlencode(params).encode()
max_attempts = 40 # 40 * 5 = 200s max
for attempt in range(max_attempts):
yield {"status": "polling", "attempt": attempt + 1, "max_attempts": max_attempts}
req = urllib.request.Request(CODEX_TOKEN_URL, data=data, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
token_data = json.loads(resp.read().decode())
# Save to auth.json credential_pool
_save_codex_credentials(token_data)
yield {"status": "success", "credentials": {
"access_token": "***",
"refresh_token": "***",
"token_type": token_data.get("token_type"),
"expires_in": token_data.get("expires_in"),
}}
return
except urllib.error.HTTPError as e:
body = e.read().decode()
try:
err_data = json.loads(body)
error = err_data.get("error", "")
if error == "authorization_pending":
time.sleep(interval)
continue
elif error == "slow_down":
time.sleep(interval + 5)
continue
elif error == "expired_token":
yield {"status": "error", "error": "Device code expired. Please try again."}
return
else:
yield {"status": "error", "error": err_data.get("error_description", error)}
return
except Exception:
yield {"status": "error", "error": body[:200]}
return
except Exception as e:
yield {"status": "error", "error": str(e)}
return
yield {"status": "error", "error": "OAuth flow timed out. Please try again."}
def _save_codex_credentials(token_data):
"""Save Codex OAuth credentials to auth.json credential_pool."""
auth = _read_auth_json()
if "credential_pool" not in auth:
auth["credential_pool"] = {}
pool = auth["credential_pool"]
if "openai-codex" not in pool:
pool["openai-codex"] = []
# Check if an oauth_device entry already exists (update in place)
updated = False
_now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
for entry in pool["openai-codex"]:
if entry.get("source") == "oauth_device":
entry["access_token"] = token_data.get("access_token", "")
entry["refresh_token"] = token_data.get("refresh_token", "")
entry["auth_type"] = "oauth"
entry["updated_at"] = _now_iso
updated = True
break
if not updated:
existing_ids = {e["id"] for e in pool.get("openai-codex", [])}
for _ in range(3): # retry on collision
cred_id = "codex-oauth-" + uuid.uuid4().hex[:8]
if cred_id not in existing_ids:
break
pool["openai-codex"].append({
"id": cred_id,
"label": "Codex OAuth",
"auth_type": "oauth",
"source": "oauth_device",
"access_token": token_data.get("access_token", ""),
"refresh_token": token_data.get("refresh_token", ""),
"priority": 1,
"created_at": _now_iso,
})
auth["updated_at"] = _now_iso
_write_auth_json(auth)