arcade-mcp/libs/arcade-mcp-server/arcade_mcp_server/resource_server/base.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

168 lines
5.2 KiB
Python

"""Base classes for MCP Resource Server authentication."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
from pydantic import BaseModel, Field
class AccessTokenValidationOptions(BaseModel):
"""Options for access token validation.
All validations are enabled by default for security.
Set to False to disable specific validations for authorization servers
that are not compliant with MCP.
Note: Token signature verification and audience validation are always enabled
and cannot be disabled. Additionally, the subject (sub claim) must always be
present in the token.
"""
verify_exp: bool = Field(
default=True,
description="Verify token expiration (exp claim)",
)
verify_iat: bool = Field(
default=True,
description="Verify issued-at time (iat claim)",
)
verify_iss: bool = Field(
default=True,
description="Verify issuer claim (iss claim)",
)
verify_nbf: bool = Field(
default=True,
description="Verify not-before time (nbf claim). Rejects tokens used before their activation time.",
)
leeway: int = Field(
default=0,
description="Clock skew tolerance in seconds for exp/nbf validation. Recommended: 30-60 seconds.",
)
@dataclass
class ResourceOwner:
"""User information extracted from validated access token.
This represents the authenticated resource owner (end-user) making requests
to the MCP server. The user_id typically comes from the 'sub' (subject) claim
in JWT tokens.
"""
user_id: str
"""User identifier from token (typically 'sub' claim)"""
client_id: str | None = None
"""OAuth client identifier from 'client_id' or 'azp' claim"""
email: str | None = None
"""User email if available in token claims"""
claims: dict[str, Any] = field(default_factory=dict)
"""All claims from the validated token for advanced use cases"""
@dataclass
class AuthorizationServerEntry:
"""Configuration entry for a single authorization server.
Each authorization server that can issue valid tokens for this
MCP server (Resource Server) needs its own entry specifying how to
verify tokens from that server.
"""
authorization_server_url: str
"""Authorization server URL for client discovery (RFC 9728)"""
issuer: str
"""Expected issuer claim in JWT tokens from this server"""
jwks_uri: str
"""JWKS endpoint to fetch public keys for token verification"""
algorithm: str = "RS256"
"""JWT signature algorithm (RS256, ES256, PS256, etc.)"""
expected_audiences: list[str] | None = None
"""Optional list of expected audience claims. If not provided,
defaults to the MCP server's canonical_url. Use this when your
authorization server returns a different aud claim (e.g., client_id)."""
validation_options: AccessTokenValidationOptions = field(
default_factory=AccessTokenValidationOptions
)
"""Token validation options for this authorization server"""
class AuthenticationError(Exception):
"""Base authentication error."""
pass
class TokenExpiredError(AuthenticationError):
"""Token has expired."""
pass
class InvalidTokenError(AuthenticationError):
"""Token is invalid (signature, audience, issuer, etc.)."""
pass
class ResourceServerValidator(ABC):
"""Base class for MCP Resource Server token validation.
An MCP server acts as an OAuth 2.1 Resource Server, validating Bearer tokens
on every HTTP request. Implementations must validate tokens according to
OAuth 2.1 Resource Server requirements, including:
- Token signature verification
- Expiration checking
- Issuer validation
- Audience validation
Tokens are validated on every request - no caching is permitted per MCP spec.
"""
@abstractmethod
async def validate_token(self, token: str) -> ResourceOwner:
"""Validate bearer token and return authenticated resource owner info.
Must validate:
- Token signature
- Expiration
- Issuer (matches expected authorization server)
- Audience (matches this MCP server's canonical URL)
Args:
token: Bearer token from Authorization header
Returns:
ResourceOwner with user_id and claims
Raises:
TokenExpiredError: Token has expired
InvalidTokenError: Token is invalid (signature, audience, issuer mismatch)
AuthenticationError: Other validation errors
"""
pass
def supports_oauth_discovery(self) -> bool:
"""Whether this validator supports OAuth discovery endpoints.
Returns True if the validator can serve OAuth 2.0 Protected Resource Metadata
(RFC 9728) to enable MCP clients to discover authorization servers.
"""
return False
def get_resource_metadata(self) -> dict[str, Any] | None:
"""Return OAuth Protected Resource Metadata (RFC 9728) if supported.
Returns:
Metadata dictionary with 'resource' and 'authorization_servers' fields,
or None if discovery is not supported.
"""
return None