353 lines
12 KiB
Python
353 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
|
|
oauth.clock.advance(provider_module.PENDING_TTL) # none of them is young any more
|
|
|
|
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
|