arcade-mcp/libs/arcade-mcp-server/arcade_mcp_server/resource_server/middleware.py
Eric Gustin 98fd13c4ed
Front-Door Auth (#696)
# Valuable references for the reviewer:
- Docs PR: https://github.com/ArcadeAI/docs/pull/583
- Implements Phase 1 of the following planning doc:
https://linear.app/arcadedev/project/arcade-mcp-supports-mcp-auth-front-door-auth-7cbaa20cb054/overview


https://github.com/user-attachments/assets/79ad43fd-f5e8-4793-a1dd-18b35acefdc3

# PR Description
Adds OAuth 2.1 Resource Server authentication to arcade-mcp-server,
enabling HTTP MCP servers to validate Bearer tokens on every request.
This unlocks tool-level authorization and secrets support for HTTP
servers.

- Multiple authorization server support
- Granular token validation options (verify_exp, verify_iat, verify_iss)
- Environment variable configuration
- OAuth discovery metadata endpoint
(/.well-known/oauth-protected-resource)
- Extracts sub claim from token as context.user_id
- Lifts transport restrictions for tools requiring auth/secrets on HTTP
when protected

```python
from arcade_mcp_server import MCPApp
from arcade_mcp_server.resource_server import ResourceServerAuth, AuthorizationServerEntry

resource_server_auth = ResourceServerAuth(
    canonical_url="http://127.0.0.1:8000/mcp",
    authorization_servers=[
        AuthorizationServerEntry(
            authorization_server_url="https://auth.example.com",
            issuer="https://auth.example.com",
            jwks_uri="https://auth.example.com/jwks",
        )
    ],
)

app = MCPApp(name="my_server", version="1.0.0", auth=resource_server_auth)
```

# Testing
Beyond the comprehensive unit tests, I also manually tested end-to-end
with WorkOS Authkit (DCR) and KeyCloak (non-DCR).

# Future Work
- CIMD support
- An `ArcadeResourceServer` to make adding front-door auth super easy
when using Arcade's Auth Server



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds OAuth 2.1 front-door auth (JWKS validation + OAuth discovery) and
propagates user identity to tools, enabling auth/secret-requiring tools
over HTTP.
> 
> - **Authentication (Front-Door OAuth 2.1)**
> - New `resource_server` module with `ResourceServerAuth`
(multi-authorization-server, metadata) and `JWKSTokenValidator`
(JWKS-based JWT validation) plus granular validation options.
> - ASGI `ResourceServerMiddleware` validates Bearer tokens on every
HTTP request and injects `resource_owner`.
> - OAuth discovery endpoint via FastAPI router at
`/.well-known/oauth-protected-resource[/<path>]`.
> - **Integration**
> - `MCPApp`/`worker` accept `auth`/`resource_server_validator`, mount
middleware, expose discovery; logs accepted auth servers.
> - HTTP transport (`http_streamable`) carries `SessionMessage` with
`resource_owner` from request → session.
> - `Context`/`Session`/`Server` plumb `resource_owner`; `Server`
selects `user_id` preferring token `sub`.
> - **Behavior Changes**
> - HTTP transport restriction lifted for tools requiring
`authorization`/`secrets` when request is authenticated; otherwise
blocked with actionable error.
> - **Configuration**
> - Env-var based auth config via `MCP_RESOURCE_SERVER_*` in
`MCPSettings.ResourceServerSettings`; `.env` auto-load.
> - **Telemetry**
>   - Usage tracking records `resource_server_type` on server start.
> - **Examples**
> - New `examples/mcp_servers/authorization` sample server (HTTP auth,
secrets, Reddit tool) with Docker setup.
> - **Tests**
> - Extensive unit tests for validators, middleware, env config,
multi-AS, transport rules, and app integration.
> - **Version**
> - Bump `arcade-mcp-server` to `1.12.0`; minor docstring tweak in
`__init__.py`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
d1116cdcafb0c7cb8f91e66682eb1fbae380da31. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->





Resolves TOO-152
2025-12-11 12:51:20 -08:00

201 lines
7.3 KiB
Python

"""ASGI middleware for MCP Resource Server authentication."""
from urllib.parse import urlparse, urlunparse
from starlette.requests import Request
from starlette.responses import Response
from starlette.types import ASGIApp, Receive, Scope, Send
from arcade_mcp_server.resource_server.base import (
AuthenticationError,
InvalidTokenError,
ResourceOwner,
ResourceServerValidator,
TokenExpiredError,
)
class ResourceServerMiddleware:
"""ASGI middleware that validates Bearer tokens on every HTTP request.
Validates tokens per MCP specification:
- Checks Authorization header for Bearer token
- Validates token on every request
- Returns 401 with WWW-Authenticate header if authentication fails
- Stores authenticated resource owner in scope for downstream use to lift
tool-auth and tool-secrets restrictions
The WWW-Authenticate header includes:
- resource_metadata URL for OAuth discovery (if validator supports it)
- error and error_description for token validation failures (RFC 6750)
"""
def __init__(
self,
app: ASGIApp,
validator: ResourceServerValidator,
canonical_url: str | None,
):
"""Initialize the Resource Server middleware.
Args:
app: ASGI application to wrap
validator: Token validator for access token validation
canonical_url: Canonical URL of this MCP server (for OAuth metadata).
Required only for validators that support OAuth discovery.
"""
self.app = app
self.validator = validator
self.canonical_url = canonical_url
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Process ASGI request with authentication.
For HTTP requests:
1. Allow CORS preflight OPTIONS requests to pass through
2. Extract Bearer token from Authorization header
3. Validate token (on EVERY request - no caching)
4. Store authenticated resource owner in scope
5. Pass to wrapped app
For non-HTTP requests, pass through without auth.
"""
# Only process HTTP requests
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request = Request(scope, receive)
# Allow CORS preflight requests to pass through without authentication.
# Browsers send OPTIONS requests without Authorization headers to check
# if the cross-origin request is allowed before sending the actual request.
if request.method == "OPTIONS":
response = self._create_cors_preflight_response()
await response(scope, receive, send)
return
try:
resource_owner = await self._authenticate_request(request)
# Store in scope for downstream usage & continue to app execution
scope["resource_owner"] = resource_owner
await self.app(scope, receive, send)
except (TokenExpiredError, InvalidTokenError) as e:
response = self._create_401_response(
error="invalid_token",
error_description=str(e),
)
await response(scope, receive, send)
except AuthenticationError:
response = self._create_401_response()
await response(scope, receive, send)
async def _authenticate_request(self, request: Request) -> ResourceOwner:
"""Extract and validate Bearer token from Authorization header.
Args:
request: Starlette request object
Returns:
ResourceOwner from validated token
Raises:
AuthenticationError: No token or invalid format
TokenExpiredError: Token has expired
InvalidTokenError: Token signature/audience/issuer invalid
"""
auth_header = request.headers.get("Authorization")
if not auth_header:
raise AuthenticationError("No Authorization header")
if not auth_header.startswith("Bearer "):
raise AuthenticationError("Invalid Authorization header format.")
# Remove "Bearer " prefix
token = auth_header[7:]
return await self.validator.validate_token(token)
def _build_metadata_url(self) -> str:
"""Build the OAuth Protected Resource Metadata URL per RFC 9728.
For example, for a canonical_url of "https://example.com/mcp" the metadata URL is:
"https://example.com/.well-known/oauth-protected-resource/mcp"
Returns:
Metadata URL
"""
if not self.canonical_url:
return ""
parsed = urlparse(self.canonical_url)
# Insert well-known path after host, with resource path as suffix
well_known_path = f"/.well-known/oauth-protected-resource{parsed.path}"
return urlunparse((parsed.scheme, parsed.netloc, well_known_path, "", "", ""))
def _create_cors_preflight_response(self) -> Response:
"""Create a CORS preflight response for OPTIONS requests.
Returns:
Response with 204 status and CORS headers
"""
return Response(
content=None,
status_code=204,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, Accept",
"Access-Control-Expose-Headers": "WWW-Authenticate, Mcp-Session-Id",
"Access-Control-Max-Age": "86400", # 24 hr
},
)
def _create_401_response(
self,
error: str | None = None,
error_description: str | None = None,
) -> Response:
"""Create RFC 6750 + RFC 9728 compliant 401 response.
The WWW-Authenticate header format follows:
- RFC 6750 (OAuth 2.0 Bearer Token Usage)
- RFC 9728 (OAuth 2.0 Protected Resource Metadata)
Args:
error: Error code (e.g., "invalid_token")
error_description: Human-readable error description
Returns:
Response with 401 status with WWW-Authenticate header
"""
www_auth_parts = []
# Add resource metadata URL if validator supports discovery (RFC 9728)
if self.validator.supports_oauth_discovery() and self.canonical_url:
metadata_url = self._build_metadata_url()
www_auth_parts.append(f'resource_metadata="{metadata_url}"')
# Add error details if token validation failed (RFC 6750)
if error:
www_auth_parts.append(f'error="{error}"')
if error_description:
www_auth_parts.append(f'error_description="{error_description}"')
www_auth_value = "Bearer " + ", ".join(www_auth_parts)
return Response(
content="Unauthorized",
status_code=401,
headers={
"WWW-Authenticate": www_auth_value,
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, Accept",
"Access-Control-Expose-Headers": "WWW-Authenticate, Mcp-Session-Id",
},
)