* 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>
307 lines
13 KiB
Python
307 lines
13 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.
|
||
|
||
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()
|