test: cover the per-user OAuth flow, threat cases, store and startup
This commit is contained in:
1 parent
566d0b65dd
commit
7286440d64
9 files changed
+2505
-7
No files matched your search
@@ -0,0 +1,351 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user