Low-level SDK server built from the catalog plus gitea_api_request, with TOOLSETS/READ_ONLY/DISABLED_TOOLS filters, schema validation without value echo, one audit event per call, static bearer verifier, identity resolver hook, /health, DNS-rebinding protection, rate limiting and fail-closed startup checks.
174 lines
6.0 KiB
Python
174 lines
6.0 KiB
Python
"""Tests for the static bearer verifier, composite verifier and identity resolution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from mcp.server.auth.provider import AccessToken
|
|
from pytest_httpx import HTTPXMock
|
|
|
|
from aegis_gitea_mcp import auth as auth_module
|
|
from aegis_gitea_mcp.auth import (
|
|
STATIC_CLIENT_ID,
|
|
AdminIdentityResolver,
|
|
CompositeVerifier,
|
|
StaticBearerVerifier,
|
|
)
|
|
from aegis_gitea_mcp.errors import ToolError
|
|
from aegis_gitea_mcp.gitea import GiteaExecutor
|
|
from tests.test_server_helpers import API, BEARER_TOKEN, GITEA_TOKEN, server_settings
|
|
|
|
# Static bearer verifier
|
|
|
|
|
|
async def test_correct_token_yields_an_admin_access_token_without_the_secret() -> None:
|
|
access = await StaticBearerVerifier(BEARER_TOKEN).verify_token(BEARER_TOKEN)
|
|
|
|
assert access is not None
|
|
assert access.client_id == STATIC_CLIENT_ID == "aegis-static-bearer"
|
|
assert access.scopes == ["gitea"]
|
|
assert access.subject == "admin"
|
|
assert access.token == "static"
|
|
assert BEARER_TOKEN not in repr(access)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"presented",
|
|
[
|
|
"",
|
|
"x",
|
|
BEARER_TOKEN + "x",
|
|
BEARER_TOKEN[:-1],
|
|
BEARER_TOKEN.upper(),
|
|
"Bearer " + BEARER_TOKEN,
|
|
],
|
|
)
|
|
async def test_wrong_tokens_are_rejected(presented: str) -> None:
|
|
assert await StaticBearerVerifier(BEARER_TOKEN).verify_token(presented) is None
|
|
|
|
|
|
async def test_comparison_is_constant_time_even_for_different_lengths(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
calls: list[tuple[bytes, bytes]] = []
|
|
real = hmac.compare_digest
|
|
|
|
def spy(left: Any, right: Any) -> bool:
|
|
calls.append((left, right))
|
|
return real(left, right)
|
|
|
|
monkeypatch.setattr(auth_module.hmac, "compare_digest", spy)
|
|
verifier = StaticBearerVerifier(BEARER_TOKEN)
|
|
|
|
await verifier.verify_token("short")
|
|
await verifier.verify_token(BEARER_TOKEN + "-much-longer-than-expected")
|
|
await verifier.verify_token(BEARER_TOKEN)
|
|
|
|
assert len(calls) == 3
|
|
# Digests of equal length are compared, never the raw secrets.
|
|
assert all(len(left) == len(right) == 32 for left, right in calls)
|
|
assert all(BEARER_TOKEN.encode() not in pair for pair in calls)
|
|
|
|
|
|
async def test_composite_verifier_returns_first_match() -> None:
|
|
other = AccessToken(token="t", client_id="oauth-client", scopes=["x"])
|
|
|
|
class Accepts:
|
|
async def verify_token(self, token: str) -> AccessToken | None:
|
|
return other if token == "oauth-token" else None
|
|
|
|
composite = CompositeVerifier(StaticBearerVerifier(BEARER_TOKEN), Accepts())
|
|
|
|
static = await composite.verify_token(BEARER_TOKEN)
|
|
oauth = await composite.verify_token("oauth-token")
|
|
|
|
assert static is not None and static.client_id == STATIC_CLIENT_ID
|
|
assert oauth is other
|
|
assert await composite.verify_token("nothing") is None
|
|
assert await CompositeVerifier().verify_token(BEARER_TOKEN) is None
|
|
|
|
|
|
# Identity resolver
|
|
|
|
|
|
async def test_no_access_token_resolves_to_the_admin_identity(httpx_mock: HTTPXMock) -> None:
|
|
httpx_mock.add_response(url=f"{API}/user", json={"login": "root"})
|
|
settings = server_settings()
|
|
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
|
|
|
|
identity = await resolver.resolve(None)
|
|
|
|
assert (identity.login, identity.mode, identity.token) == ("root", "admin-token", GITEA_TOKEN)
|
|
assert GITEA_TOKEN not in repr(identity)
|
|
assert httpx_mock.get_requests()[0].headers["Authorization"] == f"token {GITEA_TOKEN}"
|
|
|
|
|
|
async def test_login_is_looked_up_once_and_cached(httpx_mock: HTTPXMock) -> None:
|
|
httpx_mock.add_response(url=f"{API}/user", json={"login": "root"})
|
|
settings = server_settings()
|
|
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
|
|
access = AccessToken(token="static", client_id=STATIC_CLIENT_ID, scopes=["gitea"])
|
|
|
|
first = await resolver.resolve(access)
|
|
second = await resolver.resolve(access)
|
|
|
|
assert first.login == second.login == "root"
|
|
assert len(httpx_mock.get_requests()) == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"response",
|
|
[
|
|
{"status_code": 401, "json": {"message": "bad token"}},
|
|
{"status_code": 200, "json": ["not", "an", "object"]},
|
|
{"status_code": 200, "json": {"login": ""}},
|
|
{"status_code": 200, "json": {"login": 5}},
|
|
],
|
|
)
|
|
async def test_failed_lookup_leaves_the_login_unknown_and_is_throttled(
|
|
httpx_mock: HTTPXMock, response: dict[str, Any]
|
|
) -> None:
|
|
httpx_mock.add_response(url=f"{API}/user", **response)
|
|
settings = server_settings()
|
|
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
|
|
|
|
first = await resolver.resolve(None)
|
|
second = await resolver.resolve(None)
|
|
|
|
assert first.login is None and second.login is None
|
|
assert first.mode == "admin-token" and first.token == GITEA_TOKEN
|
|
assert len(httpx_mock.get_requests()) == 1 # no retry inside the back-off window
|
|
|
|
|
|
async def test_lookup_is_retried_after_the_backoff(
|
|
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
httpx_mock.add_response(url=f"{API}/user", status_code=500)
|
|
httpx_mock.add_response(url=f"{API}/user", json={"login": "root"})
|
|
settings = server_settings()
|
|
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
|
|
clock = [1000.0]
|
|
monkeypatch.setattr(auth_module.time, "monotonic", lambda: clock[0])
|
|
|
|
assert (await resolver.resolve(None)).login is None
|
|
clock[0] += auth_module._LOGIN_RETRY_SECONDS + 1
|
|
assert (await resolver.resolve(None)).login == "root"
|
|
|
|
|
|
async def test_other_client_ids_are_refused() -> None:
|
|
settings = server_settings()
|
|
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
|
|
|
|
with pytest.raises(ToolError, match="unknown identity"):
|
|
await resolver.resolve(AccessToken(token="x", client_id="oauth-client", scopes=[]))
|
|
|
|
|
|
async def test_missing_gitea_token_is_refused() -> None:
|
|
settings = server_settings(gitea_token=None)
|
|
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
|
|
|
|
with pytest.raises(ToolError, match="GITEA_TOKEN"):
|
|
await resolver.resolve(None)
|