Skip to content

End-to-End OAuth 2.0 Authentication for Splunk 10.2+ (FastMCP v3) #86

@YoungDan

Description

@YoungDan

Summary

Implement end-to-end OAuth 2.0 authentication for the MCP server using FastMCP v3 (>=3.0.0) auth primitives, where MCP clients authenticate via an external Identity Provider (IdP) and the server forwards the JWT as a Bearer token directly to Splunk 10.2's REST API -- eliminating the need for Splunk username/password credentials.

Problem Statement

Currently, the MCP server authenticates to Splunk using username/password credentials passed via HTTP headers, environment variables, or tool parameters (src/client/splunk_client.py). This has security limitations:

  • Credentials are transmitted and cached in memory
  • No token expiration or rotation
  • No integration with enterprise identity providers
  • No fine-grained authorization based on user identity

Splunk 10.2+ introduces OAuth 2.0 external authorization via OIDC, and FastMCP v3 provides OIDCProxy, OAuthProxy, and Authorization primitives. We can connect these to build a secure end-to-end flow.

Critical Design Decisions (from review)

Three issues were identified and corrected during planning:

  1. No token exchange endpoint exists. Splunk 10.2 does NOT expose POST /services/auth/oauth2/token. Instead, Splunk accepts IdP JWTs directly as Bearer tokens on standard REST API endpoints. Splunk validates the JWT against its OIDC configuration (JWKS, issuer, audience) and maps groups to roles internally.
  2. FastMCP version is v3, not v2. The project uses fastmcp>=3.0.0,<4. All API references use correct v3 class names, import paths, and constructor signatures (OIDCProxy, OAuthProxy with upstream_* params, JWTVerifier, get_access_token()).
  3. Two Splunk client files exist. Both src/client/splunk_client.py (config-based, no token support) and src/splunk_client.py (legacy, already supports token=) exist. These must be consolidated.

Architecture

End-to-End Flow

sequenceDiagram
    participant Client as MCP Client
    participant IdP as Identity Provider
    participant MCP as MCP Server
    participant Splunk as Splunk 10.2+

    Client->>MCP: GET /.well-known/oauth-protected-resource
    MCP-->>Client: Authorization server metadata (IdP URL)
    Client->>IdP: Authenticate (OAuth 2.0 flow)
    IdP-->>Client: JWT access token (with scopes, groups, client_id)
    Client->>MCP: MCP request + Bearer JWT
    MCP->>MCP: FastMCP validates JWT (JWTVerifier via JWKS)
    MCP->>MCP: Authorization checks (scopes, tags)
    MCP->>MCP: Extract JWT from AccessToken via get_access_token()
    MCP->>Splunk: REST API call with Authorization: Bearer JWT
    Splunk->>Splunk: Validate JWT via OIDC config, map groups to Splunk roles
    Splunk-->>MCP: Splunk data response
    MCP-->>Client: MCP response
Loading

The key insight: the MCP server is a pass-through for the JWT. It validates the token for its own authorization purposes (via FastMCP), then forwards the same JWT to Splunk's REST API. Splunk independently validates the JWT against its own OIDC configuration.

Two Authentication Boundaries

flowchart LR
    subgraph boundary1 [Boundary 1: MCP Client to MCP Server]
        MCPClient[MCP Client] -->|"OIDCProxy / OAuthProxy"| IdP[Identity Provider]
        IdP -->|"JWT access token"| MCPClient
        MCPClient -->|"Bearer JWT"| MCPServer[MCP Server]
        MCPServer -->|"JWTVerifier validates"| MCPServer
    end
    subgraph boundary2 [Boundary 2: MCP Server to Splunk]
        MCPServer -->|"Bearer JWT passthrough"| SplunkAPI[Splunk REST API]
        SplunkAPI -->|"OIDC validation + role mapping"| SplunkAPI
    end
Loading

FastMCP v3 Auth Primitives

Boundary 1: OIDCProxy (recommended) or OAuthProxy

