Skip to content

Commit ae4b6dc

Browse files
committed
RFC 8707 Resource Indicators Implementation
1 parent fd353c5 commit ae4b6dc

File tree

10 files changed

+751
-5
lines changed

10 files changed

+751
-5
lines changed

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: 46 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
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:
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,48 @@ 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") or data.get("resource"), # 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 True # No validation if server URL not configured
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+
# Check custom 'resource' claim if no 'aud'
98+
resource = token_data.get("resource")
99+
if resource:
100+
return self._is_valid_resource(resource)
101+
102+
# No resource binding - invalid per RFC 8707
103+
return False
104+
105+
def _is_valid_resource(self, resource: str) -> bool:
106+
"""Check if resource matches this server using hierarchical matching."""
107+
if not self.resource_url:
108+
return False
109+
110+
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[

0 commit comments

Comments
 (0)