feat!: full-access Gitea MCP server (1.0.0): full API coverage, stdio + streamable HTTP, admin and per-user OAuth #71

Merged
Latte merged 70 commits from feat/full-api-coverage into dev 2026-10-14 17:25:25 +00:00
9 changed files with 2505 additions and 7 deletions
Showing only changes of commit 7286440d64 - Show all commits

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
"""End-to-end per-user OAuth over the real HTTP app: DCR, consent, Gitea, tokens, MCP calls."""
from __future__ import annotations
import pytest
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp.oauth.provider import TOKEN_PREFIX
from tests.test_oauth_helpers import (
API,
APP_CLIENT_ID,
APP_CLIENT_SECRET,
BASE,
CLIENT_STATE,
GITEA,
GITEA_CODE,
GITEA_TOKEN,
REDIRECT,
TOKEN_URL,
USER_TOKEN,
browser,
build_oauth_app,
exchange_code,
form_of,
gitea_requests,
mcp_client,
mock_gitea_login,
payload,
pkce,
query_of,
register,
start_flow,
)
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger and the rate limiter read the process-wide settings."""
async def test_full_flow_runs_tool_calls_as_the_signed_in_user(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
registration = register(client).json()
verifier, challenge = pkce()
gitea_url = start_flow(client, registration, challenge)
# Our server sends the browser to Gitea with its own PKCE, callback and scopes.
assert gitea_url.startswith(f"{GITEA}/login/oauth/authorize?")
upstream = query_of(gitea_url)
assert upstream["client_id"] == APP_CLIENT_ID
assert upstream["redirect_uri"] == f"{BASE}/oauth/callback"
assert upstream["response_type"] == "code"
assert upstream["code_challenge_method"] == "S256"
assert upstream["code_challenge"] != challenge # ours, not the client's
assert upstream["scope"] == " ".join(oauth.settings.oauth_scopes)
assert upstream["state"] != CLIENT_STATE
mock_gitea_login(httpx_mock, login="alice")
callback = client.get(
"/oauth/callback", params={"code": GITEA_CODE, "state": upstream["state"]}
)
assert callback.status_code == 302
location = callback.headers["location"]
assert location.startswith(f"{REDIRECT}?")
back = query_of(location)
assert back["state"] == CLIENT_STATE and back["code"]
# Gitea received the code with our verifier and the app credentials.
(token_request,) = gitea_requests(httpx_mock, TOKEN_URL)
form = form_of(token_request)
assert form["grant_type"] == "authorization_code"
assert form["code"] == GITEA_CODE
assert form["client_id"] == APP_CLIENT_ID
assert form["client_secret"] == APP_CLIENT_SECRET
assert form["redirect_uri"] == f"{BASE}/oauth/callback"
assert form["code_verifier"]
(user_request,) = gitea_requests(httpx_mock, f"{API}/user")
assert user_request.headers["Authorization"] == f"Bearer {USER_TOKEN}"
tokens = exchange_code(client, registration, back["code"], verifier)
assert tokens.status_code == 200
body = tokens.json()
assert body["token_type"] == "Bearer"
assert body["expires_in"] == 3600
assert body["scope"] == "gitea"
assert body["access_token"].startswith(TOKEN_PREFIX)
assert body["refresh_token"].startswith(TOKEN_PREFIX)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"full_name": "octo/demo"})
httpx_mock.add_response(
url=f"{API}/admin/users", status_code=403, json={"message": "Only admins"}
)
async with mcp_client(oauth.app, body["access_token"]) as mcp:
result = await mcp.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
admin = await mcp.call_tool("gitea_admin_search_users", {})
assert not result.is_error
envelope = payload(result)
assert envelope["identity"] == {"login": "alice", "mode": "oauth", "sudo": None}
(repo_request,) = gitea_requests(httpx_mock, f"{API}/repos/octo/demo")
assert repo_request.headers["Authorization"] == f"Bearer {USER_TOKEN}"
assert admin.is_error
error = payload(admin)
assert error["error"]["kind"] == "forbidden"
assert error["identity"]["mode"] == "oauth"
(admin_request,) = gitea_requests(httpx_mock, f"{API}/admin/users")
assert admin_request.headers["Authorization"] == f"Bearer {USER_TOKEN}"
assert GITEA_TOKEN not in admin_request.headers["Authorization"]
+715
View File
@@ -0,0 +1,715 @@
"""Threat cases of the per-user OAuth flow, exercised over the real HTTP app.
Covered: token expiry, refresh rotation and reuse detection, revocation, code
replay, PKCE and client binding, state replay and expiry, the confused-deputy
attack (browser binding), consent CSRF and clickjacking headers, redirect URI
and resource manipulation, upstream failures, upstream refresh, the static
bearer next to OAuth, and secrets in logs, audit entries and results.
"""
from __future__ import annotations
import json
import logging
from typing import Any
import pytest
from pytest_httpx import HTTPXMock
from starlette.testclient import TestClient
from aegis_gitea_mcp.oauth.provider import TOKEN_PREFIX
from tests.test_oauth_helpers import (
API,
APP_CLIENT_ID,
APP_CLIENT_SECRET,
BASE,
BEARER_TOKEN,
CLIENT_STATE,
CSRF_FIELD,
ENCRYPTION_KEY,
GITEA,
GITEA_CODE,
GITEA_TOKEN,
REDIRECT,
RESOURCE,
TOKEN_URL,
USER_REFRESH,
USER_TOKEN,
browser,
build_oauth_app,
exchange_code,
form_of,
gitea_requests,
mcp_client,
mock_gitea_login,
payload,
pkce,
query_of,
refresh,
register,
sign_in,
start_flow,
)
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger and the rate limiter read the process-wide settings."""
def mcp_status(client: TestClient, token: str) -> int:
"""HTTP status of an MCP request with ``token`` (401 when the token is not accepted)."""
response = client.post("/mcp", json={}, headers={"Authorization": f"Bearer {token}"})
return response.status_code
def consent_form(client: TestClient) -> tuple[str, str]:
"""Register, call /authorize and open the consent page; return (request id, csrf)."""
registration = register(client).json()
authorize = client.get(
"/authorize",
params={
"client_id": registration["client_id"],
"response_type": "code",
"code_challenge": pkce()[1],
"state": CLIENT_STATE,
},
)
consent_url = authorize.headers["location"]
match = CSRF_FIELD.search(client.get(consent_url).text)
assert match is not None
return query_of(consent_url)["request"], match.group(1)
def callback_after_consent(client: TestClient, **params: str) -> Any:
"""Run the flow up to Gitea and call our callback with ``params``."""
registration = register(client).json()
gitea_url = start_flow(client, registration, pkce()[1])
return client.get("/oauth/callback", params={"state": query_of(gitea_url)["state"], **params})
# Discovery and the static bearer in the same app
def test_metadata_documents_and_the_bare_protected_resource_path() -> None:
client = browser(build_oauth_app().app)
suffixed = client.get("/.well-known/oauth-protected-resource/mcp")
bare = client.get("/.well-known/oauth-protected-resource")
server = client.get("/.well-known/oauth-authorization-server").json()
assert suffixed.status_code == bare.status_code == 200
assert bare.json() == suffixed.json()
assert bare.json()["resource"] == RESOURCE
assert bare.json()["authorization_servers"] == [BASE]
assert bare.json()["scopes_supported"] == ["gitea"]
assert server["issuer"] == BASE
assert server["registration_endpoint"] == f"{BASE}/register"
assert server["revocation_endpoint"] == f"{BASE}/revoke"
assert server["code_challenge_methods_supported"] == ["S256"]
def test_unauthenticated_mcp_request_gets_the_oauth_challenge() -> None:
response = browser(build_oauth_app().app).post("/mcp", json={})
assert response.status_code == 401
assert (
f'resource_metadata="{BASE}/.well-known/oauth-protected-resource/mcp"'
in response.headers["WWW-Authenticate"]
)
async def test_static_bearer_still_runs_as_admin_next_to_oauth(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
httpx_mock.add_response(url=f"{API}/user", json={"login": "root"}, is_reusable=True)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"full_name": "octo/demo"})
async with mcp_client(oauth.app, BEARER_TOKEN) as mcp:
result = await mcp.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
assert payload(result)["identity"] == {"login": "root", "mode": "admin-token", "sudo": None}
(request,) = gitea_requests(httpx_mock, f"{API}/repos/octo/demo")
assert request.headers["Authorization"] == f"token {GITEA_TOKEN}"
async def test_sudo_from_an_oauth_user_is_sent_with_the_users_own_token(
httpx_mock: HTTPXMock,
) -> None:
oauth = build_oauth_app()
flow = sign_in(browser(oauth.app), httpx_mock)
httpx_mock.add_response(url=f"{API}/user", status_code=403, json={"message": "no sudo"})
async with mcp_client(oauth.app, flow.tokens["access_token"]) as mcp:
result = await mcp.call_tool("gitea_user_get_current", {"sudo": "root"})
assert result.is_error and payload(result)["error"]["kind"] == "forbidden"
request = gitea_requests(httpx_mock, f"{API}/user")[-1]
assert request.headers["Authorization"] == f"Bearer {USER_TOKEN}"
assert request.headers["Sudo"] == "root"
# Our tokens: expiry, refresh rotation, revocation
def test_expired_access_token_is_rejected_with_401(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
flow = sign_in(client, httpx_mock)
oauth.clock.advance(3601)
assert mcp_status(client, flow.tokens["access_token"]) == 401
def test_refresh_rotates_both_tokens_and_reuse_revokes_the_grant(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
with browser(oauth.app) as client:
flow = sign_in(client, httpx_mock)
old_access, old_refresh = flow.tokens["access_token"], flow.tokens["refresh_token"]
rotated = refresh(client, flow, old_refresh)
assert rotated.status_code == 200
new = rotated.json()
assert new["access_token"] != old_access and new["refresh_token"] != old_refresh
assert mcp_status(client, old_access) == 401
assert mcp_status(client, new["access_token"]) != 401
replay = refresh(client, flow, old_refresh)
assert replay.status_code == 400 and replay.json()["error"] == "invalid_grant"
# Reuse of a rotated-out refresh token signals theft: the whole grant ends.
assert mcp_status(client, new["access_token"]) == 401
assert refresh(client, flow, new["refresh_token"]).json()["error"] == "invalid_grant"
def test_refresh_honours_scope_rules(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
flow = sign_in(client, httpx_mock)
widened = refresh(client, flow, flow.tokens["refresh_token"], scope="gitea admin")
same = refresh(client, flow, flow.tokens["refresh_token"], scope="gitea")
assert widened.json()["error"] == "invalid_scope"
assert same.status_code == 200 and same.json()["scope"] == "gitea"
def test_refresh_token_of_another_client_is_rejected(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
with browser(oauth.app) as client:
flow = sign_in(client, httpx_mock)
other = register(client).json()
response = client.post(
"/token",
data={
"grant_type": "refresh_token",
"refresh_token": flow.tokens["refresh_token"],
"client_id": other["client_id"],
"client_secret": other["client_secret"],
},
)
assert response.json()["error"] == "invalid_grant"
assert mcp_status(client, flow.tokens["access_token"]) != 401
def test_expired_refresh_token_is_rejected(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
flow = sign_in(client, httpx_mock)
oauth.clock.advance(31 * 24 * 3600)
assert refresh(client, flow, flow.tokens["refresh_token"]).json()["error"] == "invalid_grant"
def test_access_token_is_not_a_refresh_token(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
flow = sign_in(client, httpx_mock)
response = refresh(client, flow, flow.tokens["access_token"])
assert response.json()["error"] == "invalid_grant"
assert mcp_status(client, flow.tokens["refresh_token"]) == 401
@pytest.mark.parametrize("which", ["access_token", "refresh_token"])
def test_revoking_either_token_ends_the_grant(httpx_mock: HTTPXMock, which: str) -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
flow = sign_in(client, httpx_mock)
revoked = client.post(
"/revoke",
data={
"token": flow.tokens[which],
"client_id": flow.client_id,
"client_secret": flow.client_secret,
},
)
assert revoked.status_code == 200
assert mcp_status(client, flow.tokens["access_token"]) == 401
assert refresh(client, flow, flow.tokens["refresh_token"]).json()["error"] == "invalid_grant"
assert oauth.store.sessions == {}
def test_another_client_cannot_revoke_a_grant(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
with browser(oauth.app) as client:
flow = sign_in(client, httpx_mock)
other = register(client).json()
client.post(
"/revoke",
data={
"token": flow.tokens["access_token"],
"client_id": other["client_id"],
"client_secret": other["client_secret"],
},
)
assert mcp_status(client, flow.tokens["access_token"]) != 401
# Authorization codes and states
def test_replayed_code_is_rejected_and_revokes_the_tokens_it_produced(
httpx_mock: HTTPXMock,
) -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
flow = sign_in(client, httpx_mock)
registration = {"client_id": flow.client_id, "client_secret": flow.client_secret}
replay = exchange_code(client, registration, flow.code, flow.verifier)
assert replay.status_code == 400 and replay.json()["error"] == "invalid_grant"
assert mcp_status(client, flow.tokens["access_token"]) == 401
assert '"oauth_code_replay"' in oauth.settings.resolved_audit_log_path.read_text("utf-8")
def test_code_needs_the_right_pkce_verifier_and_client(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
registration = register(client).json()
other = register(client).json()
verifier, challenge = pkce()
gitea_url = start_flow(client, registration, challenge)
mock_gitea_login(httpx_mock)
callback = client.get(
"/oauth/callback", params={"code": GITEA_CODE, "state": query_of(gitea_url)["state"]}
)
code = query_of(callback.headers["location"])["code"]
wrong_verifier = exchange_code(client, registration, code, pkce()[0])
wrong_client = exchange_code(client, other, code, verifier)
right = exchange_code(client, registration, code, verifier)
assert wrong_verifier.json()["error"] == "invalid_grant"
assert wrong_client.json()["error"] == "invalid_grant"
assert right.status_code == 200
def test_expired_code_is_rejected(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
registration = register(client).json()
verifier, challenge = pkce()
gitea_url = start_flow(client, registration, challenge)
mock_gitea_login(httpx_mock)
callback = client.get(
"/oauth/callback", params={"code": GITEA_CODE, "state": query_of(gitea_url)["state"]}
)
code = query_of(callback.headers["location"])["code"]
oauth.clock.advance(301)
assert exchange_code(client, registration, code, verifier).json()["error"] == "invalid_grant"
@pytest.mark.parametrize("state", [None, "", "made-up-state", "x" * 500])
def test_unknown_state_is_a_generic_400(state: str | None) -> None:
client = browser(build_oauth_app().app)
params = {"code": GITEA_CODE}
if state is not None:
params["state"] = state
response = client.get("/oauth/callback", params=params)
assert response.status_code == 400
assert "location" not in response.headers
assert "made-up-state" not in response.text and GITEA_CODE not in response.text
assert response.headers["Cache-Control"] == "no-store"
def test_expired_and_replayed_states_are_rejected(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
registration = register(client).json()
expired_url = start_flow(client, registration, pkce()[1])
oauth.clock.advance(601)
expired = client.get(
"/oauth/callback", params={"code": GITEA_CODE, "state": query_of(expired_url)["state"]}
)
gitea_url = start_flow(client, registration, pkce()[1])
mock_gitea_login(httpx_mock)
state = query_of(gitea_url)["state"]
first = client.get("/oauth/callback", params={"code": GITEA_CODE, "state": state})
replayed = client.get("/oauth/callback", params={"code": GITEA_CODE, "state": state})
assert expired.status_code == 400
assert first.status_code == 302
assert replayed.status_code == 400
assert len(gitea_requests(httpx_mock, TOKEN_URL)) == 1
def test_callback_in_a_browser_that_did_not_start_the_flow_is_refused(
httpx_mock: HTTPXMock,
) -> None:
"""Confused deputy: an attacker's Gitea URL opened by a victim must not mint a code."""
oauth = build_oauth_app()
attacker = browser(oauth.app)
registration = register(attacker, redirect_uris=["https://evil.example/cb"]).json()
authorize = attacker.get(
"/authorize",
params={
"client_id": registration["client_id"],
"response_type": "code",
"code_challenge": pkce()[1],
},
)
consent_url = authorize.headers["location"]
match = CSRF_FIELD.search(attacker.get(consent_url).text)
assert match is not None
approved = attacker.post(
"/oauth/consent",
data={
"request": query_of(consent_url)["request"],
"csrf": match.group(1),
"decision": "approve",
},
)
state = query_of(approved.headers["location"])["state"]
victim = browser(oauth.app) # no cookies; Gitea silently redirects the victim here
response = victim.get("/oauth/callback", params={"code": GITEA_CODE, "state": state})
assert response.status_code == 400
assert "location" not in response.headers
assert gitea_requests(httpx_mock, GITEA) == []
# The state is spent: the attacker cannot complete it either.
retry = attacker.get("/oauth/callback", params={"code": GITEA_CODE, "state": state})
assert retry.status_code == 400
audit = oauth.settings.resolved_audit_log_path.read_text("utf-8")
assert '"oauth_callback_binding_mismatch"' in audit
def test_consent_page_shows_the_client_and_destination_escaped() -> None:
client = browser(build_oauth_app().app)
registration = register(client, client_name="<script>alert(1)</script>").json()
authorize = client.get(
"/authorize",
params={
"client_id": registration["client_id"],
"response_type": "code",
"code_challenge": pkce()[1],
},
)
page = client.get(authorize.headers["location"])
assert page.status_code == 200
assert "<script>" not in page.text and "&lt;script&gt;" in page.text
assert "localhost:3000" in page.text
assert page.headers["X-Frame-Options"] == "DENY"
assert "frame-ancestors 'none'" in page.headers["Content-Security-Policy"]
assert page.headers["Cache-Control"] == "no-store"
cookie = page.headers["set-cookie"].lower()
assert cookie.startswith("__host-aegis-oauth-csrf=")
assert "samesite=strict" in cookie and "secure" in cookie and "httponly" in cookie
def test_consent_post_needs_the_csrf_cookie_token_and_origin() -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
request_id, csrf = consent_form(client)
form = {"request": request_id, "csrf": csrf, "decision": "approve"}
cross_site = browser(oauth.app).post("/oauth/consent", data=form) # no Strict cookie
wrong_token = client.post("/oauth/consent", data={**form, "csrf": "nope"})
bad_origin = client.post(
"/oauth/consent", data=form, headers={"Origin": "https://evil.example"}
)
unknown = client.post("/oauth/consent", data={**form, "request": "unknown"})
good = client.post("/oauth/consent", data=form, headers={"Origin": BASE})
again = client.post("/oauth/consent", data=form)
assert [r.status_code for r in (cross_site, wrong_token, bad_origin, unknown)] == [400] * 4
assert good.status_code == 303
assert again.status_code == 400 # single use
binding = good.headers["set-cookie"].lower()
assert "__host-aegis-oauth-flow=" in binding and "samesite=lax" in binding
def test_unknown_consent_request_page_is_a_400() -> None:
client = browser(build_oauth_app().app)
assert client.get("/oauth/consent", params={"request": "nope"}).status_code == 400
assert client.get("/oauth/consent").status_code == 400
def test_denying_consent_returns_access_denied_to_the_client() -> None:
client = browser(build_oauth_app().app)
request_id, csrf = consent_form(client)
denied = client.post(
"/oauth/consent", data={"request": request_id, "csrf": csrf, "decision": "deny"}
)
assert denied.status_code == 303
assert denied.headers["location"].startswith(REDIRECT)
assert query_of(denied.headers["location"]) == {"error": "access_denied", "state": CLIENT_STATE}
def test_gitea_error_redirects_access_denied() -> None:
client = browser(build_oauth_app().app)
response = callback_after_consent(client, error="access_denied")
assert response.status_code == 302
assert query_of(response.headers["location"]) == {
"error": "access_denied",
"state": CLIENT_STATE,
}
@pytest.mark.parametrize("case", ["token_500", "user_401", "no_code"])
def test_upstream_failures_redirect_server_error_without_detail(
httpx_mock: HTTPXMock, case: str
) -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
if case == "token_500":
httpx_mock.add_response(
method="POST", url=TOKEN_URL, status_code=500, text="secret upstream detail"
)
elif case == "user_401":
httpx_mock.add_response(
method="POST", url=TOKEN_URL, json={"access_token": USER_TOKEN, "token_type": "bearer"}
)
httpx_mock.add_response(url=f"{API}/user", status_code=401)
response = callback_after_consent(client, code="" if case == "no_code" else GITEA_CODE)
location = response.headers["location"]
assert query_of(location) == {"error": "server_error", "state": CLIENT_STATE}
assert "secret upstream detail" not in location
assert oauth.store.sessions == {}
def test_authorize_with_an_unregistered_redirect_uri_never_redirects() -> None:
client = browser(build_oauth_app().app)
registration = register(client).json()
response = client.get(
"/authorize",
params={
"client_id": registration["client_id"],
"redirect_uri": "https://evil.example/cb",
"response_type": "code",
"code_challenge": pkce()[1],
},
)
assert response.status_code == 400
assert "location" not in response.headers
def test_authorize_for_another_resource_is_invalid_target() -> None:
client = browser(build_oauth_app().app)
registration = register(client).json()
response = client.get(
"/authorize",
params={
"client_id": registration["client_id"],
"response_type": "code",
"code_challenge": pkce()[1],
"state": CLIENT_STATE,
"resource": "https://other.example/mcp",
},
)
assert response.status_code == 302
assert query_of(response.headers["location"])["error"] == "invalid_target"
@pytest.mark.parametrize(
"uri",
[
"http://evil.example/cb",
"javascript:alert(1)",
"https://user:pw@evil.example/cb",
"https://claude.ai/cb#frag",
"custom-scheme://callback",
],
)
def test_registration_rejects_unsafe_redirect_uris(uri: str) -> None:
response = register(browser(build_oauth_app().app), redirect_uris=[uri])
assert response.status_code == 400
assert response.json()["error"] in {"invalid_redirect_uri", "invalid_client_metadata"}
@pytest.mark.parametrize(
"uri",
[
"https://claude.ai/api/mcp/auth_callback",
"http://localhost:51234/callback",
"http://127.0.0.1:8765/callback",
"http://[::1]:9000/cb",
],
)
def test_registration_accepts_https_and_loopback_redirect_uris(uri: str) -> None:
response = register(browser(build_oauth_app().app), redirect_uris=[uri])
assert response.status_code == 201
assert response.json()["client_secret"]
# Upstream (Gitea) token refresh
async def test_gitea_token_near_expiry_is_refreshed_before_the_call(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
flow = sign_in(browser(oauth.app), httpx_mock, expires_in=30)
httpx_mock.add_response(
method="POST",
url=TOKEN_URL,
json={
"access_token": "gitea-user-access-token-NEW-0123456789",
"refresh_token": "gitea-user-refresh-token-NEW-0123456789",
"expires_in": 3600,
"token_type": "bearer",
},
)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"full_name": "octo/demo"})
async with mcp_client(oauth.app, flow.tokens["access_token"]) as mcp:
result = await mcp.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
assert not result.is_error
refresh_request = gitea_requests(httpx_mock, TOKEN_URL)[-1]
assert form_of(refresh_request) == {
"grant_type": "refresh_token",
"refresh_token": USER_REFRESH,
"client_id": APP_CLIENT_ID,
"client_secret": APP_CLIENT_SECRET,
}
(repo_request,) = gitea_requests(httpx_mock, f"{API}/repos/octo/demo")
assert repo_request.headers["Authorization"] == "Bearer gitea-user-access-token-NEW-0123456789"
(session,) = oauth.store.sessions.values()
assert session.gitea_refresh_token == "gitea-user-refresh-token-NEW-0123456789"
async def test_gitea_invalid_grant_ends_the_session_without_admin_fallback(
httpx_mock: HTTPXMock,
) -> None:
oauth = build_oauth_app()
client = browser(oauth.app)
flow = sign_in(client, httpx_mock, expires_in=30)
httpx_mock.add_response(
method="POST", url=TOKEN_URL, status_code=400, json={"error": "invalid_grant"}
)
async with mcp_client(oauth.app, flow.tokens["access_token"]) as mcp:
result = await mcp.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
assert result.is_error
error = payload(result)["error"]
assert error["kind"] == "identity_error"
assert "sign in again" in error["message"]
assert gitea_requests(httpx_mock, f"{API}/repos") == []
assert oauth.store.sessions == {} and oauth.store.tokens == {}
assert mcp_status(client, flow.tokens["access_token"]) == 401
# Secrets never leak
async def test_no_secret_reaches_logs_audit_or_results(
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture
) -> None:
caplog.set_level(logging.DEBUG)
oauth = build_oauth_app()
client = browser(oauth.app)
flow = sign_in(client, httpx_mock, expires_in=30)
refreshed = "gitea-refreshed-token-0123456789abcdef"
httpx_mock.add_response(
method="POST", url=TOKEN_URL, json={"access_token": refreshed, "expires_in": 3600}
)
leak = f"{USER_REFRESH} {ENCRYPTION_KEY} {APP_CLIENT_SECRET} {refreshed}"
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"description": leak})
async with mcp_client(oauth.app, flow.tokens["access_token"]) as mcp:
result = await mcp.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
rotated = refresh(client, flow, flow.tokens["refresh_token"]).json()
client.post(
"/revoke",
data={
"token": rotated["access_token"],
"client_id": flow.client_id,
"client_secret": flow.client_secret,
},
)
text = result.content[0].text # type: ignore[union-attr]
for secret in (USER_REFRESH, ENCRYPTION_KEY, APP_CLIENT_SECRET, refreshed):
assert secret not in text
assert text.count("[REDACTED]") >= 4
secrets = [
USER_TOKEN,
USER_REFRESH,
refreshed,
GITEA_CODE,
ENCRYPTION_KEY,
APP_CLIENT_SECRET,
GITEA_TOKEN,
BEARER_TOKEN,
flow.client_secret,
flow.code,
flow.verifier,
flow.tokens["access_token"],
flow.tokens["refresh_token"],
rotated["access_token"],
rotated["refresh_token"],
query_of(flow.gitea_url)["state"],
query_of(flow.gitea_url)["code_challenge"],
]
# Lines the test's own HTTP client logs about its requests to our app are not server logs.
logs = "\n".join(
record.getMessage()
for record in caplog.records
if not (record.name in {"httpx", "httpx2"} and BASE in record.getMessage())
)
assert "oauth sign-in: Gitea user alice" in logs
audit = oauth.settings.resolved_audit_log_path.read_text(encoding="utf-8")
assert '"oauth_login"' in audit and '"oauth_revoke"' in audit
for secret in secrets:
assert secret not in logs, secret
assert secret not in audit, secret
assert TOKEN_PREFIX not in json.dumps([vars(token) for token in oauth.store.tokens.values()])
def test_oauth_endpoints_are_rate_limited(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "2")
client = browser(build_oauth_app().app)
statuses = [register(client).status_code for _ in range(3)]
assert statuses == [201, 201, 429]
+303
View File
@@ -0,0 +1,303 @@
"""Shared builders for the per-user OAuth tests: settings, a fake clock and the browser flow."""
from __future__ import annotations
import base64
import hashlib
import json
import re
import secrets
import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import parse_qs, urlsplit
import httpx
import httpx2
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from pytest_httpx import HTTPXMock
from starlette.applications import Starlette
from starlette.testclient import TestClient
from aegis_gitea_mcp.audit import AuditLogger
from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.oauth.components import build_oauth_components
from aegis_gitea_mcp.server import build_server
from aegis_gitea_mcp.transports import HttpAuthExtras, build_http_app
from tests.test_executor_helpers import API, BEARER_TOKEN, GITEA_TOKEN
BASE = "https://mcp.example.com"
RESOURCE = f"{BASE}/mcp"
GITEA = "https://gitea.example.com"
TOKEN_URL = f"{GITEA}/login/oauth/access_token"
USER_URL = f"{API}/user"
APP_CLIENT_ID = "gitea-app-client-id"
APP_CLIENT_SECRET = "gitea-app-client-secret-0123456789abcdef"
ENCRYPTION_KEY = "token-encryption-key-0123456789abcdefghijklmnop"
USER_TOKEN = "gitea-user-access-token-0123456789abcdef"
USER_REFRESH = "gitea-user-refresh-token-0123456789abcdef"
GITEA_CODE = "gitea-authorization-code-0123456789"
REDIRECT = "http://localhost:3000/callback"
CLIENT_STATE = "client-state-value-123"
__all__ = [
"API",
"BEARER_TOKEN",
"GITEA_TOKEN",
]
class FakeClock:
"""A controllable wall clock starting at the real time."""
def __init__(self) -> None:
self.now = time.time()
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
def oauth_settings(**overrides: Any) -> Settings:
"""A complete per-user OAuth configuration with a static bearer and an admin token."""
values: dict[str, Any] = {
"gitea_url": GITEA,
"gitea_token": GITEA_TOKEN,
"mcp_bearer_token": BEARER_TOKEN,
"mcp_port": 8080,
"oauth_enabled": True,
"gitea_oauth_client_id": APP_CLIENT_ID,
"gitea_oauth_client_secret": APP_CLIENT_SECRET,
"token_encryption_key": ENCRYPTION_KEY,
"public_base_url": BASE,
}
values.update(overrides)
return Settings(**values)
@dataclass
class OAuthApp:
"""The HTTP app with its OAuth pieces."""
app: Starlette
extras: HttpAuthExtras
settings: Settings
clock: FakeClock
@property
def provider(self) -> Any:
return self.extras.auth_server_provider
@property
def store(self) -> Any:
return self.provider._store
def build_oauth_app(
settings: Settings | None = None,
clock: FakeClock | None = None,
audit: AuditLogger | None = None,
) -> OAuthApp:
"""Build the full HTTP app the way ``run_http`` does, with a fake clock."""
settings = settings or oauth_settings()
clock = clock or FakeClock()
extras = build_oauth_components(settings, clock=clock, audit=audit)
server = build_server(settings, resolver=extras.resolver, executor=extras.executor)
app = build_http_app(
settings,
server=server,
token_verifier=extras.token_verifier,
auth_server_provider=extras.auth_server_provider,
extra_routes=extras.extra_routes,
client_registration_options=extras.client_registration_options,
revocation_options=extras.revocation_options,
rate_limited_paths=extras.rate_limited_paths,
)
return OAuthApp(app=app, extras=extras, settings=settings, clock=clock)
def browser(app: Starlette) -> TestClient:
"""A browser-like client (cookies, no redirect following). No lifespan is started."""
return TestClient(app, base_url=BASE, follow_redirects=False)
def pkce() -> tuple[str, str]:
verifier = secrets.token_urlsafe(48)
digest = hashlib.sha256(verifier.encode()).digest()
return verifier, base64.urlsafe_b64encode(digest).decode().rstrip("=")
def query_of(location: str) -> dict[str, str]:
return {key: values[0] for key, values in parse_qs(urlsplit(location).query).items()}
def register(client: TestClient, redirect_uris: list[str] | None = None, **extra: Any) -> Any:
body = {
"redirect_uris": redirect_uris or [REDIRECT],
"client_name": "Test client",
**extra,
}
return client.post("/register", json=body)
def mock_gitea_login(
httpx_mock: HTTPXMock,
*,
login: str = "alice",
is_admin: bool = False,
access: str = USER_TOKEN,
refresh: str | None = USER_REFRESH,
expires_in: int | None = 3600,
) -> None:
body: dict[str, Any] = {"access_token": access, "token_type": "bearer"}
if refresh is not None:
body["refresh_token"] = refresh
if expires_in is not None:
body["expires_in"] = expires_in
httpx_mock.add_response(method="POST", url=TOKEN_URL, json=body)
httpx_mock.add_response(
method="GET", url=USER_URL, json={"login": login, "is_admin": is_admin, "id": 7}
)
@dataclass
class SignIn:
"""Everything a completed browser flow produced."""
client_id: str
client_secret: str
verifier: str
code: str
tokens: dict[str, Any] = field(default_factory=dict)
gitea_url: str = ""
CSRF_FIELD = re.compile(r'name="csrf" value="([^"]+)"')
def start_flow(client: TestClient, registration: dict[str, Any], challenge: str) -> str:
"""``/authorize`` -> consent page -> approve; return Gitea's authorization URL."""
authorize = client.get(
"/authorize",
params={
"client_id": registration["client_id"],
"redirect_uri": REDIRECT,
"response_type": "code",
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": CLIENT_STATE,
"resource": RESOURCE,
},
)
assert authorize.status_code == 302, authorize.text
consent_url = authorize.headers["location"]
assert consent_url.startswith(f"{BASE}/oauth/consent?request=")
page = client.get(consent_url)
assert page.status_code == 200
match = CSRF_FIELD.search(page.text)
assert match is not None
approved = client.post(
"/oauth/consent",
data={
"request": query_of(consent_url)["request"],
"csrf": match.group(1),
"decision": "approve",
},
headers={"Origin": BASE},
)
assert approved.status_code == 303, approved.text
return str(approved.headers["location"])
def sign_in(
client: TestClient, httpx_mock: HTTPXMock, *, login: str = "alice", **gitea: Any
) -> SignIn:
"""Run the whole flow (DCR, consent, Gitea, callback, /token) for one user."""
registration = register(client).json()
verifier, challenge = pkce()
gitea_url = start_flow(client, registration, challenge)
mock_gitea_login(httpx_mock, login=login, **gitea)
callback = client.get(
"/oauth/callback", params={"code": GITEA_CODE, "state": query_of(gitea_url)["state"]}
)
assert callback.status_code == 302, callback.text
code = query_of(callback.headers["location"])["code"]
tokens = exchange_code(client, registration, code, verifier)
assert tokens.status_code == 200, tokens.text
return SignIn(
client_id=registration["client_id"],
client_secret=registration["client_secret"],
verifier=verifier,
code=code,
tokens=tokens.json(),
gitea_url=gitea_url,
)
def exchange_code(
client: TestClient, registration: dict[str, Any], code: str, verifier: str
) -> Any:
return client.post(
"/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT,
"client_id": registration["client_id"],
"client_secret": registration["client_secret"],
"code_verifier": verifier,
},
)
def refresh(client: TestClient, flow: SignIn, refresh_token: str, **extra: str) -> Any:
return client.post(
"/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": flow.client_id,
"client_secret": flow.client_secret,
**extra,
},
)
@asynccontextmanager
async def mcp_client(app: Starlette, token: str) -> AsyncIterator[Client]:
"""The SDK client calling ``app`` over an in-process ASGI transport with ``token``."""
async with (
app.router.lifespan_context(app),
httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=app),
headers={"Authorization": f"Bearer {token}"},
base_url=BASE,
) as http_client,
Client(streamable_http_client(f"{RESOURCE}", http_client=http_client)) as c,
):
yield c
def payload(result: Any) -> dict[str, Any]:
return json.loads(result.content[0].text) # type: ignore[no-any-return]
def form_of(request: httpx.Request) -> dict[str, str]:
"""The form-encoded body of a request Gitea received."""
return {key: values[0] for key, values in parse_qs(request.content.decode()).items()}
def gitea_requests(httpx_mock: HTTPXMock, url_prefix: str) -> list[httpx.Request]:
return [r for r in httpx_mock.get_requests() if str(r.url).startswith(url_prefix)]
def mcp_status_lifespan(app: Starlette, token: str) -> int:
"""HTTP status of a bare MCP request with ``token``, with the app's lifespan running."""
with browser(app) as client:
response = client.post("/mcp", json={}, headers={"Authorization": f"Bearer {token}"})
return response.status_code
+351
View File
@@ -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
+356
View File
@@ -0,0 +1,356 @@
"""Tests for the OAuth identity resolver: no mix-up between identities, upstream refresh."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
import pytest
from mcp.server.auth.provider import AccessToken
from aegis_gitea_mcp.auth import STATIC_CLIENT_ID
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.identity import Identity
from aegis_gitea_mcp.oauth.resolver import (
OAUTH_ONLY,
SIGN_IN_AGAIN,
OAuthGiteaExecutor,
OAuthIdentityResolver,
SessionIdentity,
)
from aegis_gitea_mcp.oauth.store import OAuthStore, SessionRecord, TokenRecord
from aegis_gitea_mcp.oauth.upstream import UpstreamError, UpstreamInvalidGrant, UpstreamTokens
from tests.test_oauth_helpers import (
ENCRYPTION_KEY,
GITEA_TOKEN,
USER_REFRESH,
USER_TOKEN,
FakeClock,
oauth_settings,
)
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger reads the process-wide settings."""
class FakeUpstream:
"""Stands in for GiteaUpstream.refresh."""
def __init__(self, *outcomes: UpstreamTokens | Exception, delay: float = 0.0) -> None:
self.outcomes = list(outcomes)
self.calls: list[str] = []
self.delay = delay
async def refresh(self, refresh_token: str) -> UpstreamTokens:
self.calls.append(refresh_token)
if self.delay:
await asyncio.sleep(self.delay)
outcome = self.outcomes.pop(0)
if isinstance(outcome, Exception):
raise outcome
return outcome
class FakeAdmin:
def __init__(self) -> None:
self.seen: list[AccessToken | None] = []
async def resolve(self, access: AccessToken | None) -> Identity:
self.seen.append(access)
return Identity(login="root", mode="admin-token", token=GITEA_TOKEN)
def make_store(tmp_path: Path, clock: FakeClock, **session: Any) -> OAuthStore:
store = OAuthStore.load(tmp_path, ENCRYPTION_KEY, clock)
values: dict[str, Any] = {
"session_id": "s1",
"client_id": "c1",
"login": "alice",
"is_admin": False,
"gitea_access_token": USER_TOKEN,
"gitea_refresh_token": USER_REFRESH,
"gitea_expires_at": clock.now + 3600,
"scopes": ["gitea"],
"resource": "https://mcp.example.com/mcp",
"created_at": clock.now,
}
values.update(session)
store.sessions["s1"] = SessionRecord(**values)
store.tokens["h"] = TokenRecord("access", "s1", "c1", ["gitea"], clock.now + 3600)
return store
def oauth_access(session_id: Any = "s1", subject: str = "alice", **claims: Any) -> AccessToken:
return AccessToken(
token="sha256:x",
client_id="c1",
scopes=["gitea"],
subject=subject,
claims={"auth": "oauth", "session_id": session_id, **claims},
)
STATIC = AccessToken(token="static", client_id=STATIC_CLIENT_ID, scopes=["gitea"])
def resolver(
store: OAuthStore, upstream: Any = None, admin: Any = None, clock: Any = None
) -> OAuthIdentityResolver:
return OAuthIdentityResolver(
store, upstream or FakeUpstream(), admin, clock=clock or FakeClock()
)
async def test_oauth_token_resolves_to_the_session_user(tmp_path: Path) -> None:
clock = FakeClock()
identity = await resolver(make_store(tmp_path, clock), clock=clock).resolve(oauth_access())
assert isinstance(identity, SessionIdentity)
assert (identity.login, identity.mode, identity.token) == ("alice", "oauth", USER_TOKEN)
assert identity.redact == (USER_REFRESH,)
assert USER_TOKEN not in repr(identity) and USER_REFRESH not in repr(identity)
assert identity.metadata() == {"login": "alice", "mode": "oauth", "sudo": None}
async def test_static_bearer_and_no_token_go_to_the_admin_resolver(tmp_path: Path) -> None:
clock = FakeClock()
admin = FakeAdmin()
subject = resolver(make_store(tmp_path, clock), admin=admin, clock=clock)
assert (await subject.resolve(STATIC)).mode == "admin-token"
assert (await subject.resolve(None)).mode == "admin-token"
assert admin.seen == [STATIC, None]
async def test_oauth_only_deployment_fails_closed_for_admin_mode(tmp_path: Path) -> None:
clock = FakeClock()
subject = resolver(make_store(tmp_path, clock), admin=None, clock=clock)
for access in (STATIC, None):
with pytest.raises(ToolError) as caught:
await subject.resolve(access)
assert caught.value.detail == OAUTH_ONLY and caught.value.status_code == 401
@pytest.mark.parametrize(
"access",
[
AccessToken(token="t", client_id="some-client", scopes=["gitea"]),
AccessToken(token="t", client_id="some-client", scopes=["gitea"], claims={"auth": "x"}),
oauth_access(session_id=None),
oauth_access(session_id=5),
],
)
async def test_unknown_token_kinds_never_reach_the_admin(
tmp_path: Path, access: AccessToken
) -> None:
clock = FakeClock()
admin = FakeAdmin()
with pytest.raises(ToolError, match="unknown identity"):
await resolver(make_store(tmp_path, clock), admin=admin, clock=clock).resolve(access)
assert admin.seen == []
async def test_oauth_marker_on_a_static_client_id_still_needs_a_session(tmp_path: Path) -> None:
"""A token claiming OAuth is never treated as the static bearer."""
clock = FakeClock()
admin = FakeAdmin()
forged = AccessToken(
token="t",
client_id=STATIC_CLIENT_ID,
scopes=["gitea"],
subject="alice",
claims={"auth": "oauth", "session_id": "missing"},
)
with pytest.raises(ToolError, match="sign in again"):
await resolver(make_store(tmp_path, clock), admin=admin, clock=clock).resolve(forged)
assert admin.seen == []
@pytest.mark.parametrize(("session_id", "subject"), [("gone", "alice"), ("s1", "mallory")])
async def test_ended_session_or_subject_mismatch_means_sign_in_again(
tmp_path: Path, session_id: str, subject: str
) -> None:
clock = FakeClock()
admin = FakeAdmin()
with pytest.raises(ToolError) as caught:
await resolver(make_store(tmp_path, clock), admin=admin, clock=clock).resolve(
oauth_access(session_id, subject)
)
assert caught.value.detail == SIGN_IN_AGAIN and caught.value.status_code == 401
assert admin.seen == []
async def test_token_without_expiry_is_never_refreshed(tmp_path: Path) -> None:
clock = FakeClock()
upstream = FakeUpstream()
store = make_store(tmp_path, clock, gitea_expires_at=None)
await resolver(store, upstream, clock=clock).resolve(oauth_access())
assert upstream.calls == []
async def test_refresh_keeps_the_old_refresh_token_when_gitea_sends_none(tmp_path: Path) -> None:
clock = FakeClock()
upstream = FakeUpstream(UpstreamTokens("new-access", None, clock.now + 3600))
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
identity = await resolver(store, upstream, clock=clock).resolve(oauth_access())
assert identity.token == "new-access"
assert store.sessions["s1"].gitea_refresh_token == USER_REFRESH
reloaded = OAuthStore.load(tmp_path, ENCRYPTION_KEY, clock)
assert reloaded.sessions["s1"].gitea_access_token == "new-access"
async def test_concurrent_calls_refresh_once(tmp_path: Path) -> None:
clock = FakeClock()
upstream = FakeUpstream(
UpstreamTokens("new-access", "new-refresh", clock.now + 3600), delay=0.01
)
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
subject = resolver(store, upstream, clock=clock)
identities = await asyncio.gather(*(subject.resolve(oauth_access()) for _ in range(5)))
assert upstream.calls == [USER_REFRESH]
assert {identity.token for identity in identities} == {"new-access"}
async def test_transient_failure_keeps_a_still_valid_token(tmp_path: Path) -> None:
clock = FakeClock()
upstream = FakeUpstream(UpstreamError("token request failed: HTTP 502"))
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
identity = await resolver(store, upstream, clock=clock).resolve(oauth_access())
assert identity.token == USER_TOKEN
assert "s1" in store.sessions
async def test_transient_failure_with_an_expired_token_is_retryable(tmp_path: Path) -> None:
clock = FakeClock()
upstream = FakeUpstream(UpstreamError("token request failed: HTTP 502"))
store = make_store(tmp_path, clock, gitea_expires_at=clock.now - 10)
with pytest.raises(ToolError) as caught:
await resolver(store, upstream, clock=clock).resolve(oauth_access())
assert caught.value.status_code == 503
assert "s1" in store.sessions
async def test_invalid_grant_ends_the_session_and_its_tokens(tmp_path: Path) -> None:
clock = FakeClock()
upstream = FakeUpstream(UpstreamInvalidGrant("Gitea rejected the grant"))
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
admin = FakeAdmin()
with pytest.raises(ToolError, match="sign in again"):
await resolver(store, upstream, admin=admin, clock=clock).resolve(oauth_access())
assert store.sessions == {} and store.tokens == {}
assert admin.seen == []
assert OAuthStore.load(tmp_path, ENCRYPTION_KEY, clock).sessions == {}
@pytest.mark.parametrize(("offset", "survives"), [(30.0, True), (-30.0, False)])
async def test_no_refresh_token(tmp_path: Path, offset: float, survives: bool) -> None:
clock = FakeClock()
store = make_store(
tmp_path, clock, gitea_refresh_token=None, gitea_expires_at=clock.now + offset
)
subject = resolver(store, FakeUpstream(), clock=clock)
if survives:
assert (await subject.resolve(oauth_access())).token == USER_TOKEN
else:
with pytest.raises(ToolError, match="sign in again"):
await subject.resolve(oauth_access())
assert ("s1" in store.sessions) is survives
async def test_session_ended_while_waiting_for_the_refresh_lock(tmp_path: Path) -> None:
clock = FakeClock()
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
subject = resolver(store, FakeUpstream(), clock=clock)
lock = subject._locks.setdefault("s1", asyncio.Lock())
async with lock:
task = asyncio.ensure_future(subject.resolve(oauth_access()))
await asyncio.sleep(0)
store.end_session("s1")
with pytest.raises(ToolError, match="sign in again"):
await task
async def test_save_failures_are_logged_not_raised(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
clock = FakeClock()
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
async def broken_save() -> None:
raise OSError("disk full")
monkeypatch.setattr(store, "save", broken_save)
upstream = FakeUpstream(
UpstreamTokens("new-access", None, clock.now + 3600), UpstreamInvalidGrant("x")
)
subject = resolver(store, upstream, clock=clock)
assert (await subject.resolve(oauth_access())).token == "new-access"
store.sessions["s1"].gitea_expires_at = clock.now + 10
with pytest.raises(ToolError):
await subject.resolve(oauth_access())
assert "could not save the refreshed Gitea token" in caplog.text
assert "could not save the OAuth store" in caplog.text
async def test_audit_failure_does_not_block_ending_a_session(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
clock = FakeClock()
store = make_store(tmp_path, clock, gitea_expires_at=clock.now + 10)
class BrokenAudit:
def log_security_event(self, *args: Any, **kwargs: Any) -> None:
raise RuntimeError("audit down")
subject = OAuthIdentityResolver(
store,
FakeUpstream(UpstreamInvalidGrant("x")), # type: ignore[arg-type]
None,
clock=clock,
audit=BrokenAudit(), # type: ignore[arg-type]
)
with pytest.raises(ToolError):
await subject.resolve(oauth_access())
assert store.sessions == {}
assert "audit log write failed: RuntimeError" in caplog.text
async def test_executor_redacts_session_secrets(httpx_mock: Any) -> None:
executor = OAuthGiteaExecutor(oauth_settings(), extra_secrets=[ENCRYPTION_KEY])
httpx_mock.add_response(
url="https://gitea.example.com/api/v1/version",
json={"a": USER_REFRESH, "b": ENCRYPTION_KEY, "c": USER_TOKEN},
)
identity = SessionIdentity(
login="alice", mode="oauth", token=USER_TOKEN, redact=(USER_REFRESH,)
)
outcome = await executor.request("GET", "/version", identity)
await executor.aclose()
assert outcome.payload["data"] == {"a": "[REDACTED]", "b": "[REDACTED]", "c": "[REDACTED]"}
+188
View File
@@ -0,0 +1,188 @@
"""Startup and wiring of per-user OAuth: OAuth-only deployments, store failures, log hygiene."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import pytest
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp import transports
from aegis_gitea_mcp.oauth import OAuthSetupError
from aegis_gitea_mcp.oauth import components as components_module
from aegis_gitea_mcp.oauth.components import RATE_LIMITED_PATHS, build_oauth_components
from aegis_gitea_mcp.oauth.resolver import OAuthGiteaExecutor
from aegis_gitea_mcp.oauth.store import STORE_FILENAME
from aegis_gitea_mcp.transports import AccessLogQueryFilter, http_startup_errors, run_http
from tests.test_oauth_helpers import (
API,
BEARER_TOKEN,
USER_TOKEN,
browser,
build_oauth_app,
gitea_requests,
mcp_client,
oauth_settings,
payload,
sign_in,
)
from tests.test_server_helpers import ANY_HOST
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger reads the process-wide settings."""
class UvicornSpy:
def __init__(self) -> None:
self.calls: list[tuple[Any, dict[str, Any]]] = []
def __call__(self, app: Any, **kwargs: Any) -> None:
self.calls.append((app, kwargs))
@pytest.fixture
def uvicorn_spy(monkeypatch: pytest.MonkeyPatch) -> UvicornSpy:
spy = UvicornSpy()
monkeypatch.setattr(transports.uvicorn, "run", spy)
monkeypatch.setattr(logging.getLogger("uvicorn.access"), "filters", [])
return spy
def oauth_only(**overrides: Any) -> Any:
return oauth_settings(gitea_token=None, mcp_bearer_token=None, **overrides)
def test_oauth_only_deployment_starts_on_a_public_bind(uvicorn_spy: UvicornSpy) -> None:
assert run_http(oauth_only(mcp_host=ANY_HOST)) == 0
assert len(uvicorn_spy.calls) == 1
assert (
http_startup_errors(oauth_only(mcp_host=ANY_HOST), build_oauth_components(oauth_only()))
== []
)
def test_oauth_with_a_bearer_but_no_gitea_token_is_refused(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(oauth_settings(gitea_token=None)) == 2
err = capsys.readouterr().err
assert "GITEA_TOKEN is required" in err and "unset MCP_BEARER_TOKEN" in err
assert uvicorn_spy.calls == []
def test_undecryptable_store_stops_startup_and_keeps_the_file(
tmp_path: Path, uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
(tmp_path / STORE_FILENAME).write_bytes(b"garbage")
assert run_http(oauth_settings(data_dir=tmp_path)) == 2
err = capsys.readouterr().err
assert "per-user OAuth cannot start: cannot decrypt" in err
assert (tmp_path / STORE_FILENAME).read_bytes() == b"garbage"
assert uvicorn_spy.calls == []
def test_unreadable_store_directory_is_a_setup_error(monkeypatch: pytest.MonkeyPatch) -> None:
def denied(*args: Any, **kwargs: Any) -> None:
raise PermissionError("denied")
monkeypatch.setattr(components_module.OAuthStore, "load", denied)
with pytest.raises(OAuthSetupError, match="cannot read the OAuth token store"):
build_oauth_components(oauth_settings())
@pytest.mark.parametrize(
"settings",
[
{"oauth_enabled": False},
{"gitea_oauth_client_secret": None},
],
)
def test_components_need_a_complete_oauth_configuration(settings: dict[str, Any]) -> None:
with pytest.raises(OAuthSetupError):
build_oauth_components(oauth_settings(**settings))
def test_http_public_base_url_is_refused_for_oauth() -> None:
settings = oauth_settings().model_copy(update={"public_base_url": "http://mcp.example.com"})
errors = http_startup_errors(settings, build_oauth_components(oauth_settings()))
assert errors == ["PUBLIC_BASE_URL must use https unless the host is loopback"]
def test_loopback_http_public_base_url_is_allowed() -> None:
settings = oauth_settings(public_base_url="http://127.0.0.1:8080")
assert http_startup_errors(settings, build_oauth_components(settings)) == []
def test_components_wiring() -> None:
settings = oauth_settings()
extras = build_oauth_components(settings)
assert isinstance(extras.executor, OAuthGiteaExecutor)
assert extras.rate_limited_paths == RATE_LIMITED_PATHS
paths = {route.path for route in extras.extra_routes} # type: ignore[attr-defined]
assert paths == {"/oauth/consent", "/oauth/callback", "/.well-known/oauth-protected-resource"}
assert extras.client_registration_options is not None
assert extras.client_registration_options.default_scopes == ["gitea"]
assert extras.revocation_options is not None and extras.revocation_options.enabled
assert extras.resolver._admin is not None # type: ignore[attr-defined]
assert build_oauth_components(oauth_only()).resolver._admin is None # type: ignore[union-attr]
async def test_oauth_only_app_serves_users_and_has_no_admin_path(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app(oauth_only())
flow = sign_in(browser(oauth.app), httpx_mock)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"full_name": "octo/demo"})
async with mcp_client(oauth.app, flow.tokens["access_token"]) as mcp:
result = await mcp.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
assert payload(result)["identity"]["mode"] == "oauth"
(request,) = gitea_requests(httpx_mock, f"{API}/repos")
assert request.headers["Authorization"] == f"Bearer {USER_TOKEN}"
rejected = browser(oauth.app).post(
"/mcp", json={}, headers={"Authorization": f"Bearer {BEARER_TOKEN}"}
)
assert rejected.status_code == 401
def test_access_log_filter_drops_query_strings() -> None:
record = logging.LogRecord(
"uvicorn.access",
logging.INFO,
__file__,
1,
'%s - "%s %s HTTP/%s" %d',
("1.2.3.4:5", "GET", "/oauth/callback?code=secret-code&state=secret-state", "1.1", 302),
None,
)
plain = logging.LogRecord(
"uvicorn.access", logging.INFO, __file__, 1, "%s", ("no query",), None
)
assert AccessLogQueryFilter().filter(record) is True
assert AccessLogQueryFilter().filter(plain) is True
message = record.getMessage()
assert "secret" not in message
assert '"GET /oauth/callback?[redacted] HTTP/1.1" 302' in message
assert plain.getMessage() == "no query"
def test_run_http_installs_the_access_log_filter_once(uvicorn_spy: UvicornSpy) -> None:
run_http(oauth_settings())
run_http(oauth_settings())
filters = logging.getLogger("uvicorn.access").filters
assert sum(isinstance(item, AccessLogQueryFilter) for item in filters) == 1
+278
View File
@@ -0,0 +1,278 @@
"""Tests for the encrypted OAuth store: file safety, encryption at rest, refusal modes, restart."""
from __future__ import annotations
import base64
import os
import stat
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp.oauth.store import (
STORE_FILENAME,
OAuthStore,
OAuthStoreError,
SessionRecord,
TokenRecord,
derive_fernet_key,
token_hash,
)
from tests.test_oauth_helpers import (
APP_CLIENT_SECRET,
ENCRYPTION_KEY,
USER_REFRESH,
USER_TOKEN,
FakeClock,
browser,
build_oauth_app,
mcp_status_lifespan,
oauth_settings,
sign_in,
)
KEY = ENCRYPTION_KEY
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger reads the process-wide settings."""
def session(session_id: str = "s1", *, created_at: float = 0.0) -> SessionRecord:
return SessionRecord(
session_id=session_id,
client_id="c1",
login="alice",
is_admin=False,
gitea_access_token=USER_TOKEN,
gitea_refresh_token=USER_REFRESH,
gitea_expires_at=None,
scopes=["gitea"],
resource="https://mcp.example.com/mcp",
created_at=created_at,
)
def token(session_id: str = "s1", expires_at: float = 10_000.0) -> TokenRecord:
return TokenRecord(
kind="access",
session_id=session_id,
client_id="c1",
scopes=["gitea"],
expires_at=expires_at,
)
def test_key_derivation_is_hkdf_sha256_with_the_documented_info() -> None:
expected = HKDF(
algorithm=hashes.SHA256(), length=32, salt=None, info=b"aegis-gitea-mcp oauth store"
).derive(KEY.encode())
assert derive_fernet_key(KEY) == base64.urlsafe_b64encode(expected)
assert derive_fernet_key(KEY) != derive_fernet_key(KEY + "x")
async def test_file_and_directory_permissions(tmp_path: Path) -> None:
directory = tmp_path / "state" / "nested"
store = OAuthStore.load(directory, KEY)
store.sessions["s1"] = session(created_at=1e12)
await store.save()
assert stat.S_IMODE(directory.stat().st_mode) == 0o700
assert stat.S_IMODE((directory / STORE_FILENAME).stat().st_mode) == 0o600
assert [p.name for p in directory.iterdir()] == [STORE_FILENAME] # no temp file left
async def test_existing_loose_directory_is_tightened(tmp_path: Path) -> None:
directory = tmp_path / "state"
directory.mkdir(mode=0o755)
directory.chmod(0o755)
await OAuthStore.load(directory, KEY).save()
assert stat.S_IMODE(directory.stat().st_mode) == 0o700
async def test_loose_file_permissions_are_restricted_on_load(tmp_path: Path) -> None:
store = OAuthStore.load(tmp_path, KEY)
await store.save()
path = tmp_path / STORE_FILENAME
path.chmod(0o644)
OAuthStore.load(tmp_path, KEY)
assert stat.S_IMODE(path.stat().st_mode) == 0o600
async def test_contents_are_encrypted_at_rest(tmp_path: Path, httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app(oauth_settings(data_dir=tmp_path))
flow = sign_in(browser(oauth.app), httpx_mock, login="alice-the-user")
raw = (tmp_path / STORE_FILENAME).read_bytes()
for secret in (
USER_TOKEN,
USER_REFRESH,
flow.client_secret,
flow.client_id,
flow.tokens["access_token"],
flow.tokens["refresh_token"],
"alice-the-user",
APP_CLIENT_SECRET,
KEY,
):
assert secret.encode() not in raw
plain = Fernet(derive_fernet_key(KEY)).decrypt(raw).decode()
assert USER_TOKEN in plain and flow.client_secret in plain
# Our own tokens are stored only as hashes.
assert flow.tokens["access_token"] not in plain
assert token_hash(flow.tokens["access_token"]) in plain
async def test_state_survives_a_restart(tmp_path: Path, httpx_mock: HTTPXMock) -> None:
settings = oauth_settings(data_dir=tmp_path)
first = build_oauth_app(settings)
flow = sign_in(browser(first.app), httpx_mock)
second = build_oauth_app(settings)
assert mcp_status_lifespan(second.app, flow.tokens["access_token"]) != 401
assert flow.client_id in second.store.clients
def test_symlinked_store_is_refused(tmp_path: Path) -> None:
target = tmp_path / "elsewhere.json"
target.write_bytes(b"x")
os.symlink(target, tmp_path / STORE_FILENAME)
with pytest.raises(OAuthStoreError, match="symlink"):
OAuthStore.load(tmp_path, KEY)
async def test_symlink_swapped_in_at_runtime_is_refused_on_write(tmp_path: Path) -> None:
store = OAuthStore.load(tmp_path, KEY)
os.symlink(tmp_path / "elsewhere.json", tmp_path / STORE_FILENAME)
with pytest.raises(OAuthStoreError, match="symlink"):
await store.save()
assert not (tmp_path / "elsewhere.json").exists()
def test_non_regular_store_path_is_refused(tmp_path: Path) -> None:
(tmp_path / STORE_FILENAME).mkdir()
with pytest.raises(OAuthStoreError, match="not a regular file"):
OAuthStore.load(tmp_path, KEY)
async def test_wrong_key_refuses_to_start_and_keeps_the_file(tmp_path: Path) -> None:
store = OAuthStore.load(tmp_path, KEY)
store.sessions["s1"] = session(created_at=1e12)
await store.save()
before = (tmp_path / STORE_FILENAME).read_bytes()
with pytest.raises(OAuthStoreError, match="TOKEN_ENCRYPTION_KEY") as caught:
OAuthStore.load(tmp_path, "another-key-0123456789abcdef0123456789")
assert KEY not in str(caught.value)
assert (tmp_path / STORE_FILENAME).read_bytes() == before
@pytest.mark.parametrize(
"document",
[
b"not json",
b"[]",
b'{"version": 2}',
b'{"version": 1, "clients": []}',
b'{"version": 1, "sessions": {"s": {"unexpected": 1}}}',
b'{"version": 1, "clients": {"c": {"info": [], "registered_at": 0, "last_used_at": 0}}}',
b'{"version": 1, "tokens": {"h": {"kind": "other", "session_id": "s", "client_id": "c",'
b' "scopes": [], "expires_at": 1}}}',
],
)
def test_corrupt_document_refuses_to_start(tmp_path: Path, document: bytes) -> None:
(tmp_path / STORE_FILENAME).write_bytes(Fernet(derive_fernet_key(KEY)).encrypt(document))
with pytest.raises(OAuthStoreError, match="not a valid OAuth store"):
OAuthStore.load(tmp_path, KEY)
def test_garbage_file_refuses_to_start(tmp_path: Path) -> None:
(tmp_path / STORE_FILENAME).write_bytes(b"garbage")
with pytest.raises(OAuthStoreError, match="cannot decrypt"):
OAuthStore.load(tmp_path, KEY)
async def test_prune_drops_expired_tokens_and_dead_sessions(tmp_path: Path) -> None:
clock = FakeClock()
clock.now = 5_000.0
store = OAuthStore.load(tmp_path, KEY, clock)
store.sessions["live"] = session("live", created_at=0.0)
store.sessions["expired"] = session("expired", created_at=0.0)
store.sessions["fresh"] = session("fresh", created_at=4_900.0) # sign-in in progress
store.sessions["abandoned"] = session("abandoned", created_at=0.0)
store.tokens["a"] = token("live", expires_at=6_000.0)
store.tokens["b"] = token("expired", expires_at=4_000.0)
await store.save()
reloaded = OAuthStore.load(tmp_path, KEY, clock)
assert set(reloaded.tokens) == {"a"}
assert set(reloaded.sessions) == {"live", "fresh"}
def test_store_helpers(tmp_path: Path) -> None:
store = OAuthStore.load(tmp_path, KEY)
store.sessions["s1"] = session("s1")
store.sessions["s2"] = session("s2")
store.sessions["s2"].previous_refresh_hash = "abc"
store.tokens["t1"] = token("s1")
assert store.path == tmp_path / STORE_FILENAME
assert [item.session_id for item in store.sessions_of("alice")] == ["s1", "s2"]
assert store.session_by_previous_refresh("abc") is store.sessions["s2"]
assert store.session_by_previous_refresh("nope") is None
assert store.client_has_sessions("c1") and not store.client_has_sessions("c2")
assert store.end_session("s1") is True
assert store.tokens == {} and store.end_session("s1") is False
assert USER_TOKEN not in repr(store.sessions["s2"])
assert USER_REFRESH not in repr(store.sessions["s2"])
async def test_failed_write_leaves_no_temporary_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
store = OAuthStore.load(tmp_path, KEY)
def boom(src: object, dst: object) -> None:
raise OSError("disk full")
monkeypatch.setattr(os, "replace", boom)
with pytest.raises(OSError, match="disk full"):
await store.save()
assert list(tmp_path.iterdir()) == []
async def test_directory_tightening_failure_only_warns(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
directory = tmp_path / "state"
directory.mkdir()
directory.chmod(0o755)
def refuse(self: Path, mode: int) -> None:
raise PermissionError("not owner")
monkeypatch.setattr(Path, "chmod", refuse)
await OAuthStore.load(directory, KEY).save()
assert "could not restrict" in caplog.text
+181
View File
@@ -0,0 +1,181 @@
"""Tests for the Gitea upstream OAuth client: parsing, error kinds, no secret in errors."""
from __future__ import annotations
from typing import Any
import httpx
import pytest
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp.oauth.upstream import (
GiteaUpstream,
UpstreamError,
UpstreamInvalidGrant,
UpstreamTokens,
)
from tests.test_oauth_helpers import (
APP_CLIENT_SECRET,
BASE,
TOKEN_URL,
USER_REFRESH,
USER_TOKEN,
USER_URL,
FakeClock,
form_of,
oauth_settings,
query_of,
)
def upstream(clock: FakeClock | None = None) -> GiteaUpstream:
return GiteaUpstream(oauth_settings(), clock=clock or FakeClock())
def test_authorize_url_uses_pkce_our_callback_and_the_scope_list() -> None:
settings = oauth_settings(oauth_scopes="read:repository,write:issue")
url = GiteaUpstream(settings).authorize_url(state="st", code_challenge="ch")
assert url.startswith("https://gitea.example.com/login/oauth/authorize?")
assert query_of(url) == {
"client_id": "gitea-app-client-id",
"redirect_uri": f"{BASE}/oauth/callback",
"response_type": "code",
"state": "st",
"scope": "read:repository write:issue",
"code_challenge": "ch",
"code_challenge_method": "S256",
}
async def test_refresh_parses_tokens_and_expiry(httpx_mock: HTTPXMock) -> None:
clock = FakeClock()
httpx_mock.add_response(
method="POST",
url=TOKEN_URL,
json={
"access_token": USER_TOKEN,
"refresh_token": USER_REFRESH,
"expires_in": 3600,
"token_type": "Bearer",
},
)
tokens = await upstream(clock).refresh("old-refresh")
assert tokens == UpstreamTokens(USER_TOKEN, USER_REFRESH, clock.now + 3600)
assert USER_TOKEN not in repr(tokens) and USER_REFRESH not in repr(tokens)
request = httpx_mock.get_requests()[0]
assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
assert form_of(request)["client_secret"] == APP_CLIENT_SECRET
@pytest.mark.parametrize("expires_in", [None, 0, -5, True, "3600"])
async def test_unusable_expires_in_means_no_expiry(httpx_mock: HTTPXMock, expires_in: Any) -> None:
httpx_mock.add_response(
method="POST", url=TOKEN_URL, json={"access_token": USER_TOKEN, "expires_in": expires_in}
)
tokens = await upstream().refresh("r")
assert tokens.expires_at is None and tokens.refresh_token is None
@pytest.mark.parametrize(
"body",
[
{"token_type": "bearer"},
{"access_token": "", "token_type": "bearer"},
{"access_token": 5},
{"access_token": "a\nb"},
{"access_token": USER_TOKEN, "token_type": 5},
{"access_token": USER_TOKEN, "token_type": "mac"},
{"access_token": USER_TOKEN, "refresh_token": ""},
],
)
async def test_malformed_token_answers_are_errors(httpx_mock: HTTPXMock, body: Any) -> None:
httpx_mock.add_response(method="POST", url=TOKEN_URL, json=body)
with pytest.raises(UpstreamError) as caught:
await upstream().exchange_code("code", "verifier")
assert not isinstance(caught.value, UpstreamInvalidGrant)
assert USER_TOKEN not in str(caught.value)
async def test_non_json_success_is_an_error(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(method="POST", url=TOKEN_URL, text="<html>")
with pytest.raises(UpstreamError, match="malformed"):
await upstream().refresh("r")
@pytest.mark.parametrize(
("status", "body", "invalid_grant"),
[
(400, {"error": "invalid_grant"}, True),
(401, {"error": "invalid_grant"}, True),
(400, {"error": "unauthorized_client"}, False),
(500, None, False),
],
)
async def test_token_endpoint_errors(
httpx_mock: HTTPXMock, status: int, body: Any, invalid_grant: bool
) -> None:
httpx_mock.add_response(method="POST", url=TOKEN_URL, status_code=status, json=body)
with pytest.raises(UpstreamError) as caught:
await upstream().refresh(USER_REFRESH)
assert isinstance(caught.value, UpstreamInvalidGrant) is invalid_grant
assert USER_REFRESH not in str(caught.value)
async def test_unreachable_gitea_is_an_error(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_exception(httpx.ConnectError("boom"), url=TOKEN_URL)
httpx_mock.add_exception(httpx.ConnectError("boom"), url=USER_URL)
with pytest.raises(UpstreamError, match="unreachable"):
await upstream().refresh("r")
with pytest.raises(UpstreamError, match="unreachable"):
await upstream().fetch_user(USER_TOKEN)
async def test_fetch_user(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url=USER_URL, json={"login": "alice", "is_admin": True})
httpx_mock.add_response(url=USER_URL, json={"login": "bob", "is_admin": "yes"})
alice = await upstream().fetch_user(USER_TOKEN)
bob = await upstream().fetch_user(USER_TOKEN)
assert (alice.login, alice.is_admin) == ("alice", True)
assert (bob.login, bob.is_admin) == ("bob", False)
assert httpx_mock.get_requests()[0].headers["Authorization"] == f"Bearer {USER_TOKEN}"
@pytest.mark.parametrize(
"response",
[
{"status_code": 401, "json": {"message": "bad"}},
{"json": {"login": ""}},
{"json": {"login": "x" * 256}},
{"json": {"login": "alice\nINFO forged log line"}},
{"json": ["alice"]},
{"text": "nope"},
],
)
async def test_fetch_user_failures(httpx_mock: HTTPXMock, response: dict[str, Any]) -> None:
httpx_mock.add_response(url=USER_URL, **response)
with pytest.raises(UpstreamError, match="user lookup failed"):
await upstream().fetch_user(USER_TOKEN)
async def test_redirects_are_not_followed(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
method="POST", url=TOKEN_URL, status_code=302, headers={"Location": "https://evil.example"}
)
with pytest.raises(UpstreamError, match="HTTP 302"):
await upstream().refresh("r")
assert len(httpx_mock.get_requests()) == 1
+22 -7
View File
@@ -14,6 +14,7 @@ from mcp.client.stdio import StdioServerParameters, stdio_client
from aegis_gitea_mcp import cli, transports from aegis_gitea_mcp import cli, transports
from aegis_gitea_mcp.cli import main from aegis_gitea_mcp.cli import main
from aegis_gitea_mcp.config import Settings from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.oauth import OAuthSetupError
from aegis_gitea_mcp.transports import ( from aegis_gitea_mcp.transports import (
HttpAuthExtras, HttpAuthExtras,
http_startup_errors, http_startup_errors,
@@ -216,16 +217,21 @@ def test_oauth_misconfiguration_lists_every_missing_setting(
assert uvicorn_spy.calls == [] assert uvicorn_spy.calls == []
def test_oauth_enabled_but_unavailable_does_not_start_without_a_bearer( def test_oauth_enabled_but_unavailable_does_not_start(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str] uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
assert run_http(oauth_settings()) == 2 def broken(settings: Settings) -> HttpAuthExtras:
raise OAuthSetupError("token store unreadable")
assert "not available in this build" in capsys.readouterr().err monkeypatch.setattr(transports, "load_oauth_components", broken)
assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 2
assert "per-user OAuth cannot start: token store unreadable" in capsys.readouterr().err
assert uvicorn_spy.calls == [] assert uvicorn_spy.calls == []
def test_oauth_enabled_with_a_bearer_falls_back_to_the_bearer(uvicorn_spy: UvicornSpy) -> None: def test_oauth_enabled_with_a_bearer_serves_both(uvicorn_spy: UvicornSpy) -> None:
assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 0 assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 0
assert len(uvicorn_spy.calls) == 1 assert len(uvicorn_spy.calls) == 1
@@ -245,8 +251,17 @@ def test_oauth_components_are_used_when_available(
assert len(uvicorn_spy.calls) == 1 assert len(uvicorn_spy.calls) == 1
def test_default_oauth_components_hook_returns_none() -> None: def test_default_oauth_components_hook_builds_the_oauth_components() -> None:
assert load_oauth_components(oauth_settings()) is None extras = load_oauth_components(oauth_settings())
assert extras.auth_server_provider is not None
assert extras.token_verifier is not None and extras.resolver is not None
def test_startup_errors_without_oauth_components() -> None:
errors = http_startup_errors(oauth_settings(mcp_bearer_token=BEARER_TOKEN), None)
assert errors == ["OAUTH_ENABLED=true but per-user OAuth could not be set up"]
def test_bearer_without_gitea_token_is_refused( def test_bearer_without_gitea_token_is_refused(