From 7286440d64819838d1a82986b35c290cc0cf1813 Mon Sep 17 00:00:00 2001 From: latte Date: Wed, 14 Oct 2026 16:39:38 +0200 Subject: [PATCH] test: cover the per-user OAuth flow, threat cases, store and startup --- tests/test_oauth_flow.py | 111 +++++ tests/test_oauth_flow_security.py | 715 ++++++++++++++++++++++++++++++ tests/test_oauth_helpers.py | 303 +++++++++++++ tests/test_oauth_provider.py | 351 +++++++++++++++ tests/test_oauth_resolver.py | 356 +++++++++++++++ tests/test_oauth_startup.py | 188 ++++++++ tests/test_oauth_store.py | 278 ++++++++++++ tests/test_oauth_upstream.py | 181 ++++++++ tests/test_transports_startup.py | 29 +- 9 files changed, 2505 insertions(+), 7 deletions(-) create mode 100644 tests/test_oauth_flow.py create mode 100644 tests/test_oauth_flow_security.py create mode 100644 tests/test_oauth_helpers.py create mode 100644 tests/test_oauth_provider.py create mode 100644 tests/test_oauth_resolver.py create mode 100644 tests/test_oauth_startup.py create mode 100644 tests/test_oauth_store.py create mode 100644 tests/test_oauth_upstream.py diff --git a/tests/test_oauth_flow.py b/tests/test_oauth_flow.py new file mode 100644 index 0000000..9172364 --- /dev/null +++ b/tests/test_oauth_flow.py @@ -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"] diff --git a/tests/test_oauth_flow_security.py b/tests/test_oauth_flow_security.py new file mode 100644 index 0000000..050b144 --- /dev/null +++ b/tests/test_oauth_flow_security.py @@ -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="").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 "