Skip to content

Commit 679b229

Browse files
authored
RFC 8707 Resource Indicators Implementation (#991)
1 parent 17f9c00 commit 679b229

File tree

11 files changed

+284
-5
lines changed

11 files changed

+284
-5
lines changed

examples/servers/simple-auth/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ cd examples/servers/simple-auth
4747

4848
# Start Resource Server on port 8001, connected to Authorization Server
4949
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http
50+
51+
# With RFC 8707 strict resource validation (recommended for production)
52+
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict
53+
5054
```
5155

5256

examples/servers/simple-auth/mcp_simple_auth/auth_server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ async def introspect_handler(request: Request) -> Response:
121121
"exp": access_token.expires_at,
122122
"iat": int(time.time()),
123123
"token_type": "Bearer",
124+
"aud": access_token.resource, # RFC 8707 audience claim
124125
}
125126
)
126127

examples/servers/simple-auth/mcp_simple_auth/github_oauth_provider.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def __init__(self, settings: GitHubOAuthSettings, github_callback_url: str):
6565
self.clients: dict[str, OAuthClientInformationFull] = {}
6666
self.auth_codes: dict[str, AuthorizationCode] = {}
6767
self.tokens: dict[str, AccessToken] = {}
68-
self.state_mapping: dict[str, dict[str, str]] = {}
68+
self.state_mapping: dict[str, dict[str, str | None]] = {}
6969
# Maps MCP tokens to GitHub tokens
7070
self.token_mapping: dict[str, str] = {}
7171

@@ -87,6 +87,7 @@ async def authorize(self, client: OAuthClientInformationFull, params: Authorizat
8787
"code_challenge": params.code_challenge,
8888
"redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly),
8989
"client_id": client.client_id,
90+
"resource": params.resource, # RFC 8707
9091
}
9192

9293
# Build GitHub authorization URL
@@ -110,6 +111,12 @@ async def handle_github_callback(self, code: str, state: str) -> str:
110111
code_challenge = state_data["code_challenge"]
111112
redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True"
112113
client_id = state_data["client_id"]
114+
resource = state_data.get("resource") # RFC 8707
115+
116+
# These are required values from our own state mapping
117+
assert redirect_uri is not None
118+
assert code_challenge is not None
119+
assert client_id is not None
113120

114121
# Exchange code for token with GitHub
115122
async with create_mcp_http_client() as client:
@@ -144,6 +151,7 @@ async def handle_github_callback(self, code: str, state: str) -> str:
144151
expires_at=time.time() + 300,
145152
scopes=[self.settings.mcp_scope],
146153
code_challenge=code_challenge,
154+
resource=resource, # RFC 8707
147155
)
148156
self.auth_codes[new_code] = auth_code
149157

@@ -180,6 +188,7 @@ async def exchange_authorization_code(
180188
client_id=client.client_id,
181189
scopes=authorization_code.scopes,
182190
expires_at=int(time.time()) + 3600,
191+
resource=authorization_code.resource, # RFC 8707
183192
)
184193

185194
# Find GitHub token for this client

examples/servers/simple-auth/mcp_simple_auth/server.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ class ResourceServerSettings(BaseSettings):
4343
# MCP settings
4444
mcp_scope: str = "user"
4545

46+
# RFC 8707 resource validation
47+
oauth_strict: bool = False
48+
4649
def __init__(self, **data):
4750
"""Initialize settings with values from environment variables."""
4851
super().__init__(**data)
@@ -57,8 +60,12 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
5760
2. Validates tokens via Authorization Server introspection
5861
3. Serves MCP tools and resources
5962
"""
60-
# Create token verifier for introspection
61-
token_verifier = IntrospectionTokenVerifier(settings.auth_server_introspection_endpoint)
63+
# Create token verifier for introspection with RFC 8707 resource validation
64+
token_verifier = IntrospectionTokenVerifier(
65+
introspection_endpoint=settings.auth_server_introspection_endpoint,
66+
server_url=str(settings.server_url),
67+
validate_resource=settings.oauth_strict, # Only validate when --oauth-strict is set
68+
)
6269

6370
# Create FastMCP server as a Resource Server
6471
app = FastMCP(
@@ -144,7 +151,12 @@ async def get_user_info() -> dict[str, Any]:
144151
type=click.Choice(["sse", "streamable-http"]),
145152
help="Transport protocol to use ('sse' or 'streamable-http')",
146153
)
147-
def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"]) -> int:
154+
@click.option(
155+
"--oauth-strict",
156+
is_flag=True,
157+
help="Enable RFC 8707 resource validation",
158+
)
159+
def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> int:
148160
"""
149161
Run the MCP Resource Server.
150162
@@ -171,6 +183,7 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
171183
auth_server_url=auth_server_url,
172184
auth_server_introspection_endpoint=f"{auth_server}/introspect",
173185
auth_server_github_user_endpoint=f"{auth_server}/github/user",
186+
oauth_strict=oauth_strict,
174187
)
175188
except ValueError as e:
176189
logger.error(f"Configuration error: {e}")

examples/servers/simple-auth/mcp_simple_auth/token_verifier.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44

55
from mcp.server.auth.provider import AccessToken, TokenVerifier
6+
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
67

78
logger = logging.getLogger(__name__)
89

@@ -18,8 +19,16 @@ class IntrospectionTokenVerifier(TokenVerifier):
1819
- Comprehensive configuration options
1920
"""
2021

21-
def __init__(self, introspection_endpoint: str):
22+
def __init__(
23+
self,
24+
introspection_endpoint: str,
25+
server_url: str,
26+
validate_resource: bool = False,
27+
):
2228
self.introspection_endpoint = introspection_endpoint
29+
self.server_url = server_url
30+
self.validate_resource = validate_resource
31+
self.resource_url = resource_url_from_server_url(server_url)
2332

2433
async def verify_token(self, token: str) -> AccessToken | None:
2534
"""Verify token via introspection endpoint."""
@@ -54,12 +63,43 @@ async def verify_token(self, token: str) -> AccessToken | None:
5463
if not data.get("active", False):
5564
return None
5665

66+
# RFC 8707 resource validation (only when --oauth-strict is set)
67+
if self.validate_resource and not self._validate_resource(data):
68+
logger.warning(f"Token resource validation failed. Expected: {self.resource_url}")
69+
return None
70+
5771
return AccessToken(
5872
token=token,
5973
client_id=data.get("client_id", "unknown"),
6074
scopes=data.get("scope", "").split() if data.get("scope") else [],
6175
expires_at=data.get("exp"),
76+
resource=data.get("aud"), # Include resource in token
6277
)
6378
except Exception as e:
6479
logger.warning(f"Token introspection failed: {e}")
6580
return None
81+
82+
def _validate_resource(self, token_data: dict) -> bool:
83+
"""Validate token was issued for this resource server."""
84+
if not self.server_url or not self.resource_url:
85+
return False # Fail if strict validation requested but URLs missing
86+
87+
# Check 'aud' claim first (standard JWT audience)
88+
aud = token_data.get("aud")
89+
if isinstance(aud, list):
90+
for audience in aud:
91+
if self._is_valid_resource(audience):
92+
return True
93+
return False
94+
elif aud:
95+
return self._is_valid_resource(aud)
96+
97+
# No resource binding - invalid per RFC 8707
98+
return False
99+
100+
def _is_valid_resource(self, resource: str) -> bool:
101+
"""Check if resource matches this server using hierarchical matching."""
102+
if not self.resource_url:
103+
return False
104+
105+
return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource)

src/mcp/client/auth.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
OAuthToken,
2828
ProtectedResourceMetadata,
2929
)
30+
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
3031
from mcp.types import LATEST_PROTOCOL_VERSION
3132

3233
logger = logging.getLogger(__name__)
@@ -134,6 +135,21 @@ def clear_tokens(self) -> None:
134135
self.current_tokens = None
135136
self.token_expiry_time = None
136137

138+
def get_resource_url(self) -> str:
139+
"""Get resource URL for RFC 8707.
140+
141+
Uses PRM resource if it's a valid parent, otherwise uses canonical server URL.
142+
"""
143+
resource = resource_url_from_server_url(self.server_url)
144+
145+
# If PRM provides a resource that's a valid parent, use it
146+
if self.protected_resource_metadata and self.protected_resource_metadata.resource:
147+
prm_resource = str(self.protected_resource_metadata.resource)
148+
if check_resource_allowed(requested_resource=resource, configured_resource=prm_resource):
149+
resource = prm_resource
150+
151+
return resource
152+
137153

138154
class OAuthClientProvider(httpx.Auth):
139155
"""
@@ -256,6 +272,7 @@ async def _perform_authorization(self) -> tuple[str, str]:
256272
"state": state,
257273
"code_challenge": pkce_params.code_challenge,
258274
"code_challenge_method": "S256",
275+
"resource": self.context.get_resource_url(), # RFC 8707
259276
}
260277

261278
if self.context.client_metadata.scope:
@@ -293,6 +310,7 @@ async def _exchange_token(self, auth_code: str, code_verifier: str) -> httpx.Req
293310
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
294311
"client_id": self.context.client_info.client_id,
295312
"code_verifier": code_verifier,
313+
"resource": self.context.get_resource_url(), # RFC 8707
296314
}
297315

298316
if self.context.client_info.client_secret:
@@ -343,6 +361,7 @@ async def _refresh_token(self) -> httpx.Request:
343361
"grant_type": "refresh_token",
344362
"refresh_token": self.context.current_tokens.refresh_token,
345363
"client_id": self.context.client_info.client_id,
364+
"resource": self.context.get_resource_url(), # RFC 8707
346365
}
347366

348367
if self.context.client_info.client_secret:

src/mcp/server/auth/handlers/authorize.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ class AuthorizationRequest(BaseModel):
3535
None,
3636
description="Optional scope; if specified, should be " "a space-separated list of scope strings",
3737
)
38+
resource: str | None = Field(
39+
None,
40+
description="RFC 8707 resource indicator - the MCP server this token will be used with",
41+
)
3842

3943

4044
class AuthorizationErrorResponse(BaseModel):
@@ -197,6 +201,7 @@ async def error_response(
197201
code_challenge=auth_request.code_challenge,
198202
redirect_uri=redirect_uri,
199203
redirect_uri_provided_explicitly=auth_request.redirect_uri is not None,
204+
resource=auth_request.resource, # RFC 8707
200205
)
201206

202207
try:

src/mcp/server/auth/handlers/token.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class AuthorizationCodeRequest(BaseModel):
2424
client_secret: str | None = None
2525
# See https://datatracker.ietf.org/doc/html/rfc7636#section-4.5
2626
code_verifier: str = Field(..., description="PKCE code verifier")
27+
# RFC 8707 resource indicator
28+
resource: str | None = Field(None, description="Resource indicator for the token")
2729

2830

2931
class RefreshTokenRequest(BaseModel):
@@ -34,6 +36,8 @@ class RefreshTokenRequest(BaseModel):
3436
client_id: str
3537
# we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
3638
client_secret: str | None = None
39+
# RFC 8707 resource indicator
40+
resource: str | None = Field(None, description="Resource indicator for the token")
3741

3842

3943
class TokenRequest(

src/mcp/server/auth/provider.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class AuthorizationParams(BaseModel):
1313
code_challenge: str
1414
redirect_uri: AnyUrl
1515
redirect_uri_provided_explicitly: bool
16+
resource: str | None = None # RFC 8707 resource indicator
1617

1718

1819
class AuthorizationCode(BaseModel):
@@ -23,6 +24,7 @@ class AuthorizationCode(BaseModel):
2324
code_challenge: str
2425
redirect_uri: AnyUrl
2526
redirect_uri_provided_explicitly: bool
27+
resource: str | None = None # RFC 8707 resource indicator
2628

2729

2830
class RefreshToken(BaseModel):
@@ -37,6 +39,7 @@ class AccessToken(BaseModel):
3739
client_id: str
3840
scopes: list[str]
3941
expires_at: int | None = None
42+
resource: str | None = None # RFC 8707 resource indicator
4043

4144

4245
RegistrationErrorCode = Literal[

src/mcp/shared/auth_utils.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Utilities for OAuth 2.0 Resource Indicators (RFC 8707)."""
2+
3+
from urllib.parse import urlparse, urlsplit, urlunsplit
4+
5+
from pydantic import AnyUrl, HttpUrl
6+
7+
8+
def resource_url_from_server_url(url: str | HttpUrl | AnyUrl) -> str:
9+
"""Convert server URL to canonical resource URL per RFC 8707.
10+
11+
RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component".
12+
Returns absolute URI with lowercase scheme/host for canonical form.
13+
14+
Args:
15+
url: Server URL to convert
16+
17+
Returns:
18+
Canonical resource URL string
19+
"""
20+
# Convert to string if needed
21+
url_str = str(url)
22+
23+
# Parse the URL and remove fragment, create canonical form
24+
parsed = urlsplit(url_str)
25+
canonical = urlunsplit(parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower(), fragment=""))
26+
27+
return canonical
28+
29+
30+
def check_resource_allowed(requested_resource: str, configured_resource: str) -> bool:
31+
"""Check if a requested resource URL matches a configured resource URL.
32+
33+
A requested resource matches if it has the same scheme, domain, port,
34+
and its path starts with the configured resource's path. This allows
35+
hierarchical matching where a token for a parent resource can be used
36+
for child resources.
37+
38+
Args:
39+
requested_resource: The resource URL being requested
40+
configured_resource: The resource URL that has been configured
41+
42+
Returns:
43+
True if the requested resource matches the configured resource
44+
"""
45+
# Parse both URLs
46+
requested = urlparse(requested_resource)
47+
configured = urlparse(configured_resource)
48+
49+
# Compare scheme, host, and port (origin)
50+
if requested.scheme.lower() != configured.scheme.lower() or requested.netloc.lower() != configured.netloc.lower():
51+
return False
52+
53+
# Handle cases like requested=/foo and configured=/foo/
54+
requested_path = requested.path
55+
configured_path = configured.path
56+
57+
# If requested path is shorter, it cannot be a child
58+
if len(requested_path) < len(configured_path):
59+
return False
60+
61+
# Check if the requested path starts with the configured path
62+
# Ensure both paths end with / for proper comparison
63+
# This ensures that paths like "/api123" don't incorrectly match "/api"
64+
if not requested_path.endswith("/"):
65+
requested_path += "/"
66+
if not configured_path.endswith("/"):
67+
configured_path += "/"
68+
69+
return requested_path.startswith(configured_path)

0 commit comments

Comments
 (0)