Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce5ae6caa5 | ||
|
|
e02cede9f2 | ||
|
|
7286440d64 | ||
|
|
566d0b65dd |
No files matched your search
@@ -38,3 +38,36 @@ Chat requires dynamic client registration and Claude prefers it. Details:
|
||||
|
||||
The server holds per-user Gitea tokens. That is the most sensitive asset after
|
||||
the admin token and is covered by the threat model.
|
||||
|
||||
## Implementation notes (chunk B5)
|
||||
|
||||
- **OAuth-only deployments are allowed.** With `OAUTH_ENABLED=true`, no
|
||||
`MCP_BEARER_TOKEN` and no `GITEA_TOKEN`, the server starts and every user
|
||||
brings their own Gitea token. Admin-token mode is then unavailable and fails
|
||||
closed. `GITEA_TOKEN` stays required whenever admin-token mode is reachable
|
||||
(a static bearer, or an unauthenticated loopback endpoint).
|
||||
- **Own consent step.** Gitea remembers consent for our single Gitea
|
||||
application, so a dynamically registered client with an attacker's redirect
|
||||
URI could otherwise obtain a token for a victim who merely opens a link (the
|
||||
confused-deputy problem of OAuth proxies). `/authorize` therefore leads to our
|
||||
consent page, which names the client and the redirect URI. Approving needs a
|
||||
CSRF token bound by a `SameSite=Strict` cookie and a matching `Origin`, and
|
||||
sets a browser-binding cookie that `/oauth/callback` requires, so a Gitea
|
||||
redirect URL obtained by someone else cannot be completed in another browser.
|
||||
- **Token binding.** Tokens are bound to the MCP endpoint URL (RFC 8707) and
|
||||
carry the single scope `gitea`; the SDK enforces both
|
||||
(`validate_token_resource=True`, `required_scopes=["gitea"]`), and the static
|
||||
bearer reports the same resource and scope. `/authorize` with another
|
||||
`resource` is refused with `invalid_target`.
|
||||
- **Replay handling.** A replayed authorization code, or a rotated-out refresh
|
||||
token presented again, revokes the whole grant (RFC 6749 4.1.2, OAuth 2.1
|
||||
refresh token reuse detection). Revoking either token ends the grant.
|
||||
- **Store key.** The Fernet key is HKDF-SHA256(`TOKEN_ENCRYPTION_KEY`,
|
||||
info `aegis-gitea-mcp oauth store`), so the setting can be any long random
|
||||
string. Changing it makes the store unreadable; startup then stops instead of
|
||||
wiping the file.
|
||||
- **Limits.** At most 500 registered clients (the least recently used client
|
||||
without a session is evicted, otherwise registration is refused), 50
|
||||
sessions per Gitea login (the oldest ends), and 1024 pending sign-ins. The
|
||||
unauthenticated OAuth endpoints share the per-IP rate limit, and query
|
||||
strings are removed from access log lines.
|
||||
@@ -27,6 +27,9 @@ if TYPE_CHECKING:
|
||||
#: ``client_id`` of the access token the static bearer verifier returns.
|
||||
STATIC_CLIENT_ID = "aegis-static-bearer"
|
||||
|
||||
#: The one OAuth scope this server grants and requires on the MCP endpoint.
|
||||
MCP_SCOPE = "gitea"
|
||||
|
||||
# After a failed ``GET /user`` the admin login lookup is not retried for this long.
|
||||
_LOGIN_RETRY_SECONDS = 30.0
|
||||
|
||||
@@ -45,10 +48,14 @@ class StaticBearerVerifier:
|
||||
|
||||
Args:
|
||||
token: The expected bearer token.
|
||||
resource: Resource (MCP endpoint URL) reported on the access token, so
|
||||
the SDK's RFC 8707 audience check accepts the static bearer when
|
||||
per-user OAuth enables it.
|
||||
"""
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
def __init__(self, token: str, *, resource: str | None = None) -> None:
|
||||
self._expected = _digest(token)
|
||||
self._resource = resource
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Return an admin access token when ``token`` matches, else ``None``."""
|
||||
@@ -57,7 +64,8 @@ class StaticBearerVerifier:
|
||||
return AccessToken(
|
||||
token="static", # noqa: S106 (placeholder, never the real secret)
|
||||
client_id=STATIC_CLIENT_ID,
|
||||
scopes=["gitea"],
|
||||
scopes=[MCP_SCOPE],
|
||||
resource=self._resource,
|
||||
subject="admin",
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Security properties (all covered by tests):
|
||||
* path values are validated and percent-encoded (see :mod:`.encoding`);
|
||||
* redirects are never followed (a 3xx is an error of kind ``redirect``);
|
||||
* cookies are never stored, so one identity can not leak into another call;
|
||||
* the identity token, the configured Gitea token, the MCP bearer token, the
|
||||
* the identity token and ``Identity.redact``, the configured Gitea token, the MCP bearer token, the
|
||||
OAuth client secret, ``extra_secrets`` and secret values submitted in a
|
||||
request body are replaced with ``[REDACTED]`` in every result, always;
|
||||
* secret-pattern masking/blocking follows ``SECRET_DETECTION_MODE``, except for
|
||||
@@ -412,7 +412,9 @@ class GiteaExecutor:
|
||||
# Sending
|
||||
|
||||
def _secrets(self, identity: Identity, call: _Call) -> tuple[str, ...]:
|
||||
return normalize_secrets([identity.token, *self._base_secrets, *call.call_secrets])
|
||||
return normalize_secrets(
|
||||
[identity.token, *identity.redact, *self._base_secrets, *call.call_secrets]
|
||||
)
|
||||
|
||||
async def _send(self, call: _Call, identity: Identity) -> ToolOutcome:
|
||||
headers = {
|
||||
|
||||
@@ -23,11 +23,15 @@ class Identity:
|
||||
mode: ``admin-token`` or ``oauth``.
|
||||
token: Gitea token for this identity. Excluded from ``repr`` and never
|
||||
part of :meth:`metadata`.
|
||||
redact: Further secrets of this identity (for example the OAuth user's
|
||||
Gitea refresh token) that the executor replaces in every result.
|
||||
Excluded from ``repr`` and comparisons.
|
||||
"""
|
||||
|
||||
login: str | None
|
||||
mode: IdentityMode
|
||||
token: str = field(repr=False)
|
||||
redact: tuple[str, ...] = field(default=(), repr=False, compare=False)
|
||||
|
||||
def metadata(self, sudo: str | None = None) -> dict[str, Any]:
|
||||
"""Return result and audit metadata: ``{"login", "mode", "sudo"}``, never the token."""
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Per-user OAuth mode (ADR 0004): authorization server for MCP clients, Gitea sign-in.
|
||||
|
||||
Import :func:`build_oauth_components` from :mod:`aegis_gitea_mcp.oauth.components`;
|
||||
this package module stays import-light so the transport can load it lazily.
|
||||
"""
|
||||
|
||||
|
||||
class OAuthSetupError(Exception):
|
||||
"""Per-user OAuth cannot start (the message is safe to print)."""
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Browser-facing OAuth routes: the consent page and the Gitea callback.
|
||||
|
||||
``GET /oauth/consent`` shows which client asks for access and where the result
|
||||
is sent; ``POST /oauth/consent`` applies the decision (CSRF protected with a
|
||||
``SameSite=Strict`` double-submit cookie and an ``Origin`` check) and sends the
|
||||
browser to Gitea, setting a browser-binding cookie. ``GET /oauth/callback``
|
||||
completes the sign-in only in the browser that holds that cookie.
|
||||
|
||||
Responses never echo request parameters: an invalid or expired request gets a
|
||||
fixed message with status 400. Pages are not cacheable and cannot be framed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from aegis_gitea_mcp.oauth.provider import CONSENT_PATH, GiteaOAuthProvider
|
||||
from aegis_gitea_mcp.oauth.upstream import CALLBACK_PATH
|
||||
|
||||
COOKIE_TTL_SECONDS = 600
|
||||
_INVALID_MESSAGE = (
|
||||
"This sign-in request is invalid or has expired. Start the sign-in again from your MCP client."
|
||||
)
|
||||
_SECURITY_HEADERS = {
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Content-Security-Policy": (
|
||||
"default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'"
|
||||
),
|
||||
}
|
||||
_PAGE = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;max-width:34rem;margin:3rem auto;padding:0 1rem;
|
||||
line-height:1.5;color:#1f2328;background:#fff}}
|
||||
code{{background:#f3f4f6;padding:.1rem .3rem;border-radius:4px;word-break:break-all}}
|
||||
.warn{{background:#fff8e1;border:1px solid #f0c36d;padding:.75rem;border-radius:6px}}
|
||||
button{{font-size:1rem;padding:.5rem 1.25rem;margin-right:.5rem;cursor:pointer}}
|
||||
</style></head><body>{body}</body></html>"""
|
||||
|
||||
|
||||
class _Cookies:
|
||||
"""Cookie names and attributes; ``__Host-`` names and ``Secure`` on https."""
|
||||
|
||||
def __init__(self, base_url: str) -> None:
|
||||
self.secure = urlsplit(base_url).scheme == "https"
|
||||
prefix = "__Host-" if self.secure else ""
|
||||
self.csrf = f"{prefix}aegis-oauth-csrf"
|
||||
self.binding = f"{prefix}aegis-oauth-flow"
|
||||
|
||||
def set(self, response: Response, name: str, value: str, *, same_site: str) -> None:
|
||||
response.set_cookie(
|
||||
name,
|
||||
value,
|
||||
max_age=COOKIE_TTL_SECONDS,
|
||||
path="/",
|
||||
secure=self.secure,
|
||||
httponly=True,
|
||||
samesite=same_site, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def clear(self, response: Response, name: str, *, same_site: str) -> None:
|
||||
response.delete_cookie(
|
||||
name,
|
||||
path="/",
|
||||
secure=self.secure,
|
||||
httponly=True,
|
||||
samesite=same_site, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _page(title: str, body: str, status_code: int = 200) -> HTMLResponse:
|
||||
return HTMLResponse(
|
||||
_PAGE.format(title=html.escape(title), body=body),
|
||||
status_code=status_code,
|
||||
headers=_SECURITY_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
def _invalid() -> HTMLResponse:
|
||||
return _page("Sign-in failed", f"<h1>Sign-in failed</h1><p>{_INVALID_MESSAGE}</p>", 400)
|
||||
|
||||
|
||||
def _redirect(location: str, status_code: int) -> RedirectResponse:
|
||||
return RedirectResponse(location, status_code=status_code, headers=_SECURITY_HEADERS)
|
||||
|
||||
|
||||
def _text(value: Any, limit: int = 200) -> str:
|
||||
return value[:limit] if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def build_browser_routes(provider: GiteaOAuthProvider) -> list[Route]:
|
||||
"""Return the consent and callback routes for ``provider``."""
|
||||
cookies = _Cookies(provider.base_url)
|
||||
origin_parts = urlsplit(provider.base_url)
|
||||
expected_origin = f"{origin_parts.scheme}://{origin_parts.netloc}"
|
||||
|
||||
async def consent_page(request: Request) -> Response:
|
||||
request_id = _text(request.query_params.get("request"))
|
||||
pending = provider.consent_request(request_id)
|
||||
csrf = provider.begin_consent(request_id)
|
||||
if pending is None or csrf is None:
|
||||
return _invalid()
|
||||
client = html.escape(pending.client_name or "An unnamed client")
|
||||
destination = urlsplit(pending.redirect_uri)
|
||||
host = html.escape(destination.netloc)
|
||||
body = (
|
||||
"<h1>Allow access to Gitea?</h1>"
|
||||
f"<p><strong>{client}</strong> wants to use Gitea as you through this MCP server. "
|
||||
"It can do everything your Gitea account can do within the configured scopes.</p>"
|
||||
f"<p>The result goes to <code>{host}</code> "
|
||||
f"(<code>{html.escape(pending.redirect_uri)}</code>).</p>"
|
||||
'<p class="warn">Only continue if you started this sign-in yourself from your '
|
||||
"MCP client. If a link sent by someone else brought you here, deny.</p>"
|
||||
f'<form method="post" action="{CONSENT_PATH}">'
|
||||
f'<input type="hidden" name="request" value="{html.escape(request_id)}">'
|
||||
f'<input type="hidden" name="csrf" value="{html.escape(csrf)}">'
|
||||
'<button type="submit" name="decision" value="approve">Continue to Gitea</button>'
|
||||
'<button type="submit" name="decision" value="deny">Deny</button>'
|
||||
"</form>"
|
||||
)
|
||||
response = _page("Allow access to Gitea?", body)
|
||||
cookies.set(response, cookies.csrf, csrf, same_site="strict")
|
||||
return response
|
||||
|
||||
async def consent_submit(request: Request) -> Response:
|
||||
origin = request.headers.get("origin")
|
||||
if origin is not None and origin != expected_origin:
|
||||
return _invalid()
|
||||
form = await request.form()
|
||||
decision = provider.finish_consent(
|
||||
_text(form.get("request")),
|
||||
_text(form.get("csrf")),
|
||||
_text(request.cookies.get(cookies.csrf)),
|
||||
approve=form.get("decision") == "approve",
|
||||
)
|
||||
if decision is None:
|
||||
return _invalid()
|
||||
response = _redirect(decision.redirect, 303)
|
||||
cookies.clear(response, cookies.csrf, same_site="strict")
|
||||
if decision.binding_token is not None:
|
||||
# Lax: the cookie must come back on Gitea's top-level redirect to us.
|
||||
cookies.set(response, cookies.binding, decision.binding_token, same_site="lax")
|
||||
return response
|
||||
|
||||
async def callback(request: Request) -> Response:
|
||||
params = request.query_params
|
||||
location = await provider.complete_callback(
|
||||
_text(params.get("state")),
|
||||
_text(request.cookies.get(cookies.binding)),
|
||||
_text(params.get("code"), 4096),
|
||||
_text(params.get("error")),
|
||||
)
|
||||
response = _invalid() if location is None else _redirect(location, 302)
|
||||
cookies.clear(response, cookies.binding, same_site="lax")
|
||||
return response
|
||||
|
||||
return [
|
||||
Route(CONSENT_PATH, consent_page, methods=["GET"]),
|
||||
Route(CONSENT_PATH, consent_submit, methods=["POST"]),
|
||||
Route(CALLBACK_PATH, callback, methods=["GET"]),
|
||||
]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Assembles the per-user OAuth pieces for the HTTP transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
from mcp.server.auth.routes import create_protected_resource_routes
|
||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
|
||||
from pydantic import AnyHttpUrl, SecretStr
|
||||
from starlette.routing import Route
|
||||
|
||||
from aegis_gitea_mcp.audit import AuditLogger
|
||||
from aegis_gitea_mcp.auth import MCP_SCOPE, AdminIdentityResolver
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
from aegis_gitea_mcp.gitea import GiteaExecutor
|
||||
from aegis_gitea_mcp.oauth import OAuthSetupError
|
||||
from aegis_gitea_mcp.oauth.callback import build_browser_routes
|
||||
from aegis_gitea_mcp.oauth.provider import CONSENT_PATH, GiteaOAuthProvider, OAuthTokenVerifier
|
||||
from aegis_gitea_mcp.oauth.resolver import OAuthIdentityResolver
|
||||
from aegis_gitea_mcp.oauth.store import OAuthStore, OAuthStoreError
|
||||
from aegis_gitea_mcp.oauth.upstream import CALLBACK_PATH, GiteaUpstream
|
||||
from aegis_gitea_mcp.transports import HttpAuthExtras
|
||||
|
||||
#: Unauthenticated OAuth endpoints that get the per-IP rate limit.
|
||||
RATE_LIMITED_PATHS = ("/register", "/authorize", CONSENT_PATH, CALLBACK_PATH)
|
||||
PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"
|
||||
|
||||
|
||||
def bare_protected_resource_route(settings: Settings) -> Route:
|
||||
"""``/.well-known/oauth-protected-resource`` serving the same document as the suffixed path.
|
||||
|
||||
The SDK only mounts the RFC 9728 path-suffixed URL; some clients probe the
|
||||
bare path too.
|
||||
"""
|
||||
base = settings.public_base_url or ""
|
||||
auth = AuthSettings(
|
||||
issuer_url=base, # type: ignore[arg-type]
|
||||
resource_server_url=f"{base}{settings.mcp_path}", # type: ignore[arg-type]
|
||||
validate_token_resource=True,
|
||||
)
|
||||
(suffixed,) = create_protected_resource_routes(
|
||||
resource_url=cast(AnyHttpUrl, auth.resource_server_url),
|
||||
authorization_servers=[auth.issuer_url],
|
||||
scopes_supported=[MCP_SCOPE],
|
||||
)
|
||||
return Route(PROTECTED_RESOURCE_PATH, endpoint=suffixed.endpoint, methods=["GET", "OPTIONS"])
|
||||
|
||||
|
||||
def build_oauth_components(
|
||||
settings: Settings,
|
||||
*,
|
||||
clock: Callable[[], float] = time.time,
|
||||
client_factory: Callable[[], httpx.AsyncClient] | None = None,
|
||||
executor_client: httpx.AsyncClient | None = None,
|
||||
audit: AuditLogger | None = None,
|
||||
) -> HttpAuthExtras:
|
||||
"""Build the provider, verifier, resolver, executor and routes for per-user OAuth.
|
||||
|
||||
An OAuth-only deployment (no ``GITEA_TOKEN``) is supported: users bring
|
||||
their own Gitea tokens, and anything that would need admin-token mode
|
||||
fails closed.
|
||||
|
||||
Args:
|
||||
settings: Application settings with a complete OAuth configuration.
|
||||
clock: Wall clock (epoch seconds); tests inject a fake one.
|
||||
client_factory: Creates the HTTP client for Gitea OAuth exchanges.
|
||||
executor_client: HTTP client for the tool executor (tests).
|
||||
audit: Audit logger for OAuth security events.
|
||||
|
||||
Raises:
|
||||
OAuthSetupError: The configuration is incomplete or the token store
|
||||
cannot be opened.
|
||||
"""
|
||||
problems = settings.oauth_config_errors()
|
||||
if not settings.oauth_enabled or problems:
|
||||
raise OAuthSetupError("; ".join(problems) or "OAUTH_ENABLED is not set")
|
||||
key = cast(SecretStr, settings.token_encryption_key) # checked by oauth_config_errors
|
||||
try:
|
||||
store = OAuthStore.load(settings.data_dir, key.get_secret_value(), clock)
|
||||
except OAuthStoreError as exc:
|
||||
raise OAuthSetupError(str(exc)) from None
|
||||
except OSError as exc:
|
||||
raise OAuthSetupError(
|
||||
f"cannot read the OAuth token store in {settings.data_dir} ({type(exc).__name__})"
|
||||
) from None
|
||||
|
||||
upstream = GiteaUpstream(settings, client_factory=client_factory, clock=clock)
|
||||
provider = GiteaOAuthProvider(settings, store, upstream, clock=clock, audit=audit)
|
||||
executor = GiteaExecutor(
|
||||
settings, client=executor_client, extra_secrets=[key.get_secret_value()]
|
||||
)
|
||||
admin = AdminIdentityResolver(settings, executor) if settings.gitea_token is not None else None
|
||||
resolver = OAuthIdentityResolver(store, upstream, admin, clock=clock, audit=audit)
|
||||
return HttpAuthExtras(
|
||||
token_verifier=OAuthTokenVerifier(provider),
|
||||
auth_server_provider=provider,
|
||||
resolver=resolver,
|
||||
executor=executor,
|
||||
extra_routes=(*build_browser_routes(provider), bare_protected_resource_route(settings)),
|
||||
client_registration_options=ClientRegistrationOptions(
|
||||
enabled=True, valid_scopes=[MCP_SCOPE], default_scopes=[MCP_SCOPE]
|
||||
),
|
||||
revocation_options=RevocationOptions(enabled=True),
|
||||
rate_limited_paths=RATE_LIMITED_PATHS,
|
||||
)
|
||||
@@ -0,0 +1,670 @@
|
||||
"""OAuth 2.1 authorization server for MCP clients, proxying sign-in to Gitea.
|
||||
|
||||
Implements the SDK's ``OAuthAuthorizationServerProvider``. The SDK mounts
|
||||
``/authorize``, ``/token``, ``/register``, ``/revoke`` and the metadata, and
|
||||
does PKCE, redirect URI matching and client authentication; this module holds
|
||||
the state behind them.
|
||||
|
||||
Sign-in flow (each step single use, every value 256-bit random):
|
||||
|
||||
1. ``/authorize`` (SDK) calls :meth:`GiteaOAuthProvider.authorize`, which parks
|
||||
the client's request under a random request id and sends the browser to our
|
||||
consent page.
|
||||
2. The consent page (``callback.py``) shows which client asks for access and
|
||||
where the result goes. Approving needs a CSRF token that is bound to the
|
||||
browser by a ``SameSite=Strict`` cookie. On approval a fresh Gitea ``state``
|
||||
and a browser-binding secret are created; the binding secret goes into a
|
||||
cookie and the browser is sent to Gitea with our own PKCE challenge.
|
||||
3. ``/oauth/callback`` pops the state, checks the binding cookie (so a Gitea
|
||||
redirect URL obtained by someone else cannot be completed in a victim's
|
||||
browser), exchanges Gitea's code, looks up the user and mints our
|
||||
authorization code for the client's registered redirect URI.
|
||||
4. ``/token`` (SDK, PKCE checked) calls :meth:`exchange_authorization_code`,
|
||||
which issues opaque ``agm_`` access and refresh tokens. Only their SHA-256
|
||||
hashes are stored.
|
||||
|
||||
The consent step exists because Gitea remembers consent for our (single,
|
||||
static) Gitea application: without it, anyone could register a client with
|
||||
their own redirect URI and turn a victim's click on a link into a token for
|
||||
the victim's account (the confused-deputy problem of OAuth proxies).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Generic, TypeVar
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
AuthorizationParams,
|
||||
AuthorizeError,
|
||||
OAuthAuthorizationServerProvider,
|
||||
RefreshToken,
|
||||
RegistrationError,
|
||||
TokenError,
|
||||
construct_redirect_uri,
|
||||
)
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
from pydantic import AnyHttpUrl, ValidationError
|
||||
|
||||
from aegis_gitea_mcp.audit import AuditLogger, get_audit_logger
|
||||
from aegis_gitea_mcp.auth import MCP_SCOPE
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
from aegis_gitea_mcp.oauth.store import (
|
||||
ClientRecord,
|
||||
OAuthStore,
|
||||
OAuthStoreError,
|
||||
SessionRecord,
|
||||
TokenRecord,
|
||||
token_hash,
|
||||
)
|
||||
from aegis_gitea_mcp.oauth.upstream import GiteaUpstream, UpstreamError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TOKEN_PREFIX = "agm_" # noqa: S105 (public token prefix, not a secret)
|
||||
ACCESS_TOKEN_TTL = 3600
|
||||
REFRESH_TOKEN_TTL = 30 * 24 * 3600
|
||||
AUTHORIZATION_CODE_TTL = 300
|
||||
PENDING_TTL = 600
|
||||
MAX_PENDING = 1024
|
||||
MAX_CODES = 1024
|
||||
MAX_CLIENTS = 500
|
||||
MAX_CLIENT_METADATA_BYTES = 8192
|
||||
MAX_REDIRECT_URIS = 10
|
||||
MAX_SESSIONS_PER_LOGIN = 50
|
||||
CONSENT_PATH = "/oauth/consent"
|
||||
|
||||
_LOOPBACK_REDIRECT_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
||||
_CLIENT_AUTH_METHODS = frozenset({"none", "client_secret_post", "client_secret_basic"})
|
||||
_REDIRECT_RULE = (
|
||||
"redirect URIs must use https://, or http:// on localhost, 127.0.0.1 or [::1], "
|
||||
"without credentials or a fragment"
|
||||
)
|
||||
|
||||
V = TypeVar("V")
|
||||
Clock = Callable[[], float]
|
||||
|
||||
|
||||
def redirect_uri_problem(uri: str) -> str | None:
|
||||
"""Return why ``uri`` is not an acceptable redirect URI, or None when it is."""
|
||||
try:
|
||||
parts = urlsplit(uri)
|
||||
parts.port # noqa: B018 (accessing .port validates it)
|
||||
except ValueError:
|
||||
return _REDIRECT_RULE
|
||||
if parts.fragment or "#" in uri or parts.username is not None or parts.password is not None:
|
||||
return _REDIRECT_RULE
|
||||
host = (parts.hostname or "").lower()
|
||||
if not host:
|
||||
return _REDIRECT_RULE
|
||||
if parts.scheme == "https":
|
||||
return None
|
||||
if parts.scheme == "http" and host in _LOOPBACK_REDIRECT_HOSTS:
|
||||
return None
|
||||
return _REDIRECT_RULE
|
||||
|
||||
|
||||
def same_resource(left: str, right: str) -> bool:
|
||||
"""Compare two resource URLs the way the SDK's bearer middleware does."""
|
||||
try:
|
||||
normalized = [str(AnyHttpUrl(value)).removesuffix("/") for value in (left, right)]
|
||||
except ValidationError:
|
||||
return False
|
||||
return normalized[0] == normalized[1]
|
||||
|
||||
|
||||
def s256(verifier: str) -> str:
|
||||
"""PKCE S256 challenge for ``verifier``."""
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _matches(value: str | None, expected_hash: str | None) -> bool:
|
||||
if not value or not expected_hash:
|
||||
return False
|
||||
return secrets.compare_digest(token_hash(value), expected_hash)
|
||||
|
||||
|
||||
class _ExpiringMap(Generic[V]):
|
||||
"""Bounded in-memory map with per-entry expiry; the oldest entry is evicted when full."""
|
||||
|
||||
def __init__(self, capacity: int, clock: Clock) -> None:
|
||||
self._capacity = capacity
|
||||
self._clock = clock
|
||||
self._entries: dict[str, tuple[float, V]] = {}
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
def _purge(self) -> None:
|
||||
now = self._clock()
|
||||
for key in [key for key, (expires, _) in self._entries.items() if expires <= now]:
|
||||
del self._entries[key]
|
||||
|
||||
def put(self, key: str, value: V, ttl: float) -> None:
|
||||
self._purge()
|
||||
while len(self._entries) >= self._capacity:
|
||||
del self._entries[next(iter(self._entries))]
|
||||
self._entries[key] = (self._clock() + ttl, value)
|
||||
|
||||
def get(self, key: str) -> V | None:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if entry[0] <= self._clock():
|
||||
del self._entries[key]
|
||||
return None
|
||||
return entry[1]
|
||||
|
||||
def pop(self, key: str) -> V | None:
|
||||
value = self.get(key)
|
||||
self._entries.pop(key, None)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingAuthorization:
|
||||
"""A client's authorization request waiting for consent and the Gitea sign-in."""
|
||||
|
||||
client_id: str
|
||||
client_name: str | None
|
||||
redirect_uri: str
|
||||
redirect_uri_provided_explicitly: bool
|
||||
code_challenge: str
|
||||
client_state: str | None = field(repr=False)
|
||||
scopes: list[str]
|
||||
resource: str
|
||||
code_verifier: str = field(repr=False)
|
||||
csrf_hash: str | None = field(default=None, repr=False)
|
||||
binding_hash: str | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
class StoredAuthorizationCode(AuthorizationCode):
|
||||
"""Our authorization code plus the session it grants (``code`` holds only its hash)."""
|
||||
|
||||
session_id: str
|
||||
|
||||
|
||||
class StoredRefreshToken(RefreshToken):
|
||||
"""A loaded refresh token (``token`` holds only its hash)."""
|
||||
|
||||
session_id: str
|
||||
token_hash: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConsentDecision:
|
||||
"""Where to send the browser after the consent form, plus the binding cookie value."""
|
||||
|
||||
redirect: str = field(repr=False)
|
||||
binding_token: str | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
class GiteaOAuthProvider(
|
||||
OAuthAuthorizationServerProvider[StoredAuthorizationCode, StoredRefreshToken, AccessToken]
|
||||
):
|
||||
"""The SDK authorization server provider backed by Gitea sign-in.
|
||||
|
||||
Args:
|
||||
settings: Application settings (``PUBLIC_BASE_URL``, ``MCP_PATH``, scopes).
|
||||
store: Persistent store for clients, sessions and token hashes.
|
||||
upstream: Gitea OAuth client.
|
||||
clock: Wall clock (epoch seconds).
|
||||
audit: Audit logger for sign-in and revocation events (defaults to the
|
||||
process-wide one).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
store: OAuthStore,
|
||||
upstream: GiteaUpstream,
|
||||
*,
|
||||
clock: Clock = time.time,
|
||||
audit: AuditLogger | None = None,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._store = store
|
||||
self._upstream = upstream
|
||||
self._clock = clock
|
||||
self._audit_logger = audit
|
||||
base = settings.public_base_url or ""
|
||||
self.base_url = base
|
||||
self.resource = f"{base}{settings.mcp_path}"
|
||||
self._requests: _ExpiringMap[PendingAuthorization] = _ExpiringMap(MAX_PENDING, clock)
|
||||
self._states: _ExpiringMap[PendingAuthorization] = _ExpiringMap(MAX_PENDING, clock)
|
||||
self._codes: _ExpiringMap[StoredAuthorizationCode] = _ExpiringMap(MAX_CODES, clock)
|
||||
# Hashes of exchanged codes -> session id, to detect replays (RFC 6749 4.1.2).
|
||||
self._used_codes: _ExpiringMap[str] = _ExpiringMap(MAX_CODES, clock)
|
||||
|
||||
# Helpers
|
||||
|
||||
def _audit(self, event: str, description: str, severity: str, **metadata: Any) -> None:
|
||||
try:
|
||||
audit = self._audit_logger or get_audit_logger()
|
||||
audit.log_security_event(event, description, severity=severity, metadata=metadata)
|
||||
except Exception as exc:
|
||||
logger.error("audit log write failed: %s", type(exc).__name__)
|
||||
|
||||
def _end_session(self, session_id: str, event: str, description: str) -> None:
|
||||
session = self._store.sessions.get(session_id)
|
||||
if self._store.end_session(session_id) and session is not None:
|
||||
self._audit(
|
||||
event, description, "medium", login=session.login, client_id=session.client_id
|
||||
)
|
||||
|
||||
async def _issue_tokens(
|
||||
self, session: SessionRecord, access_scopes: list[str], refresh_scopes: list[str]
|
||||
) -> OAuthToken:
|
||||
now = self._clock()
|
||||
access = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
refresh = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
self._store.tokens[token_hash(access)] = TokenRecord(
|
||||
kind="access",
|
||||
session_id=session.session_id,
|
||||
client_id=session.client_id,
|
||||
scopes=list(access_scopes),
|
||||
expires_at=now + ACCESS_TOKEN_TTL,
|
||||
)
|
||||
self._store.tokens[token_hash(refresh)] = TokenRecord(
|
||||
kind="refresh",
|
||||
session_id=session.session_id,
|
||||
client_id=session.client_id,
|
||||
scopes=list(refresh_scopes),
|
||||
expires_at=now + REFRESH_TOKEN_TTL,
|
||||
)
|
||||
await self._store.save()
|
||||
return OAuthToken(
|
||||
access_token=access,
|
||||
token_type="Bearer", # noqa: S106 (token type name)
|
||||
expires_in=ACCESS_TOKEN_TTL,
|
||||
refresh_token=refresh,
|
||||
scope=" ".join(access_scopes),
|
||||
)
|
||||
|
||||
# Client registration
|
||||
|
||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||
"""Return a registered client (and note that it was used)."""
|
||||
record = self._store.clients.get(client_id)
|
||||
if record is None:
|
||||
return None
|
||||
record.last_used_at = self._clock()
|
||||
return OAuthClientInformationFull.model_validate(record.info)
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Validate and persist a dynamically registered client.
|
||||
|
||||
Redirect URIs must be https, or http on a loopback host. When
|
||||
:data:`MAX_CLIENTS` is reached, the least recently used client without
|
||||
any session is evicted; when every client has a session, registration
|
||||
is refused.
|
||||
|
||||
Raises:
|
||||
RegistrationError: Invalid redirect URIs, metadata or the client cap.
|
||||
"""
|
||||
uris = [str(uri) for uri in client_info.redirect_uris or []]
|
||||
if not uris or len(uris) > MAX_REDIRECT_URIS:
|
||||
raise RegistrationError(
|
||||
"invalid_redirect_uri", f"between 1 and {MAX_REDIRECT_URIS} redirect URIs required"
|
||||
)
|
||||
for uri in uris:
|
||||
problem = redirect_uri_problem(uri)
|
||||
if problem is not None:
|
||||
raise RegistrationError("invalid_redirect_uri", problem)
|
||||
if client_info.token_endpoint_auth_method not in _CLIENT_AUTH_METHODS:
|
||||
raise RegistrationError(
|
||||
"invalid_client_metadata", "unsupported token_endpoint_auth_method"
|
||||
)
|
||||
info = client_info.model_dump(mode="json", exclude_none=True)
|
||||
if len(json.dumps(info)) > MAX_CLIENT_METADATA_BYTES:
|
||||
raise RegistrationError("invalid_client_metadata", "client metadata is too large")
|
||||
if len(self._store.clients) >= MAX_CLIENTS and not self._evict_one_client():
|
||||
logger.warning("oauth client registration refused: client limit reached")
|
||||
raise RegistrationError(
|
||||
"invalid_client_metadata", "client registration limit reached; try again later"
|
||||
)
|
||||
now = self._clock()
|
||||
self._store.clients[client_info.client_id] = ClientRecord(
|
||||
info=info, registered_at=now, last_used_at=now
|
||||
)
|
||||
await self._store.save()
|
||||
logger.info("oauth client registered: %s", client_info.client_id)
|
||||
|
||||
def _evict_one_client(self) -> bool:
|
||||
candidates = [
|
||||
(record.last_used_at, client_id)
|
||||
for client_id, record in self._store.clients.items()
|
||||
if not self._store.client_has_sessions(client_id)
|
||||
]
|
||||
if not candidates:
|
||||
return False
|
||||
_, client_id = min(candidates)
|
||||
del self._store.clients[client_id]
|
||||
logger.info("oauth client evicted (least recently used, no sessions): %s", client_id)
|
||||
return True
|
||||
|
||||
# Authorization
|
||||
|
||||
async def authorize(
|
||||
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
||||
) -> str:
|
||||
"""Park the request and send the browser to our consent page.
|
||||
|
||||
Raises:
|
||||
AuthorizeError: ``invalid_target`` when the client asks for a token
|
||||
for another resource than this server's MCP endpoint.
|
||||
"""
|
||||
if params.resource is not None and not same_resource(params.resource, self.resource):
|
||||
raise AuthorizeError("invalid_target", "unknown resource")
|
||||
request_id = secrets.token_urlsafe(32)
|
||||
pending = PendingAuthorization(
|
||||
client_id=client.client_id,
|
||||
client_name=client.client_name,
|
||||
redirect_uri=str(params.redirect_uri),
|
||||
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
||||
code_challenge=params.code_challenge,
|
||||
client_state=params.state,
|
||||
scopes=list(params.scopes or [MCP_SCOPE]),
|
||||
resource=self.resource,
|
||||
code_verifier=secrets.token_urlsafe(64),
|
||||
)
|
||||
self._requests.put(token_hash(request_id), pending, PENDING_TTL)
|
||||
return f"{self.base_url}{CONSENT_PATH}?{urlencode({'request': request_id})}"
|
||||
|
||||
def consent_request(self, request_id: str) -> PendingAuthorization | None:
|
||||
"""The pending request shown on the consent page, or None when unknown or expired."""
|
||||
return self._requests.get(token_hash(request_id)) if request_id else None
|
||||
|
||||
def begin_consent(self, request_id: str) -> str | None:
|
||||
"""Mint the CSRF token for the consent form (the newest page wins)."""
|
||||
pending = self.consent_request(request_id)
|
||||
if pending is None:
|
||||
return None
|
||||
csrf = secrets.token_urlsafe(32)
|
||||
pending.csrf_hash = token_hash(csrf)
|
||||
return csrf
|
||||
|
||||
def finish_consent(
|
||||
self, request_id: str, csrf_form: str, csrf_cookie: str, *, approve: bool
|
||||
) -> ConsentDecision | None:
|
||||
"""Apply the consent decision; None when the request or CSRF proof is invalid."""
|
||||
key = token_hash(request_id) if request_id else ""
|
||||
pending = self._requests.get(key) if key else None
|
||||
if pending is None:
|
||||
return None
|
||||
# Double submit: the form value and the SameSite=Strict cookie must both
|
||||
# carry the token minted for this request.
|
||||
if not (
|
||||
_matches(csrf_form, pending.csrf_hash) and _matches(csrf_cookie, pending.csrf_hash)
|
||||
):
|
||||
logger.warning("oauth consent rejected: CSRF check failed")
|
||||
return None
|
||||
self._requests.pop(key)
|
||||
if not approve:
|
||||
logger.info("oauth consent denied for client %s", pending.client_id)
|
||||
return ConsentDecision(
|
||||
redirect=construct_redirect_uri(
|
||||
pending.redirect_uri, error="access_denied", state=pending.client_state
|
||||
)
|
||||
)
|
||||
state = secrets.token_urlsafe(32)
|
||||
binding = secrets.token_urlsafe(32)
|
||||
pending.binding_hash = token_hash(binding)
|
||||
self._states.put(token_hash(state), pending, PENDING_TTL)
|
||||
location = self._upstream.authorize_url(
|
||||
state=state, code_challenge=s256(pending.code_verifier)
|
||||
)
|
||||
return ConsentDecision(redirect=location, binding_token=binding)
|
||||
|
||||
async def complete_callback(
|
||||
self, state: str, binding: str, code: str, error: str
|
||||
) -> str | None:
|
||||
"""Finish the Gitea sign-in; return the client redirect, or None for a bad request.
|
||||
|
||||
A bad request (unknown, expired or replayed state, or a browser that
|
||||
did not start the flow) never redirects anywhere.
|
||||
"""
|
||||
pending = self._states.pop(token_hash(state)) if state else None
|
||||
if pending is None:
|
||||
logger.warning("oauth callback rejected: unknown, expired or replayed state")
|
||||
return None
|
||||
if not _matches(binding, pending.binding_hash):
|
||||
logger.warning("oauth callback rejected: the browser did not start this sign-in")
|
||||
self._audit(
|
||||
"oauth_callback_binding_mismatch",
|
||||
"OAuth callback from a browser that did not start the sign-in",
|
||||
"high",
|
||||
client_id=pending.client_id,
|
||||
)
|
||||
return None
|
||||
if pending.client_id not in self._store.clients:
|
||||
logger.warning("oauth callback rejected: client no longer registered")
|
||||
return None
|
||||
|
||||
def back(**params: str | None) -> str:
|
||||
return construct_redirect_uri(
|
||||
pending.redirect_uri, **params, state=pending.client_state
|
||||
)
|
||||
|
||||
if error:
|
||||
logger.info("oauth sign-in cancelled or refused at Gitea")
|
||||
return back(error="access_denied")
|
||||
if not code:
|
||||
logger.warning("oauth sign-in failed: Gitea sent no code")
|
||||
return back(error="server_error")
|
||||
try:
|
||||
tokens = await self._upstream.exchange_code(code, pending.code_verifier)
|
||||
user = await self._upstream.fetch_user(tokens.access_token)
|
||||
except UpstreamError as exc:
|
||||
logger.warning("oauth sign-in failed: %s", exc)
|
||||
return back(error="server_error")
|
||||
|
||||
now = self._clock()
|
||||
session = SessionRecord(
|
||||
session_id=uuid.uuid4().hex,
|
||||
client_id=pending.client_id,
|
||||
login=user.login,
|
||||
is_admin=user.is_admin,
|
||||
gitea_access_token=tokens.access_token,
|
||||
gitea_refresh_token=tokens.refresh_token,
|
||||
gitea_expires_at=tokens.expires_at,
|
||||
scopes=list(pending.scopes),
|
||||
resource=pending.resource,
|
||||
created_at=now,
|
||||
)
|
||||
existing = sorted(self._store.sessions_of(user.login), key=lambda item: item.created_at)
|
||||
for old in existing[: max(0, len(existing) - MAX_SESSIONS_PER_LOGIN + 1)]:
|
||||
self._end_session(
|
||||
old.session_id, "oauth_session_evicted", "Oldest OAuth session of the user ended"
|
||||
)
|
||||
self._store.sessions[session.session_id] = session
|
||||
try:
|
||||
await self._store.save()
|
||||
except (OSError, OAuthStoreError) as exc:
|
||||
self._store.sessions.pop(session.session_id, None)
|
||||
logger.error(
|
||||
"oauth sign-in failed: could not save the session (%s)", type(exc).__name__
|
||||
)
|
||||
return back(error="server_error")
|
||||
|
||||
our_code = secrets.token_urlsafe(32)
|
||||
self._codes.put(
|
||||
token_hash(our_code),
|
||||
StoredAuthorizationCode(
|
||||
code=token_hash(our_code),
|
||||
scopes=list(pending.scopes),
|
||||
expires_at=now + AUTHORIZATION_CODE_TTL,
|
||||
client_id=pending.client_id,
|
||||
code_challenge=pending.code_challenge,
|
||||
redirect_uri=pending.redirect_uri, # type: ignore[arg-type]
|
||||
redirect_uri_provided_explicitly=pending.redirect_uri_provided_explicitly,
|
||||
resource=pending.resource,
|
||||
subject=user.login,
|
||||
session_id=session.session_id,
|
||||
),
|
||||
AUTHORIZATION_CODE_TTL,
|
||||
)
|
||||
logger.info("oauth sign-in: Gitea user %s via client %s", user.login, pending.client_id)
|
||||
self._audit(
|
||||
"oauth_login",
|
||||
"User signed in with Gitea OAuth",
|
||||
"low",
|
||||
login=user.login,
|
||||
client_id=pending.client_id,
|
||||
gitea_admin=user.is_admin,
|
||||
)
|
||||
return back(code=our_code)
|
||||
|
||||
# Authorization codes
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> StoredAuthorizationCode | None:
|
||||
"""Look up our code; a replayed code revokes the grant it produced."""
|
||||
key = token_hash(authorization_code)
|
||||
used_by = self._used_codes.get(key)
|
||||
if used_by is not None:
|
||||
logger.warning("oauth authorization code replayed; revoking the grant")
|
||||
self._end_session(
|
||||
used_by, "oauth_code_replay", "Authorization code replayed; grant revoked"
|
||||
)
|
||||
await self._store.save()
|
||||
return None
|
||||
code = self._codes.get(key)
|
||||
if code is None or code.client_id != client.client_id:
|
||||
return None
|
||||
return code
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: StoredAuthorizationCode
|
||||
) -> OAuthToken:
|
||||
"""Consume the code (single use) and issue our access and refresh tokens."""
|
||||
code = self._codes.pop(authorization_code.code)
|
||||
if code is None or code.client_id != client.client_id:
|
||||
raise TokenError("invalid_grant", "authorization code does not exist")
|
||||
self._used_codes.put(authorization_code.code, code.session_id, AUTHORIZATION_CODE_TTL)
|
||||
session = self._store.sessions.get(code.session_id)
|
||||
if session is None or session.client_id != client.client_id:
|
||||
raise TokenError("invalid_grant", "authorization code does not exist")
|
||||
return await self._issue_tokens(session, code.scopes, code.scopes)
|
||||
|
||||
# Refresh tokens
|
||||
|
||||
async def load_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: str
|
||||
) -> StoredRefreshToken | None:
|
||||
"""Look up a refresh token; reuse of a rotated-out token revokes the grant."""
|
||||
if not refresh_token.startswith(TOKEN_PREFIX):
|
||||
return None
|
||||
key = token_hash(refresh_token)
|
||||
record = self._store.tokens.get(key)
|
||||
if record is None:
|
||||
reused = self._store.session_by_previous_refresh(key)
|
||||
if reused is not None:
|
||||
logger.warning("oauth refresh token reused; revoking the grant")
|
||||
self._end_session(
|
||||
reused.session_id,
|
||||
"oauth_refresh_reuse",
|
||||
"Rotated refresh token reused; grant revoked",
|
||||
)
|
||||
await self._store.save()
|
||||
return None
|
||||
session = self._store.sessions.get(record.session_id)
|
||||
if record.kind != "refresh" or session is None or record.client_id != client.client_id:
|
||||
return None
|
||||
if record.expires_at <= self._clock():
|
||||
return None
|
||||
return StoredRefreshToken(
|
||||
token=f"sha256:{key}",
|
||||
client_id=record.client_id,
|
||||
scopes=list(record.scopes),
|
||||
expires_at=int(record.expires_at),
|
||||
resource=session.resource,
|
||||
subject=session.login,
|
||||
session_id=record.session_id,
|
||||
token_hash=key,
|
||||
)
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
refresh_token: StoredRefreshToken,
|
||||
scopes: list[str],
|
||||
) -> OAuthToken:
|
||||
"""Rotate: revoke the old pair and issue a new pair for the same session."""
|
||||
record = self._store.tokens.get(refresh_token.token_hash)
|
||||
session = self._store.sessions.get(refresh_token.session_id)
|
||||
if (
|
||||
record is None
|
||||
or record.kind != "refresh"
|
||||
or record.client_id != client.client_id
|
||||
or session is None
|
||||
):
|
||||
raise TokenError("invalid_grant", "refresh token does not exist")
|
||||
for key in self._store.session_token_hashes(session.session_id):
|
||||
del self._store.tokens[key]
|
||||
session.previous_refresh_hash = refresh_token.token_hash
|
||||
logger.info("oauth tokens refreshed for %s", session.login)
|
||||
return await self._issue_tokens(session, scopes, record.scopes)
|
||||
|
||||
# Access tokens and revocation
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify one of our access tokens (``token`` on the result is only a hash)."""
|
||||
if not token.startswith(TOKEN_PREFIX):
|
||||
return None
|
||||
key = token_hash(token)
|
||||
record = self._store.tokens.get(key)
|
||||
if record is None or record.kind != "access" or record.expires_at <= self._clock():
|
||||
return None
|
||||
session = self._store.sessions.get(record.session_id)
|
||||
if session is None:
|
||||
return None
|
||||
return AccessToken(
|
||||
token=f"sha256:{key}",
|
||||
client_id=record.client_id,
|
||||
scopes=list(record.scopes),
|
||||
expires_at=int(record.expires_at),
|
||||
resource=session.resource,
|
||||
subject=session.login,
|
||||
claims={"auth": "oauth", "session_id": session.session_id},
|
||||
)
|
||||
|
||||
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
||||
"""Revoke the whole grant (access and refresh tokens and the Gitea session)."""
|
||||
if isinstance(token, StoredRefreshToken):
|
||||
session_id: Any = token.session_id
|
||||
elif isinstance(token, AccessToken):
|
||||
session_id = (token.claims or {}).get("session_id")
|
||||
else:
|
||||
return
|
||||
if not isinstance(session_id, str):
|
||||
return
|
||||
self._end_session(session_id, "oauth_revoke", "OAuth grant revoked by the client")
|
||||
await self._store.save()
|
||||
logger.info("oauth grant revoked")
|
||||
|
||||
|
||||
class OAuthTokenVerifier:
|
||||
"""``TokenVerifier`` for our issued access tokens (anything else is not ours)."""
|
||||
|
||||
def __init__(self, provider: GiteaOAuthProvider) -> None:
|
||||
self._provider = provider
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Return the access token for an issued ``agm_`` token, else None."""
|
||||
if not token.startswith(TOKEN_PREFIX):
|
||||
return None
|
||||
return await self._provider.load_access_token(token)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Identity resolution for per-user OAuth, with automatic upstream refresh.
|
||||
|
||||
Static and OAuth identities never mix:
|
||||
|
||||
* an access token carrying our OAuth marker (``claims["auth"] == "oauth"``)
|
||||
always resolves to that session's Gitea user, or fails; it never falls back
|
||||
to the admin token;
|
||||
* only the static bearer (``client_id == STATIC_CLIENT_ID`` and no OAuth
|
||||
marker) or no token at all reaches the admin resolver, and in an OAuth-only
|
||||
deployment (no ``GITEA_TOKEN``) that fails closed;
|
||||
* any other access token is refused.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
|
||||
from aegis_gitea_mcp.audit import AuditLogger, get_audit_logger
|
||||
from aegis_gitea_mcp.auth import STATIC_CLIENT_ID, IdentityResolver
|
||||
from aegis_gitea_mcp.errors import ToolError
|
||||
from aegis_gitea_mcp.identity import Identity
|
||||
from aegis_gitea_mcp.oauth.store import OAuthStore, OAuthStoreError, SessionRecord
|
||||
from aegis_gitea_mcp.oauth.upstream import GiteaUpstream, UpstreamError, UpstreamInvalidGrant
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Refresh the user's Gitea token when it expires within this many seconds.
|
||||
REFRESH_MARGIN_SECONDS = 60.0
|
||||
|
||||
SIGN_IN_AGAIN = "your Gitea sign-in has ended; sign in again through your MCP client"
|
||||
OAUTH_ONLY = (
|
||||
"this server accepts only per-user OAuth sign-in (no GITEA_TOKEN is configured for "
|
||||
"admin-token mode)"
|
||||
)
|
||||
|
||||
|
||||
class OAuthIdentityResolver:
|
||||
"""Maps a verified access token to the identity a tool call runs as.
|
||||
|
||||
Args:
|
||||
store: OAuth store (sessions and Gitea tokens).
|
||||
upstream: Gitea OAuth client used to refresh the user's Gitea token.
|
||||
admin: Resolver for the static bearer (admin-token mode), or None in an
|
||||
OAuth-only deployment.
|
||||
clock: Wall clock (epoch seconds).
|
||||
audit: Audit logger for ended sessions (defaults to the process-wide one).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: OAuthStore,
|
||||
upstream: GiteaUpstream,
|
||||
admin: IdentityResolver | None,
|
||||
*,
|
||||
clock: Callable[[], float] = time.time,
|
||||
audit: AuditLogger | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._upstream = upstream
|
||||
self._admin = admin
|
||||
self._clock = clock
|
||||
self._audit_logger = audit
|
||||
self._locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
async def resolve(self, access: AccessToken | None) -> Identity:
|
||||
"""Return the user's identity for an OAuth token, the admin's for the static bearer.
|
||||
|
||||
Raises:
|
||||
ToolError: Unknown token kind, ended session, failed upstream refresh,
|
||||
or admin-token mode requested while it is not configured.
|
||||
"""
|
||||
claims = (access.claims or {}) if access is not None else {}
|
||||
if access is not None and claims.get("auth") == "oauth":
|
||||
return await self._resolve_session(access, claims.get("session_id"))
|
||||
if access is None or access.client_id == STATIC_CLIENT_ID:
|
||||
if self._admin is None:
|
||||
raise ToolError(OAUTH_ONLY, status_code=401)
|
||||
return await self._admin.resolve(access)
|
||||
raise ToolError("unknown identity", status_code=401)
|
||||
|
||||
async def _resolve_session(self, access: AccessToken, session_id: object) -> Identity:
|
||||
if not isinstance(session_id, str):
|
||||
raise ToolError("unknown identity", status_code=401)
|
||||
session = self._store.sessions.get(session_id)
|
||||
if session is None or session.login != access.subject:
|
||||
raise ToolError(SIGN_IN_AGAIN, status_code=401)
|
||||
if self._needs_refresh(session):
|
||||
session = await self._refresh(session_id)
|
||||
redact = (session.gitea_refresh_token,) if session.gitea_refresh_token else ()
|
||||
# The executor redacts ``Identity.redact`` (the Gitea refresh token) from results.
|
||||
return Identity(
|
||||
login=session.login, mode="oauth", token=session.gitea_access_token, redact=redact
|
||||
)
|
||||
|
||||
def _needs_refresh(self, session: SessionRecord) -> bool:
|
||||
expires = session.gitea_expires_at
|
||||
return expires is not None and expires - self._clock() <= REFRESH_MARGIN_SECONDS
|
||||
|
||||
async def _refresh(self, session_id: str) -> SessionRecord:
|
||||
lock = self._locks.setdefault(session_id, asyncio.Lock())
|
||||
async with lock:
|
||||
session = self._store.sessions.get(session_id)
|
||||
if session is None:
|
||||
raise ToolError(SIGN_IN_AGAIN, status_code=401)
|
||||
if not self._needs_refresh(session):
|
||||
return session # refreshed by a concurrent call
|
||||
still_valid = (session.gitea_expires_at or 0.0) > self._clock()
|
||||
if session.gitea_refresh_token is None:
|
||||
if still_valid:
|
||||
return session
|
||||
await self._end(session, "Gitea token expired and no refresh token was issued")
|
||||
raise ToolError(SIGN_IN_AGAIN, status_code=401)
|
||||
try:
|
||||
tokens = await self._upstream.refresh(session.gitea_refresh_token)
|
||||
except UpstreamInvalidGrant:
|
||||
await self._end(session, "Gitea refused the refresh token")
|
||||
raise ToolError(SIGN_IN_AGAIN, status_code=401) from None
|
||||
except UpstreamError as exc:
|
||||
logger.warning("gitea token refresh failed for %s: %s", session.login, exc)
|
||||
if still_valid:
|
||||
return session
|
||||
raise ToolError(
|
||||
"could not refresh your Gitea sign-in; try again shortly", status_code=503
|
||||
) from None
|
||||
updated = dataclasses.replace(
|
||||
session,
|
||||
gitea_access_token=tokens.access_token,
|
||||
gitea_refresh_token=tokens.refresh_token or session.gitea_refresh_token,
|
||||
gitea_expires_at=tokens.expires_at,
|
||||
)
|
||||
self._store.sessions[session_id] = updated
|
||||
try:
|
||||
await self._store.save()
|
||||
except (OSError, OAuthStoreError) as exc:
|
||||
logger.error("could not save the refreshed Gitea token (%s)", type(exc).__name__)
|
||||
logger.info("gitea token refreshed for %s", session.login)
|
||||
return updated
|
||||
|
||||
async def _end(self, session: SessionRecord, reason: str) -> None:
|
||||
self._store.end_session(session.session_id)
|
||||
self._locks.pop(session.session_id, None)
|
||||
logger.warning("oauth session ended for %s: %s", session.login, reason)
|
||||
try:
|
||||
audit = self._audit_logger or get_audit_logger()
|
||||
audit.log_security_event(
|
||||
"oauth_session_ended",
|
||||
reason,
|
||||
severity="low",
|
||||
metadata={"login": session.login, "client_id": session.client_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("audit log write failed: %s", type(exc).__name__)
|
||||
try:
|
||||
await self._store.save()
|
||||
except (OSError, OAuthStoreError) as exc:
|
||||
logger.error("could not save the OAuth store (%s)", type(exc).__name__)
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Encrypted on-disk store for OAuth clients, sessions and token hashes.
|
||||
|
||||
One file, ``DATA_DIR/oauth-store.json``, holds the whole state as a single
|
||||
Fernet-encrypted JSON document. The Fernet key is derived from
|
||||
``TOKEN_ENCRYPTION_KEY`` with HKDF-SHA256, so the setting can be any long random
|
||||
string (the setup command writes a ``secrets.token_urlsafe`` value, not a Fernet
|
||||
key).
|
||||
|
||||
What is stored:
|
||||
|
||||
* registered MCP clients, including their client secrets (the SDK compares the
|
||||
presented secret with the stored one at ``/token``);
|
||||
* sessions: the Gitea login and the user's Gitea access and refresh tokens;
|
||||
* our own access and refresh tokens, only as SHA-256 hashes.
|
||||
|
||||
File safety: the directory is created with mode 0700 and the file is written
|
||||
with mode 0600 through a fresh ``O_EXCL`` temporary file and an atomic
|
||||
``os.replace``. A symlink at the store path is refused. A file that cannot be
|
||||
decrypted or parsed stops startup with a clear message instead of being wiped,
|
||||
because wiping would silently sign every user out and hide a wrong key.
|
||||
|
||||
Mutations happen synchronously in memory (so a check-and-delete is atomic under
|
||||
asyncio) and are persisted with :meth:`OAuthStore.save`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STORE_FILENAME = "oauth-store.json"
|
||||
STORE_VERSION = 1
|
||||
HKDF_INFO = b"aegis-gitea-mcp oauth store"
|
||||
|
||||
#: A session that never received tokens (an abandoned sign-in) is dropped after this long.
|
||||
ABANDONED_SESSION_SECONDS = 600.0
|
||||
|
||||
Clock = Callable[[], float]
|
||||
TokenKind = Literal["access", "refresh"]
|
||||
|
||||
|
||||
class OAuthStoreError(Exception):
|
||||
"""The OAuth store cannot be used (startup error; the message is safe to print)."""
|
||||
|
||||
|
||||
def derive_fernet_key(secret: str) -> bytes:
|
||||
"""Derive the Fernet key for the store from ``TOKEN_ENCRYPTION_KEY`` (HKDF-SHA256)."""
|
||||
hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=HKDF_INFO)
|
||||
return base64.urlsafe_b64encode(hkdf.derive(secret.encode("utf-8")))
|
||||
|
||||
|
||||
def token_hash(value: str) -> str:
|
||||
"""SHA-256 hex digest used as the stored form of our tokens, codes and states."""
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientRecord:
|
||||
"""A dynamically registered MCP client.
|
||||
|
||||
Attributes:
|
||||
info: ``OAuthClientInformationFull`` as JSON (includes the client secret).
|
||||
registered_at: Registration time (epoch seconds).
|
||||
last_used_at: Last time the client was looked up (epoch seconds).
|
||||
"""
|
||||
|
||||
info: dict[str, Any] = field(repr=False)
|
||||
registered_at: float
|
||||
last_used_at: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionRecord:
|
||||
"""One signed-in grant: a Gitea user, the client it was granted to and the Gitea tokens.
|
||||
|
||||
Attributes:
|
||||
session_id: Random identifier (never sent to clients).
|
||||
client_id: MCP client the grant belongs to.
|
||||
login: Gitea login of the user.
|
||||
is_admin: Whether Gitea reported the user as site admin at sign-in
|
||||
(informational only; Gitea enforces permissions).
|
||||
gitea_access_token: The user's Gitea access token.
|
||||
gitea_refresh_token: The user's Gitea refresh token, if Gitea issued one.
|
||||
gitea_expires_at: Expiry of the Gitea access token (epoch seconds), if known.
|
||||
scopes: Our scopes granted to the client.
|
||||
resource: The resource (MCP endpoint URL) the grant is bound to.
|
||||
created_at: Sign-in time (epoch seconds).
|
||||
previous_refresh_hash: Hash of the last rotated-out refresh token (reuse detection).
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
client_id: str
|
||||
login: str
|
||||
is_admin: bool
|
||||
gitea_access_token: str = field(repr=False)
|
||||
gitea_refresh_token: str | None = field(repr=False)
|
||||
gitea_expires_at: float | None
|
||||
scopes: list[str]
|
||||
resource: str
|
||||
created_at: float
|
||||
previous_refresh_hash: str | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenRecord:
|
||||
"""One of our tokens, stored under its SHA-256 hash."""
|
||||
|
||||
kind: TokenKind
|
||||
session_id: str
|
||||
client_id: str
|
||||
scopes: list[str]
|
||||
expires_at: float
|
||||
|
||||
|
||||
def _restrict_directory(directory: Path) -> None:
|
||||
"""Create the state directory with mode 0700, or tighten an existing one we own."""
|
||||
if not directory.exists():
|
||||
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
return
|
||||
mode = stat.S_IMODE(directory.stat().st_mode)
|
||||
if mode & 0o077:
|
||||
try:
|
||||
directory.chmod(0o700)
|
||||
except OSError:
|
||||
logger.warning("could not restrict the permissions of the state directory to 0700")
|
||||
|
||||
|
||||
def _parse_document(document: Any) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
if not isinstance(document, dict) or document.get("version") != STORE_VERSION:
|
||||
raise ValueError("unsupported store version")
|
||||
clients_raw = document.get("clients", {})
|
||||
sessions_raw = document.get("sessions", {})
|
||||
tokens_raw = document.get("tokens", {})
|
||||
if not all(isinstance(part, dict) for part in (clients_raw, sessions_raw, tokens_raw)):
|
||||
raise ValueError("malformed store sections")
|
||||
clients = {str(key): ClientRecord(**value) for key, value in clients_raw.items()}
|
||||
sessions = {str(key): SessionRecord(**value) for key, value in sessions_raw.items()}
|
||||
tokens = {str(key): TokenRecord(**value) for key, value in tokens_raw.items()}
|
||||
for record in clients.values():
|
||||
if not isinstance(record.info, dict):
|
||||
raise ValueError("malformed client record")
|
||||
for token in tokens.values():
|
||||
if token.kind not in ("access", "refresh"):
|
||||
raise ValueError("malformed token record")
|
||||
return clients, sessions, tokens
|
||||
|
||||
|
||||
class OAuthStore:
|
||||
"""In-memory OAuth state backed by one encrypted file.
|
||||
|
||||
Use :meth:`load` to create it. All getters and mutators are synchronous;
|
||||
:meth:`save` persists the current state (pruning expired entries first).
|
||||
|
||||
Args:
|
||||
directory: State directory (``DATA_DIR``).
|
||||
secret: ``TOKEN_ENCRYPTION_KEY``.
|
||||
clock: Wall clock (epoch seconds); tests inject a fake one.
|
||||
"""
|
||||
|
||||
def __init__(self, directory: Path, secret: str, clock: Clock = time.time) -> None:
|
||||
self._directory = directory
|
||||
self._path = directory / STORE_FILENAME
|
||||
self._fernet = Fernet(derive_fernet_key(secret))
|
||||
self._clock = clock
|
||||
self._lock = asyncio.Lock()
|
||||
self.clients: dict[str, ClientRecord] = {}
|
||||
self.sessions: dict[str, SessionRecord] = {}
|
||||
self.tokens: dict[str, TokenRecord] = {}
|
||||
|
||||
@classmethod
|
||||
def load(cls, directory: Path, secret: str, clock: Clock = time.time) -> OAuthStore:
|
||||
"""Open the store, reading and decrypting the file when it exists.
|
||||
|
||||
Raises:
|
||||
OAuthStoreError: The path is a symlink or not a regular file, or the
|
||||
file cannot be decrypted or parsed.
|
||||
"""
|
||||
store = cls(directory, secret, clock)
|
||||
store._read()
|
||||
store.prune()
|
||||
return store
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Location of the store file."""
|
||||
return self._path
|
||||
|
||||
# Reading and writing the file
|
||||
|
||||
def _read(self) -> None:
|
||||
try:
|
||||
info = os.lstat(self._path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if stat.S_ISLNK(info.st_mode):
|
||||
raise OAuthStoreError(
|
||||
f"{self._path} is a symlink; refusing to use it as the OAuth token store"
|
||||
)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise OAuthStoreError(f"{self._path} is not a regular file")
|
||||
fd = os.open(self._path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
with os.fdopen(fd, "rb") as handle:
|
||||
raw = handle.read()
|
||||
if stat.S_IMODE(info.st_mode) & 0o077:
|
||||
logger.warning("OAuth store file had loose permissions; restricting it to 0600")
|
||||
os.chmod(self._path, 0o600)
|
||||
try:
|
||||
plain = self._fernet.decrypt(raw)
|
||||
except InvalidToken:
|
||||
raise OAuthStoreError(
|
||||
f"cannot decrypt {self._path}: TOKEN_ENCRYPTION_KEY does not match the key the "
|
||||
"store was written with, or the file is damaged. Restore the original key, or "
|
||||
"move the file away to start over (this signs every OAuth user out)."
|
||||
) from None
|
||||
try:
|
||||
self.clients, self.sessions, self.tokens = _parse_document(json.loads(plain))
|
||||
except (ValueError, TypeError, KeyError):
|
||||
raise OAuthStoreError(
|
||||
f"{self._path} is not a valid OAuth store document; move the file away to "
|
||||
"start over (this signs every OAuth user out)"
|
||||
) from None
|
||||
|
||||
def _document(self) -> bytes:
|
||||
document = {
|
||||
"version": STORE_VERSION,
|
||||
"clients": {key: asdict(value) for key, value in self.clients.items()},
|
||||
"sessions": {key: asdict(value) for key, value in self.sessions.items()},
|
||||
"tokens": {key: asdict(value) for key, value in self.tokens.items()},
|
||||
}
|
||||
return json.dumps(document, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
def _write(self, data: bytes) -> None:
|
||||
_restrict_directory(self._directory)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
if stat.S_ISLNK(os.lstat(self._path).st_mode):
|
||||
raise OAuthStoreError(
|
||||
f"{self._path} is a symlink; refusing to write the OAuth token store"
|
||||
)
|
||||
temporary = self._directory / f".{STORE_FILENAME}.{secrets.token_hex(8)}.tmp"
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(temporary, flags, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, self._path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.unlink(temporary)
|
||||
raise
|
||||
|
||||
async def save(self) -> None:
|
||||
"""Prune expired entries and persist the state atomically (encrypted, mode 0600)."""
|
||||
async with self._lock:
|
||||
self.prune()
|
||||
self._write(self._fernet.encrypt(self._document()))
|
||||
|
||||
# Maintenance
|
||||
|
||||
def prune(self) -> None:
|
||||
"""Drop expired tokens, and sessions that have no tokens left."""
|
||||
now = self._clock()
|
||||
for key in [key for key, token in self.tokens.items() if token.expires_at <= now]:
|
||||
del self.tokens[key]
|
||||
live = {token.session_id for token in self.tokens.values()}
|
||||
for session_id, session in list(self.sessions.items()):
|
||||
if session_id in live:
|
||||
continue
|
||||
if session.created_at + ABANDONED_SESSION_SECONDS < now:
|
||||
del self.sessions[session_id]
|
||||
|
||||
# Sessions and tokens
|
||||
|
||||
def session_token_hashes(self, session_id: str) -> list[str]:
|
||||
"""Hashes of every token issued for ``session_id``."""
|
||||
return [key for key, token in self.tokens.items() if token.session_id == session_id]
|
||||
|
||||
def end_session(self, session_id: str) -> bool:
|
||||
"""Delete a session and all of its tokens; return whether it existed."""
|
||||
for key in self.session_token_hashes(session_id):
|
||||
del self.tokens[key]
|
||||
return self.sessions.pop(session_id, None) is not None
|
||||
|
||||
def sessions_of(self, login: str) -> Iterator[SessionRecord]:
|
||||
"""Every session of one Gitea login."""
|
||||
return (session for session in self.sessions.values() if session.login == login)
|
||||
|
||||
def session_by_previous_refresh(self, refresh_hash: str) -> SessionRecord | None:
|
||||
"""The session whose last rotated-out refresh token has ``refresh_hash``."""
|
||||
for session in self.sessions.values():
|
||||
if session.previous_refresh_hash is not None and secrets.compare_digest(
|
||||
session.previous_refresh_hash, refresh_hash
|
||||
):
|
||||
return session
|
||||
return None
|
||||
|
||||
def client_has_sessions(self, client_id: str) -> bool:
|
||||
"""Whether any session belongs to ``client_id``."""
|
||||
return any(session.client_id == client_id for session in self.sessions.values())
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Gitea as the upstream OAuth2 provider: code exchange, refresh and the user lookup.
|
||||
|
||||
We are a confidential client of Gitea (``GITEA_OAUTH_CLIENT_ID`` and
|
||||
``GITEA_OAUTH_CLIENT_SECRET``, sent form-encoded). Errors never carry Gitea's
|
||||
response body or any token: callers only learn a coarse kind.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from aegis_gitea_mcp import __version__
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
|
||||
#: Callback path on our server that Gitea redirects to (registered on the Gitea app).
|
||||
CALLBACK_PATH = "/oauth/callback"
|
||||
|
||||
_MAX_TOKEN_CHARS = 32_767 # Gitea's MAX_TOKEN_LENGTH
|
||||
# Gitea user names: alphanumerics, dash, underscore and dot (also keeps logs clean).
|
||||
_LOGIN = re.compile(r"^[A-Za-z0-9_.-]{1,255}$")
|
||||
|
||||
|
||||
class UpstreamError(Exception):
|
||||
"""A request to Gitea's OAuth endpoints failed (message is a coarse, safe kind)."""
|
||||
|
||||
|
||||
class UpstreamInvalidGrant(UpstreamError):
|
||||
"""Gitea rejected the code or refresh token (``invalid_grant``): the user must sign in again."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpstreamTokens:
|
||||
"""Tokens returned by Gitea's token endpoint (excluded from ``repr``)."""
|
||||
|
||||
access_token: str = field(repr=False)
|
||||
refresh_token: str | None = field(repr=False)
|
||||
expires_at: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GiteaUser:
|
||||
"""The signed-in Gitea user."""
|
||||
|
||||
login: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
def _default_client(settings: Settings) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(float(settings.request_timeout_seconds)),
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def _valid_token(value: Any) -> bool:
|
||||
return isinstance(value, str) and 0 < len(value) <= _MAX_TOKEN_CHARS and value.isprintable()
|
||||
|
||||
|
||||
class GiteaUpstream:
|
||||
"""Client for Gitea's ``/login/oauth/access_token`` and ``/api/v1/user``.
|
||||
|
||||
Args:
|
||||
settings: Application settings (Gitea URL, OAuth app credentials, timeout).
|
||||
client_factory: Creates the ``httpx.AsyncClient`` for one exchange. A new
|
||||
client per exchange means no cookies or connections are shared.
|
||||
clock: Wall clock used to turn ``expires_in`` into an expiry time.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
*,
|
||||
client_factory: Callable[[], httpx.AsyncClient] | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._client_factory = client_factory or (lambda: _default_client(settings))
|
||||
self._clock = clock
|
||||
self._token_url = f"{settings.gitea_url}/login/oauth/access_token"
|
||||
self._user_url = f"{settings.gitea_api_url}/user"
|
||||
secret = settings.gitea_oauth_client_secret
|
||||
self._client_id = settings.gitea_oauth_client_id or ""
|
||||
self._client_secret = secret.get_secret_value() if secret is not None else ""
|
||||
self._headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": f"aegis-gitea-mcp/{__version__}",
|
||||
}
|
||||
|
||||
@property
|
||||
def redirect_uri(self) -> str:
|
||||
"""Our callback URL as registered on the Gitea OAuth application."""
|
||||
return f"{self._settings.public_base_url}{CALLBACK_PATH}"
|
||||
|
||||
def authorize_url(self, *, state: str, code_challenge: str) -> str:
|
||||
"""Gitea's authorization URL for one sign-in (PKCE S256, fixed scope list)."""
|
||||
query = {
|
||||
"client_id": self._client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
"scope": " ".join(self._settings.oauth_scopes),
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
return f"{self._settings.gitea_url}/login/oauth/authorize?{urlencode(query)}"
|
||||
|
||||
async def exchange_code(self, code: str, code_verifier: str) -> UpstreamTokens:
|
||||
"""Exchange Gitea's authorization code (with our PKCE verifier) for tokens."""
|
||||
return await self._token_request(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
)
|
||||
|
||||
async def refresh(self, refresh_token: str) -> UpstreamTokens:
|
||||
"""Refresh the user's Gitea access token.
|
||||
|
||||
Raises:
|
||||
UpstreamInvalidGrant: Gitea no longer accepts the refresh token.
|
||||
UpstreamError: Any other failure (network, 5xx, malformed answer).
|
||||
"""
|
||||
return await self._token_request(
|
||||
{"grant_type": "refresh_token", "refresh_token": refresh_token}
|
||||
)
|
||||
|
||||
async def fetch_user(self, access_token: str) -> GiteaUser:
|
||||
"""Look up the user that owns ``access_token`` (``GET /api/v1/user``)."""
|
||||
headers = {**self._headers, "Authorization": f"Bearer {access_token}"}
|
||||
try:
|
||||
async with self._client_factory() as client:
|
||||
response = await client.get(self._user_url, headers=headers)
|
||||
except httpx.HTTPError:
|
||||
raise UpstreamError("user lookup failed: Gitea unreachable") from None
|
||||
if response.status_code != 200:
|
||||
raise UpstreamError(f"user lookup failed: HTTP {response.status_code}")
|
||||
body = _json(response) or {}
|
||||
login = body.get("login")
|
||||
if not isinstance(login, str) or not _LOGIN.match(login):
|
||||
raise UpstreamError("user lookup failed: malformed answer")
|
||||
return GiteaUser(login=login, is_admin=body.get("is_admin") is True)
|
||||
|
||||
async def _token_request(self, form: dict[str, str]) -> UpstreamTokens:
|
||||
payload = {**form, "client_id": self._client_id, "client_secret": self._client_secret}
|
||||
try:
|
||||
async with self._client_factory() as client:
|
||||
response = await client.post(self._token_url, data=payload, headers=self._headers)
|
||||
except httpx.HTTPError:
|
||||
raise UpstreamError("token request failed: Gitea unreachable") from None
|
||||
body = _json(response)
|
||||
if response.status_code != 200:
|
||||
if response.status_code in (400, 401) and body and body.get("error") == "invalid_grant":
|
||||
raise UpstreamInvalidGrant("Gitea rejected the grant")
|
||||
raise UpstreamError(f"token request failed: HTTP {response.status_code}")
|
||||
if body is None:
|
||||
raise UpstreamError("token request failed: malformed answer")
|
||||
access = body.get("access_token")
|
||||
refresh = body.get("refresh_token")
|
||||
token_type = body.get("token_type", "bearer")
|
||||
expires_in = body.get("expires_in")
|
||||
if not isinstance(access, str) or not _valid_token(access):
|
||||
raise UpstreamError("token request failed: malformed answer")
|
||||
if not isinstance(token_type, str):
|
||||
raise UpstreamError("token request failed: malformed answer")
|
||||
if token_type.lower() != "bearer":
|
||||
raise UpstreamError("token request failed: unexpected token type")
|
||||
if refresh is not None and not _valid_token(refresh):
|
||||
raise UpstreamError("token request failed: malformed answer")
|
||||
expires_at: float | None = None
|
||||
if isinstance(expires_in, bool):
|
||||
expires_in = None
|
||||
if isinstance(expires_in, (int, float)) and expires_in > 0:
|
||||
expires_at = self._clock() + float(expires_in)
|
||||
return UpstreamTokens(access_token=access, refresh_token=refresh, expires_at=expires_at)
|
||||
|
||||
|
||||
def _json(response: httpx.Response) -> dict[str, Any] | None:
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
return body if isinstance(body, dict) else None
|
||||
@@ -28,6 +28,11 @@ _SECRET_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
"github_token",
|
||||
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
|
||||
),
|
||||
(
|
||||
# Opaque OAuth tokens this server issues (``agm_`` + token_urlsafe(32)).
|
||||
"aegis_oauth_token",
|
||||
re.compile(r"\bagm_[A-Za-z0-9_-]{32,}"),
|
||||
),
|
||||
(
|
||||
"jwt",
|
||||
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b"),
|
||||
|
||||
@@ -32,8 +32,15 @@ from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from aegis_gitea_mcp import __version__
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.auth import CompositeVerifier, IdentityResolver, StaticBearerVerifier
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
from aegis_gitea_mcp.auth import (
|
||||
MCP_SCOPE,
|
||||
CompositeVerifier,
|
||||
IdentityResolver,
|
||||
StaticBearerVerifier,
|
||||
)
|
||||
from aegis_gitea_mcp.config import Settings, is_loopback_host
|
||||
from aegis_gitea_mcp.gitea import GiteaExecutor
|
||||
from aegis_gitea_mcp.oauth import OAuthSetupError
|
||||
from aegis_gitea_mcp.rate_limit import RequestRateLimiter
|
||||
from aegis_gitea_mcp.server import (
|
||||
SERVER_NAME,
|
||||
@@ -69,6 +76,8 @@ class HttpAuthExtras:
|
||||
extra_routes: Extra unauthenticated routes (for example ``/oauth/callback``).
|
||||
client_registration_options: Dynamic client registration settings.
|
||||
revocation_options: Token revocation settings.
|
||||
executor: Gitea executor shared by the server and the resolver.
|
||||
rate_limited_paths: Unauthenticated paths that get the per-IP rate limit.
|
||||
"""
|
||||
|
||||
token_verifier: TokenVerifier | None = None
|
||||
@@ -77,12 +86,19 @@ class HttpAuthExtras:
|
||||
extra_routes: tuple[Route, ...] = ()
|
||||
client_registration_options: ClientRegistrationOptions | None = None
|
||||
revocation_options: RevocationOptions | None = None
|
||||
executor: GiteaExecutor | None = None
|
||||
rate_limited_paths: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def load_oauth_components(settings: Settings) -> HttpAuthExtras | None:
|
||||
"""Return the per-user OAuth components for ``OAUTH_ENABLED``, or None when unavailable."""
|
||||
del settings
|
||||
return None
|
||||
def load_oauth_components(settings: Settings) -> HttpAuthExtras:
|
||||
"""Build the per-user OAuth components for ``OAUTH_ENABLED``.
|
||||
|
||||
Raises:
|
||||
OAuthSetupError: Incomplete configuration or an unusable token store.
|
||||
"""
|
||||
from aegis_gitea_mcp.oauth.components import build_oauth_components
|
||||
|
||||
return build_oauth_components(settings)
|
||||
|
||||
|
||||
# Health probe
|
||||
@@ -133,15 +149,24 @@ class GiteaProbe:
|
||||
|
||||
|
||||
class RateLimitMiddleware:
|
||||
"""ASGI middleware applying the per-IP and per-token limits to the MCP path."""
|
||||
"""ASGI middleware applying the per-IP and per-token limits to the MCP path.
|
||||
|
||||
def __init__(self, app: ASGIApp, mcp_path: str, limiter: RequestRateLimiter) -> None:
|
||||
``extra_paths`` (for example the unauthenticated OAuth endpoints) share the limits.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
mcp_path: str,
|
||||
limiter: RequestRateLimiter,
|
||||
extra_paths: Sequence[str] = (),
|
||||
) -> None:
|
||||
self.app = app
|
||||
self._mcp_path = mcp_path
|
||||
self._paths = frozenset({mcp_path, *(path.rstrip("/") for path in extra_paths)})
|
||||
self._limiter = limiter
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http" or scope["path"].rstrip("/") != self._mcp_path:
|
||||
if scope["type"] != "http" or scope["path"].rstrip("/") not in self._paths:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
client = scope.get("client")
|
||||
@@ -191,19 +216,27 @@ def transport_security(settings: Settings) -> TransportSecuritySettings:
|
||||
)
|
||||
|
||||
|
||||
def _resource_url(settings: Settings) -> str:
|
||||
return f"{_base_url(settings)}{settings.mcp_path}"
|
||||
|
||||
|
||||
def _auth_settings(
|
||||
settings: Settings,
|
||||
client_registration_options: ClientRegistrationOptions | None,
|
||||
revocation_options: RevocationOptions | None,
|
||||
*,
|
||||
authorization_server: bool,
|
||||
) -> AuthSettings:
|
||||
issuer = _base_url(settings)
|
||||
return AuthSettings(
|
||||
issuer_url=issuer, # type: ignore[arg-type]
|
||||
resource_server_url=f"{issuer}{settings.mcp_path}", # type: ignore[arg-type]
|
||||
required_scopes=None,
|
||||
# The static bearer carries no RFC 8707 resource; a per-user verifier
|
||||
# that binds tokens to this resource checks the audience itself.
|
||||
validate_token_resource=False,
|
||||
resource_server_url=_resource_url(settings), # type: ignore[arg-type]
|
||||
# With our own authorization server every token (OAuth-issued and the
|
||||
# static bearer) names this endpoint as its RFC 8707 resource and carries
|
||||
# the one scope we grant, so the SDK enforces both. Without it, extra
|
||||
# verifiers are trusted to check the audience themselves.
|
||||
required_scopes=[MCP_SCOPE] if authorization_server else None,
|
||||
validate_token_resource=authorization_server,
|
||||
client_registration_options=client_registration_options,
|
||||
revocation_options=revocation_options,
|
||||
)
|
||||
@@ -219,6 +252,7 @@ def build_http_app(
|
||||
client_registration_options: ClientRegistrationOptions | None = None,
|
||||
revocation_options: RevocationOptions | None = None,
|
||||
probe: GiteaProbe | None = None,
|
||||
rate_limited_paths: Sequence[str] = (),
|
||||
) -> Starlette:
|
||||
"""Build the streamable HTTP ASGI app (MCP endpoint, ``/health``, ``/``).
|
||||
|
||||
@@ -236,6 +270,8 @@ def build_http_app(
|
||||
client_registration_options: Dynamic client registration settings.
|
||||
revocation_options: Token revocation settings.
|
||||
probe: Gitea reachability probe for ``/health`` (defaults to a new one).
|
||||
rate_limited_paths: Further paths (besides the MCP endpoint) that get the
|
||||
rate limit, for example the OAuth endpoints.
|
||||
|
||||
Raises:
|
||||
ValueError: ``auth_server_provider`` given without any token verifier.
|
||||
@@ -245,7 +281,11 @@ def build_http_app(
|
||||
|
||||
verifiers: list[TokenVerifier] = []
|
||||
if settings.mcp_bearer_token is not None:
|
||||
verifiers.append(StaticBearerVerifier(settings.mcp_bearer_token.get_secret_value()))
|
||||
verifiers.append(
|
||||
StaticBearerVerifier(
|
||||
settings.mcp_bearer_token.get_secret_value(), resource=_resource_url(settings)
|
||||
)
|
||||
)
|
||||
if token_verifier is not None:
|
||||
verifiers.append(token_verifier)
|
||||
if auth_server_provider is not None and not verifiers:
|
||||
@@ -289,7 +329,12 @@ def build_http_app(
|
||||
transport_security=transport_security(settings),
|
||||
host=settings.mcp_host,
|
||||
auth=(
|
||||
_auth_settings(settings, client_registration_options, revocation_options)
|
||||
_auth_settings(
|
||||
settings,
|
||||
client_registration_options,
|
||||
revocation_options,
|
||||
authorization_server=auth_server_provider is not None,
|
||||
)
|
||||
if verifier is not None
|
||||
else None
|
||||
),
|
||||
@@ -310,7 +355,10 @@ def build_http_app(
|
||||
|
||||
app.router.lifespan_context = lifespan
|
||||
app.add_middleware(
|
||||
RateLimitMiddleware, mcp_path=settings.mcp_path, limiter=RequestRateLimiter(settings)
|
||||
RateLimitMiddleware,
|
||||
mcp_path=settings.mcp_path,
|
||||
limiter=RequestRateLimiter(settings),
|
||||
extra_paths=tuple(rate_limited_paths),
|
||||
)
|
||||
return app
|
||||
|
||||
@@ -319,13 +367,22 @@ def build_http_app(
|
||||
|
||||
|
||||
def http_startup_errors(settings: Settings, extras: HttpAuthExtras | None) -> list[str]:
|
||||
"""List reasons the HTTP server must not start (empty when startup may proceed)."""
|
||||
"""List reasons the HTTP server must not start (empty when startup may proceed).
|
||||
|
||||
An OAuth-only deployment (OAuth on, no ``MCP_BEARER_TOKEN``, no
|
||||
``GITEA_TOKEN``) may start: every user brings their own Gitea token and
|
||||
admin-token mode is simply unavailable. ``GITEA_TOKEN`` stays required
|
||||
whenever admin-token mode is reachable (a static bearer, or no HTTP auth).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
bearer = settings.mcp_bearer_token is not None
|
||||
if settings.oauth_enabled:
|
||||
errors.extend(settings.oauth_config_errors())
|
||||
if not settings.oauth_config_errors() and extras is None and not bearer:
|
||||
errors.append("OAUTH_ENABLED=true but per-user OAuth is not available in this build")
|
||||
if not settings.oauth_config_errors() and extras is None:
|
||||
errors.append("OAUTH_ENABLED=true but per-user OAuth could not be set up")
|
||||
public = urlsplit(settings.public_base_url or "")
|
||||
if public.scheme == "http" and not is_loopback_host(public.hostname or ""):
|
||||
errors.append("PUBLIC_BASE_URL must use https unless the host is loopback")
|
||||
authenticated = bearer or extras is not None
|
||||
if not settings.is_loopback_bind() and not authenticated:
|
||||
errors.append(
|
||||
@@ -333,7 +390,10 @@ def http_startup_errors(settings: Settings, extras: HttpAuthExtras | None) -> li
|
||||
"MCP_BEARER_TOKEN (or enable OAuth), or bind to 127.0.0.1"
|
||||
)
|
||||
if (bearer or extras is None) and settings.gitea_token is None:
|
||||
errors.append("GITEA_TOKEN is required for admin-token mode (static bearer or no auth)")
|
||||
message = "GITEA_TOKEN is required for admin-token mode (static bearer or no auth)"
|
||||
if settings.oauth_enabled and bearer:
|
||||
message += "; for an OAuth-only deployment unset MCP_BEARER_TOKEN"
|
||||
errors.append(message)
|
||||
return errors
|
||||
|
||||
|
||||
@@ -351,11 +411,6 @@ def http_startup_warnings(settings: Settings, extras: HttpAuthExtras | None) ->
|
||||
"bind address are rejected with 421. Set PUBLIC_BASE_URL (the public https URL) "
|
||||
"or ALLOWED_HOSTS (comma separated host names clients use)"
|
||||
)
|
||||
if settings.oauth_enabled and extras is None and settings.mcp_bearer_token is not None:
|
||||
warnings.append(
|
||||
"OAUTH_ENABLED=true but per-user OAuth is not available in this build: only "
|
||||
"MCP_BEARER_TOKEN is accepted and every call runs as the GITEA_TOKEN owner"
|
||||
)
|
||||
if settings.forwarded_allow_ips == "*":
|
||||
warnings.append(
|
||||
"FORWARDED_ALLOW_IPS=*: X-Forwarded-For is trusted from any peer, so clients can "
|
||||
@@ -382,13 +437,35 @@ def _audit_startup_error(settings: Settings) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
class AccessLogQueryFilter(logging.Filter):
|
||||
"""Drops query strings from uvicorn access log lines.
|
||||
|
||||
OAuth redirects carry one-time codes and states in the query string
|
||||
(``/oauth/callback?code=...&state=...``); they must not reach the logs.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""Rewrite the path argument of an access log record; never drops the record."""
|
||||
args = record.args
|
||||
if isinstance(args, tuple) and len(args) >= 3 and isinstance(args[2], str):
|
||||
path, separator, _ = args[2].partition("?")
|
||||
if separator:
|
||||
record.args = (*args[:2], f"{path}?[redacted]", *args[3:])
|
||||
return True
|
||||
|
||||
|
||||
def run_http(settings: Settings) -> int:
|
||||
"""Run the streamable HTTP server; return a process exit code.
|
||||
|
||||
Returns 2 without starting when a startup check fails (including an audit
|
||||
log that cannot be opened for append).
|
||||
"""
|
||||
extras = load_oauth_components(settings) if settings.oauth_enabled else None
|
||||
extras: HttpAuthExtras | None = None
|
||||
if settings.oauth_enabled and not settings.oauth_config_errors():
|
||||
try:
|
||||
extras = load_oauth_components(settings)
|
||||
except OAuthSetupError as exc:
|
||||
return _fail(f"per-user OAuth cannot start: {exc}")
|
||||
errors = http_startup_errors(settings, extras)
|
||||
if errors:
|
||||
for message in errors:
|
||||
@@ -400,7 +477,11 @@ def run_http(settings: Settings) -> int:
|
||||
for message in http_startup_warnings(settings, extras):
|
||||
print(f"aegis-gitea-mcp: warning: {message}", file=sys.stderr)
|
||||
try:
|
||||
server = build_server(settings, resolver=extras.resolver if extras else None)
|
||||
server = build_server(
|
||||
settings,
|
||||
resolver=extras.resolver if extras else None,
|
||||
executor=extras.executor if extras else None,
|
||||
)
|
||||
except ServerConfigError as exc:
|
||||
return _fail(str(exc))
|
||||
app = build_http_app(
|
||||
@@ -411,7 +492,11 @@ def run_http(settings: Settings) -> int:
|
||||
extra_routes=extras.extra_routes if extras else (),
|
||||
client_registration_options=extras.client_registration_options if extras else None,
|
||||
revocation_options=extras.revocation_options if extras else None,
|
||||
rate_limited_paths=extras.rate_limited_paths if extras else (),
|
||||
)
|
||||
access_logger = logging.getLogger("uvicorn.access")
|
||||
if not any(isinstance(item, AccessLogQueryFilter) for item in access_logger.filters):
|
||||
access_logger.addFilter(AccessLogQueryFilter())
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=settings.mcp_host,
|
||||
|
||||
@@ -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,352 @@
|
||||
"""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.gitea import GiteaExecutor
|
||||
from aegis_gitea_mcp.identity import Identity
|
||||
from aegis_gitea_mcp.oauth.resolver import (
|
||||
OAUTH_ONLY,
|
||||
SIGN_IN_AGAIN,
|
||||
OAuthIdentityResolver,
|
||||
)
|
||||
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 (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 = GiteaExecutor(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 = Identity(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.gitea import GiteaExecutor
|
||||
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.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, GiteaExecutor)
|
||||
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
|
||||
@@ -9,6 +9,19 @@ def test_detect_secrets_api_key_pattern() -> None:
|
||||
assert findings
|
||||
|
||||
|
||||
def test_issued_oauth_tokens_are_masked() -> None:
|
||||
"""Our own ``agm_`` OAuth tokens are masked wherever they show up in content."""
|
||||
issued = "agm_" + "Ab3_-x" * 7 + "Z" # the shape of agm_ + token_urlsafe(32)
|
||||
text = f"pasted by mistake: {issued}."
|
||||
|
||||
findings = detect_secrets(text)
|
||||
masked = sanitize_data({"body": text})
|
||||
|
||||
assert [finding.secret_type for finding in findings] == ["aegis_oauth_token"]
|
||||
assert issued not in masked["body"] and masked["body"].endswith(".")
|
||||
assert detect_secrets("agm_short") == []
|
||||
|
||||
|
||||
def test_sanitize_data_mask_mode() -> None:
|
||||
"""Mask mode should preserve structure while redacting values."""
|
||||
payload = {"content": "api_key=AKIA1234567890ABCDEF"}
|
||||
|
||||
@@ -14,6 +14,7 @@ from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||
from aegis_gitea_mcp import cli, transports
|
||||
from aegis_gitea_mcp.cli import main
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
from aegis_gitea_mcp.oauth import OAuthSetupError
|
||||
from aegis_gitea_mcp.transports import (
|
||||
HttpAuthExtras,
|
||||
http_startup_errors,
|
||||
@@ -143,12 +144,12 @@ def test_no_host_warning_on_loopback(
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
def test_oauth_enabled_with_a_bearer_warns_that_oauth_is_unavailable(
|
||||
def test_oauth_enabled_with_a_bearer_starts_without_warnings(
|
||||
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 0
|
||||
|
||||
assert "per-user OAuth is not available" in capsys.readouterr().err
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["*,10.0.0.1", "not-an-ip", "10.0.0.1/99"])
|
||||
@@ -216,16 +217,21 @@ def test_oauth_misconfiguration_lists_every_missing_setting(
|
||||
assert uvicorn_spy.calls == []
|
||||
|
||||
|
||||
def test_oauth_enabled_but_unavailable_does_not_start_without_a_bearer(
|
||||
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
|
||||
def test_oauth_enabled_but_unavailable_does_not_start(
|
||||
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> 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 == []
|
||||
|
||||
|
||||
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 len(uvicorn_spy.calls) == 1
|
||||
|
||||
@@ -245,8 +251,17 @@ def test_oauth_components_are_used_when_available(
|
||||
assert len(uvicorn_spy.calls) == 1
|
||||
|
||||
|
||||
def test_default_oauth_components_hook_returns_none() -> None:
|
||||
assert load_oauth_components(oauth_settings()) is None
|
||||
def test_default_oauth_components_hook_builds_the_oauth_components() -> 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(
|
||||
|
||||
Reference in new issue
Block a user