hermes-agent/hermes_cli/dashboard_auth/native_flow.py
Teknium 4d23b2238e fix(dashboard-auth): harden the public native-authorize surface
Two tightenings on /auth/native/authorize (a public pre-auth route):

- Per-IP pending cap (8): the broker store is capacity-bounded fail-closed
  at 256 entries with a 600s TTL, so one unauthenticated spammer could fill
  it and deny native sign-in gateway-wide for the pending window. Pending
  entries now record the requester IP and each address is capped well above
  any legitimate concurrent-login count; other addresses keep signing in.
- Loopback redirect_uri accepts IP literals only (127.0.0.1 / ::1):
  'localhost' can be re-pointed via the hosts file or a hostile resolver
  (RFC 8252 \u00a78.3 says to use loopback IP literals); the desktop always
  sends 127.0.0.1, so nothing legitimate used the name.

3 new tests: per-IP cap enforced, cap frees on TTL expiry, localhost
redirect rejected at the route.
2026-07-22 06:50:50 -07:00

297 lines
12 KiB
Python

"""Gateway-brokered RFC 8252 (OAuth 2.0 for Native Apps) authorization store.
The desktop app is a *native* OAuth client that wants to sign in to a gated
gateway **without an embedded webview and without relying on browser session
cookies**. It cannot be a direct OAuth client of the upstream IDP (Nous
Portal): the Portal ``client_id`` is per-gateway-instance
(``agent:{instance_id}``) and the Portal validates that the ``redirect_uri``
ends in ``/auth/callback`` on the gateway's own public origin — a desktop
loopback ``127.0.0.1`` redirect is rejected. So the **gateway brokers** the
flow: it is the authorization server *to the desktop*, and an OAuth client *to
the Portal*. This is still a textbook RFC 8252 deployment — system browser,
loopback redirect, PKCE, tokens returned to the app (never cookies).
Wire shape (all gateway-side state lives in this module):
1. Desktop generates its OWN PKCE pair ``(cv_d, cc_d)`` and a ``state``, opens
a loopback listener on ``127.0.0.1:<port>``, and opens the system browser
to the gateway's ``GET /auth/native/authorize?...`` carrying ``cc_d``,
``state``, and its loopback ``redirect_uri``.
2. The gateway ``authorize`` route stashes a **pending authorization**
(``register_pending``) keyed by an opaque ``broker_state`` and runs the
EXISTING upstream PKCE flow (``provider.start_login`` → Portal
``/oauth/authorize`` → gateway ``/auth/callback``). The desktop's
``cc_d`` / ``state`` / loopback ``redirect_uri`` ride through the upstream
round trip inside the gateway's own PKCE cookie, so no desktop secret is
ever exposed to the Portal.
3. On the upstream callback the gateway holds a verified :class:`Session`. It
**mints a one-time gateway authorization code** (``complete_pending``)
bound to the desktop's ``cc_d``, and 302s the browser to the desktop's
``redirect_uri?code=<gw_code>&state=<state>``.
4. The desktop's loopback listener catches ``gw_code``, then POSTs
``/auth/native/token`` with ``gw_code`` + its ``cv_d``. The gateway
verifies ``SHA256(cv_d) == cc_d`` (``redeem_code``), consumes the code
(single use), and returns the upstream ``access_token`` /
``refresh_token`` / ``expires_at`` **in the JSON body**.
5. The desktop stores those in the OS keychain and authenticates REST with
``Authorization: Bearer <access_token>`` (via the existing ``token_auth``
seam) and mints ws-tickets the same way — no cookies anywhere.
Security properties this module guarantees:
* **PKCE binding (RFC 7636).** A gateway code is redeemable only by the client
that presented the matching ``code_challenge``. An attacker who intercepts
the loopback ``gw_code`` (e.g. a hostile process racing the redirect) cannot
exchange it without ``cv_d``, which never leaves the desktop.
* **Single use.** ``redeem_code`` pops the entry; a replay finds nothing.
* **Short TTLs.** A pending authorization lives ``_PENDING_TTL`` seconds (the
interactive login window); a minted code lives ``_CODE_TTL`` seconds (the
loopback round trip is sub-second). Expired entries are refused and GC'd.
* **Opaque, high-entropy handles.** ``broker_state`` and ``gw_code`` are
256-bit ``secrets.token_urlsafe`` values; comparison is constant-time.
* **No secret logging.** The module stores tokens transiently in memory only
between callback and redemption; nothing here writes them to disk (the
audit log strips token fields).
In-memory and process-local: the dashboard is a single process, so no
distributed coordination is needed (mirrors ``ws_tickets``). A functional API
(not a class) keeps ``time.time`` patchable in tests.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import threading
import time
from dataclasses import dataclass
from typing import Dict, Optional
from hermes_cli.dashboard_auth.base import Session
# TTL for a pending authorization (step 2→3): the whole interactive login,
# including the user typing Portal credentials / approving in the browser.
_PENDING_TTL_SECONDS = 600 # 10 minutes — mirrors the PKCE cookie lifetime.
# TTL for a minted gateway code (step 3→4): only the loopback redirect + the
# desktop's immediate token POST, which is sub-second in practice.
_CODE_TTL_SECONDS = 120 # 2 minutes — generous for a slow local hop.
# Cap the number of concurrent pending/issued entries so a misbehaving or
# malicious client cannot grow the store unbounded. Well above any legitimate
# concurrent-login count for a single desktop user.
_MAX_ENTRIES = 256
# Per-IP cap on concurrent PENDING authorizations. /auth/native/authorize is a
# public (pre-auth) route, so without this a single unauthenticated spammer
# could fill the global store (600s TTL each) and lock out legitimate native
# logins for the pending window. A real desktop runs at most a couple of
# concurrent sign-ins from one address; 8 is generous.
_MAX_PENDING_PER_IP = 8
_lock = threading.Lock()
@dataclass
class _Pending:
"""An in-flight native authorization awaiting the upstream callback.
Created when the desktop hits ``/auth/native/authorize`` and consumed when
the upstream ``/auth/callback`` completes and mints the gateway code.
"""
code_challenge: str # the DESKTOP's S256 challenge (cc_d), base64url no-pad
redirect_uri: str # the desktop's loopback redirect (127.0.0.1:<port>/...)
client_state: str # the desktop's own ``state`` (echoed back on redirect)
client_ip: str # requester IP at authorize time (per-IP pending cap)
expires_at: int
@dataclass
class _IssuedCode:
"""A minted one-time gateway authorization code bound to a Session."""
code_challenge: str # cc_d — verified against cv_d at redemption
session: Session
expires_at: int
# broker_state -> _Pending
_pending: Dict[str, _Pending] = {}
# gw_code -> _IssuedCode
_issued: Dict[str, _IssuedCode] = {}
class NativeFlowError(Exception):
"""Base for native-flow failures (bad/expired/replayed handle, PKCE fail)."""
class PendingNotFound(NativeFlowError):
"""The broker_state is unknown or expired (login window lapsed)."""
class CodeInvalid(NativeFlowError):
"""The gateway code is unknown, expired, already redeemed, or PKCE-mismatched."""
def _b64url_no_pad(raw: bytes) -> str:
"""Base64url without ``=`` padding (RFC 7636 §4)."""
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def _s256(verifier: str) -> str:
"""RFC 7636 S256 transform: base64url(sha256(ascii(verifier)))."""
return _b64url_no_pad(hashlib.sha256(verifier.encode("ascii")).digest())
def _gc_locked(now: int) -> None:
"""Drop expired pending + issued entries. Caller holds ``_lock``."""
expired_p = [k for k, v in _pending.items() if v.expires_at < now]
for k in expired_p:
_pending.pop(k, None)
expired_c = [k for k, v in _issued.items() if v.expires_at < now]
for k in expired_c:
_issued.pop(k, None)
def _capacity_ok_locked() -> bool:
return (len(_pending) + len(_issued)) < _MAX_ENTRIES
def register_pending(
*,
code_challenge: str,
redirect_uri: str,
client_state: str,
client_ip: str = "",
now: Optional[int] = None,
) -> str:
"""Stash a pending native authorization; return an opaque ``broker_state``.
Called by ``/auth/native/authorize``. ``code_challenge`` is the DESKTOP's
S256 challenge (``cc_d``) — we never see the verifier until redemption.
``redirect_uri`` is the desktop's loopback callback and ``client_state`` is
the desktop's own CSRF ``state`` (echoed verbatim on the final redirect).
``client_ip`` is the requester's address, used only for the per-IP pending
cap below.
The returned ``broker_state`` is what the gateway threads through its OWN
upstream PKCE round trip (inside the ``hermes_session_pkce`` cookie), so the
callback can find this entry again via :func:`complete_pending`.
Raises ``NativeFlowError`` if the store is at capacity or the caller's IP
already holds ``_MAX_PENDING_PER_IP`` live pending entries (fail closed —
this is a public pre-auth route, so one spammer must not be able to fill
the global store and deny sign-in to everyone else).
"""
now = int(time.time()) if now is None else now
broker_state = secrets.token_urlsafe(32)
with _lock:
_gc_locked(now)
if not _capacity_ok_locked():
raise NativeFlowError("native-flow authorization store at capacity")
if client_ip and (
sum(1 for v in _pending.values() if v.client_ip == client_ip)
>= _MAX_PENDING_PER_IP
):
raise NativeFlowError(
"too many pending native authorizations from this address"
)
_pending[broker_state] = _Pending(
code_challenge=code_challenge,
redirect_uri=redirect_uri,
client_state=client_state,
client_ip=client_ip,
expires_at=now + _PENDING_TTL_SECONDS,
)
return broker_state
def get_pending(broker_state: str, *, now: Optional[int] = None) -> _Pending:
"""Return the pending authorization for ``broker_state`` without consuming it.
Read-only peek used by the callback to learn the desktop's ``redirect_uri``
and ``client_state`` for the final 302. Raises :class:`PendingNotFound` if
unknown or expired (the entry is GC'd on expiry).
"""
now = int(time.time()) if now is None else now
with _lock:
_gc_locked(now)
entry = _pending.get(broker_state)
if entry is None:
raise PendingNotFound("unknown or expired native authorization")
return entry
def complete_pending(
broker_state: str,
*,
session: Session,
now: Optional[int] = None,
) -> str:
"""Consume a pending authorization and mint a one-time gateway code.
Called by ``/auth/callback`` once the upstream :class:`Session` is verified.
Pops the pending entry (single use), binds a fresh ``gw_code`` to the
desktop's ``code_challenge`` + the verified ``session``, and returns the
``gw_code`` for the loopback redirect.
Raises :class:`PendingNotFound` if the broker_state is unknown/expired.
"""
now = int(time.time()) if now is None else now
with _lock:
_gc_locked(now)
pending = _pending.pop(broker_state, None)
if pending is None:
raise PendingNotFound("unknown or expired native authorization")
if not _capacity_ok_locked():
raise NativeFlowError("native-flow code store at capacity")
gw_code = secrets.token_urlsafe(32)
_issued[gw_code] = _IssuedCode(
code_challenge=pending.code_challenge,
session=session,
expires_at=now + _CODE_TTL_SECONDS,
)
return gw_code
def redeem_code(
*,
code: str,
code_verifier: str,
now: Optional[int] = None,
) -> Session:
"""Verify PKCE + consume a gateway code; return the bound :class:`Session`.
Called by ``/auth/native/token``. Enforces:
* the code exists and is unexpired (else :class:`CodeInvalid`);
* ``S256(code_verifier) == code_challenge`` in constant time (RFC 7636);
* single use — the entry is popped BEFORE the PKCE check so a wrong
verifier cannot be retried against the same code.
On any failure the code is already consumed (no oracle, no replay).
"""
now = int(time.time()) if now is None else now
with _lock:
_gc_locked(now)
issued = _issued.pop(code, None)
# Pop happened under the lock; every return path below has already
# consumed the code, so a replay (valid or not) finds nothing.
if issued is None:
raise CodeInvalid("unknown, expired, or already-redeemed code")
if issued.expires_at < now:
raise CodeInvalid("code expired")
expected = issued.code_challenge
actual = _s256(code_verifier)
if not hmac.compare_digest(expected, actual):
raise CodeInvalid("PKCE verification failed")
return issued.session
def _reset_for_tests() -> None:
"""Test-only: drop all pending + issued state."""
with _lock:
_pending.clear()
_issued.clear()