157 lines
6.1 KiB
Python
157 lines
6.1 KiB
Python
"""#94558 — a non-JWT bearer must not be reported as "Auth provider unreachable".
|
|
|
|
Hosted agents answered every opaque/peer bearer on the gated API with a fast
|
|
HTTP 503 ``{"detail": "Auth provider 'nous' unreachable"}`` while Portal was
|
|
perfectly healthy: ``NousDashboardAuthProvider._verify_jwt`` folded *every*
|
|
``PyJWKClient`` failure — including ``DecodeError('Not enough segments')`` for
|
|
a token that is not a JWT at all — into ``ProviderError``. Only a transport
|
|
failure fetching the JWKS is "unreachable"; anything else means "not my
|
|
token" (``verify_session`` -> None -> 401 / next provider).
|
|
|
|
Real ``NousDashboardAuthProvider`` + real ``SelfHostedOIDCProvider`` JWKS path,
|
|
a real local HTTP JWKS server (reachable case) or a closed port (unreachable),
|
|
and the real gated web_server app for the HTTP-level assertion.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
import jwt
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
from hermes_cli import web_server
|
|
from hermes_cli.dashboard_auth import (
|
|
InvalidCodeError,
|
|
ProviderError,
|
|
classify_jwks_lookup_error,
|
|
clear_providers,
|
|
register_provider,
|
|
)
|
|
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE
|
|
import plugins.dashboard_auth.nous as nous_plugin
|
|
|
|
OPAQUE_PEER_KEY = "hk_live_opaque_peer_key_0123456789abcdef"
|
|
# Well-formed RS256 JWT header with an unknown kid, bogus payload/signature.
|
|
FOREIGN_KID_JWT = "eyJhbGciOiJSUzI1NiIsImtpZCI6Inp6eiJ9.e30.sig"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def empty_jwks_server():
|
|
"""A reachable JWKS endpoint that knows no keys."""
|
|
|
|
class _H(BaseHTTPRequestHandler):
|
|
def do_GET(self): # noqa: N802
|
|
self.send_response(200)
|
|
self.send_header("content-type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps({"keys": []}).encode())
|
|
|
|
def log_message(self, *a): # silence
|
|
pass
|
|
|
|
srv = HTTPServer(("127.0.0.1", 0), _H)
|
|
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
t.start()
|
|
yield f"http://127.0.0.1:{srv.server_address[1]}"
|
|
srv.shutdown()
|
|
|
|
|
|
def _nous(portal_url: str) -> nous_plugin.NousDashboardAuthProvider:
|
|
return nous_plugin.NousDashboardAuthProvider(client_id="agent:test-instance", portal_url=portal_url)
|
|
|
|
|
|
# ── classifier ────────────────────────────────────────────────────────────
|
|
|
|
def test_classifier_maps_transport_failure_to_provider_error():
|
|
exc = jwt.PyJWKClientConnectionError("Fail to fetch data from the url")
|
|
assert isinstance(classify_jwks_lookup_error(exc), ProviderError)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"exc",
|
|
[
|
|
jwt.DecodeError("Not enough segments"),
|
|
jwt.PyJWKSetError("The JWK Set did not contain any keys"),
|
|
jwt.InvalidTokenError("bad"),
|
|
],
|
|
)
|
|
def test_classifier_maps_unverifiable_token_to_invalid_code(exc):
|
|
assert isinstance(classify_jwks_lookup_error(exc), InvalidCodeError)
|
|
|
|
|
|
def test_classifier_keeps_bare_jwk_client_error_as_provider_fault():
|
|
assert isinstance(classify_jwks_lookup_error(jwt.PyJWKClientError("weird JWKS shape")), ProviderError)
|
|
|
|
|
|
# ── Nous provider ─────────────────────────────────────────────────────────
|
|
|
|
def test_opaque_bearer_with_healthy_portal_is_not_unreachable(empty_jwks_server):
|
|
provider = _nous(empty_jwks_server)
|
|
assert provider.verify_session(access_token=OPAQUE_PEER_KEY) is None
|
|
|
|
|
|
def test_foreign_kid_jwt_with_healthy_portal_is_not_unreachable(empty_jwks_server):
|
|
provider = _nous(empty_jwks_server)
|
|
assert provider.verify_session(access_token=FOREIGN_KID_JWT) is None
|
|
|
|
|
|
def test_real_jwt_with_unreachable_portal_still_raises_provider_error():
|
|
provider = _nous("http://127.0.0.1:9") # discard port: connection refused
|
|
with pytest.raises(ProviderError):
|
|
provider.verify_session(access_token=FOREIGN_KID_JWT)
|
|
|
|
|
|
def test_opaque_bearer_with_unreachable_portal_is_still_just_not_ours():
|
|
"""No network call is even needed to know an opaque string is not our JWT."""
|
|
provider = _nous("http://127.0.0.1:9")
|
|
assert provider.verify_session(access_token=OPAQUE_PEER_KEY) is None
|
|
|
|
|
|
# ── self-hosted OIDC provider (sibling site of the same hunk) ──────────────
|
|
|
|
def test_self_hosted_provider_shares_the_classification(empty_jwks_server, monkeypatch):
|
|
import plugins.dashboard_auth.self_hosted as sh
|
|
|
|
provider = object.__new__(sh.SelfHostedOIDCProvider)
|
|
provider._jwks_client = None
|
|
provider._client_id = "hermes"
|
|
monkeypatch.setattr(
|
|
provider, "_get_discovery",
|
|
lambda: {"jwks_uri": f"{empty_jwks_server}/jwks", "issuer": empty_jwks_server},
|
|
)
|
|
with pytest.raises(InvalidCodeError):
|
|
provider._verify_id_token(OPAQUE_PEER_KEY)
|
|
|
|
|
|
# ── HTTP level: the gated API answers 401, not 503 ────────────────────────
|
|
|
|
@pytest.fixture
|
|
def _gated_nous(empty_jwks_server):
|
|
clear_providers()
|
|
prev = {k: getattr(web_server.app.state, k, None) for k in ("bound_host", "bound_port", "auth_required")}
|
|
web_server.app.state.bound_host = "agent.example.test"
|
|
web_server.app.state.bound_port = 443
|
|
web_server.app.state.auth_required = True
|
|
register_provider(_nous(empty_jwks_server))
|
|
yield TestClient(web_server.app, base_url="https://agent.example.test")
|
|
clear_providers()
|
|
for k, v in prev.items():
|
|
setattr(web_server.app.state, k, v)
|
|
|
|
|
|
def test_gated_api_rejects_opaque_bearer_with_401_not_503(_gated_nous):
|
|
r = _gated_nous.get("/api/auth/me", headers={"Authorization": f"Bearer {OPAQUE_PEER_KEY}"})
|
|
assert r.status_code != 503, r.text
|
|
assert r.status_code == 401
|
|
assert "unreachable" not in r.text.lower()
|
|
|
|
|
|
def test_gated_api_rejects_opaque_cookie_with_401_not_503(_gated_nous):
|
|
_gated_nous.cookies.set(SESSION_AT_COOKIE, OPAQUE_PEER_KEY)
|
|
r = _gated_nous.get("/api/auth/me")
|
|
assert r.status_code != 503, r.text
|
|
assert "unreachable" not in r.text.lower()
|