Add AuthSettings.validate_token_resource to check a bearer token's resource (#3447)
This commit is contained in:
parent
9771e6b59c
commit
0c9136841f
8 changed files with 293 additions and 41 deletions
|
|
@ -71,6 +71,7 @@ def create_resource_server(settings: ResourceServerSettings) -> MCPServer:
|
|||
issuer_url=settings.auth_server_url,
|
||||
required_scopes=[settings.mcp_scope],
|
||||
resource_server_url=settings.server_url,
|
||||
validate_token_resource=True, # tokens must be reported as issued for server_url
|
||||
),
|
||||
)
|
||||
# Store settings for later use in run()
|
||||
|
|
|
|||
|
|
@ -69,12 +69,19 @@ class IntrospectionTokenVerifier(TokenVerifier):
|
|||
logger.warning(f"Token resource validation failed. Expected: {self.resource_url}")
|
||||
return None
|
||||
|
||||
# `aud` may be a string or a list; report the entry naming this server when there is
|
||||
# one, otherwise what the token was issued for, so the server can compare it.
|
||||
aud: str | list[str] | None = data.get("aud")
|
||||
audiences = aud if isinstance(aud, list) else [aud] if aud else []
|
||||
own = self.resource_url.rstrip("/")
|
||||
resource = next((a for a in audiences if a.rstrip("/") == own), audiences[0] if audiences else 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
|
||||
resource=resource,
|
||||
subject=data.get("sub"), # RFC 7662 subject (resource owner)
|
||||
claims=data,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,8 +24,9 @@ mcp = MCPServer(
|
|||
# Auth settings for RFC 9728 Protected Resource Metadata
|
||||
auth=AuthSettings(
|
||||
issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL
|
||||
resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL
|
||||
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), # This server's URL (mcp.run() default)
|
||||
required_scopes=["user"],
|
||||
validate_token_resource=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -169,4 +169,5 @@ def auth_settings(
|
|||
required_scopes=scopes,
|
||||
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes),
|
||||
identity_assertion_enabled=identity_assertion_enabled,
|
||||
validate_token_resource=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class StaticTokenVerifier(TokenVerifier):
|
|||
client_id="demo-client",
|
||||
scopes=[REQUIRED_SCOPE],
|
||||
expires_at=int(time.time()) + 3600,
|
||||
resource=RESOURCE_URL,
|
||||
subject="demo-user",
|
||||
)
|
||||
|
||||
|
|
@ -40,6 +41,7 @@ def build_app() -> Starlette:
|
|||
issuer_url=AnyHttpUrl(ISSUER),
|
||||
resource_server_url=AnyHttpUrl(RESOURCE_URL),
|
||||
required_scopes=[REQUIRED_SCOPE],
|
||||
validate_token_resource=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ def build_app() -> Starlette:
|
|||
issuer_url=AnyHttpUrl(ISSUER),
|
||||
resource_server_url=AnyHttpUrl(RESOURCE_URL),
|
||||
required_scopes=[REQUIRED_SCOPE],
|
||||
validate_token_resource=True,
|
||||
),
|
||||
token_verifier=StaticTokenVerifier(),
|
||||
transport_security=NO_DNS_REBIND,
|
||||
|
|
|
|||
|
|
@ -66,7 +66,14 @@ def build_app() -> Starlette:
|
|||
if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}":
|
||||
return JSONResponse({"error": "invalid_client"}, status_code=401)
|
||||
access = f"access_{secrets.token_hex(16)}"
|
||||
issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None)
|
||||
resource = form.get("resource") # RFC 8707: bind the token to the resource the client asked for
|
||||
issued[access] = AccessToken(
|
||||
token=access,
|
||||
client_id=DEMO_CLIENT_ID,
|
||||
scopes=[DEMO_SCOPE],
|
||||
expires_at=None,
|
||||
resource=resource if isinstance(resource, str) else None,
|
||||
)
|
||||
body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE)
|
||||
return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"})
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,14 @@ def build_app() -> Starlette:
|
|||
if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}":
|
||||
return JSONResponse({"error": "invalid_client"}, status_code=401)
|
||||
access = f"access_{secrets.token_hex(16)}"
|
||||
issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None)
|
||||
resource = form.get("resource") # RFC 8707: bind the token to the resource the client asked for
|
||||
issued[access] = AccessToken(
|
||||
token=access,
|
||||
client_id=DEMO_CLIENT_ID,
|
||||
scopes=[DEMO_SCOPE],
|
||||
expires_at=None,
|
||||
resource=resource if isinstance(resource, str) else None,
|
||||
)
|
||||
body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE)
|
||||
return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue