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 "