arcade-mcp/libs/arcade-mcp-server/arcade_mcp_server/fastapi/auth_routes.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

98 lines
3.2 KiB
Python

"""FastAPI routes for MCP Resource Server authorization endpoints.
The routes defined here enable MCP clients to discover authorization servers
associated with this MCP server.
"""
import logging
from urllib.parse import urlparse
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from arcade_mcp_server.resource_server.base import ResourceServerValidator
logger = logging.getLogger(__name__)
def create_auth_router(
resource_server_validator: ResourceServerValidator,
canonical_url: str | None,
) -> APIRouter:
"""Create FastAPI router with OAuth discovery endpoints.
The well-known URI is constructed by inserting the well-known path after the host.
If the canonical URL has a path component, then it becomes a suffix on the well-known path.
For example:
- canonical_url "https://example.com" -> "/.well-known/oauth-protected-resource"
- canonical_url "https://example.com/mcp" -> "/.well-known/oauth-protected-resource/mcp"
Args:
resource_server_validator: The resource server validator instance
canonical_url: Canonical URL of the MCP server
Returns:
APIRouter configured with OAuth discovery endpoints
"""
router = APIRouter(tags=["MCP Protocol"])
path_suffix = ""
if canonical_url:
parsed = urlparse(canonical_url)
path_suffix = parsed.path
well_known_base = "/.well-known/oauth-protected-resource"
well_known_path = f"{well_known_base}{path_suffix}"
async def oauth_protected_resource() -> JSONResponse:
"""OAuth 2.0 Protected Resource Metadata (RFC 9728)"""
if not canonical_url:
return JSONResponse(
{"error": "Server canonical URL not configured"},
status_code=500,
)
metadata = resource_server_validator.get_resource_metadata()
if metadata is None:
logger.error(
"Resource metadata unavailable for OAuth discovery endpoint. "
"This is unexpected - the validator should provide metadata if OAuth discovery is enabled."
)
return JSONResponse(
{"error": "Resource metadata not available"},
status_code=500,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
)
return JSONResponse(
metadata,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
)
# Register the well-known endpoint at the RFC 9728 compliant path
router.add_api_route(
well_known_path,
oauth_protected_resource,
methods=["GET"],
name="oauth_protected_resource",
)
# Also register at base path if there's a suffix for extra compatibility
if path_suffix:
router.add_api_route(
well_known_base,
oauth_protected_resource,
methods=["GET"],
include_in_schema=False,
)
return router