OIDCProxy (from fastmcp.server.auth.oidc_proxy) is the recommended choice for IdP-agnostic design. It auto-discovers endpoints from the IdP's /.well-known/openid-configuration, reducing env var count from 11 to 5.

from fastmcp.server.auth.oidc_proxy import OIDCProxy

auth = OIDCProxy(
    config_url=os.environ["IDP_OIDC_CONFIG_URL"],
    client_id=os.environ["MCP_OAUTH_CLIENT_ID"],
    client_secret=os.environ["MCP_OAUTH_CLIENT_SECRET"],
    base_url=os.environ["MCP_BASE_URL"],
    audience=os.environ.get("MCP_OAUTH_AUDIENCE"),
)

OAuthProxy fallback (from fastmcp.server.auth import OAuthProxy) for IdPs without OIDC discovery:

from fastmcp.server.auth import OAuthProxy
from fastmcp.server.auth.providers.jwt import JWTVerifier

token_verifier = JWTVerifier(
    jwks_uri=os.environ["IDP_JWKS_URI"],
    issuer=os.environ["IDP_ISSUER_URL"],
    audience=os.environ.get("MCP_OAUTH_AUDIENCE"),
)

auth = OAuthProxy(
    upstream_authorization_endpoint=os.environ["IDP_AUTHORIZE_URL"],
    upstream_token_endpoint=os.environ["IDP_TOKEN_URL"],
    upstream_client_id=os.environ["MCP_OAUTH_CLIENT_ID"],
    upstream_client_secret=os.environ["MCP_OAUTH_CLIENT_SECRET"],
    token_verifier=token_verifier,
    base_url=os.environ["MCP_BASE_URL"],
)

Authorization: FastMCP v3 callable system

from fastmcp.server.auth import AuthContext, require_scopes, restrict_tag
from fastmcp.server.middleware import AuthMiddleware
from fastmcp.server.dependencies import get_access_token
  • require_scopes("splunk:read") -- scope-based per-tool
  • restrict_tag("admin", scopes=["splunk:admin"]) -- tag-based via middleware
  • get_access_token() -- retrieve AccessToken inside tools (contains .token raw string, .claims, .scopes)

Implementation Phases

Phase 0: Spike -- Validate JWT Bearer Passthrough to Splunk

Before building anything, validate the core assumption:

  • Test 1: Does splunklib.client.Service(token=<idp_jwt>) set Authorization: Bearer <jwt> or Authorization: Splunk <jwt>? The legacy Splunk token format uses Splunk prefix, not Bearer.
  • Test 2: If splunklib uses the wrong header format, test raw httpx calls with Authorization: Bearer <jwt> against a Splunk 10.2 instance with OIDC configured.
  • Outcome: Determines whether Phase 1 uses splunklib or httpx for the OAuth path.

Phase 1: Splunk JWT Bearer Adapter

  • Create src/client/splunk_bearer_auth.py -- JWT Bearer passthrough adapter (NOT a token exchange)
  • SplunkBearerAuth class that creates Splunk connections using JWT Bearer tokens
  • Fallback to httpx-based wrapper if splunklib does not support Bearer format

Phase 2: Consolidate Splunk Client Files

  • Merge token support from src/splunk_client.py into src/client/splunk_client.py
  • Add bearer_token parameter to get_splunk_service()
  • Deprecate root-level src/splunk_client.py

Phase 3: FastMCP Auth Provider Setup

  • Create src/auth/__init__.py package
  • Create src/auth/splunk_oidc_auth.py with create_splunk_auth_provider() factory
  • Factory returns OIDCProxy (preferred) or OAuthProxy based on env vars
  • Composition over inheritance -- no subclassing

Phase 4: Authorization Middleware

  • Create src/auth/splunk_authorization.py
  • require_splunk_group(group) -- checks JWT groups claim
  • Custom auth checks using FastMCP v3 callable AuthContext pattern

Phase 5: Wire Into Server Startup

  • Modify src/server.py (lines 486-591) to support SPLUNK_AUTH_MODE=oauth
  • Call create_splunk_auth_provider() to get the auth provider
  • Pass it to FastMCP(auth=auth_provider)
  • Add AuthMiddleware instances for tag-based authorization

