forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -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