Files
AegisGitea-MCP/tests/test_oauth_provider.py
T

352 lines
12 KiB
Python

"""Unit tests for the OAuth provider: registration rules, caps, races and edge cases."""
from __future__ import annotations
from typing import Any
import pytest
from mcp.server.auth.provider import (
AccessToken,
AuthorizationParams,
RefreshToken,
RegistrationError,
TokenError,
)
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp.oauth import provider as provider_module
from aegis_gitea_mcp.oauth.provider import (
OAuthTokenVerifier,
StoredAuthorizationCode,
StoredRefreshToken,
_ExpiringMap,
redirect_uri_problem,
s256,
same_resource,
)
from tests.test_oauth_helpers import (
REDIRECT,
RESOURCE,
FakeClock,
browser,
build_oauth_app,
sign_in,
)
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger reads the process-wide settings."""
def client_info(client_id: str = "c1", **overrides: Any) -> OAuthClientInformationFull:
values: dict[str, Any] = {
"client_id": client_id,
"client_secret": "secret-0123456789",
"redirect_uris": [REDIRECT],
"token_endpoint_auth_method": "client_secret_post",
"scope": "gitea",
}
values.update(overrides)
return OAuthClientInformationFull.model_validate(values)
@pytest.mark.parametrize(
("uri", "ok"),
[
("https://claude.ai/api/mcp/auth_callback", True),
("http://localhost/cb", True),
("http://LOCALHOST:1234/cb", True),
("http://127.0.0.1:1/cb", True),
("http://[::1]/cb", True),
("http://127.0.0.2/cb", False),
("http://localhost.evil.example/cb", False),
("https://x.example:99999/cb", False),
("https:///nohost", False),
("ftp://x.example/cb", False),
("https://x.example/cb#", False),
],
)
def test_redirect_uri_rules(uri: str, ok: bool) -> None:
assert (redirect_uri_problem(uri) is None) is ok
def test_same_resource() -> None:
assert same_resource("https://mcp.example.com/mcp/", "https://MCP.example.com:443/mcp")
assert not same_resource("https://mcp.example.com/other", "https://mcp.example.com/mcp")
assert not same_resource("not a url", "https://mcp.example.com/mcp")
def test_s256_matches_rfc7636_appendix_b() -> None:
assert (
s256("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
== "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
)
def test_expiring_map_evicts_the_oldest_and_expires_entries() -> None:
clock = FakeClock()
entries: _ExpiringMap[int] = _ExpiringMap(2, clock)
entries.put("a", 1, 10)
entries.put("b", 2, 10)
entries.put("c", 3, 10)
entries.put("short", 4, 1)
assert len(entries) == 2 and entries.get("a") is None and entries.get("b") is None
clock.advance(2)
entries.put("d", 5, 10) # the expired entry is purged, nothing live is evicted
assert entries.get("c") == 3 and entries.get("d") == 5 and len(entries) == 2
clock.advance(11)
assert entries.get("c") is None and entries.pop("d") is None
async def test_registration_metadata_limits() -> None:
provider = build_oauth_app().provider
with pytest.raises(RegistrationError) as many:
await provider.register_client(
client_info(redirect_uris=[f"https://x.example/{n}" for n in range(11)])
)
with pytest.raises(RegistrationError) as method:
await provider.register_client(client_info(token_endpoint_auth_method="private_key_jwt"))
with pytest.raises(RegistrationError) as large:
await provider.register_client(client_info(client_name="x" * 9000))
assert many.value.error == "invalid_redirect_uri"
assert method.value.error == large.value.error == "invalid_client_metadata"
assert provider._store.clients == {}
async def test_public_clients_may_register() -> None:
provider = build_oauth_app().provider
await provider.register_client(
client_info(token_endpoint_auth_method="none", client_secret=None)
)
assert (await provider.get_client("c1")).token_endpoint_auth_method == "none"
assert await provider.get_client("unknown") is None
async def test_client_cap_evicts_the_least_recently_used_client_without_sessions(
monkeypatch: pytest.MonkeyPatch, httpx_mock: HTTPXMock
) -> None:
monkeypatch.setattr(provider_module, "MAX_CLIENTS", 3)
oauth = build_oauth_app()
flow = sign_in(browser(oauth.app), httpx_mock) # client with a session
provider = oauth.provider
await provider.register_client(client_info("old"))
oauth.clock.advance(5)
await provider.register_client(client_info("newer"))
oauth.clock.advance(5)
await provider.get_client("old") # used recently: "newer" is now least recently used
await provider.register_client(client_info("newest"))
assert set(provider._store.clients) == {flow.client_id, "old", "newest"}
async def test_client_cap_refuses_when_every_client_has_a_session(
monkeypatch: pytest.MonkeyPatch, httpx_mock: HTTPXMock
) -> None:
monkeypatch.setattr(provider_module, "MAX_CLIENTS", 1)
oauth = build_oauth_app()
sign_in(browser(oauth.app), httpx_mock)
with pytest.raises(RegistrationError, match="limit"):
await oauth.provider.register_client(client_info("another"))
async def test_authorize_defaults_scope_and_binds_our_resource() -> None:
provider = build_oauth_app().provider
client = client_info()
params = AuthorizationParams(
state=None,
scopes=None,
code_challenge="challenge",
redirect_uri=AnyUrl(REDIRECT),
redirect_uri_provided_explicitly=False,
)
location = await provider.authorize(client, params)
request_id = location.split("request=")[1]
pending = provider.consent_request(request_id)
assert pending.scopes == ["gitea"] and pending.resource == RESOURCE
assert pending.code_verifier not in repr(pending)
assert provider.consent_request("") is None
assert provider.begin_consent("unknown") is None
async def test_exchange_races_and_missing_sessions_are_invalid_grant(
httpx_mock: HTTPXMock,
) -> None:
oauth = build_oauth_app()
flow = sign_in(browser(oauth.app), httpx_mock)
provider = oauth.provider
client = await provider.get_client(flow.client_id)
(session_id,) = oauth.store.sessions
code = StoredAuthorizationCode(
code="never-issued",
scopes=["gitea"],
expires_at=oauth.clock.now + 60,
client_id=flow.client_id,
code_challenge="x",
redirect_uri=AnyUrl(REDIRECT),
redirect_uri_provided_explicitly=True,
session_id=session_id,
)
stale_refresh = StoredRefreshToken(
token="sha256:x",
client_id=flow.client_id,
scopes=["gitea"],
session_id=session_id,
token_hash="not-stored",
)
with pytest.raises(TokenError) as code_error:
await provider.exchange_authorization_code(client, code)
with pytest.raises(TokenError) as refresh_error:
await provider.exchange_refresh_token(client, stale_refresh, ["gitea"])
assert code_error.value.error == refresh_error.value.error == "invalid_grant"
async def test_code_whose_session_vanished_is_invalid_grant(httpx_mock: HTTPXMock) -> None:
from tests.test_oauth_helpers import (
GITEA_CODE,
mock_gitea_login,
pkce,
query_of,
register,
start_flow,
)
oauth = build_oauth_app()
client = browser(oauth.app)
registration = register(client).json()
gitea_url = start_flow(client, registration, pkce()[1])
mock_gitea_login(httpx_mock)
callback = client.get(
"/oauth/callback", params={"code": GITEA_CODE, "state": query_of(gitea_url)["state"]}
)
code_value = query_of(callback.headers["location"])["code"]
info = await oauth.provider.get_client(registration["client_id"])
code = await oauth.provider.load_authorization_code(info, code_value)
oauth.store.sessions.clear()
with pytest.raises(TokenError):
await oauth.provider.exchange_authorization_code(info, code)
async def test_load_helpers_ignore_foreign_tokens(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
flow = sign_in(browser(oauth.app), httpx_mock)
provider = oauth.provider
client = await provider.get_client(flow.client_id)
assert await provider.load_refresh_token(client, "not-ours") is None
assert await provider.load_refresh_token(client, flow.tokens["access_token"]) is None
assert await provider.load_access_token("not-ours") is None
assert await provider.load_access_token(flow.tokens["refresh_token"]) is None
assert await OAuthTokenVerifier(provider).verify_token("static-bearer") is None
access = await OAuthTokenVerifier(provider).verify_token(flow.tokens["access_token"])
assert access is not None
assert access.subject == "alice" and access.resource == RESOURCE
assert flow.tokens["access_token"] not in repr(access)
oauth.store.sessions.clear()
assert await provider.load_access_token(flow.tokens["access_token"]) is None
assert await provider.load_refresh_token(client, flow.tokens["refresh_token"]) is None
async def test_revoking_tokens_without_a_session_is_a_no_op(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
sign_in(browser(oauth.app), httpx_mock)
await oauth.provider.revoke_token(AccessToken(token="t", client_id="c", scopes=[]))
await oauth.provider.revoke_token(RefreshToken(token="t", client_id="c", scopes=[]))
assert len(oauth.store.sessions) == 1
async def test_sessions_per_login_are_capped(
monkeypatch: pytest.MonkeyPatch, httpx_mock: HTTPXMock
) -> None:
monkeypatch.setattr(provider_module, "MAX_SESSIONS_PER_LOGIN", 2)
oauth = build_oauth_app()
client = browser(oauth.app)
first = sign_in(client, httpx_mock)
oauth.clock.advance(1)
sign_in(client, httpx_mock)
oauth.clock.advance(1)
sign_in(client, httpx_mock)
assert len(oauth.store.sessions) == 2
assert await oauth.provider.load_access_token(first.tokens["access_token"]) is None
async def test_callback_for_an_evicted_client_is_refused(httpx_mock: HTTPXMock) -> None:
from tests.test_oauth_helpers import GITEA_CODE, pkce, query_of, register, start_flow
oauth = build_oauth_app()
client = browser(oauth.app)
registration = register(client).json()
gitea_url = start_flow(client, registration, pkce()[1])
oauth.store.clients.clear()
response = client.get(
"/oauth/callback", params={"code": GITEA_CODE, "state": query_of(gitea_url)["state"]}
)
assert response.status_code == 400
async def test_session_save_failure_redirects_server_error(
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
) -> None:
from tests.test_oauth_helpers import (
CLIENT_STATE,
GITEA_CODE,
mock_gitea_login,
pkce,
query_of,
register,
start_flow,
)
oauth = build_oauth_app()
client = browser(oauth.app)
registration = register(client).json()
gitea_url = start_flow(client, registration, pkce()[1])
mock_gitea_login(httpx_mock)
async def broken_save() -> None:
raise OSError("disk full")
monkeypatch.setattr(oauth.store, "save", broken_save)
response = client.get(
"/oauth/callback", params={"code": GITEA_CODE, "state": query_of(gitea_url)["state"]}
)
assert query_of(response.headers["location"]) == {
"error": "server_error",
"state": CLIENT_STATE,
}
assert oauth.store.sessions == {}
async def test_audit_failures_are_logged(
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture
) -> None:
class BrokenAudit:
def log_security_event(self, *args: Any, **kwargs: Any) -> None:
raise RuntimeError("audit down")
oauth = build_oauth_app(audit=BrokenAudit()) # type: ignore[arg-type]
sign_in(browser(oauth.app), httpx_mock)
assert "audit log write failed: RuntimeError" in caplog.text