Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
"""Tests for the BasicAuthProvider plugin (username/password, scrypt, signed
|
||||
tokens).
|
||||
|
||||
Loads the plugin module directly (it's a bundled backend plugin, not on the
|
||||
import path as a package) and exercises the provider behaviour + the
|
||||
``register(ctx)`` entry point's config/env resolution and skip reasons.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.dashboard_auth.basic as basic_plugin
|
||||
from hermes_cli.dashboard_auth import (
|
||||
InvalidCredentialsError,
|
||||
RefreshExpiredError,
|
||||
assert_protocol_compliance,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def basic():
|
||||
return basic_plugin
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_basic_env(monkeypatch):
|
||||
for var in (
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_SECRET",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
def test_hash_then_verify_round_trips(self, basic):
|
||||
h = basic.hash_password("hunter2")
|
||||
assert h.startswith("scrypt$")
|
||||
assert basic._verify_password("hunter2", h)
|
||||
|
||||
def test_wrong_password_fails(self, basic):
|
||||
h = basic.hash_password("hunter2")
|
||||
assert not basic._verify_password("wrong", h)
|
||||
|
||||
def test_malformed_hash_returns_false(self, basic):
|
||||
assert not basic._verify_password("x", "not-a-valid-hash")
|
||||
assert not basic._verify_password("x", "bcrypt$wrong$scheme")
|
||||
|
||||
def test_two_hashes_of_same_password_differ(self, basic):
|
||||
# Distinct random salts → distinct encoded hashes.
|
||||
assert basic.hash_password("pw") != basic.hash_password("pw")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProvider:
|
||||
def _make(self, basic, **kw):
|
||||
h = basic.hash_password("hunter2")
|
||||
return basic.BasicAuthProvider(
|
||||
username="admin",
|
||||
password_hash=h,
|
||||
secret=secrets.token_bytes(32),
|
||||
**kw,
|
||||
)
|
||||
|
||||
def test_protocol_compliant(self, basic):
|
||||
assert assert_protocol_compliance(basic.BasicAuthProvider) is None
|
||||
|
||||
def test_supports_password_true(self, basic):
|
||||
assert basic.BasicAuthProvider.supports_password is True
|
||||
|
||||
def test_login_mints_session(self, basic):
|
||||
p = self._make(basic)
|
||||
s = p.complete_password_login(username="admin", password="hunter2")
|
||||
assert s.user_id == "admin"
|
||||
assert s.provider == "basic"
|
||||
assert s.access_token and s.refresh_token
|
||||
|
||||
def test_bad_credentials_raise(self, basic):
|
||||
p = self._make(basic)
|
||||
for u, pw in [("admin", "wrong"), ("ghost", "hunter2"), ("", "")]:
|
||||
with pytest.raises(InvalidCredentialsError):
|
||||
p.complete_password_login(username=u, password=pw)
|
||||
|
||||
def test_verify_round_trips_and_rejects_tamper(self, basic):
|
||||
p = self._make(basic)
|
||||
s = p.complete_password_login(username="admin", password="hunter2")
|
||||
assert p.verify_session(access_token=s.access_token) is not None
|
||||
assert p.verify_session(access_token="garbage") is None
|
||||
|
||||
def test_access_token_not_accepted_as_refresh(self, basic):
|
||||
p = self._make(basic)
|
||||
s = p.complete_password_login(username="admin", password="hunter2")
|
||||
# A refresh token must not verify as an access token and vice
|
||||
# versa — the ``kind`` claim is enforced.
|
||||
assert p.verify_session(access_token=s.refresh_token) is None
|
||||
with pytest.raises(RefreshExpiredError):
|
||||
p.refresh_session(refresh_token=s.access_token)
|
||||
|
||||
def test_refresh_round_trips(self, basic):
|
||||
p = self._make(basic)
|
||||
s = p.complete_password_login(username="admin", password="hunter2")
|
||||
r = p.refresh_session(refresh_token=s.refresh_token)
|
||||
assert r.user_id == "admin"
|
||||
assert p.verify_session(access_token=r.access_token) is not None
|
||||
|
||||
|
||||
def test_cross_secret_token_does_not_verify(self, basic):
|
||||
p1 = self._make(basic)
|
||||
p2 = self._make(basic) # different random secret
|
||||
s = p1.complete_password_login(username="admin", password="hunter2")
|
||||
assert p2.verify_session(access_token=s.access_token) is None
|
||||
|
||||
def test_revoke_is_silent(self, basic):
|
||||
p = self._make(basic)
|
||||
p.revoke_session(refresh_token="anything") # must not raise
|
||||
|
||||
def test_oauth_methods_raise_not_implemented(self, basic):
|
||||
p = self._make(basic)
|
||||
with pytest.raises(NotImplementedError):
|
||||
p.start_login(redirect_uri="https://x/auth/callback")
|
||||
with pytest.raises(NotImplementedError):
|
||||
p.complete_login(
|
||||
code="c", state="s", code_verifier="v", redirect_uri="r"
|
||||
)
|
||||
|
||||
def test_construction_validates_inputs(self, basic):
|
||||
good_hash = basic.hash_password("pw")
|
||||
with pytest.raises(ValueError):
|
||||
basic.BasicAuthProvider(
|
||||
username="", password_hash=good_hash, secret=b"x" * 32
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
basic.BasicAuthProvider(
|
||||
username="admin", password_hash="", secret=b"x" * 32
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
basic.BasicAuthProvider(
|
||||
username="admin", password_hash=good_hash, secret=b"short"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# register() entry point — config/env resolution + skip reasons
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegister:
|
||||
def test_skips_when_no_username(self, basic, monkeypatch):
|
||||
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "username" in basic.LAST_SKIP_REASON
|
||||
|
||||
|
||||
def test_registers_with_env_plaintext_password(self, basic, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "hunter2")
|
||||
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert isinstance(provider, basic.BasicAuthProvider)
|
||||
# Round-trips: the registered provider authenticates the env creds.
|
||||
s = provider.complete_password_login(username="admin", password="hunter2")
|
||||
assert s.user_id == "admin"
|
||||
assert basic.LAST_SKIP_REASON == ""
|
||||
|
||||
|
||||
def test_env_password_overrides_config(self, basic, monkeypatch):
|
||||
cfg_hash = basic.hash_password("config-pw")
|
||||
monkeypatch.setattr(
|
||||
basic,
|
||||
"_load_config_basic_auth_section",
|
||||
lambda: {"username": "admin", "password_hash": cfg_hash},
|
||||
)
|
||||
# Env plaintext should win over the config hash.
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "env-pw")
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
# env password works ...
|
||||
assert provider.complete_password_login(
|
||||
username="admin", password="env-pw"
|
||||
)
|
||||
# ... and the config password no longer does.
|
||||
with pytest.raises(InvalidCredentialsError):
|
||||
provider.complete_password_login(username="admin", password="config-pw")
|
||||
|
||||
def test_explicit_secret_makes_sessions_portable(self, basic, monkeypatch):
|
||||
# Two providers built from the SAME explicit secret accept each
|
||||
# other's tokens (the restart-/multi-worker-survival contract).
|
||||
shared = secrets.token_bytes(32).hex()
|
||||
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "hunter2")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_SECRET", shared)
|
||||
|
||||
ctx1, ctx2 = MagicMock(), MagicMock()
|
||||
basic.register(ctx1)
|
||||
basic.register(ctx2)
|
||||
p1 = ctx1.register_dashboard_auth_provider.call_args.args[0]
|
||||
p2 = ctx2.register_dashboard_auth_provider.call_args.args[0]
|
||||
s = p1.complete_password_login(username="admin", password="hunter2")
|
||||
assert p2.verify_session(access_token=s.access_token) is not None
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Tests for the DrainSecretProvider plugin (non-interactive bearer secret).
|
||||
|
||||
Task 2.0b. Loads the bundled drain plugin module directly and exercises:
|
||||
* the entropy gate (assess_secret_strength) — fail-closed on weak secrets,
|
||||
* constant-time verify_token returning a scoped TokenPrincipal,
|
||||
* the register(ctx) entry point's env/config resolution, skip reasons, and
|
||||
token-route registration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.dashboard_auth.drain as drain_plugin
|
||||
from hermes_cli.dashboard_auth import TokenPrincipal, assert_protocol_compliance
|
||||
from hermes_cli.dashboard_auth import token_auth
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def drain():
|
||||
return drain_plugin
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env_and_routes(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_DRAIN_SECRET", raising=False)
|
||||
token_auth.clear_token_routes()
|
||||
yield
|
||||
token_auth.clear_token_routes()
|
||||
|
||||
|
||||
def _strong_secret() -> str:
|
||||
# token_urlsafe(32) → 43 url-safe-b64 chars ≈ 256 bits.
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entropy gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEntropyGate:
|
||||
def test_strong_secret_passes(self, drain):
|
||||
assert drain.assess_secret_strength(_strong_secret()) is None
|
||||
|
||||
def test_empty_rejected(self, drain):
|
||||
assert drain.assess_secret_strength("") is not None
|
||||
|
||||
def test_too_short_rejected(self, drain):
|
||||
# 42 chars — one under the 43-char bar.
|
||||
assert drain.assess_secret_strength("a1B2c3" * 7) is not None
|
||||
|
||||
def test_long_but_repeated_rejected(self, drain):
|
||||
# 60 chars, one distinct character → low distinct count + low entropy.
|
||||
assert drain.assess_secret_strength("a" * 60) is not None
|
||||
|
||||
|
||||
def test_custom_min_chars_enforced(self, drain):
|
||||
s = _strong_secret() # 43 chars
|
||||
assert drain.assess_secret_strength(s, min_chars=999) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProvider:
|
||||
def test_protocol_compliance(self, drain):
|
||||
assert_protocol_compliance(drain.DrainSecretProvider)
|
||||
|
||||
def test_supports_token_flag(self, drain):
|
||||
p = drain.DrainSecretProvider(secret=_strong_secret())
|
||||
assert p.supports_token is True
|
||||
|
||||
def test_is_non_interactive(self, drain):
|
||||
# Excluded from interactive surfaces via list_session_providers().
|
||||
p = drain.DrainSecretProvider(secret=_strong_secret())
|
||||
assert p.supports_session is False
|
||||
|
||||
def test_verify_token_accepts_matching_secret(self, drain):
|
||||
s = _strong_secret()
|
||||
p = drain.DrainSecretProvider(secret=s, scope="drain")
|
||||
principal = p.verify_token(token=s)
|
||||
assert isinstance(principal, TokenPrincipal)
|
||||
assert principal.principal == "drain-control"
|
||||
assert principal.provider == "drain-secret"
|
||||
assert principal.scopes == ("drain",)
|
||||
|
||||
|
||||
def test_verify_token_rejects_empty(self, drain):
|
||||
p = drain.DrainSecretProvider(secret=_strong_secret())
|
||||
assert p.verify_token(token="") is None
|
||||
|
||||
|
||||
def test_construction_rejects_weak_secret(self, drain):
|
||||
with pytest.raises(ValueError):
|
||||
drain.DrainSecretProvider(secret="weak")
|
||||
|
||||
|
||||
def test_interactive_methods_raise(self, drain):
|
||||
p = drain.DrainSecretProvider(secret=_strong_secret())
|
||||
with pytest.raises(NotImplementedError):
|
||||
p.start_login(redirect_uri="r")
|
||||
with pytest.raises(NotImplementedError):
|
||||
p.complete_login(code="c", state="s", code_verifier="v", redirect_uri="r")
|
||||
with pytest.raises(NotImplementedError):
|
||||
p.refresh_session(refresh_token="r")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# register() entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegister:
|
||||
def test_skips_when_no_secret(self, drain, monkeypatch):
|
||||
monkeypatch.setattr(drain, "_load_config_drain_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
drain.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "HERMES_DASHBOARD_DRAIN_SECRET" in drain.LAST_SKIP_REASON
|
||||
assert not token_auth.is_token_route(drain.DRAIN_ROUTE_PATH)
|
||||
|
||||
def test_skips_and_fails_closed_on_weak_secret(self, drain, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_DRAIN_SECRET", "tooweak")
|
||||
monkeypatch.setattr(drain, "_load_config_drain_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
drain.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "rejected" in drain.LAST_SKIP_REASON
|
||||
# fail-closed: the route is NOT token-authable, so it stays gated.
|
||||
assert not token_auth.is_token_route(drain.DRAIN_ROUTE_PATH)
|
||||
|
||||
def test_registers_with_strong_env_secret(self, drain, monkeypatch):
|
||||
s = _strong_secret()
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_DRAIN_SECRET", s)
|
||||
monkeypatch.setattr(drain, "_load_config_drain_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
drain.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert isinstance(provider, drain.DrainSecretProvider)
|
||||
assert provider.verify_token(token=s) is not None
|
||||
assert drain.LAST_SKIP_REASON == ""
|
||||
# The drain endpoint is now token-authable.
|
||||
assert token_auth.is_token_route(drain.DRAIN_ROUTE_PATH)
|
||||
|
||||
def test_config_scope_applied(self, drain, monkeypatch):
|
||||
s = _strong_secret()
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_DRAIN_SECRET", s)
|
||||
monkeypatch.setattr(
|
||||
drain, "_load_config_drain_auth_section", lambda: {"scope": "lifecycle"}
|
||||
)
|
||||
ctx = MagicMock()
|
||||
drain.register(ctx)
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert provider.verify_token(token=s).scopes == ("lifecycle",)
|
||||
|
||||
def test_config_min_secret_chars_can_reject_otherwise_ok_secret(
|
||||
self, drain, monkeypatch
|
||||
):
|
||||
s = _strong_secret() # 43 chars — fine by default, too short at 999
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_DRAIN_SECRET", s)
|
||||
monkeypatch.setattr(
|
||||
drain,
|
||||
"_load_config_drain_auth_section",
|
||||
lambda: {"min_secret_chars": 999},
|
||||
)
|
||||
ctx = MagicMock()
|
||||
drain.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "rejected" in drain.LAST_SKIP_REASON
|
||||
@@ -0,0 +1,665 @@
|
||||
"""Tests for the bundled Nous dashboard-auth plugin.
|
||||
|
||||
Covers four shapes from Phase 4 of ``.hermes/plans/2026-05-21-dashboard-oauth-auth.md``:
|
||||
|
||||
1. Plugin entry-point registration gating (env var checks).
|
||||
2. ``start_login`` shape (PKCE/state, authorize URL parameters).
|
||||
3. ``complete_login`` httpx-mocked happy path + error mapping.
|
||||
4. ``verify_session`` JWT verification — RSA keypair, audience/issuer pinning,
|
||||
``agent_instance_id`` cross-check, ``oauth_contract_version`` tolerance.
|
||||
|
||||
Also exercises ``revoke_session`` (no-op) and ``refresh_session``
|
||||
(unconditional ``RefreshExpiredError``).
|
||||
|
||||
All HTTP is mocked: nothing in this file talks to a real Portal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Any, Dict
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
import plugins.dashboard_auth.nous as nous_plugin
|
||||
from hermes_cli.dashboard_auth import (
|
||||
InvalidCodeError,
|
||||
LoginStart,
|
||||
ProviderError,
|
||||
RefreshExpiredError,
|
||||
Session,
|
||||
assert_protocol_compliance,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RSA keypair fixture (module-scope — keygen is slow)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rsa_keypair() -> Dict[str, Any]:
|
||||
"""Generate an RS256 keypair + matching JWK for verify_session tests."""
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_pem = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
public_numbers = key.public_key().public_numbers()
|
||||
|
||||
def _b64url_uint(n: int) -> str:
|
||||
length = (n.bit_length() + 7) // 8
|
||||
return (
|
||||
base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
|
||||
)
|
||||
|
||||
jwk = {
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": "test-key-1",
|
||||
"n": _b64url_uint(public_numbers.n),
|
||||
"e": _b64url_uint(public_numbers.e),
|
||||
}
|
||||
return {"private_pem": private_pem, "jwk": jwk, "kid": jwk["kid"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token-mint helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mint_token(
|
||||
rsa_keypair: Dict[str, Any],
|
||||
*,
|
||||
iss: str = "https://portal.example.com",
|
||||
aud: str = "agent:inst123",
|
||||
sub: str = "usr_abc",
|
||||
agent_instance_id: str | None = "inst123",
|
||||
oauth_contract_version: Any = 1,
|
||||
org_id: str | None = "org_xyz",
|
||||
scope: str = "agent_dashboard:access",
|
||||
ttl_seconds: int = 900,
|
||||
extra_claims: Dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
now = int(time.time())
|
||||
claims = {
|
||||
"iss": iss,
|
||||
"aud": aud,
|
||||
"sub": sub,
|
||||
"iat": now,
|
||||
"exp": now + ttl_seconds,
|
||||
"scope": scope,
|
||||
}
|
||||
if agent_instance_id is not None:
|
||||
claims["agent_instance_id"] = agent_instance_id
|
||||
if oauth_contract_version is not None:
|
||||
claims["oauth_contract_version"] = oauth_contract_version
|
||||
if org_id is not None:
|
||||
claims["org_id"] = org_id
|
||||
if extra_claims:
|
||||
claims.update(extra_claims)
|
||||
return jwt.encode(
|
||||
claims,
|
||||
rsa_keypair["private_pem"],
|
||||
algorithm="RS256",
|
||||
headers={"kid": rsa_keypair["kid"]},
|
||||
)
|
||||
|
||||
|
||||
def _patched_jwks(provider: nous_plugin.NousDashboardAuthProvider, rsa_keypair):
|
||||
"""Patch the provider's JWKS client to return our fixture key."""
|
||||
fake_key = MagicMock()
|
||||
fake_key.key = serialization.load_pem_private_key(
|
||||
rsa_keypair["private_pem"].encode(), password=None
|
||||
).public_key()
|
||||
fake_client = MagicMock()
|
||||
fake_client.get_signing_key_from_jwt.return_value = fake_key
|
||||
provider._jwks_client = fake_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConstruction:
|
||||
def test_protocol_compliance(self):
|
||||
assert_protocol_compliance(nous_plugin.NousDashboardAuthProvider)
|
||||
|
||||
def test_name_and_display(self):
|
||||
p = nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:inst1", portal_url="https://portal.example.com"
|
||||
)
|
||||
assert p.name == "nous"
|
||||
assert p.display_name == "Nous Research"
|
||||
|
||||
def test_extracts_agent_instance_id(self):
|
||||
p = nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:abc-123", portal_url="https://portal.example.com"
|
||||
)
|
||||
assert p._agent_instance_id == "abc-123"
|
||||
|
||||
def test_strips_trailing_slash_from_portal_url(self):
|
||||
p = nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:x", portal_url="https://portal.example.com/"
|
||||
)
|
||||
assert p._portal_url == "https://portal.example.com"
|
||||
|
||||
def test_rejects_malformed_client_id(self):
|
||||
with pytest.raises(ValueError, match="agent:"):
|
||||
nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="hermes-dashboard", portal_url="https://x"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point: env-gated registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPluginRegister:
|
||||
def test_skips_when_client_id_missing(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
# Skip reason is surfaced for the gate's fail-closed message.
|
||||
assert "HERMES_DASHBOARD_OAUTH_CLIENT_ID" in nous_plugin.LAST_SKIP_REASON
|
||||
|
||||
def test_registers_with_default_portal_url_when_only_client_id_set(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""Phase 7 follow-up: HERMES_DASHBOARD_PORTAL_URL is optional —
|
||||
defaults to the production Nous Portal. The user shouldn't have
|
||||
to set it for the common production deployment path."""
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:inst1")
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert isinstance(registered, nous_plugin.NousDashboardAuthProvider)
|
||||
assert registered._portal_url == "https://portal.nousresearch.com"
|
||||
# Skip reason cleared on successful registration.
|
||||
assert nous_plugin.LAST_SKIP_REASON == ""
|
||||
|
||||
|
||||
def test_empty_portal_url_env_uses_default(self, monkeypatch):
|
||||
"""Explicit empty string still falls back to the production
|
||||
default — same handling as 'unset' so an empty Fly secret can't
|
||||
accidentally point the dashboard at nowhere."""
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:inst1")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_PORTAL_URL", "")
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._portal_url == "https://portal.nousresearch.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point: config.yaml + env-override precedence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigYamlSource:
|
||||
"""``dashboard.oauth.{client_id,portal_url}`` in ``config.yaml`` is the
|
||||
canonical surface for these settings. ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``
|
||||
and ``HERMES_DASHBOARD_PORTAL_URL`` are operator overrides that win when
|
||||
set — this is the contract Fly.io's platform-secret injection relies on,
|
||||
and the contract that lets local devs experiment without setting env
|
||||
vars.
|
||||
|
||||
Each test pins exactly one tier of the precedence chain so a regression
|
||||
that flips the order is caught:
|
||||
|
||||
env (when truthy) > config.yaml (when truthy) > plugin default
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def patch_config(self, monkeypatch):
|
||||
"""Yield a callable that replaces ``hermes_cli.config.load_config``
|
||||
with a stub returning the given dict. Tests pass the intended
|
||||
``dashboard.oauth`` block; the stub returns the wrapping structure."""
|
||||
|
||||
def _set(oauth_block: Dict[str, Any] | None) -> None:
|
||||
cfg = {}
|
||||
if oauth_block is not None:
|
||||
cfg = {"dashboard": {"oauth": oauth_block}}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config", lambda: cfg
|
||||
)
|
||||
|
||||
return _set
|
||||
|
||||
def test_config_yaml_only_client_id_registers(self, patch_config, monkeypatch):
|
||||
"""No env var, only config.yaml — plugin reads from config and
|
||||
registers successfully. This is the path Teknium's review pushed
|
||||
for (".env is for secrets only")."""
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
|
||||
patch_config({"client_id": "agent:from-config"})
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_id == "agent:from-config"
|
||||
# Defaults to production portal URL when neither config nor env
|
||||
# specifies one.
|
||||
assert registered._portal_url == "https://portal.nousresearch.com"
|
||||
|
||||
|
||||
def test_env_overrides_config_client_id(self, patch_config, monkeypatch):
|
||||
"""Env wins. Critical for Fly.io: the Portal injects
|
||||
HERMES_DASHBOARD_OAUTH_CLIENT_ID at deploy time and we MUST
|
||||
honour it even if a stale config.yaml ships in the image."""
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:from-env")
|
||||
patch_config({"client_id": "agent:from-config"})
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_id == "agent:from-env", (
|
||||
"env var must override config.yaml — Fly secret injection "
|
||||
"depends on this precedence"
|
||||
)
|
||||
|
||||
|
||||
def test_neither_source_skips_with_helpful_reason(
|
||||
self, patch_config, monkeypatch
|
||||
):
|
||||
"""Neither env nor config.yaml set — skip with a reason that
|
||||
mentions BOTH surfaces so operators don't guess wrong about
|
||||
which one to populate."""
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
|
||||
patch_config(None)
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
# Old behaviour: skip reason mentions the env var.
|
||||
assert "HERMES_DASHBOARD_OAUTH_CLIENT_ID" in nous_plugin.LAST_SKIP_REASON
|
||||
# New behaviour: skip reason ALSO mentions the config.yaml path
|
||||
# so the user knows it's a valid alternative.
|
||||
assert "dashboard.oauth.client_id" in nous_plugin.LAST_SKIP_REASON, (
|
||||
f"skip reason omits the config.yaml surface — operators "
|
||||
f"won't know it exists. got: {nous_plugin.LAST_SKIP_REASON!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start_login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStartLogin:
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:inst1", portal_url="https://portal.example.com"
|
||||
)
|
||||
|
||||
def test_returns_login_start(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
assert isinstance(result, LoginStart)
|
||||
|
||||
def test_redirect_url_targets_portal_authorize(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
assert result.redirect_url.startswith(
|
||||
"https://portal.example.com/oauth/authorize?"
|
||||
)
|
||||
|
||||
def test_authorize_url_has_required_params(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
parsed = urllib.parse.urlparse(result.redirect_url)
|
||||
params = dict(urllib.parse.parse_qsl(parsed.query))
|
||||
assert params["response_type"] == "code"
|
||||
assert params["client_id"] == "agent:inst1"
|
||||
assert params["redirect_uri"] == "https://hermes.fly.dev/auth/callback"
|
||||
assert params["scope"] == "agent_dashboard:access"
|
||||
assert params["code_challenge_method"] == "S256"
|
||||
assert "state" in params
|
||||
assert "code_challenge" in params
|
||||
|
||||
def test_code_verifier_in_cookie_payload_43_to_128_chars(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
assert "hermes_session_pkce" in result.cookie_payload
|
||||
pkce = result.cookie_payload["hermes_session_pkce"]
|
||||
# Shape: ``state=…;verifier=…`` (matches stub-provider convention so
|
||||
# the auth-route layer's parser works uniformly across providers).
|
||||
parts = dict(seg.split("=", 1) for seg in pkce.split(";") if "=" in seg)
|
||||
verifier = parts["verifier"]
|
||||
# RFC 7636 §4.1
|
||||
assert 43 <= len(verifier) <= 128
|
||||
|
||||
def test_state_in_cookie_payload_matches_url_param(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
parsed = urllib.parse.urlparse(result.redirect_url)
|
||||
params = dict(urllib.parse.parse_qsl(parsed.query))
|
||||
pkce = result.cookie_payload["hermes_session_pkce"]
|
||||
parts = dict(seg.split("=", 1) for seg in pkce.split(";") if "=" in seg)
|
||||
assert parts["state"] == params["state"]
|
||||
|
||||
|
||||
def test_two_calls_produce_different_state_and_verifier(self, provider):
|
||||
a = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
b = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
assert a.cookie_payload["hermes_session_pkce"] != b.cookie_payload[
|
||||
"hermes_session_pkce"
|
||||
]
|
||||
|
||||
|
||||
def test_allows_http_with_arbitrary_host(self, provider):
|
||||
# http:// is permitted for any host now, not just localhost — the
|
||||
# Portal-side check is authoritative on which redirect_uris are
|
||||
# accepted; this client-side fast-fail must not reject self-hosted
|
||||
# dashboards reached over plain HTTP (LAN IPs, internal hostnames,
|
||||
# TLS-terminating reverse proxies). Should not raise.
|
||||
provider.start_login(redirect_uri="http://hermes.fly.dev/auth/callback")
|
||||
provider.start_login(redirect_uri="http://192.168.1.50:8080/auth/callback")
|
||||
provider.start_login(redirect_uri="http://my-internal-host/auth/callback")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# complete_login (httpx mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCompleteLogin:
|
||||
@pytest.fixture
|
||||
def provider(self, rsa_keypair):
|
||||
p = nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:inst123", portal_url="https://portal.example.com"
|
||||
)
|
||||
_patched_jwks(p, rsa_keypair)
|
||||
return p
|
||||
|
||||
def _mock_post(self, status_code: int, body: Any, *, ctype: str = "application/json"):
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = status_code
|
||||
if isinstance(body, dict):
|
||||
resp.text = json.dumps(body)
|
||||
resp.json = MagicMock(return_value=body)
|
||||
else:
|
||||
resp.text = body
|
||||
# _parse_json_body bails on non-application/json before .json()
|
||||
# is called, but be safe for callers that pass a non-dict body
|
||||
# with ctype=application/json.
|
||||
resp.json = MagicMock(side_effect=ValueError("not json"))
|
||||
resp.headers = {"content-type": ctype}
|
||||
return resp
|
||||
|
||||
def test_happy_path_returns_session(self, provider, rsa_keypair):
|
||||
access_token = _mint_token(rsa_keypair)
|
||||
mock_resp = self._mock_post(
|
||||
200,
|
||||
{
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "rt_initial_value",
|
||||
},
|
||||
)
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
session = provider.complete_login(
|
||||
code="abc",
|
||||
state="state-val",
|
||||
code_verifier="vfy",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
assert isinstance(session, Session)
|
||||
assert session.user_id == "usr_abc"
|
||||
assert session.provider == "nous"
|
||||
assert session.access_token == access_token
|
||||
# The dashboard auth-code grant now issues a refresh token (NAS #293);
|
||||
# complete_login must surface it so the middleware persists it.
|
||||
assert session.refresh_token == "rt_initial_value"
|
||||
assert session.org_id == "org_xyz"
|
||||
assert session.email == ""
|
||||
assert session.display_name == ""
|
||||
|
||||
|
||||
def test_400_raises_invalid_code(self, provider):
|
||||
mock_resp = self._mock_post(400, {"error": "invalid_grant"})
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(InvalidCodeError, match="invalid_grant"):
|
||||
provider.complete_login(
|
||||
code="bad", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
|
||||
def test_500_raises_provider_error(self, provider):
|
||||
mock_resp = self._mock_post(500, "internal server error", ctype="text/plain")
|
||||
mock_resp.text = "internal server error"
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(ProviderError, match="500"):
|
||||
provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
|
||||
def test_missing_access_token_raises(self, provider):
|
||||
mock_resp = self._mock_post(200, {"token_type": "Bearer"})
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(ProviderError, match="access_token"):
|
||||
provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
|
||||
def test_unexpected_token_type_raises(self, provider, rsa_keypair):
|
||||
access_token = _mint_token(rsa_keypair)
|
||||
mock_resp = self._mock_post(
|
||||
200, {"access_token": access_token, "token_type": "DPoP"}
|
||||
)
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(ProviderError, match="token_type"):
|
||||
provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
|
||||
def test_network_error_raises_provider_error(self, provider):
|
||||
with patch(
|
||||
"plugins.dashboard_auth.nous.httpx.post",
|
||||
side_effect=httpx.ConnectError("conn refused"),
|
||||
):
|
||||
with pytest.raises(ProviderError, match="unreachable"):
|
||||
provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
|
||||
def test_captures_refresh_token_if_present_forward_compat(
|
||||
self, provider, rsa_keypair
|
||||
):
|
||||
"""Forward-compat: contract V1 doesn't issue, but if a future Portal
|
||||
does, we should preserve it in the Session for later use."""
|
||||
access_token = _mint_token(rsa_keypair)
|
||||
mock_resp = self._mock_post(
|
||||
200,
|
||||
{
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "rt-opaque",
|
||||
},
|
||||
)
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
session = provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
assert session.refresh_token == "rt-opaque"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVerifySession:
|
||||
@pytest.fixture
|
||||
def provider(self, rsa_keypair):
|
||||
p = nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:inst123", portal_url="https://portal.example.com"
|
||||
)
|
||||
_patched_jwks(p, rsa_keypair)
|
||||
return p
|
||||
|
||||
def test_jwks_client_sends_explicit_http_headers(self, provider):
|
||||
"""Constructor-contract regression: the JWKS fetch must send an
|
||||
explicit Accept + User-Agent so it isn't blocked by the Portal WAF
|
||||
(same fix as the self_hosted provider)."""
|
||||
provider._jwks_client = None
|
||||
with patch("jwt.PyJWKClient") as client_cls:
|
||||
provider._get_jwks_client()
|
||||
client_cls.assert_called_once_with(
|
||||
provider._jwks_url,
|
||||
cache_keys=True,
|
||||
lifespan=nous_plugin._JWKS_CACHE_SECONDS,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "HermesAgent/1.0",
|
||||
},
|
||||
)
|
||||
|
||||
def test_expired_token_returns_none(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair, ttl_seconds=-1)
|
||||
assert provider.verify_session(access_token=token) is None
|
||||
|
||||
def test_wrong_audience_raises_provider_error(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair, aud="agent:other-instance")
|
||||
with pytest.raises(ProviderError, match="verification failed"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
|
||||
def test_verification_failure_message_surfaces_token_claims(
|
||||
self, provider, rsa_keypair
|
||||
):
|
||||
"""Operators need to see the actual iss/aud the token carries to debug
|
||||
config drift between HERMES_DASHBOARD_PORTAL_URL/CLIENT_ID and Portal."""
|
||||
token = _mint_token(rsa_keypair, iss="https://evil.example")
|
||||
with pytest.raises(ProviderError) as excinfo:
|
||||
provider.verify_session(access_token=token)
|
||||
msg = str(excinfo.value)
|
||||
# Both the observed (token) and expected (configured) values appear.
|
||||
assert "'https://evil.example'" in msg
|
||||
assert "'https://portal.example.com'" in msg # configured portal URL
|
||||
|
||||
|
||||
def test_agent_instance_id_mismatch_rejected(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair, agent_instance_id="some-other-id")
|
||||
with pytest.raises(ProviderError, match="agent_instance_id mismatch"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
|
||||
def test_contract_version_missing_warns_but_succeeds(
|
||||
self, provider, rsa_keypair, caplog
|
||||
):
|
||||
import logging
|
||||
token = _mint_token(rsa_keypair, oauth_contract_version=None)
|
||||
with caplog.at_level(logging.WARNING, logger="plugins.dashboard_auth.nous"):
|
||||
session = provider.verify_session(access_token=token)
|
||||
assert session is not None
|
||||
assert any(
|
||||
"oauth_contract_version" in r.message for r in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_jwks_unreachable_raises_provider_error(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair)
|
||||
# Replace the patched client so it raises.
|
||||
bad_client = MagicMock()
|
||||
bad_client.get_signing_key_from_jwt.side_effect = jwt.PyJWKClientError(
|
||||
"fetch failed"
|
||||
)
|
||||
provider._jwks_client = bad_client
|
||||
with pytest.raises(ProviderError, match="JWKS"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# refresh_session + revoke_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRefreshAndRevoke:
|
||||
@pytest.fixture
|
||||
def provider(self, rsa_keypair):
|
||||
p = nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:inst123", portal_url="https://portal.example.com"
|
||||
)
|
||||
_patched_jwks(p, rsa_keypair)
|
||||
return p
|
||||
|
||||
def _mock_post(self, status_code, body, *, ctype="application/json"):
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = status_code
|
||||
if isinstance(body, dict):
|
||||
resp.text = json.dumps(body)
|
||||
resp.json = MagicMock(return_value=body)
|
||||
else:
|
||||
resp.text = body
|
||||
resp.json = MagicMock(side_effect=ValueError("not json"))
|
||||
resp.headers = {"content-type": ctype}
|
||||
return resp
|
||||
|
||||
def test_refresh_happy_path_returns_rotated_session(self, provider, rsa_keypair):
|
||||
# Portal returns a fresh access token AND a rotated refresh token.
|
||||
access_token = _mint_token(rsa_keypair)
|
||||
mock_resp = self._mock_post(
|
||||
200,
|
||||
{
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "rt_rotated_value",
|
||||
},
|
||||
)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp
|
||||
) as mock_post:
|
||||
session = provider.refresh_session(refresh_token="rt_old_value")
|
||||
|
||||
assert isinstance(session, Session)
|
||||
assert session.access_token == access_token
|
||||
# The ROTATED refresh token must be surfaced so the middleware can
|
||||
# persist it back to the cookie.
|
||||
assert session.refresh_token == "rt_rotated_value"
|
||||
assert session.provider == "nous"
|
||||
|
||||
# Posts grant_type=refresh_token with the RT in BOTH the body (Portal's
|
||||
# schema requires it there) and the X-Refresh-Token header (log
|
||||
# redaction). Verified against the live preview deploy.
|
||||
_, kwargs = mock_post.call_args
|
||||
assert kwargs["data"]["grant_type"] == "refresh_token"
|
||||
assert kwargs["data"]["client_id"] == "agent:inst123"
|
||||
assert kwargs["data"]["refresh_token"] == "rt_old_value"
|
||||
assert kwargs["headers"]["x-nous-refresh-token"] == "rt_old_value"
|
||||
|
||||
|
||||
def test_revoke_is_noop(self, provider):
|
||||
# Must not raise; returns None implicitly.
|
||||
assert provider.revoke_session(refresh_token="anything") is None
|
||||
assert provider.revoke_session(refresh_token="") is None
|
||||
@@ -0,0 +1,156 @@
|
||||
"""#94558 — a non-JWT bearer must not be reported as "Auth provider unreachable".
|
||||
|
||||
Hosted agents answered every opaque/peer bearer on the gated API with a fast
|
||||
HTTP 503 ``{"detail": "Auth provider 'nous' unreachable"}`` while Portal was
|
||||
perfectly healthy: ``NousDashboardAuthProvider._verify_jwt`` folded *every*
|
||||
``PyJWKClient`` failure — including ``DecodeError('Not enough segments')`` for
|
||||
a token that is not a JWT at all — into ``ProviderError``. Only a transport
|
||||
failure fetching the JWKS is "unreachable"; anything else means "not my
|
||||
token" (``verify_session`` -> None -> 401 / next provider).
|
||||
|
||||
Real ``NousDashboardAuthProvider`` + real ``SelfHostedOIDCProvider`` JWKS path,
|
||||
a real local HTTP JWKS server (reachable case) or a closed port (unreachable),
|
||||
and the real gated web_server app for the HTTP-level assertion.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.dashboard_auth import (
|
||||
InvalidCodeError,
|
||||
ProviderError,
|
||||
classify_jwks_lookup_error,
|
||||
clear_providers,
|
||||
register_provider,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE
|
||||
import plugins.dashboard_auth.nous as nous_plugin
|
||||
|
||||
OPAQUE_PEER_KEY = "hk_live_opaque_peer_key_0123456789abcdef"
|
||||
# Well-formed RS256 JWT header with an unknown kid, bogus payload/signature.
|
||||
FOREIGN_KID_JWT = "eyJhbGciOiJSUzI1NiIsImtpZCI6Inp6eiJ9.e30.sig"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def empty_jwks_server():
|
||||
"""A reachable JWKS endpoint that knows no keys."""
|
||||
|
||||
class _H(BaseHTTPRequestHandler):
|
||||
def do_GET(self): # noqa: N802
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"keys": []}).encode())
|
||||
|
||||
def log_message(self, *a): # silence
|
||||
pass
|
||||
|
||||
srv = HTTPServer(("127.0.0.1", 0), _H)
|
||||
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
t.start()
|
||||
yield f"http://127.0.0.1:{srv.server_address[1]}"
|
||||
srv.shutdown()
|
||||
|
||||
|
||||
def _nous(portal_url: str) -> nous_plugin.NousDashboardAuthProvider:
|
||||
return nous_plugin.NousDashboardAuthProvider(client_id="agent:test-instance", portal_url=portal_url)
|
||||
|
||||
|
||||
# ── classifier ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_classifier_maps_transport_failure_to_provider_error():
|
||||
exc = jwt.PyJWKClientConnectionError("Fail to fetch data from the url")
|
||||
assert isinstance(classify_jwks_lookup_error(exc), ProviderError)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
jwt.DecodeError("Not enough segments"),
|
||||
jwt.PyJWKSetError("The JWK Set did not contain any keys"),
|
||||
jwt.InvalidTokenError("bad"),
|
||||
],
|
||||
)
|
||||
def test_classifier_maps_unverifiable_token_to_invalid_code(exc):
|
||||
assert isinstance(classify_jwks_lookup_error(exc), InvalidCodeError)
|
||||
|
||||
|
||||
def test_classifier_keeps_bare_jwk_client_error_as_provider_fault():
|
||||
assert isinstance(classify_jwks_lookup_error(jwt.PyJWKClientError("weird JWKS shape")), ProviderError)
|
||||
|
||||
|
||||
# ── Nous provider ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_opaque_bearer_with_healthy_portal_is_not_unreachable(empty_jwks_server):
|
||||
provider = _nous(empty_jwks_server)
|
||||
assert provider.verify_session(access_token=OPAQUE_PEER_KEY) is None
|
||||
|
||||
|
||||
def test_foreign_kid_jwt_with_healthy_portal_is_not_unreachable(empty_jwks_server):
|
||||
provider = _nous(empty_jwks_server)
|
||||
assert provider.verify_session(access_token=FOREIGN_KID_JWT) is None
|
||||
|
||||
|
||||
def test_real_jwt_with_unreachable_portal_still_raises_provider_error():
|
||||
provider = _nous("http://127.0.0.1:9") # discard port: connection refused
|
||||
with pytest.raises(ProviderError):
|
||||
provider.verify_session(access_token=FOREIGN_KID_JWT)
|
||||
|
||||
|
||||
def test_opaque_bearer_with_unreachable_portal_is_still_just_not_ours():
|
||||
"""No network call is even needed to know an opaque string is not our JWT."""
|
||||
provider = _nous("http://127.0.0.1:9")
|
||||
assert provider.verify_session(access_token=OPAQUE_PEER_KEY) is None
|
||||
|
||||
|
||||
# ── self-hosted OIDC provider (sibling site of the same hunk) ──────────────
|
||||
|
||||
def test_self_hosted_provider_shares_the_classification(empty_jwks_server, monkeypatch):
|
||||
import plugins.dashboard_auth.self_hosted as sh
|
||||
|
||||
provider = object.__new__(sh.SelfHostedOIDCProvider)
|
||||
provider._jwks_client = None
|
||||
provider._client_id = "hermes"
|
||||
monkeypatch.setattr(
|
||||
provider, "_get_discovery",
|
||||
lambda: {"jwks_uri": f"{empty_jwks_server}/jwks", "issuer": empty_jwks_server},
|
||||
)
|
||||
with pytest.raises(InvalidCodeError):
|
||||
provider._verify_id_token(OPAQUE_PEER_KEY)
|
||||
|
||||
|
||||
# ── HTTP level: the gated API answers 401, not 503 ────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def _gated_nous(empty_jwks_server):
|
||||
clear_providers()
|
||||
prev = {k: getattr(web_server.app.state, k, None) for k in ("bound_host", "bound_port", "auth_required")}
|
||||
web_server.app.state.bound_host = "agent.example.test"
|
||||
web_server.app.state.bound_port = 443
|
||||
web_server.app.state.auth_required = True
|
||||
register_provider(_nous(empty_jwks_server))
|
||||
yield TestClient(web_server.app, base_url="https://agent.example.test")
|
||||
clear_providers()
|
||||
for k, v in prev.items():
|
||||
setattr(web_server.app.state, k, v)
|
||||
|
||||
|
||||
def test_gated_api_rejects_opaque_bearer_with_401_not_503(_gated_nous):
|
||||
r = _gated_nous.get("/api/auth/me", headers={"Authorization": f"Bearer {OPAQUE_PEER_KEY}"})
|
||||
assert r.status_code != 503, r.text
|
||||
assert r.status_code == 401
|
||||
assert "unreachable" not in r.text.lower()
|
||||
|
||||
|
||||
def test_gated_api_rejects_opaque_cookie_with_401_not_503(_gated_nous):
|
||||
_gated_nous.cookies.set(SESSION_AT_COOKIE, OPAQUE_PEER_KEY)
|
||||
r = _gated_nous.get("/api/auth/me")
|
||||
assert r.status_code != 503, r.text
|
||||
assert "unreachable" not in r.text.lower()
|
||||
@@ -0,0 +1,729 @@
|
||||
"""Tests for the bundled self-hosted OIDC dashboard-auth plugin.
|
||||
|
||||
Covers, by analogy with ``test_nous_provider.py``:
|
||||
|
||||
1. Plugin entry-point registration gating (env + config.yaml precedence).
|
||||
2. ``start_login`` shape (PKCE/state, authorize URL parameters, OIDC discovery).
|
||||
3. ``complete_login`` httpx-mocked happy path + error mapping (ID-token grant).
|
||||
4. ``verify_session`` ID-token verification — RSA keypair, audience/issuer
|
||||
pinning, standard OIDC claim mapping (sub/email/name/groups).
|
||||
5. ``refresh_session`` rotation + error mapping, ``revoke_session`` (RFC 7009).
|
||||
6. OIDC discovery: endpoint extraction, issuer pinning, https enforcement.
|
||||
|
||||
All HTTP is mocked: nothing here talks to a real IDP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Any, Dict
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
import plugins.dashboard_auth.self_hosted as oidc_plugin
|
||||
from hermes_cli.dashboard_auth import (
|
||||
InvalidCodeError,
|
||||
LoginStart,
|
||||
ProviderError,
|
||||
RefreshExpiredError,
|
||||
Session,
|
||||
assert_protocol_compliance,
|
||||
)
|
||||
|
||||
_ISSUER = "https://auth.example.com/application/o/hermes"
|
||||
_CLIENT_ID = "hermes-dashboard"
|
||||
|
||||
_DISCOVERY_DOC = {
|
||||
"issuer": _ISSUER,
|
||||
"authorization_endpoint": f"{_ISSUER}/authorize",
|
||||
"token_endpoint": f"{_ISSUER}/token",
|
||||
"jwks_uri": f"{_ISSUER}/jwks",
|
||||
"revocation_endpoint": f"{_ISSUER}/revoke",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RSA keypair fixture (module-scope — keygen is slow)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rsa_keypair() -> Dict[str, Any]:
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_pem = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
public_numbers = key.public_key().public_numbers()
|
||||
|
||||
def _b64url_uint(n: int) -> str:
|
||||
length = (n.bit_length() + 7) // 8
|
||||
return (
|
||||
base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
|
||||
)
|
||||
|
||||
jwk = {
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": "test-key-1",
|
||||
"n": _b64url_uint(public_numbers.n),
|
||||
"e": _b64url_uint(public_numbers.e),
|
||||
}
|
||||
return {"private_pem": private_pem, "jwk": jwk, "kid": jwk["kid"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token-mint helper — standard OIDC ID-token claims
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mint_id_token(
|
||||
rsa_keypair: Dict[str, Any],
|
||||
*,
|
||||
iss: str = _ISSUER,
|
||||
aud: str = _CLIENT_ID,
|
||||
sub: str = "usr_abc",
|
||||
email: str | None = "alice@example.com",
|
||||
name: str | None = "Alice Example",
|
||||
groups: Any = None,
|
||||
org_id: str | None = None,
|
||||
ttl_seconds: int = 900,
|
||||
extra_claims: Dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
now = int(time.time())
|
||||
claims: Dict[str, Any] = {
|
||||
"iss": iss,
|
||||
"aud": aud,
|
||||
"sub": sub,
|
||||
"iat": now,
|
||||
"exp": now + ttl_seconds,
|
||||
}
|
||||
if email is not None:
|
||||
claims["email"] = email
|
||||
if name is not None:
|
||||
claims["name"] = name
|
||||
if groups is not None:
|
||||
claims["groups"] = groups
|
||||
if org_id is not None:
|
||||
claims["org_id"] = org_id
|
||||
if extra_claims:
|
||||
claims.update(extra_claims)
|
||||
return jwt.encode(
|
||||
claims,
|
||||
rsa_keypair["private_pem"],
|
||||
algorithm="RS256",
|
||||
headers={"kid": rsa_keypair["kid"]},
|
||||
)
|
||||
|
||||
|
||||
def _make_provider(
|
||||
rsa_keypair,
|
||||
*,
|
||||
scopes: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
auth_methods: Any = "__unset__",
|
||||
):
|
||||
"""Construct a provider with discovery + JWKS stubbed (no network).
|
||||
|
||||
``client_secret`` flips the provider into confidential mode. ``auth_methods``
|
||||
overrides ``token_endpoint_auth_methods_supported`` in the seeded discovery
|
||||
doc (pass a list, or ``None`` to omit the key entirely); left unset, the
|
||||
discovery doc carries no auth-methods key (the absent-key default).
|
||||
"""
|
||||
kwargs: Dict[str, Any] = {"issuer": _ISSUER, "client_id": _CLIENT_ID}
|
||||
if scopes is not None:
|
||||
kwargs["scopes"] = scopes
|
||||
if client_secret is not None:
|
||||
kwargs["client_secret"] = client_secret
|
||||
p = oidc_plugin.SelfHostedOIDCProvider(**kwargs)
|
||||
# Pre-seed discovery so nothing hits the network.
|
||||
disco = dict(_DISCOVERY_DOC)
|
||||
if auth_methods != "__unset__":
|
||||
if auth_methods is None:
|
||||
disco.pop("token_endpoint_auth_methods_supported", None)
|
||||
else:
|
||||
disco["token_endpoint_auth_methods_supported"] = auth_methods
|
||||
p._discovery = disco
|
||||
p._discovery_fetched_at = time.time()
|
||||
# Patch the JWKS client to return our fixture key.
|
||||
fake_key = MagicMock()
|
||||
fake_key.key = serialization.load_pem_private_key(
|
||||
rsa_keypair["private_pem"].encode(), password=None
|
||||
).public_key()
|
||||
fake_client = MagicMock()
|
||||
fake_client.get_signing_key_from_jwt.return_value = fake_key
|
||||
p._jwks_client = fake_client
|
||||
return p
|
||||
|
||||
|
||||
def _mock_post(status_code: int, body: Any, *, ctype: str = "application/json"):
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = status_code
|
||||
if isinstance(body, dict):
|
||||
resp.text = json.dumps(body)
|
||||
resp.json = MagicMock(return_value=body)
|
||||
else:
|
||||
resp.text = body
|
||||
resp.json = MagicMock(side_effect=ValueError("not json"))
|
||||
resp.headers = {"content-type": ctype}
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConstruction:
|
||||
def test_protocol_compliance(self):
|
||||
assert_protocol_compliance(oidc_plugin.SelfHostedOIDCProvider)
|
||||
|
||||
|
||||
def test_strips_trailing_slash_from_issuer(self):
|
||||
p = oidc_plugin.SelfHostedOIDCProvider(
|
||||
issuer=_ISSUER + "/", client_id=_CLIENT_ID
|
||||
)
|
||||
assert p._issuer == _ISSUER
|
||||
|
||||
def test_requires_issuer(self):
|
||||
with pytest.raises(ValueError, match="issuer"):
|
||||
oidc_plugin.SelfHostedOIDCProvider(issuer="", client_id=_CLIENT_ID)
|
||||
|
||||
|
||||
def test_rejects_non_https_issuer(self):
|
||||
with pytest.raises(ProviderError, match="https"):
|
||||
oidc_plugin.SelfHostedOIDCProvider(
|
||||
issuer="http://auth.example.com", client_id=_CLIENT_ID
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiscovery:
|
||||
def _provider(self):
|
||||
return oidc_plugin.SelfHostedOIDCProvider(
|
||||
issuer=_ISSUER, client_id=_CLIENT_ID
|
||||
)
|
||||
|
||||
def _mock_get(self, status_code, body, *, ctype="application/json"):
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = status_code
|
||||
resp.json = MagicMock(return_value=body)
|
||||
resp.text = json.dumps(body) if isinstance(body, dict) else str(body)
|
||||
resp.headers = {"content-type": ctype}
|
||||
return resp
|
||||
|
||||
|
||||
def test_fetches_and_caches(self):
|
||||
p = self._provider()
|
||||
mock_resp = self._mock_get(200, dict(_DISCOVERY_DOC))
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.get", return_value=mock_resp
|
||||
) as mock_get:
|
||||
disco1 = p._get_discovery()
|
||||
disco2 = p._get_discovery()
|
||||
assert disco1["token_endpoint"] == f"{_ISSUER}/token"
|
||||
assert disco1["authorization_endpoint"] == f"{_ISSUER}/authorize"
|
||||
assert disco1["jwks_uri"] == f"{_ISSUER}/jwks"
|
||||
assert disco1["revocation_endpoint"] == f"{_ISSUER}/revoke"
|
||||
# Cached — only one network call.
|
||||
assert mock_get.call_count == 1
|
||||
assert disco2 is disco1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC discovery against a REAL HTTP server that redirects (regression)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiscoveryRealRedirect:
|
||||
"""Discovery must follow a 3xx on the .well-known GET.
|
||||
|
||||
The rest of the discovery suite mocks ``httpx.get`` with a canned 200, so
|
||||
it cannot see httpx's ``follow_redirects=False`` default. Many real IDPs
|
||||
answer the discovery GET with a redirect rather than a direct 200 —
|
||||
Authentik canonicalises the ``.well-known`` path, and any IDP behind a
|
||||
reverse proxy doing http→https upgrade redirects too. Before the fix the
|
||||
bare 3xx (empty body) tripped the ``status != 200`` guard and surfaced as
|
||||
``provider_unreachable`` → HTTP 503 (the symptom in the user report:
|
||||
``curl -o`` writing zero bytes is exactly a redirect with no body).
|
||||
|
||||
This exercises the real httpx transport against a loopback server, so it
|
||||
fails without ``follow_redirects=True`` and passes with it — a behaviour
|
||||
contract, not a mock-shaped snapshot.
|
||||
"""
|
||||
|
||||
def _serve(self, handler_cls):
|
||||
import http.server
|
||||
import socketserver
|
||||
import threading
|
||||
|
||||
# Bind :0 so the OS picks a free port (parallel-runner safe).
|
||||
httpd = socketserver.TCPServer(("127.0.0.1", 0), handler_cls)
|
||||
port = httpd.server_address[1]
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return httpd, port
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start_login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStartLogin:
|
||||
@pytest.fixture
|
||||
def provider(self, rsa_keypair):
|
||||
return _make_provider(rsa_keypair)
|
||||
|
||||
def test_returns_login_start(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.example/auth/callback"
|
||||
)
|
||||
assert isinstance(result, LoginStart)
|
||||
|
||||
|
||||
def test_authorize_url_has_required_params(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.example/auth/callback"
|
||||
)
|
||||
parsed = urllib.parse.urlparse(result.redirect_url)
|
||||
params = dict(urllib.parse.parse_qsl(parsed.query))
|
||||
assert params["response_type"] == "code"
|
||||
assert params["client_id"] == _CLIENT_ID
|
||||
assert params["redirect_uri"] == "https://hermes.example/auth/callback"
|
||||
assert params["scope"] == "openid profile email"
|
||||
assert params["code_challenge_method"] == "S256"
|
||||
assert "state" in params
|
||||
assert "code_challenge" in params
|
||||
|
||||
|
||||
def test_state_in_cookie_matches_url(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.example/auth/callback"
|
||||
)
|
||||
parsed = urllib.parse.urlparse(result.redirect_url)
|
||||
params = dict(urllib.parse.parse_qsl(parsed.query))
|
||||
pkce = result.cookie_payload["hermes_session_pkce"]
|
||||
parts = dict(seg.split("=", 1) for seg in pkce.split(";") if "=" in seg)
|
||||
assert parts["state"] == params["state"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# complete_login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCompleteLogin:
|
||||
@pytest.fixture
|
||||
def provider(self, rsa_keypair):
|
||||
return _make_provider(rsa_keypair)
|
||||
|
||||
def test_happy_path_returns_session(self, provider, rsa_keypair):
|
||||
id_token = _mint_id_token(rsa_keypair)
|
||||
mock_resp = _mock_post(
|
||||
200,
|
||||
{
|
||||
"access_token": "opaque-at",
|
||||
"id_token": id_token,
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "rt_initial",
|
||||
},
|
||||
)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||
):
|
||||
session = provider.complete_login(
|
||||
code="abc",
|
||||
state="s",
|
||||
code_verifier="vfy",
|
||||
redirect_uri="https://hermes.example/auth/callback",
|
||||
)
|
||||
assert isinstance(session, Session)
|
||||
assert session.user_id == "usr_abc"
|
||||
assert session.provider == "self-hosted"
|
||||
assert session.email == "alice@example.com"
|
||||
assert session.display_name == "Alice Example"
|
||||
# The verified ID token is stored in the access_token slot.
|
||||
assert session.access_token == id_token
|
||||
assert session.refresh_token == "rt_initial"
|
||||
|
||||
def test_tolerates_missing_refresh_token(self, provider, rsa_keypair):
|
||||
id_token = _mint_id_token(rsa_keypair)
|
||||
mock_resp = _mock_post(
|
||||
200, {"id_token": id_token, "token_type": "Bearer"}
|
||||
)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||
):
|
||||
session = provider.complete_login(
|
||||
code="abc",
|
||||
state="s",
|
||||
code_verifier="vfy",
|
||||
redirect_uri="https://hermes.example/auth/callback",
|
||||
)
|
||||
assert session.refresh_token == ""
|
||||
|
||||
def test_missing_id_token_raises(self, provider):
|
||||
mock_resp = _mock_post(
|
||||
200, {"access_token": "opaque", "token_type": "Bearer"}
|
||||
)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||
):
|
||||
with pytest.raises(ProviderError, match="id_token"):
|
||||
provider.complete_login(
|
||||
code="x",
|
||||
state="s",
|
||||
code_verifier="v",
|
||||
redirect_uri="https://hermes.example/auth/callback",
|
||||
)
|
||||
|
||||
def test_400_raises_invalid_code(self, provider):
|
||||
mock_resp = _mock_post(400, {"error": "invalid_grant"})
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||
):
|
||||
with pytest.raises(InvalidCodeError, match="invalid_grant"):
|
||||
provider.complete_login(
|
||||
code="bad",
|
||||
state="s",
|
||||
code_verifier="v",
|
||||
redirect_uri="https://hermes.example/auth/callback",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Confidential client (client_secret) — token-endpoint client authentication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_GOOD_TOKEN_RESP_KEYS = {"token_type": "Bearer", "refresh_token": "rt_initial"}
|
||||
|
||||
|
||||
def _decode_basic(header_value: str) -> tuple[str, str]:
|
||||
"""Decode a ``Basic <b64>`` Authorization header back to (user, pass)."""
|
||||
assert header_value.startswith("Basic ")
|
||||
raw = base64.b64decode(header_value[len("Basic ") :]).decode("utf-8")
|
||||
user, _, pw = raw.partition(":")
|
||||
# client_id / secret are form-url-encoded before base64 (RFC 6749 §2.3.1).
|
||||
return urllib.parse.unquote(user), urllib.parse.unquote(pw)
|
||||
|
||||
|
||||
class TestConfidentialClient:
|
||||
"""A configured ``client_secret`` authenticates the client at the token
|
||||
endpoint (basic header or post body, auto-selected from discovery), while
|
||||
PKCE is still sent. A public client (no secret) is byte-identical to the
|
||||
pre-confidential-client behaviour — no secret anywhere, no auth header."""
|
||||
|
||||
def _complete(self, provider, rsa_keypair):
|
||||
id_token = _mint_id_token(rsa_keypair)
|
||||
mock_resp = _mock_post(200, {"id_token": id_token, **_GOOD_TOKEN_RESP_KEYS})
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||
) as mock_post:
|
||||
provider.complete_login(
|
||||
code="the-code",
|
||||
state="s",
|
||||
code_verifier="the-verifier",
|
||||
redirect_uri="https://hermes.example/auth/callback",
|
||||
)
|
||||
_, kwargs = mock_post.call_args
|
||||
return kwargs
|
||||
|
||||
# -- public client: nothing changes ------------------------------------
|
||||
|
||||
def test_public_client_sends_no_secret_or_auth_header(self, rsa_keypair):
|
||||
# No client_secret configured → no Authorization header, no
|
||||
# client_secret in the body. Pins the unchanged public-client contract.
|
||||
provider = _make_provider(rsa_keypair) # public
|
||||
kwargs = self._complete(provider, rsa_keypair)
|
||||
assert "Authorization" not in kwargs["headers"]
|
||||
assert "client_secret" not in kwargs["data"]
|
||||
# PKCE still present.
|
||||
assert kwargs["data"]["code_verifier"] == "the-verifier"
|
||||
# Header is exactly the pre-feature value.
|
||||
assert kwargs["headers"] == {"Accept": "application/json"}
|
||||
|
||||
# -- basic (default & explicit) ----------------------------------------
|
||||
|
||||
def test_confidential_defaults_to_basic_when_methods_absent(self, rsa_keypair):
|
||||
# Discovery advertises no auth methods → OIDC default is Basic.
|
||||
provider = _make_provider(
|
||||
rsa_keypair, client_secret="s3cr3t", auth_methods=None
|
||||
)
|
||||
kwargs = self._complete(provider, rsa_keypair)
|
||||
assert "client_secret" not in kwargs["data"] # not in body for basic
|
||||
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
|
||||
assert (user, pw) == (_CLIENT_ID, "s3cr3t")
|
||||
# PKCE still sent alongside the secret.
|
||||
assert kwargs["data"]["code_verifier"] == "the-verifier"
|
||||
|
||||
|
||||
# -- post --------------------------------------------------------------
|
||||
|
||||
|
||||
# -- url-encoding of reserved chars ------------------------------------
|
||||
|
||||
def test_basic_url_encodes_reserved_chars_in_secret(self, rsa_keypair):
|
||||
# A secret with ':' / '@' / space must round-trip through the Basic
|
||||
# header exactly — these are exactly the chars that corrupt a naive
|
||||
# "id:secret" concatenation.
|
||||
tricky = "p@ss:wo rd/+="
|
||||
provider = _make_provider(
|
||||
rsa_keypair, client_secret=tricky, auth_methods=["client_secret_basic"]
|
||||
)
|
||||
kwargs = self._complete(provider, rsa_keypair)
|
||||
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
|
||||
assert user == _CLIENT_ID
|
||||
assert pw == tricky
|
||||
|
||||
# -- blank secret is treated as public ---------------------------------
|
||||
|
||||
|
||||
# -- refresh grant also authenticates ----------------------------------
|
||||
|
||||
def test_refresh_grant_authenticates_confidential_client(self, rsa_keypair):
|
||||
provider = _make_provider(
|
||||
rsa_keypair, client_secret="s3cr3t", auth_methods=["client_secret_post"]
|
||||
)
|
||||
id_token = _mint_id_token(rsa_keypair)
|
||||
mock_resp = _mock_post(
|
||||
200, {"id_token": id_token, "token_type": "Bearer", "refresh_token": "rt2"}
|
||||
)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||
) as mock_post:
|
||||
provider.refresh_session(refresh_token="rt_old")
|
||||
_, kwargs = mock_post.call_args
|
||||
assert kwargs["data"]["grant_type"] == "refresh_token"
|
||||
assert kwargs["data"]["client_secret"] == "s3cr3t"
|
||||
|
||||
|
||||
# -- revocation also authenticates -------------------------------------
|
||||
|
||||
|
||||
# -- the secret never appears in logs ----------------------------------
|
||||
|
||||
def test_secret_not_in_repr_or_log(self, rsa_keypair, caplog):
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
provider = _make_provider(
|
||||
rsa_keypair, client_secret="sup3r-s3cr3t", auth_methods=None
|
||||
)
|
||||
# The provider object's repr must not leak the secret.
|
||||
assert "sup3r-s3cr3t" not in repr(provider)
|
||||
assert "sup3r-s3cr3t" not in caplog.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVerifySession:
|
||||
@pytest.fixture
|
||||
def provider(self, rsa_keypair):
|
||||
return _make_provider(rsa_keypair)
|
||||
|
||||
|
||||
def test_expired_returns_none(self, provider, rsa_keypair):
|
||||
token = _mint_id_token(rsa_keypair, ttl_seconds=-1)
|
||||
assert provider.verify_session(access_token=token) is None
|
||||
|
||||
def test_wrong_audience_raises(self, provider, rsa_keypair):
|
||||
token = _mint_id_token(rsa_keypair, aud="some-other-client")
|
||||
with pytest.raises(ProviderError, match="verification failed"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
|
||||
def test_failure_message_surfaces_claims(self, provider, rsa_keypair):
|
||||
token = _mint_id_token(rsa_keypair, iss="https://evil.example")
|
||||
with pytest.raises(ProviderError) as excinfo:
|
||||
provider.verify_session(access_token=token)
|
||||
msg = str(excinfo.value)
|
||||
assert "'https://evil.example'" in msg
|
||||
assert f"'{_ISSUER}'" in msg
|
||||
|
||||
|
||||
def test_jwks_unreachable_raises(self, provider, rsa_keypair):
|
||||
token = _mint_id_token(rsa_keypair)
|
||||
bad_client = MagicMock()
|
||||
bad_client.get_signing_key_from_jwt.side_effect = jwt.PyJWKClientError(
|
||||
"fetch failed"
|
||||
)
|
||||
provider._jwks_client = bad_client
|
||||
with pytest.raises(ProviderError, match="JWKS"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
def test_jwks_client_sends_explicit_http_headers(self):
|
||||
provider = oidc_plugin.SelfHostedOIDCProvider(
|
||||
issuer=_ISSUER, client_id=_CLIENT_ID
|
||||
)
|
||||
provider._discovery = dict(_DISCOVERY_DOC)
|
||||
provider._discovery_fetched_at = time.time()
|
||||
|
||||
with patch("jwt.PyJWKClient") as client_cls:
|
||||
provider._get_jwks_client()
|
||||
|
||||
client_cls.assert_called_once_with(
|
||||
_DISCOVERY_DOC["jwks_uri"],
|
||||
cache_keys=True,
|
||||
lifespan=oidc_plugin._JWKS_CACHE_SECONDS,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "HermesAgent/1.0",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# refresh_session + revoke_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRefreshAndRevoke:
|
||||
@pytest.fixture
|
||||
def provider(self, rsa_keypair):
|
||||
return _make_provider(rsa_keypair)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point: env + config.yaml precedence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPluginRegister:
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_env(self, monkeypatch):
|
||||
for var in (
|
||||
"HERMES_DASHBOARD_OIDC_ISSUER",
|
||||
"HERMES_DASHBOARD_OIDC_CLIENT_ID",
|
||||
"HERMES_DASHBOARD_OIDC_SCOPES",
|
||||
"HERMES_DASHBOARD_OIDC_CLIENT_SECRET",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
@pytest.fixture
|
||||
def patch_config(self, monkeypatch):
|
||||
def _set(oauth_block):
|
||||
cfg = {}
|
||||
if oauth_block is not None:
|
||||
cfg = {"dashboard": {"oauth": oauth_block}}
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: cfg)
|
||||
|
||||
return _set
|
||||
|
||||
def test_skips_when_unconfigured(self, patch_config):
|
||||
patch_config(None)
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "HERMES_DASHBOARD_OIDC_ISSUER" in oidc_plugin.LAST_SKIP_REASON
|
||||
assert "self_hosted" in oidc_plugin.LAST_SKIP_REASON
|
||||
|
||||
|
||||
def test_registers_from_env(self, patch_config, monkeypatch):
|
||||
patch_config(None)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert isinstance(registered, oidc_plugin.SelfHostedOIDCProvider)
|
||||
assert registered._issuer == _ISSUER
|
||||
assert registered._client_id == _CLIENT_ID
|
||||
assert registered._scopes == "openid profile email"
|
||||
assert oidc_plugin.LAST_SKIP_REASON == ""
|
||||
|
||||
|
||||
def test_env_overrides_config(self, patch_config, monkeypatch):
|
||||
patch_config(
|
||||
{
|
||||
"self_hosted": {
|
||||
"issuer": "https://config.example",
|
||||
"client_id": "config-client",
|
||||
}
|
||||
}
|
||||
)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._issuer == _ISSUER
|
||||
assert registered._client_id == _CLIENT_ID
|
||||
|
||||
|
||||
def test_config_load_failure_falls_through(self, monkeypatch):
|
||||
def _broken():
|
||||
raise OSError("unreadable")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", _broken)
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx) # must not raise
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
|
||||
|
||||
# -- client_secret wiring ----------------------------------------------
|
||||
|
||||
|
||||
def test_secret_from_env(self, patch_config, monkeypatch):
|
||||
patch_config(None)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "env-secret")
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_secret == "env-secret"
|
||||
|
||||
|
||||
def test_env_secret_overrides_config(self, patch_config, monkeypatch):
|
||||
patch_config(
|
||||
{
|
||||
"self_hosted": {
|
||||
"issuer": _ISSUER,
|
||||
"client_id": _CLIENT_ID,
|
||||
"client_secret": "cfg-secret",
|
||||
}
|
||||
}
|
||||
)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "env-secret")
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_secret == "env-secret"
|
||||
|
||||
def test_empty_env_secret_does_not_shadow_config(self, patch_config, monkeypatch):
|
||||
patch_config(
|
||||
{
|
||||
"self_hosted": {
|
||||
"issuer": _ISSUER,
|
||||
"client_id": _CLIENT_ID,
|
||||
"client_secret": "cfg-secret",
|
||||
}
|
||||
}
|
||||
)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "")
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_secret == "cfg-secret"
|
||||
|
||||
Reference in New Issue
Block a user