Spaces:
Running
Running
Merge branch 'main' into claude-wt-20250625-195151
Browse files
src/fastmcp/server/auth/providers/bearer.py
CHANGED
|
@@ -24,6 +24,7 @@ from fastmcp.server.auth.auth import (
|
|
| 24 |
OAuthProvider,
|
| 25 |
RevocationOptions,
|
| 26 |
)
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
class JWKData(TypedDict, total=False):
|
|
@@ -199,6 +200,7 @@ class BearerAuthProvider(OAuthProvider):
|
|
| 199 |
self.public_key = public_key
|
| 200 |
self.jwks_uri = jwks_uri
|
| 201 |
self.jwt = JsonWebToken(["RS256"])
|
|
|
|
| 202 |
|
| 203 |
# Simple JWKS cache
|
| 204 |
self._jwks_cache: dict[str, str] = {}
|
|
@@ -265,6 +267,9 @@ class BearerAuthProvider(OAuthProvider):
|
|
| 265 |
# Select the appropriate key
|
| 266 |
if kid:
|
| 267 |
if kid not in self._jwks_cache:
|
|
|
|
|
|
|
|
|
|
| 268 |
raise ValueError(f"Key ID '{kid}' not found in JWKS")
|
| 269 |
return self._jwks_cache[kid]
|
| 270 |
else:
|
|
@@ -279,6 +284,7 @@ class BearerAuthProvider(OAuthProvider):
|
|
| 279 |
raise ValueError("No keys found in JWKS")
|
| 280 |
|
| 281 |
except Exception as e:
|
|
|
|
| 282 |
raise ValueError(f"Failed to fetch JWKS: {e}")
|
| 283 |
|
| 284 |
async def load_access_token(self, token: str) -> AccessToken | None:
|
|
@@ -298,15 +304,27 @@ class BearerAuthProvider(OAuthProvider):
|
|
| 298 |
# Decode and verify the JWT token
|
| 299 |
claims = self.jwt.decode(token, verification_key)
|
| 300 |
|
|
|
|
|
|
|
|
|
|
| 301 |
# Validate expiration
|
| 302 |
exp = claims.get("exp")
|
| 303 |
if exp and exp < time.time():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
return None
|
| 305 |
|
| 306 |
# Validate issuer - note we use issuer instead of issuer_url here because
|
| 307 |
# issuer is optional, allowing users to make this check optional
|
| 308 |
if self.issuer:
|
| 309 |
if claims.get("iss") != self.issuer:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
return None
|
| 311 |
|
| 312 |
# Validate audience if configured
|
|
@@ -314,26 +332,33 @@ class BearerAuthProvider(OAuthProvider):
|
|
| 314 |
aud = claims.get("aud")
|
| 315 |
|
| 316 |
# Handle different combinations of audience types
|
|
|
|
| 317 |
if isinstance(self.audience, list):
|
| 318 |
# self.audience is a list - check if any expected audience is present
|
| 319 |
if isinstance(aud, list):
|
| 320 |
# Both are lists - check for intersection
|
| 321 |
-
|
| 322 |
-
|
|
|
|
| 323 |
else:
|
| 324 |
# aud is a string - check if it's in our expected list
|
| 325 |
-
|
| 326 |
-
return None
|
| 327 |
else:
|
| 328 |
# self.audience is a string - use original logic
|
| 329 |
if isinstance(aud, list):
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
return None
|
| 334 |
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
scopes = self._extract_scopes(claims)
|
| 338 |
|
| 339 |
return AccessToken(
|
|
@@ -344,8 +369,10 @@ class BearerAuthProvider(OAuthProvider):
|
|
| 344 |
)
|
| 345 |
|
| 346 |
except JoseError:
|
|
|
|
| 347 |
return None
|
| 348 |
-
except Exception:
|
|
|
|
| 349 |
return None
|
| 350 |
|
| 351 |
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
|
|
|
|
| 24 |
OAuthProvider,
|
| 25 |
RevocationOptions,
|
| 26 |
)
|
| 27 |
+
from fastmcp.utilities.logging import get_logger
|
| 28 |
|
| 29 |
|
| 30 |
class JWKData(TypedDict, total=False):
|
|
|
|
| 200 |
self.public_key = public_key
|
| 201 |
self.jwks_uri = jwks_uri
|
| 202 |
self.jwt = JsonWebToken(["RS256"])
|
| 203 |
+
self.logger = get_logger(__name__)
|
| 204 |
|
| 205 |
# Simple JWKS cache
|
| 206 |
self._jwks_cache: dict[str, str] = {}
|
|
|
|
| 267 |
# Select the appropriate key
|
| 268 |
if kid:
|
| 269 |
if kid not in self._jwks_cache:
|
| 270 |
+
self.logger.debug(
|
| 271 |
+
"JWKS key lookup failed: key ID '%s' not found", kid
|
| 272 |
+
)
|
| 273 |
raise ValueError(f"Key ID '{kid}' not found in JWKS")
|
| 274 |
return self._jwks_cache[kid]
|
| 275 |
else:
|
|
|
|
| 284 |
raise ValueError("No keys found in JWKS")
|
| 285 |
|
| 286 |
except Exception as e:
|
| 287 |
+
self.logger.debug("JWKS fetch failed: %s", str(e))
|
| 288 |
raise ValueError(f"Failed to fetch JWKS: {e}")
|
| 289 |
|
| 290 |
async def load_access_token(self, token: str) -> AccessToken | None:
|
|
|
|
| 304 |
# Decode and verify the JWT token
|
| 305 |
claims = self.jwt.decode(token, verification_key)
|
| 306 |
|
| 307 |
+
# Extract client ID early for logging
|
| 308 |
+
client_id = claims.get("client_id") or claims.get("sub") or "unknown"
|
| 309 |
+
|
| 310 |
# Validate expiration
|
| 311 |
exp = claims.get("exp")
|
| 312 |
if exp and exp < time.time():
|
| 313 |
+
self.logger.debug(
|
| 314 |
+
"Token validation failed: expired token for client %s", client_id
|
| 315 |
+
)
|
| 316 |
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
| 317 |
return None
|
| 318 |
|
| 319 |
# Validate issuer - note we use issuer instead of issuer_url here because
|
| 320 |
# issuer is optional, allowing users to make this check optional
|
| 321 |
if self.issuer:
|
| 322 |
if claims.get("iss") != self.issuer:
|
| 323 |
+
self.logger.debug(
|
| 324 |
+
"Token validation failed: issuer mismatch for client %s",
|
| 325 |
+
client_id,
|
| 326 |
+
)
|
| 327 |
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
| 328 |
return None
|
| 329 |
|
| 330 |
# Validate audience if configured
|
|
|
|
| 332 |
aud = claims.get("aud")
|
| 333 |
|
| 334 |
# Handle different combinations of audience types
|
| 335 |
+
audience_valid = False
|
| 336 |
if isinstance(self.audience, list):
|
| 337 |
# self.audience is a list - check if any expected audience is present
|
| 338 |
if isinstance(aud, list):
|
| 339 |
# Both are lists - check for intersection
|
| 340 |
+
audience_valid = any(
|
| 341 |
+
expected in aud for expected in self.audience
|
| 342 |
+
)
|
| 343 |
else:
|
| 344 |
# aud is a string - check if it's in our expected list
|
| 345 |
+
audience_valid = aud in self.audience
|
|
|
|
| 346 |
else:
|
| 347 |
# self.audience is a string - use original logic
|
| 348 |
if isinstance(aud, list):
|
| 349 |
+
audience_valid = self.audience in aud
|
| 350 |
+
else:
|
| 351 |
+
audience_valid = aud == self.audience
|
|
|
|
| 352 |
|
| 353 |
+
if not audience_valid:
|
| 354 |
+
self.logger.debug(
|
| 355 |
+
"Token validation failed: audience mismatch for client %s",
|
| 356 |
+
client_id,
|
| 357 |
+
)
|
| 358 |
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
| 359 |
+
return None
|
| 360 |
+
|
| 361 |
+
# Extract scopes
|
| 362 |
scopes = self._extract_scopes(claims)
|
| 363 |
|
| 364 |
return AccessToken(
|
|
|
|
| 369 |
)
|
| 370 |
|
| 371 |
except JoseError:
|
| 372 |
+
self.logger.debug("Token validation failed: JWT signature/format invalid")
|
| 373 |
return None
|
| 374 |
+
except Exception as e:
|
| 375 |
+
self.logger.debug("Token validation failed: %s", str(e))
|
| 376 |
return None
|
| 377 |
|
| 378 |
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
|