Files
hermes-agent/hermes_cli/dashboard_auth/native_flow.py
Buff Pesos 56f1afc834 feat(dashboard-auth): extend RFC 8252 native sign-in to password providers (system-browser autofill) (#75808)
* feat(dashboard-auth): extend RFC 8252 native sign-in to password providers

The desktop app runs password sign-in for gated gateways in an embedded
Electron BrowserWindow, where OS password managers (macOS Passwords /
iCloud Keychain autofill) cannot reach the form — Chromium-in-Electron
has no bridge to them, so users retype credentials by hand even though
the /login form already carries the right autocomplete attributes.

The existing RFC 8252 native flow (system browser + loopback + PKCE)
solves exactly this for OAuth providers, but was explicitly disabled for
password providers on the grounds that they have "no IDP round trip to
broker". The brokering is still worth having: it moves the credential
form into the system browser, where password-manager autofill just works.

Gateway-only change; the desktop needs no changes (runNativeLogin is
already page-agnostic), and older desktop builds pick the capability up
automatically once the gateway advertises it:

* /auth/native/authorize now accepts a supports_password provider:
  register the pending broker authorization as usual, then 302 the
  system browser to the interactive /login form with the opaque
  broker_state in the gateway's PKCE cookie (the same server-controlled
  channel the OAuth branch uses) instead of an IDP redirect.
* /auth/password-login: when the server-set PKCE cookie carries a
  broker handle, a successful credential check completes the pending
  authorization exactly like the /auth/callback native branch — mint
  the one-time loopback code, return the loopback redirect (validated
  loopback-only at authorize time) as `next`, clear the PKCE cookie,
  and set NO session cookies. A lapsed broker is a clean 400 telling
  the user to restart sign-in; a failed credential attempt leaves the
  pending entry intact so the user can retype.
* /api/status now advertises "native_pkce" whenever any interactive
  session provider is registered (previously only for non-password
  providers), so the desktop selects the system-browser strategy for
  password-only gateways.

Security posture is unchanged from the existing flow: loopback-literal
redirect_uri enforcement, PKCE S256 binding, single-use short-TTL codes,
constant-time comparison, and the same rate limiter on password attempts.

Tests: full authorize → /login → password-login → loopback → token →
bearer round trip, wrong-password keeps the pending entry, lapsed broker
→ 400, no-broker browser login keeps minting cookies, and the /api/status
advertisement for password-only gateways.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dashboard-auth): bind native password completion to the authorize-time provider

Review follow-ups for #75808:

* /auth/password-login now enforces that body.provider matches the
  provider recorded in the server-set PKCE cookie by
  /auth/native/authorize before completing a pending native
  authorization. /login renders a form for every session provider, so
  without this a native flow started for provider A could be completed
  with provider B's credentials, binding B's session into A's pending
  entry. The mismatch is rejected BEFORE credential verification (no
  session minted, no oracle) and preserves both the pending entry and
  the cookie, so the user can still submit the correct provider's form.
  Covered by a two-password-provider E2E regression test.

* Update the two docs spots that still said password-only providers do
  not advertise native_pkce (website desktop-native-signin guide and the
  auth_flows type comment in web/src/lib/api.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: map contributor email for #75808 (buffpesos)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-08-14 21:39:01 +00:00

307 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.
Password providers ride the same broker with step 2 swapped: there is no
upstream IDP, so ``/auth/native/authorize`` sends the system browser to the
interactive ``/login`` form (broker_state in the PKCE cookie) and a successful
``/auth/password-login`` plays the role of the upstream callback — it calls
:func:`complete_pending` and bounces the browser to the loopback redirect.
Steps 4–5 are identical. The point of brokering a password login at all is
that the system browser can autofill from the OS password manager (macOS
Passwords, etc.), which no embedded desktop webview can.
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()