Hermes-agent
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
"""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_refresh_with_garbage_raises(self, basic):
|
||||
p = self._make(basic)
|
||||
with pytest.raises(RefreshExpiredError):
|
||||
p.refresh_session(refresh_token="garbage")
|
||||
|
||||
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_skips_when_username_but_no_password(self, basic, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
|
||||
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "password" 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_registers_with_precomputed_hash(self, basic, monkeypatch):
|
||||
h = basic.hash_password("s3cret")
|
||||
monkeypatch.setattr(
|
||||
basic,
|
||||
"_load_config_basic_auth_section",
|
||||
lambda: {"username": "ops", "password_hash": h},
|
||||
)
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert provider.complete_password_login(
|
||||
username="ops", password="s3cret"
|
||||
).user_id == "ops"
|
||||
|
||||
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,848 @@
|
||||
"""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_skips_when_client_id_malformed(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "hermes-dashboard")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_PORTAL_URL", "https://p.example")
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
# Skip reason names the offending value + contract shape.
|
||||
assert "agent:" in nous_plugin.LAST_SKIP_REASON
|
||||
assert "hermes-dashboard" in nous_plugin.LAST_SKIP_REASON
|
||||
|
||||
def test_registers_with_explicit_portal_url(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:inst1")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_PORTAL_URL", "https://p.example")
|
||||
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:inst1"
|
||||
assert registered._portal_url == "https://p.example"
|
||||
|
||||
def test_strips_whitespace_from_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", " agent:x ")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_PORTAL_URL", " https://p.example ")
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
|
||||
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_config_yaml_client_id_and_portal_url(self, patch_config, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
|
||||
patch_config({
|
||||
"client_id": "agent:from-config",
|
||||
"portal_url": "https://staging.portal.example",
|
||||
})
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_id == "agent:from-config"
|
||||
assert registered._portal_url == "https://staging.portal.example"
|
||||
|
||||
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_env_overrides_config_portal_url(self, patch_config, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:x")
|
||||
monkeypatch.setenv(
|
||||
"HERMES_DASHBOARD_PORTAL_URL", "https://env.portal.example",
|
||||
)
|
||||
patch_config({
|
||||
"client_id": "agent:x",
|
||||
"portal_url": "https://config.portal.example",
|
||||
})
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._portal_url == "https://env.portal.example"
|
||||
|
||||
def test_empty_env_string_does_not_shadow_config(
|
||||
self, patch_config, monkeypatch
|
||||
):
|
||||
"""``HERMES_DASHBOARD_OAUTH_CLIENT_ID=`` (set but empty) is
|
||||
common in CI/Fly when a secret is provisioned-but-not-populated.
|
||||
It MUST NOT shadow a valid config.yaml value with an empty
|
||||
string — operators would lose the gate."""
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "")
|
||||
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"
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
def test_config_yaml_load_failure_falls_through_cleanly(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""If load_config() raises (e.g. malformed YAML, IOError), the
|
||||
plugin must not crash — it falls through to the env-only path
|
||||
and either succeeds (if env is set) or surfaces the standard
|
||||
'not set' skip reason."""
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
|
||||
|
||||
def _broken_load():
|
||||
raise OSError("config.yaml not readable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config", _broken_load
|
||||
)
|
||||
ctx = MagicMock()
|
||||
# Must not raise.
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
|
||||
def test_config_yaml_with_non_dict_oauth_section(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""cfg_get handles 'config has a string where a section was
|
||||
expected' robustly. Verify the plugin inherits that resilience
|
||||
so a malformed user config doesn't crash startup."""
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"dashboard": {"oauth": "wrong type"}},
|
||||
)
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
# Falls through to the no-env-and-no-config path.
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_code_challenge_is_s256_of_verifier(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)
|
||||
verifier = parts["verifier"]
|
||||
expected_challenge = (
|
||||
base64.urlsafe_b64encode(
|
||||
hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
)
|
||||
.rstrip(b"=")
|
||||
.decode()
|
||||
)
|
||||
assert params["code_challenge"] == expected_challenge
|
||||
|
||||
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_rejects_non_http_scheme(self, provider):
|
||||
with pytest.raises(ProviderError, match="http"):
|
||||
provider.start_login(redirect_uri="ftp://x/auth/callback")
|
||||
|
||||
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")
|
||||
|
||||
def test_allows_http_localhost(self, provider):
|
||||
# Should not raise.
|
||||
provider.start_login(redirect_uri="http://localhost:8080/auth/callback")
|
||||
provider.start_login(redirect_uri="http://127.0.0.1:8080/auth/callback")
|
||||
|
||||
def test_rejects_wrong_callback_path(self, provider):
|
||||
with pytest.raises(ProviderError, match="/auth/callback"):
|
||||
provider.start_login(redirect_uri="https://x.example/oauth/cb")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_happy_path_tolerates_missing_refresh_token(self, provider, rsa_keypair):
|
||||
# If Portal omits refresh_token (older deploy), the session is still
|
||||
# valid as access-token-only; refresh_token defaults to "".
|
||||
access_token = _mint_token(rsa_keypair)
|
||||
mock_resp = self._mock_post(
|
||||
200, {"access_token": access_token, "token_type": "Bearer"}
|
||||
)
|
||||
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 session.refresh_token == ""
|
||||
|
||||
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_happy_path_returns_session(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair)
|
||||
session = provider.verify_session(access_token=token)
|
||||
assert session is not None
|
||||
assert session.user_id == "usr_abc"
|
||||
assert session.org_id == "org_xyz"
|
||||
|
||||
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_wrong_issuer_raises_provider_error(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair, iss="https://evil.example")
|
||||
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_missing_sub_raises(self, provider, rsa_keypair):
|
||||
# PyJWT's "require" set includes sub, so this surfaces as
|
||||
# InvalidTokenError → ProviderError before we ever touch _session_from_claims.
|
||||
token = _mint_token(rsa_keypair, sub="")
|
||||
# Empty sub still encodes successfully; PyJWT's require check only
|
||||
# asserts presence. Our own _session_from_claims rejects empty.
|
||||
with pytest.raises(ProviderError, match="sub"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
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_agent_instance_id_missing_is_tolerated(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair, agent_instance_id=None)
|
||||
session = provider.verify_session(access_token=token)
|
||||
assert session is not None
|
||||
|
||||
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_contract_version_mismatch_rejected(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair, oauth_contract_version=2)
|
||||
with pytest.raises(ProviderError, match="oauth_contract_version"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
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_refresh_400_raises_refresh_expired(self, provider):
|
||||
# Expired / revoked / reuse-detected RT → Portal 400 → force re-login.
|
||||
mock_resp = self._mock_post(400, {"error": "invalid_grant"})
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(RefreshExpiredError, match="invalid_grant"):
|
||||
provider.refresh_session(refresh_token="rt_dead")
|
||||
|
||||
def test_refresh_empty_token_raises_refresh_expired_without_network(self, provider):
|
||||
# No RT present — fail fast as a dead session, never hit the network.
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post") as mock_post:
|
||||
with pytest.raises(RefreshExpiredError):
|
||||
provider.refresh_session(refresh_token="")
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_refresh_network_error_raises_provider_error(self, provider):
|
||||
with patch(
|
||||
"plugins.dashboard_auth.nous.httpx.post",
|
||||
side_effect=httpx.RequestError("boom"),
|
||||
):
|
||||
with pytest.raises(ProviderError, match="unreachable"):
|
||||
provider.refresh_session(refresh_token="rt_x")
|
||||
|
||||
def test_refresh_500_raises_provider_error(self, provider):
|
||||
mock_resp = self._mock_post(500, "oops", ctype="text/plain")
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(ProviderError):
|
||||
provider.refresh_session(refresh_token="rt_x")
|
||||
|
||||
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,882 @@
|
||||
"""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):
|
||||
"""Construct a provider with discovery + JWKS stubbed (no network)."""
|
||||
kwargs: Dict[str, Any] = {"issuer": _ISSUER, "client_id": _CLIENT_ID}
|
||||
if scopes is not None:
|
||||
kwargs["scopes"] = scopes
|
||||
p = oidc_plugin.SelfHostedOIDCProvider(**kwargs)
|
||||
# Pre-seed discovery so nothing hits the network.
|
||||
p._discovery = dict(_DISCOVERY_DOC)
|
||||
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_name_and_display(self):
|
||||
p = oidc_plugin.SelfHostedOIDCProvider(issuer=_ISSUER, client_id=_CLIENT_ID)
|
||||
assert p.name == "self-hosted"
|
||||
assert p.display_name == "Self-Hosted OIDC"
|
||||
|
||||
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_requires_client_id(self):
|
||||
with pytest.raises(ValueError, match="client_id"):
|
||||
oidc_plugin.SelfHostedOIDCProvider(issuer=_ISSUER, 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
|
||||
)
|
||||
|
||||
def test_allows_http_localhost_issuer(self):
|
||||
# Local dev against a loopback IDP is allowed.
|
||||
p = oidc_plugin.SelfHostedOIDCProvider(
|
||||
issuer="http://localhost:9000", client_id=_CLIENT_ID
|
||||
)
|
||||
assert p._issuer == "http://localhost:9000"
|
||||
|
||||
def test_default_scopes(self):
|
||||
p = oidc_plugin.SelfHostedOIDCProvider(issuer=_ISSUER, client_id=_CLIENT_ID)
|
||||
assert p._scopes == "openid profile email"
|
||||
|
||||
def test_empty_scopes_falls_back_to_default(self):
|
||||
p = oidc_plugin.SelfHostedOIDCProvider(
|
||||
issuer=_ISSUER, client_id=_CLIENT_ID, scopes=" "
|
||||
)
|
||||
assert p._scopes == "openid profile email"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_discovery_url(self):
|
||||
p = self._provider()
|
||||
assert p._discovery_url() == (
|
||||
f"{_ISSUER}/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
def test_discovery_404_raises(self):
|
||||
p = self._provider()
|
||||
mock_resp = self._mock_get(404, {})
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.get", return_value=mock_resp
|
||||
):
|
||||
with pytest.raises(ProviderError, match="404"):
|
||||
p._get_discovery()
|
||||
|
||||
def test_discovery_unreachable_raises(self):
|
||||
p = self._provider()
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.get",
|
||||
side_effect=httpx.ConnectError("no route"),
|
||||
):
|
||||
with pytest.raises(ProviderError, match="unreachable"):
|
||||
p._get_discovery()
|
||||
|
||||
def test_discovery_missing_endpoint_raises(self):
|
||||
p = self._provider()
|
||||
doc = dict(_DISCOVERY_DOC)
|
||||
del doc["token_endpoint"]
|
||||
mock_resp = self._mock_get(200, doc)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.get", return_value=mock_resp
|
||||
):
|
||||
with pytest.raises(ProviderError, match="token_endpoint"):
|
||||
p._get_discovery()
|
||||
|
||||
def test_discovery_issuer_mismatch_raises(self):
|
||||
p = self._provider()
|
||||
doc = dict(_DISCOVERY_DOC)
|
||||
doc["issuer"] = "https://evil.example"
|
||||
mock_resp = self._mock_get(200, doc)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.get", return_value=mock_resp
|
||||
):
|
||||
with pytest.raises(ProviderError, match="issuer mismatch"):
|
||||
p._get_discovery()
|
||||
|
||||
def test_discovery_issuer_trailing_slash_tolerated(self):
|
||||
p = self._provider()
|
||||
doc = dict(_DISCOVERY_DOC)
|
||||
doc["issuer"] = _ISSUER + "/" # only a trailing-slash difference
|
||||
mock_resp = self._mock_get(200, doc)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.get", return_value=mock_resp
|
||||
):
|
||||
disco = p._get_discovery()
|
||||
assert disco["token_endpoint"] == f"{_ISSUER}/token"
|
||||
|
||||
def test_discovery_rejects_non_https_endpoint(self):
|
||||
p = self._provider()
|
||||
doc = dict(_DISCOVERY_DOC)
|
||||
doc["token_endpoint"] = "http://auth.example.com/token" # not loopback
|
||||
mock_resp = self._mock_get(200, doc)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.get", return_value=mock_resp
|
||||
):
|
||||
with pytest.raises(ProviderError, match="https"):
|
||||
p._get_discovery()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_redirect_url_targets_authorize_endpoint(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.example/auth/callback"
|
||||
)
|
||||
assert result.redirect_url.startswith(f"{_ISSUER}/authorize?")
|
||||
|
||||
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_custom_scopes_used(self, rsa_keypair):
|
||||
provider = _make_provider(rsa_keypair, scopes="openid email groups")
|
||||
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["scope"] == "openid email groups"
|
||||
|
||||
def test_code_verifier_length(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.example/auth/callback"
|
||||
)
|
||||
pkce = result.cookie_payload["hermes_session_pkce"]
|
||||
parts = dict(seg.split("=", 1) for seg in pkce.split(";") if "=" in seg)
|
||||
assert 43 <= len(parts["verifier"]) <= 128 # RFC 7636 §4.1
|
||||
|
||||
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"]
|
||||
|
||||
def test_code_challenge_is_s256_of_verifier(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)
|
||||
expected = (
|
||||
base64.urlsafe_b64encode(
|
||||
hashlib.sha256(parts["verifier"].encode("ascii")).digest()
|
||||
)
|
||||
.rstrip(b"=")
|
||||
.decode()
|
||||
)
|
||||
assert params["code_challenge"] == expected
|
||||
|
||||
def test_two_calls_differ(self, provider):
|
||||
a = provider.start_login(redirect_uri="https://hermes.example/auth/callback")
|
||||
b = provider.start_login(redirect_uri="https://hermes.example/auth/callback")
|
||||
assert (
|
||||
a.cookie_payload["hermes_session_pkce"]
|
||||
!= b.cookie_payload["hermes_session_pkce"]
|
||||
)
|
||||
|
||||
def test_rejects_wrong_callback_path(self, provider):
|
||||
with pytest.raises(ProviderError, match="/auth/callback"):
|
||||
provider.start_login(redirect_uri="https://x.example/oauth/cb")
|
||||
|
||||
def test_allows_http_localhost_redirect(self, provider):
|
||||
provider.start_login(redirect_uri="http://localhost:8080/auth/callback")
|
||||
provider.start_login(redirect_uri="http://127.0.0.1:8080/auth/callback")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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",
|
||||
)
|
||||
|
||||
def test_500_raises_provider_error(self, provider):
|
||||
mock_resp = _mock_post(500, "boom", ctype="text/plain")
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.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.example/auth/callback",
|
||||
)
|
||||
|
||||
def test_network_error_raises_provider_error(self, provider):
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.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.example/auth/callback",
|
||||
)
|
||||
|
||||
def test_unexpected_token_type_raises(self, provider, rsa_keypair):
|
||||
id_token = _mint_id_token(rsa_keypair)
|
||||
mock_resp = _mock_post(
|
||||
200, {"id_token": id_token, "token_type": "DPoP"}
|
||||
)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.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.example/auth/callback",
|
||||
)
|
||||
|
||||
def test_posts_authorization_code_grant(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
|
||||
) 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
|
||||
assert kwargs["data"]["grant_type"] == "authorization_code"
|
||||
assert kwargs["data"]["code"] == "the-code"
|
||||
assert kwargs["data"]["code_verifier"] == "the-verifier"
|
||||
assert kwargs["data"]["client_id"] == _CLIENT_ID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVerifySession:
|
||||
@pytest.fixture
|
||||
def provider(self, rsa_keypair):
|
||||
return _make_provider(rsa_keypair)
|
||||
|
||||
def test_happy_path(self, provider, rsa_keypair):
|
||||
token = _mint_id_token(rsa_keypair)
|
||||
session = provider.verify_session(access_token=token)
|
||||
assert session is not None
|
||||
assert session.user_id == "usr_abc"
|
||||
assert session.email == "alice@example.com"
|
||||
assert session.display_name == "Alice Example"
|
||||
|
||||
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_wrong_issuer_raises(self, provider, rsa_keypair):
|
||||
token = _mint_id_token(rsa_keypair, iss="https://evil.example")
|
||||
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_missing_sub_raises(self, provider, rsa_keypair):
|
||||
token = _mint_id_token(rsa_keypair, sub="")
|
||||
with pytest.raises(ProviderError, match="sub"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
def test_display_name_falls_back_to_preferred_username(
|
||||
self, provider, rsa_keypair
|
||||
):
|
||||
token = _mint_id_token(
|
||||
rsa_keypair,
|
||||
name=None,
|
||||
email=None,
|
||||
extra_claims={"preferred_username": "alice42"},
|
||||
)
|
||||
session = provider.verify_session(access_token=token)
|
||||
assert session is not None
|
||||
assert session.display_name == "alice42"
|
||||
|
||||
def test_org_id_from_org_claim(self, provider, rsa_keypair):
|
||||
token = _mint_id_token(rsa_keypair, org_id="acme-corp")
|
||||
session = provider.verify_session(access_token=token)
|
||||
assert session is not None
|
||||
assert session.org_id == "acme-corp"
|
||||
|
||||
def test_org_id_from_groups_when_no_org_claim(self, provider, rsa_keypair):
|
||||
token = _mint_id_token(rsa_keypair, groups=["admins", "users"])
|
||||
session = provider.verify_session(access_token=token)
|
||||
assert session is not None
|
||||
assert session.org_id == "admins,users"
|
||||
|
||||
def test_org_id_empty_when_neither_present(self, provider, rsa_keypair):
|
||||
token = _mint_id_token(rsa_keypair)
|
||||
session = provider.verify_session(access_token=token)
|
||||
assert session is not None
|
||||
assert session.org_id == ""
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# refresh_session + revoke_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRefreshAndRevoke:
|
||||
@pytest.fixture
|
||||
def provider(self, rsa_keypair):
|
||||
return _make_provider(rsa_keypair)
|
||||
|
||||
def test_refresh_happy_path_rotates(self, provider, rsa_keypair):
|
||||
id_token = _mint_id_token(rsa_keypair)
|
||||
mock_resp = _mock_post(
|
||||
200,
|
||||
{
|
||||
"id_token": id_token,
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "rt_rotated",
|
||||
},
|
||||
)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||
) as mock_post:
|
||||
session = provider.refresh_session(refresh_token="rt_old")
|
||||
assert isinstance(session, Session)
|
||||
assert session.access_token == id_token
|
||||
assert session.refresh_token == "rt_rotated"
|
||||
assert session.provider == "self-hosted"
|
||||
_, kwargs = mock_post.call_args
|
||||
assert kwargs["data"]["grant_type"] == "refresh_token"
|
||||
assert kwargs["data"]["refresh_token"] == "rt_old"
|
||||
assert kwargs["data"]["client_id"] == _CLIENT_ID
|
||||
|
||||
def test_refresh_keeps_previous_rt_when_idp_omits(self, provider, rsa_keypair):
|
||||
# Some IDPs don't rotate; keep the caller's existing RT alive.
|
||||
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.refresh_session(refresh_token="rt_kept")
|
||||
assert session.refresh_token == "rt_kept"
|
||||
|
||||
def test_refresh_400_raises_refresh_expired(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(RefreshExpiredError, match="invalid_grant"):
|
||||
provider.refresh_session(refresh_token="rt_dead")
|
||||
|
||||
def test_refresh_empty_token_no_network(self, provider):
|
||||
with patch("plugins.dashboard_auth.self_hosted.httpx.post") as mock_post:
|
||||
with pytest.raises(RefreshExpiredError):
|
||||
provider.refresh_session(refresh_token="")
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_refresh_network_error_raises_provider_error(self, provider):
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post",
|
||||
side_effect=httpx.RequestError("boom"),
|
||||
):
|
||||
with pytest.raises(ProviderError, match="unreachable"):
|
||||
provider.refresh_session(refresh_token="rt_x")
|
||||
|
||||
def test_revoke_posts_to_revocation_endpoint(self, provider):
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post"
|
||||
) as mock_post:
|
||||
provider.revoke_session(refresh_token="rt_x")
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
assert args[0] == f"{_ISSUER}/revoke"
|
||||
assert kwargs["data"]["token"] == "rt_x"
|
||||
|
||||
def test_revoke_empty_token_noop(self, provider):
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post"
|
||||
) as mock_post:
|
||||
assert provider.revoke_session(refresh_token="") is None
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_revoke_swallows_errors(self, provider):
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post",
|
||||
side_effect=httpx.RequestError("down"),
|
||||
):
|
||||
# Must not raise.
|
||||
assert provider.revoke_session(refresh_token="rt_x") is None
|
||||
|
||||
def test_revoke_noop_when_no_revocation_endpoint(self, provider):
|
||||
provider._discovery["revocation_endpoint"] = ""
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post"
|
||||
) as mock_post:
|
||||
assert provider.revoke_session(refresh_token="rt_x") is None
|
||||
mock_post.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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",
|
||||
):
|
||||
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_skips_when_only_issuer_set(self, patch_config, monkeypatch):
|
||||
patch_config(None)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
|
||||
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_registers_from_config_yaml(self, patch_config):
|
||||
patch_config(
|
||||
{"self_hosted": {"issuer": _ISSUER, "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 registered._issuer == _ISSUER
|
||||
assert registered._client_id == _CLIENT_ID
|
||||
|
||||
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_empty_env_does_not_shadow_config(self, patch_config, monkeypatch):
|
||||
patch_config(
|
||||
{"self_hosted": {"issuer": _ISSUER, "client_id": _CLIENT_ID}}
|
||||
)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", "")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_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 registered._issuer == _ISSUER
|
||||
|
||||
def test_custom_scopes_from_config(self, patch_config):
|
||||
patch_config(
|
||||
{
|
||||
"self_hosted": {
|
||||
"issuer": _ISSUER,
|
||||
"client_id": _CLIENT_ID,
|
||||
"scopes": "openid email",
|
||||
}
|
||||
}
|
||||
)
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._scopes == "openid email"
|
||||
|
||||
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()
|
||||
|
||||
def test_non_dict_oauth_section_tolerated(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"dashboard": {"oauth": "wrong type"}},
|
||||
)
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
|
||||
def test_non_https_issuer_skips_with_reason(self, patch_config, monkeypatch):
|
||||
patch_config(None)
|
||||
monkeypatch.setenv(
|
||||
"HERMES_DASHBOARD_OIDC_ISSUER", "http://insecure.example"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "construction failed" in oidc_plugin.LAST_SKIP_REASON
|
||||
Reference in New Issue
Block a user