Files
hermes-agent/tests/hermes_cli/test_dashboard_auth_native_flow.py
T
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

591 lines
21 KiB
Python

"""E2E + unit tests for the RFC 8252 native-app (system-browser + loopback +
PKCE) dashboard-auth flow.
Covers:
* ``native_flow`` broker unit behaviour — PKCE binding, single-use codes,
expiry, capacity, replay resistance.
* The full ``/auth/native/authorize`` → ``/auth/callback`` →
``/auth/native/token`` round trip in-process against ``StubAuthProvider``.
* ``/api/status`` capability advertisement (``auth_flows``).
* Cookieless bearer authentication of a gated route (the whole point of the
feature — a desktop authenticates REST with ``Authorization: Bearer`` and
sets/needs no cookie).
* ``/auth/native/refresh`` token rotation and terminal-expiry semantics.
Run: pytest tests/hermes_cli/test_dashboard_auth_native_flow.py
"""
from __future__ import annotations
import hashlib
import base64
import time
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import (
clear_providers,
register_provider,
)
from hermes_cli.dashboard_auth import native_flow
from hermes_cli.dashboard_auth.base import Session
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
# ---------------------------------------------------------------------------
# PKCE helpers (desktop side)
# ---------------------------------------------------------------------------
def _b64url_no_pad(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def _make_pkce() -> tuple[str, str]:
"""Return ``(verifier, challenge)`` — the desktop's PKCE pair."""
verifier = _b64url_no_pad(b"desktop-verifier-secret-material-0123456789abcd")
challenge = _b64url_no_pad(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge
class _PasswordOnlyProvider(StubAuthProvider):
"""Mirrors the bundled ``basic`` provider's flags: a session provider
(``supports_session`` defaults True) that authenticates by username +
password and can never be the target of the native OAuth broker flow.
``start_login`` raises to prove the route must reject it before ever
attempting a redirect."""
name = "pwonly"
display_name = "Password Only (test)"
supports_password = True
def start_login(self, *, redirect_uri):
raise AssertionError(
"native authorize must reject a password provider before "
"calling start_login"
)
class _SecondStubProvider(StubAuthProvider):
"""A second brokerable OAuth provider, so tests can create an ambiguous
multi-provider deployment."""
name = "stub2"
display_name = "Stub IdP Two (test only)"
# ---------------------------------------------------------------------------
# native_flow broker unit tests
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_broker():
native_flow._reset_for_tests()
# Snapshot the shared app.state auth fields + provider registry so a test
# that flips auth_required / registers a stub provider can't leak into a
# later test file (e.g. the MCP dashboard-oauth suite shares web_server.app).
prev_required = getattr(web_server.app.state, "auth_required", None)
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
yield
native_flow._reset_for_tests()
clear_providers()
web_server.app.state.auth_required = prev_required
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
def _stub_session(exp_offset: int = 3600) -> Session:
now = int(time.time())
return Session(
user_id="u1",
email="u1@example.test",
display_name="U One",
org_id="org1",
provider="stub",
expires_at=now + exp_offset,
access_token="at-opaque",
refresh_token="rt-opaque",
)
# ---------------------------------------------------------------------------
# Route-level E2E against StubAuthProvider
# ---------------------------------------------------------------------------
@pytest.fixture
def gated_client():
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
# follow_redirects=False so we can inspect each 302 leg of the flow.
client = TestClient(
web_server.app, base_url="https://fly-app.fly.dev",
follow_redirects=False,
)
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def _walk_native_login(client, *, redirect_uri, challenge, state="cli-state"):
"""Drive authorize → (stub redirects to callback) → loopback code.
Returns the ``code`` + ``state`` the gateway put on the loopback redirect.
"""
# 1. Desktop opens the system browser at /auth/native/authorize.
r = client.get(
"/auth/native/authorize",
params={
"provider": "stub",
"code_challenge": challenge,
"code_challenge_method": "S256",
"redirect_uri": redirect_uri,
"state": state,
},
)
assert r.status_code == 302, r.text
# Stub's start_login redirects straight to /auth/callback?code=stub_code.
loc = r.headers["location"]
parsed = urlparse(loc)
cb_qs = parse_qs(parsed.query)
# Carry the gateway PKCE cookie forward (holds broker_state + verifier).
cookies = r.cookies
# 2. Browser hits the gateway callback.
r2 = client.get(
"/auth/callback",
params={"code": cb_qs["code"][0], "state": cb_qs["state"][0]},
cookies=cookies,
)
assert r2.status_code == 302, r2.text
# 3. The callback 302s to the desktop's loopback redirect_uri.
loop = urlparse(r2.headers["location"])
assert f"{loop.scheme}://{loop.netloc}" == redirect_uri.rsplit("/", 1)[0] or \
loop.netloc in redirect_uri
loop_qs = parse_qs(loop.query)
# No session cookie must be set on the native callback response.
set_cookie = r2.headers.get("set-cookie", "")
assert "hermes_session_at" not in set_cookie, (
f"native callback must NOT set a session cookie; got {set_cookie!r}"
)
return loop_qs["code"][0], loop_qs["state"][0]
def test_native_authorize_rejects_non_loopback_redirect(gated_client):
_verifier, challenge = _make_pkce()
r = gated_client.get(
"/auth/native/authorize",
params={
"provider": "stub",
"code_challenge": challenge,
"code_challenge_method": "S256",
"redirect_uri": "https://evil.example.com/steal",
"state": "s",
},
)
assert r.status_code == 400
assert "loopback" in r.json()["detail"].lower()
# ---------------------------------------------------------------------------
# Empty-provider auto-select (the desktop omits ``provider``; the gateway
# picks when there is exactly one brokerable candidate) — regression #78906
# ---------------------------------------------------------------------------
def _native_authorize_params(challenge, **overrides):
params = {
"code_challenge": challenge,
"code_challenge_method": "S256",
"redirect_uri": "http://127.0.0.1:53999/cb",
"state": "s",
}
params.update(overrides)
return params
def test_native_authorize_empty_provider_auto_selects_oauth_with_password_also_registered(
gated_client,
):
"""Regression for #78906: a password provider is a session provider but
can never be the target of the native OAuth broker flow, so it must not
count toward the empty-provider auto-select. With one OAuth provider +
one password provider (the normal SSO-with-password-fallback setup) the
desktop's empty-provider request must auto-select the OAuth provider
(302), not fail with ``Unknown provider: ''`` (404)."""
register_provider(_PasswordOnlyProvider())
_verifier, challenge = _make_pkce()
r = gated_client.get(
"/auth/native/authorize",
params=_native_authorize_params(challenge),
)
assert r.status_code == 302, r.text
assert "code=stub_code" in r.headers["location"]
def test_native_authorize_empty_provider_auto_selects_single_oauth(gated_client):
"""The common hosted case: exactly one brokerable provider; an empty
``provider`` auto-selects it (302), so the desktop needn't hardcode the
name."""
_verifier, challenge = _make_pkce()
r = gated_client.get(
"/auth/native/authorize",
params=_native_authorize_params(challenge),
)
assert r.status_code == 302, r.text
assert "code=stub_code" in r.headers["location"]
def test_native_authorize_empty_provider_ambiguous_multiple_oauth_404(gated_client):
"""Two brokerable providers: the empty-provider convenience cannot pick
unambiguously, so the request still fails — the desktop must pass
``?provider=`` explicitly."""
register_provider(_SecondStubProvider())
_verifier, challenge = _make_pkce()
r = gated_client.get(
"/auth/native/authorize",
params=_native_authorize_params(challenge),
)
assert r.status_code == 404
def test_native_authorize_empty_provider_password_only_brokers_to_login(
gated_client,
):
"""Password-only deployment: an empty ``provider`` selects the lone
session provider and — now that native sign-in brokers password
providers through the system browser — 302s to ``/login`` with the
broker in the PKCE cookie, rather than the old 400."""
clear_providers()
register_provider(_PasswordOnlyProvider())
_verifier, challenge = _make_pkce()
r = gated_client.get(
"/auth/native/authorize",
params=_native_authorize_params(challenge),
)
assert r.status_code == 302, r.text
assert r.headers["location"].endswith("/login")
set_cookie = r.headers.get("set-cookie", "")
assert "broker=" in set_cookie
# ---------------------------------------------------------------------------
# Cookieless bearer auth of a gated route — the core deliverable
# ---------------------------------------------------------------------------
def test_bearer_authenticates_gated_route_without_cookie(gated_client):
"""A desktop that redeemed tokens can call a gated route with only an
``Authorization: Bearer`` header — no cookie in the jar."""
verifier, challenge = _make_pkce()
code, _state = _walk_native_login(
gated_client, redirect_uri="http://127.0.0.1:53999/cb",
challenge=challenge,
)
tokens = gated_client.post(
"/auth/native/token",
json={"code": code, "code_verifier": verifier},
).json()
at = tokens["access_token"]
# /api/auth/me is gated; a cookieless request with the bearer must pass
# and identify the user.
r = gated_client.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {at}"},
)
assert r.status_code == 200, r.text
assert r.json()["user_id"] == "stub-user-1"
# ---------------------------------------------------------------------------
# Capability advertisement on /api/status
# ---------------------------------------------------------------------------
def test_status_loopback_mode_has_no_auth_flows():
clear_providers()
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
try:
client = TestClient(web_server.app, base_url="http://127.0.0.1:8080")
body = client.get("/api/status").json()
assert body["auth_required"] is False
assert body["auth_flows"] == []
finally:
web_server.app.state.auth_required = prev_required
# ---------------------------------------------------------------------------
# Native flow for password providers (system-browser autofill path)
# ---------------------------------------------------------------------------
#
# A password provider has no IDP round trip, but the native flow still buys
# the desktop the one thing an embedded webview can never have: the system
# browser's OS-password-manager autofill. /auth/native/authorize lands the
# browser on /login (broker_state in the PKCE cookie) and a successful
# /auth/password-login completes the pending authorization exactly like the
# OAuth callback does.
@pytest.fixture
def pw_gated_client():
from hermes_cli.dashboard_auth.routes import _reset_password_rate_limit
from tests.hermes_cli.test_dashboard_auth_password_login import (
PasswordProvider,
)
clear_providers()
register_provider(PasswordProvider())
_reset_password_rate_limit()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(
web_server.app, base_url="https://fly-app.fly.dev",
follow_redirects=False,
)
yield client
clear_providers()
_reset_password_rate_limit()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def test_status_advertises_native_pkce_for_password_only_gateway(
pw_gated_client,
):
body = pw_gated_client.get("/api/status").json()
assert body["auth_required"] is True
assert "cookie" in body["auth_flows"]
assert "native_pkce" in body["auth_flows"]
def test_native_authorize_password_provider_redirects_to_login(
pw_gated_client,
):
"""Empty ``provider`` auto-picks the single password provider and lands
the system browser on /login with the broker in the PKCE cookie."""
_verifier, challenge = _make_pkce()
r = pw_gated_client.get(
"/auth/native/authorize",
params={
"code_challenge": challenge,
"code_challenge_method": "S256",
"redirect_uri": "http://127.0.0.1:53999/cb",
"state": "desk-state",
},
)
assert r.status_code == 302, r.text
assert r.headers["location"].endswith("/login")
set_cookie = r.headers.get("set-cookie", "")
assert "pkce" in set_cookie
assert "broker=" in set_cookie
def _start_native_password_login(client, *, challenge, state="desk-state"):
r = client.get(
"/auth/native/authorize",
params={
"provider": "testpw",
"code_challenge": challenge,
"code_challenge_method": "S256",
"redirect_uri": "http://127.0.0.1:53999/cb",
"state": state,
},
)
assert r.status_code == 302, r.text
return r.cookies
def test_native_password_login_full_roundtrip(pw_gated_client):
"""authorize → /login → password-login → loopback code → bearer tokens."""
verifier, challenge = _make_pkce()
cookies = _start_native_password_login(pw_gated_client, challenge=challenge)
# The browser form POSTs the credentials; the PKCE cookie rides along.
r = pw_gated_client.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
cookies=cookies,
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
# ``next`` is the desktop's loopback redirect carrying code + state —
# NOT a dashboard path.
assert body["next"].startswith("http://127.0.0.1:53999/cb?")
qs = parse_qs(urlparse(body["next"]).query)
assert qs["state"][0] == "desk-state"
code = qs["code"][0]
# No browser session on the native branch; the PKCE cookie is cleared.
set_cookie = r.headers.get("set-cookie", "")
assert "hermes_session_at" not in set_cookie, (
f"native password login must NOT set a session cookie; got {set_cookie!r}"
)
assert "pkce" in set_cookie # the clearing Set-Cookie
# Desktop redeems the loopback code with its PKCE verifier.
tokens = pw_gated_client.post(
"/auth/native/token",
json={"code": code, "code_verifier": verifier},
).json()
assert tokens["provider"] == "testpw"
assert tokens["user_id"] == "admin"
# Cookieless bearer auth of a gated route — the point of the flow.
r2 = pw_gated_client.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert r2.status_code == 200, r2.text
assert r2.json()["user_id"] == "admin"
def test_native_password_login_wrong_password_keeps_pending(pw_gated_client):
"""A failed credential attempt must not consume the pending
authorization — the user retypes and succeeds on the same broker."""
verifier, challenge = _make_pkce()
cookies = _start_native_password_login(pw_gated_client, challenge=challenge)
r = pw_gated_client.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "wrong"},
cookies=cookies,
)
assert r.status_code == 401
r2 = pw_gated_client.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
cookies=cookies,
)
assert r2.status_code == 200, r2.text
assert r2.json()["next"].startswith("http://127.0.0.1:53999/cb?")
def test_native_password_login_expired_broker_returns_400(pw_gated_client):
"""A broker cookie whose pending entry lapsed (TTL) is a clean 400
telling the user to restart sign-in — never a silent cookie login."""
_verifier, challenge = _make_pkce()
cookies = _start_native_password_login(pw_gated_client, challenge=challenge)
native_flow._reset_for_tests() # simulate the pending TTL lapsing
r = pw_gated_client.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
cookies=cookies,
)
assert r.status_code == 400
assert "restart" in r.json()["detail"].lower()
def test_native_password_login_rejects_cross_provider_completion(
pw_gated_client,
):
"""A native flow started for provider A must not be completable with
provider B's credentials: /login renders every provider's form, and the
pending authorization is bound to the provider recorded in the
server-set PKCE cookie. The mismatch is rejected BEFORE credential
verification and preserves the pending entry, so the user can still
submit the form the flow was started for."""
from tests.hermes_cli.test_dashboard_auth_password_login import (
PasswordProvider,
)
class SecondPasswordProvider(PasswordProvider):
name = "testpw2"
display_name = "Test Password 2"
register_provider(SecondPasswordProvider())
verifier, challenge = _make_pkce()
# Native flow initiated for provider A ("testpw").
cookies = _start_native_password_login(pw_gated_client, challenge=challenge)
# Valid credentials for provider B ("testpw2") must NOT complete A's
# pending authorization.
r = pw_gated_client.post(
"/auth/password-login",
json={
"provider": "testpw2", "username": "admin", "password": "hunter2",
},
cookies=cookies,
)
assert r.status_code == 400, r.text
assert "different provider" in r.json()["detail"]
set_cookie = r.headers.get("set-cookie", "")
assert "hermes_session_at" not in set_cookie
# The pending entry survived — provider A completes normally.
r2 = pw_gated_client.post(
"/auth/password-login",
json={
"provider": "testpw", "username": "admin", "password": "hunter2",
},
cookies=cookies,
)
assert r2.status_code == 200, r2.text
qs = parse_qs(urlparse(r2.json()["next"]).query)
tokens = pw_gated_client.post(
"/auth/native/token",
json={"code": qs["code"][0], "code_verifier": verifier},
).json()
assert tokens["provider"] == "testpw"
def test_password_login_without_broker_still_mints_cookies(pw_gated_client):
"""Guard: an ordinary browser password login (no native broker cookie)
keeps the existing cookie-minting behaviour."""
r = pw_gated_client.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
)
assert r.status_code == 200, r.text
assert r.json()["next"] == "/"
set_cookie = r.headers.get("set-cookie", "")
assert "hermes_session_at" in set_cookie
# ---------------------------------------------------------------------------
# Native refresh
# ---------------------------------------------------------------------------
def test_native_refresh_dead_token_returns_401(gated_client):
r = gated_client.post(
"/auth/native/refresh",
json={"refresh_token": "garbage-not-a-real-rt", "provider": "stub"},
)
assert r.status_code == 401
assert r.json()["error"] == "session_expired"