RFC 8707 Resource Indicators Implementation (#991)
This commit is contained in:
parent
17f9c00c53
commit
679b22970e
5 changed files with 284 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue