RFC 8707 Resource Indicators Implementation (#991)

This commit is contained in:
Inna Harper 2025-06-23 17:10:20 +01:00 • committed by GitHub
commit 679b22970e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 284 additions and 5 deletions

View file

@ -47,6 +47,10 @@ cd examples/servers/simple-auth
# Start Resource Server on port 8001, connected to Authorization Server
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http
# With RFC 8707 strict resource validation (recommended for production)
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict
```

View file

@ -121,6 +121,7 @@ def create_authorization_server(server_settings: AuthServerSettings, github_sett
"exp": access_token.expires_at,
"iat": int(time.time()),
"token_type": "Bearer",
"aud": access_token.resource, # RFC 8707 audience claim
}
)

View file

@ -65,7 +65,7 @@ class GitHubOAuthProvider(OAuthAuthorizationServerProvider):
self.clients: dict[str, OAuthClientInformationFull] = {}
self.auth_codes: dict[str, AuthorizationCode] = {}
self.tokens: dict[str, AccessToken] = {}
self.state_mapping: dict[str, dict[str, str]] = {}
self.state_mapping: dict[str, dict[str, str | None]] = {}
# Maps MCP tokens to GitHub tokens
self.token_mapping: dict[str, str] = {}
@ -87,6 +87,7 @@ class GitHubOAuthProvider(OAuthAuthorizationServerProvider):
"code_challenge": params.code_challenge,
"redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly),
"client_id": client.client_id,
"resource": params.resource, # RFC 8707
}
# Build GitHub authorization URL
@ -110,6 +111,12 @@ class GitHubOAuthProvider(OAuthAuthorizationServerProvider):
code_challenge = state_data["code_challenge"]
redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True"
client_id = state_data["client_id"]
resource = state_data.get("resource") # RFC 8707
# These are required values from our own state mapping
assert redirect_uri is not None
assert code_challenge is not None
assert client_id is not None
# Exchange code for token with GitHub
async with create_mcp_http_client() as client:
@ -144,6 +151,7 @@ class GitHubOAuthProvider(OAuthAuthorizationServerProvider):
expires_at=time.time() + 300,
scopes=[self.settings.mcp_scope],
code_challenge=code_challenge,
resource=resource, # RFC 8707
)
self.auth_codes[new_code] = auth_code
@ -180,6 +188,7 @@ class GitHubOAuthProvider(OAuthAuthorizationServerProvider):
client_id=client.client_id,
scopes=authorization_code.scopes,
expires_at=int(time.time()) + 3600,
resource=authorization_code.resource, # RFC 8707
)
# Find GitHub token for this client

View file

@ -43,6 +43,9 @@ class ResourceServerSettings(BaseSettings):
# MCP settings
mcp_scope: str = "user"
# RFC 8707 resource validation
oauth_strict: bool = False
def __init__(self, **data):
"""Initialize settings with values from environment variables."""
super().__init__(**data)
@ -57,8 +60,12 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
2. Validates tokens via Authorization Server introspection
3. Serves MCP tools and resources
"""
# Create token verifier for introspection
token_verifier = IntrospectionTokenVerifier(settings.auth_server_introspection_endpoint)
# Create token verifier for introspection with RFC 8707 resource validation
token_verifier = IntrospectionTokenVerifier(
introspection_endpoint=settings.auth_server_introspection_endpoint,
server_url=str(settings.server_url),
validate_resource=settings.oauth_strict, # Only validate when --oauth-strict is set
)
# Create FastMCP server as a Resource Server
app = FastMCP(
@ -144,7 +151,12 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
type=click.Choice(["sse", "streamable-http"]),
help="Transport protocol to use ('sse' or 'streamable-http')",
)
def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"]) -> int:
@click.option(
"--oauth-strict",
is_flag=True,
help="Enable RFC 8707 resource validation",
)
def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> int:
"""
Run the MCP Resource Server.
@ -171,6 +183,7 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
auth_server_url=auth_server_url,
auth_server_introspection_endpoint=f"{auth_server}/introspect",
auth_server_github_user_endpoint=f"{auth_server}/github/user",
oauth_strict=oauth_strict,
)
except ValueError as e:
logger.error(f"Configuration error: {e}")

View file

@ -3,6 +3,7 @@
import logging
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
logger = logging.getLogger(__name__)
@ -18,8 +19,16 @@ class IntrospectionTokenVerifier(TokenVerifier):
- Comprehensive configuration options
"""
def __init__(self, introspection_endpoint: str):
def __init__(
self,
introspection_endpoint: str,
server_url: str,
validate_resource: bool = False,
):
self.introspection_endpoint = introspection_endpoint
self.server_url = server_url
self.validate_resource = validate_resource
self.resource_url = resource_url_from_server_url(server_url)
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify token via introspection endpoint."""
@ -54,12 +63,43 @@ class IntrospectionTokenVerifier(TokenVerifier):
if not data.get("active", False):
return None
# RFC 8707 resource validation (only when --oauth-strict is set)
if self.validate_resource and not self._validate_resource(data):
logger.warning(f"Token resource validation failed. Expected: {self.resource_url}")
return None
return AccessToken(
token=token,
client_id=data.get("client_id", "unknown"),
scopes=data.get("scope", "").split() if data.get("scope") else [],
expires_at=data.get("exp"),
resource=data.get("aud"), # Include resource in token
)
except Exception as e:
logger.warning(f"Token introspection failed: {e}")
return None
def _validate_resource(self, token_data: dict) -> bool:
"""Validate token was issued for this resource server."""
if not self.server_url or not self.resource_url:
return False # Fail if strict validation requested but URLs missing
# Check 'aud' claim first (standard JWT audience)
aud = token_data.get("aud")
if isinstance(aud, list):
for audience in aud:
if self._is_valid_resource(audience):
return True
return False
elif aud:
return self._is_valid_resource(aud)
# No resource binding - invalid per RFC 8707
return False
def _is_valid_resource(self, resource: str) -> bool:
"""Check if resource matches this server using hierarchical matching."""
if not self.resource_url:
return False
return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource)