mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
The Desktop app can now sign in to a gated gateway using the user's SYSTEM browser and OAuth 2.0 for Native Apps (RFC 8252) instead of an embedded Electron BrowserWindow, and authenticates with bearer tokens it holds itself instead of relying on HttpOnly browser session cookies. Why brokered: the upstream IDP (Nous Portal) binds client_id to the gateway instance and only permits redirect_uris on the gateway's own origin, so a desktop loopback redirect can't be a direct Portal client. The gateway therefore acts as the authorization server TO the desktop and an OAuth client TO the Portal, reusing the existing PKCE start_login/complete_login provider path unchanged. Server (Ben's dashboard-auth lane): - native_flow.py: in-memory broker — binds the desktop's PKCE challenge to a completed Session, mints a single-use, short-TTL, PKCE-verified gateway authorization code. Constant-time compare, single-use (consumed before the PKCE check so a wrong verifier can't be retried), capacity-bounded. - routes.py: GET /auth/native/authorize (starts the brokered PKCE login, loopback-only redirect_uri, S256-only), POST /auth/native/token (loopback code + verifier -> tokens in the JSON body, never Set-Cookie), POST /auth/native/refresh (desktop-held RT rotation). /auth/callback branches to mint a loopback code + 302 to 127.0.0.1 when a broker_state rides the PKCE cookie; the cookie/SPA path is untouched. - middleware.py: the gate accepts Authorization: Bearer <access_token>, verified via the same verify_session provider stack (no cookie set/read), with the same "provider unreachable -> 503, not logout" semantics. - web_server.py /api/status: advertise auth_flows (["cookie","native_pkce"]) so clients can detect the capability; native_pkce only when a brokerable OAuth provider is registered. Desktop (Ben's lane): - native-oauth.ts: pure PKCE/capability/URL/callback/token helpers. - native-oauth-login.ts: loopback-listener orchestration (system browser via openExternal, ephemeral 127.0.0.1 listener, state/PKCE verification), all I/O injected for testability. - main.ts: capability-gated oauth-login IPC — native flow when advertised, automatic fallback to the existing embedded-webview cookie flow otherwise; tokens stored encrypted (safeStorage/OS keychain), REST + ws-ticket authenticated by bearer, transparent refresh, logout clears both shapes. Tests: 18 server pytest (broker unit + full authorize->callback->token E2E + cookieless bearer auth of a gated route + ws-ticket mint + capability advertisement + refresh); desktop node --test/vitest for both pure modules (PKCE, capability detection, callback CSRF, loopback round trip, timeout, browser-open failure). Electron project typechecks clean. Docs: website/docs/guides/desktop-native-signin.md.
94 lines
3.1 KiB
Python
94 lines
3.1 KiB
Python
"""Audit log for dashboard-auth events.
|
|
|
|
Profile-aware location: ``$HERMES_HOME/logs/dashboard-auth.log``.
|
|
Format: one JSON object per line. Token-like fields are stripped before
|
|
serialisation to avoid leaking refresh tokens or JWTs to disk.
|
|
|
|
This module deliberately keeps a minimal dependency surface — no imports
|
|
from ``hermes_constants`` or other hermes_cli modules — so it can be
|
|
imported safely from middleware code that loads early in the startup
|
|
sequence.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
import enum
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
_log = logging.getLogger(__name__)
|
|
_write_lock = threading.Lock()
|
|
|
|
# Field names that must never appear in the log raw. Any kwarg matching
|
|
# these is silently dropped.
|
|
_REDACTED_FIELDS: frozenset = frozenset({
|
|
"access_token", "refresh_token", "code", "code_verifier",
|
|
"state", "ticket", "cookie", "Authorization", "authorization",
|
|
})
|
|
|
|
|
|
class AuditEvent(enum.Enum):
|
|
"""Event types written to dashboard-auth.log.
|
|
|
|
Values are the literal ``event`` field on the JSON line.
|
|
"""
|
|
|
|
LOGIN_START = "login_start"
|
|
LOGIN_SUCCESS = "login_success"
|
|
LOGIN_FAILURE = "login_failure"
|
|
LOGOUT = "logout"
|
|
REFRESH_SUCCESS = "refresh_success"
|
|
REFRESH_FAILURE = "refresh_failure"
|
|
REVOKE = "revoke"
|
|
SESSION_VERIFY_FAILURE = "session_verify_failure"
|
|
WS_TICKET_MINTED = "ws_ticket_minted"
|
|
WS_TICKET_REJECTED = "ws_ticket_rejected"
|
|
TOKEN_AUTH_SUCCESS = "token_auth_success"
|
|
TOKEN_AUTH_FAILURE = "token_auth_failure"
|
|
# RFC 8252 native-app (system-browser + loopback + PKCE) flow.
|
|
NATIVE_AUTHORIZE_START = "native_authorize_start"
|
|
NATIVE_CODE_ISSUED = "native_code_issued"
|
|
NATIVE_TOKEN_SUCCESS = "native_token_success"
|
|
NATIVE_TOKEN_FAILURE = "native_token_failure"
|
|
|
|
|
|
def _resolve_log_path() -> Path:
|
|
"""``$HERMES_HOME/logs/dashboard-auth.log`` with the standard fallback.
|
|
|
|
Mirrors ``hermes_constants.get_hermes_home`` semantics: env var wins,
|
|
else ``~/.hermes``. A local copy avoids an import cycle with the
|
|
middleware which lives below ``hermes_cli``.
|
|
"""
|
|
home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")
|
|
return Path(home) / "logs" / "dashboard-auth.log"
|
|
|
|
|
|
def audit_log(event: AuditEvent, **fields: Any) -> None:
|
|
"""Append one event to the audit log.
|
|
|
|
Token-like fields are dropped. Missing log directory is created.
|
|
Write failures are logged at WARNING but never raise — auth must not
|
|
fail because the audit logger broke.
|
|
"""
|
|
safe_fields = {
|
|
k: v for k, v in fields.items()
|
|
if k not in _REDACTED_FIELDS
|
|
}
|
|
entry = {
|
|
"ts": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
|
"event": event.value,
|
|
**safe_fields,
|
|
}
|
|
line = json.dumps(entry, separators=(",", ":")) + "\n"
|
|
path = _resolve_log_path()
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with _write_lock:
|
|
with open(path, "a", encoding="utf-8") as f:
|
|
f.write(line)
|
|
except Exception as e:
|
|
_log.warning("dashboard-auth audit log write failed: %s", e)
|