Phase 6: Token Forwarding in Request Pipeline

  • Modify src/core/base.py BaseTool.get_splunk_service() to use get_access_token()
  • Extract .token (raw JWT string) from AccessToken
  • Pass to get_splunk_service(bearer_token=raw_jwt)
  • Fall back to existing username/password path when no token present
flowchart TD
    Request[Incoming MCP Request with Bearer JWT] --> FastMCPAuth[FastMCP Auth Layer validates JWT]
    FastMCPAuth --> AuthMiddleware[AuthMiddleware checks scopes/tags]
    AuthMiddleware --> Tool[Tool executes]
    Tool --> GetToken["get_access_token() extracts JWT"]
    GetToken --> GetService["get_splunk_service(bearer_token=jwt)"]
    GetService --> SplunkBearerAuth[SplunkBearerAuth forwards JWT]
    SplunkBearerAuth --> SplunkAPI[Splunk REST API validates JWT via OIDC]
Loading

Phase 7: Tests

  • Unit: SplunkBearerAuth -- mock splunklib.client.Service and verify token is passed correctly
  • Unit: create_splunk_auth_provider() -- verify correct provider is created based on env vars
  • Unit: Custom auth checks -- verify group/scope matching logic
  • Integration: End-to-end with StaticTokenVerifier for dev testing (no real IdP needed)

Phase 8: Documentation

  • Create docs/auth-oauth.md setup guide
  • Splunk-side OIDC configuration instructions
  • IdP-side app registration instructions
  • MCP server env vars reference
  • Backward compatibility notes

Environment Variables (Reduced)

With OIDCProxy, only 5-6 env vars needed:

Variable Purpose
SPLUNK_AUTH_MODE oauth or legacy (default: legacy)
IDP_OIDC_CONFIG_URL IdP's /.well-known/openid-configuration URL
MCP_OAUTH_CLIENT_ID Client ID registered at the IdP
MCP_OAUTH_CLIENT_SECRET Client secret from the IdP
MCP_BASE_URL Public URL of the MCP server
MCP_OAUTH_AUDIENCE (optional) JWT audience parameter

For OAuthProxy fallback (non-OIDC IdPs), add: IDP_AUTHORIZE_URL, IDP_TOKEN_URL, IDP_JWKS_URI, IDP_ISSUER_URL

File Changes Summary

File Change
src/auth/__init__.py New package
src/auth/splunk_oidc_auth.py New: Auth provider factory (OIDCProxy / OAuthProxy)
src/auth/splunk_authorization.py New: Custom auth checks for group/scope mapping
src/client/splunk_bearer_auth.py New: JWT Bearer passthrough adapter for Splunk
src/client/splunk_client.py Modified: Add bearer_token param, merge token support from root-level file
src/splunk_client.py Deprecate: Consolidate into src/client/splunk_client.py
src/core/base.py Modified: Use get_access_token() to forward JWT
src/server.py Modified: OAuth provider setup via create_splunk_auth_provider()
docs/auth-oauth.md New: Setup guide
tests/test_splunk_bearer_auth.py New: Unit tests for bearer adapter
tests/test_splunk_oidc_auth.py New: Unit tests for auth provider factory
tests/test_splunk_authorization.py New: Unit tests for auth checks

Backward Compatibility

  • Default behavior unchanged (SPLUNK_AUTH_MODE=legacy)
  • Existing MCP_AUTH_PROVIDER dynamic loading preserved (OAuth mode is an alternative, not a replacement)
  • All current env vars (SPLUNK_HOST, SPLUNK_USERNAME, etc.) continue to work
  • MCP_AUTH_DISABLED=true still bypasses all auth
  • HTTP header-based client config still supported as fallback

Dependencies

  • fastmcp>=3.0.0,<4 (already in pyproject.toml)
  • httpx>=0.27.0 (already in pyproject.toml -- needed if splunklib does not support Bearer format)
  • splunk-sdk>=2.1.0 (already in pyproject.toml)
  • No new dependencies required

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions