"""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", }, headers={"Origin": BASE}, ) 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 "