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,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"]
|
||||||
@@ -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 "<script>" 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]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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]"}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user