Source code for openswmm_mcp.auth

"""OAuth / JWT authentication for the OpenSWMM MCP HTTP transport.

Authentication strategy
-----------------------
* **stdio transport** -- No authentication is applied.  The server runs as a
  local subprocess and inherits the caller's OS-level identity, so adding
  token verification would only add friction with no security benefit.

* **http / sse transport** -- Three schemes are supported, selected by the
  ``ServerSettings`` fields ``jwt_jwks_url`` and ``oauth_issuer``:

  1. *JWT only* (``jwt_jwks_url`` is set): incoming requests must carry a
     Bearer token whose signature is verified against the JSON Web Key Set
     at the given URL.
  2. *OAuth only* (``oauth_issuer`` is set): a full OAuth 2.0 authorization
     code / client-credentials flow is used, with the issuer URL serving as
     the OpenID Connect discovery root.
  3. *Both* (both fields set): the two providers are combined so that a
     request succeeds if *either* verifier accepts the credential.
  4. *Neither* set: the server starts **without** authentication.  This is
     useful for local development or environments that handle auth at an
     upstream reverse-proxy layer.

The ``fastmcp[auth]`` extras package must be installed for any of the
authenticated modes.  If the extras are missing and auth is requested, the
function raises an ``ImportError`` with an actionable message.
"""

from __future__ import annotations

import logging
from typing import Any

from openswmm_mcp.config import ServerSettings

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Lazy, guarded imports -- the auth extras may not be installed.
# ---------------------------------------------------------------------------

_HAS_AUTH_EXTRAS = False

# The functional providers below are what ``create_auth`` actually builds, so
# their presence -- not the legacy ``BearerAuthProvider`` symbol, which fastmcp
# 3.x removed -- determines whether the auth extras are installed.
try:
    from fastmcp.server.auth import JWTVerifier as _JWTVerifier  # type: ignore[import-untyped]

    _HAS_AUTH_EXTRAS = True
except ImportError:
    _JWTVerifier = None  # type: ignore[assignment,misc]

try:
    from fastmcp.server.auth import MultiAuth as _MultiAuth  # type: ignore[import-untyped]
except ImportError:
    _MultiAuth = None  # type: ignore[assignment,misc]

# Legacy alias: pre-3.x fastmcp exposed JWT bearer verification as
# ``BearerAuthProvider``; newer versions renamed it to ``JWTVerifier``.  Keep
# the name importable and, on old fastmcp lacking ``JWTVerifier``, fall back to
# it -- without gating the extras flag on a symbol that may not exist.
try:
    from fastmcp.server.auth import (
        BearerAuthProvider as _BearerAuthProvider,  # type: ignore[import-untyped]
    )

    if _JWTVerifier is None:
        _JWTVerifier = _BearerAuthProvider  # type: ignore[assignment,misc]
    _HAS_AUTH_EXTRAS = True
except ImportError:
    _BearerAuthProvider = _JWTVerifier  # type: ignore[assignment,misc]


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def _ensure_auth_extras(feature: str) -> None:
    """Raise a helpful ``ImportError`` if the auth extras are not installed."""
    if not _HAS_AUTH_EXTRAS:
        raise ImportError(
            f"The '{feature}' auth feature requires the fastmcp auth extras. "
            "Install them with:  pip install 'fastmcp[auth]'"
        )


[docs] def create_auth(settings: ServerSettings) -> Any | None: """Build an authentication provider based on *settings*, or return ``None``. Parameters ---------- settings: The :class:`~openswmm_mcp.config.ServerSettings` loaded at startup. Returns ------- An auth provider instance understood by :class:`fastmcp.FastMCP`, or ``None`` if no authentication should be applied. Raises ------ ImportError If authentication is requested but the ``fastmcp[auth]`` extras are not installed. """ # stdio is always unauthenticated -- it is a local subprocess. if settings.transport == "stdio": logger.debug("Transport is stdio; skipping authentication setup.") return None jwt_provider: Any | None = None oauth_provider: Any | None = None # --- JWT verification via an explicit JWKS endpoint --- if settings.jwt_jwks_url: _ensure_auth_extras("JWT") logger.info("Configuring JWT verification (jwks_uri=%s)", settings.jwt_jwks_url) jwt_provider = _JWTVerifier( # type: ignore[misc] jwks_uri=settings.jwt_jwks_url, audience=settings.oauth_audience, ) # --- OIDC issuer: validate JWTs against the issuer's JWKS --- # fastmcp 3.x's OAuthProvider is a full OAuth *server* base; validating # bearer tokens minted by an external issuer is a JWTVerifier keyed on that # issuer's JWKS. fastmcp does not perform OIDC discovery, so derive the # conventional JWKS path (set OPENSWMM_MCP_JWT_JWKS_URL explicitly when the # issuer publishes its keys elsewhere). if settings.oauth_issuer: _ensure_auth_extras("OAuth") jwks_uri = f"{settings.oauth_issuer.rstrip('/')}/.well-known/jwks.json" logger.info("Configuring OIDC verification (issuer=%s)", settings.oauth_issuer) oauth_provider = _JWTVerifier( # type: ignore[misc] jwks_uri=jwks_uri, issuer=settings.oauth_issuer, audience=settings.oauth_audience, ) # --- Combine verifiers when both are configured --- if jwt_provider and oauth_provider: _ensure_auth_extras("MultiAuth") if _MultiAuth is None: # fastmcp version does not expose MultiAuth; fall back to the # issuer verifier alone and log a warning. logger.warning( "Both JWT and OIDC are configured, but fastmcp.server.auth.MultiAuth " "is not available in this version. Falling back to the issuer verifier." ) return oauth_provider logger.info("Combining JWT and OIDC verifiers into a MultiAuth provider.") return _MultiAuth(verifiers=[jwt_provider, oauth_provider]) # Return whichever single provider is configured, or None. provider = jwt_provider or oauth_provider if provider is None and settings.transport in ("http", "sse"): logger.warning( "HTTP transport is active but no auth is configured. " "Set OPENSWMM_MCP_JWT_JWKS_URL or OPENSWMM_MCP_OAUTH_ISSUER " "to enable authentication, or ensure an upstream proxy handles it." ) return provider