"""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