Compare commits

...
4 Commits
Author SHA1 Message Date
Latte ce5ae6caa5 fix: redact identity secrets through a public hook and mask issued agm_ tokens
ci / gitleaks (push) Successful in 8s
ci / docs-check (push) Failing after 30s
ci / lint (push) Successful in 42s
ci / test-integration (push) Successful in 46s
ci / security (push) Failing after 27s
ci / test-mcp (push) Successful in 1m16s
ci / test-unit (push) Successful in 2m17s
ci / build-and-push (push) Successful in 17s
Identity gains a redact field that the executor honours, so the OAuth
resolver no longer needs an executor subclass overriding private
internals. Opaque agm_ tokens issued by our authorization server are now
masked by the secret patterns wherever they appear in content.
2026-10-14 16:45:36 +02:00
Latte e02cede9f2 fix: drop the obsolete OAuth-unavailable startup warning test 2026-10-14 16:44:04 +02:00
Latte 7286440d64 test: cover the per-user OAuth flow, threat cases, store and startup 2026-10-14 16:41:18 +02:00
Latte 566d0b65dd feat: add per-user OAuth mode proxying sign-in to Gitea
Authorization server for MCP clients (SDK provider with dynamic client
registration, PKCE, refresh rotation and revocation) that signs users in
through Gitea and runs tool calls with the user's own Gitea token.

- consent page with CSRF protection and a browser-binding cookie on the
  Gitea callback (confused-deputy protection for the shared Gitea app)
- encrypted token store (Fernet, HKDF-derived key, 0600, atomic writes,
  symlink and corrupt-file refusal); our tokens stored only as hashes
- tokens bound to the MCP endpoint resource and the gitea scope; the
  static bearer keeps working next to OAuth
- code replay and refresh token reuse revoke the grant
- automatic upstream refresh with per-session locking; invalid_grant
  ends the session and never falls back to the admin token
- OAuth-only deployments (no GITEA_TOKEN, no bearer) may start
- rate limit on the OAuth endpoints, query strings dropped from access logs
2026-10-14 16:41:18 +02:00
23 changed files with 4321 additions and 42 deletions

No files matched your search

+33
View File
@@ -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.
+10 -2
View File
@@ -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",
)
+4 -2
View File
@@ -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 = {
+4
View File
@@ -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."""
+9
View File
@@ -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)."""
+173
View File
@@ -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"]),
]
+108
View File
@@ -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,
)
+670
View File
@@ -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)
+166
View File
@@ -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__)
+318
View File
@@ -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())
+191
View File
@@ -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
+5
View File
@@ -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"),
+114 -29
View File
@@ -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,
+111
View File
@@ -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"]
+715
View File
@@ -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 "&lt;script&gt;" 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]
+303
View File
@@ -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
+351
View File
@@ -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
+352
View File
@@ -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]"}
+188
View File
@@ -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
+278
View File
@@ -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
+181
View File
@@ -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
+13
View File
@@ -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"}
+24 -9
View File
@@ -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(