Jeremiah Lowin commited on
Commit
904d4e5
·
1 Parent(s): abb2759

Add jwks tests

Browse files
pyproject.toml CHANGED
@@ -52,6 +52,7 @@ dev = [
52
  "pytest-cov>=6.1.1",
53
  "pytest-env>=1.1.5",
54
  "pytest-flakefinder",
 
55
  "pytest-report>=0.2.1",
56
  "pytest-timeout>=2.4.0",
57
  "pytest-xdist>=3.6.1",
 
52
  "pytest-cov>=6.1.1",
53
  "pytest-env>=1.1.5",
54
  "pytest-flakefinder",
55
+ "pytest-httpx>=0.35.0",
56
  "pytest-report>=0.2.1",
57
  "pytest-timeout>=2.4.0",
58
  "pytest-xdist>=3.6.1",
src/fastmcp/server/auth/bearer.py DELETED
@@ -1,256 +0,0 @@
1
- """
2
- Simple JWT Bearer Token validation for hosted MCP servers.
3
-
4
- Uses RS256 (asymmetric) where your control plane signs with a private key
5
- and hosted MCP servers validate with the corresponding public key.
6
-
7
- Example usage:
8
- # Static public key
9
- provider = BearerTokenValidatorProvider(
10
- public_key='''-----BEGIN PUBLIC KEY-----
11
- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
12
- -----END PUBLIC KEY-----''',
13
- issuer="https://auth.yourservice.com"
14
- )
15
-
16
- # Or JWKS URI (recommended for production - allows key rotation)
17
- provider = BearerTokenValidatorProvider(
18
- jwks_uri="https://auth.yourservice.com/.well-known/jwks.json",
19
- issuer="https://auth.yourservice.com"
20
- )
21
- """
22
-
23
- import time
24
- from typing import Any
25
-
26
- import httpx
27
- from authlib.jose import JsonWebKey, JsonWebToken
28
- from authlib.jose.errors import JoseError
29
- from mcp.server.auth.provider import (
30
- AccessToken,
31
- AuthorizationCode,
32
- AuthorizationParams,
33
- RefreshToken,
34
- )
35
- from mcp.shared.auth import (
36
- OAuthClientInformationFull,
37
- OAuthToken,
38
- )
39
-
40
- from fastmcp.server.auth.auth import (
41
- ClientRegistrationOptions,
42
- OAuthProvider,
43
- RevocationOptions,
44
- )
45
-
46
-
47
- class BearerTokenValidatorProvider(OAuthProvider):
48
- """
49
- Simple JWT Bearer Token validator for hosted MCP servers.
50
- Uses RS256 asymmetric encryption. Supports either static public key
51
- or JWKS URI for key rotation.
52
- """
53
-
54
- def __init__(
55
- self,
56
- issuer: str,
57
- public_key: str | None = None,
58
- jwks_uri: str | None = None,
59
- audience: str | None = None,
60
- required_scopes: list[str] | None = None,
61
- ):
62
- """
63
- Initialize the provider.
64
-
65
- Args:
66
- issuer: Expected issuer claim (your control plane)
67
- public_key: RSA public key in PEM format (for static key)
68
- jwks_uri: URI to fetch keys from (for key rotation)
69
- audience: Expected audience claim (optional)
70
- required_scopes: List of required scopes for access
71
- """
72
- if not (public_key or jwks_uri):
73
- raise ValueError("Either public_key or jwks_uri must be provided")
74
- if public_key and jwks_uri:
75
- raise ValueError("Provide either public_key or jwks_uri, not both")
76
-
77
- super().__init__(
78
- issuer_url=issuer,
79
- client_registration_options=ClientRegistrationOptions(enabled=False),
80
- revocation_options=RevocationOptions(enabled=False),
81
- required_scopes=required_scopes,
82
- )
83
-
84
- self.issuer = issuer
85
- self.audience = audience
86
- self.public_key = public_key
87
- self.jwks_uri = jwks_uri
88
- self.jwt = JsonWebToken(["RS256"])
89
-
90
- # Simple JWKS cache
91
- self._jwks_cache: dict[str, str] = {}
92
- self._jwks_cache_time: float = 0
93
- self._cache_ttl = 3600 # 1 hour
94
-
95
- async def _get_verification_key(self, token: str) -> str:
96
- """Get the verification key for the token."""
97
- if self.public_key:
98
- return self.public_key
99
-
100
- # Extract kid from token header for JWKS lookup
101
- try:
102
- import base64
103
- import json
104
-
105
- header_b64 = token.split(".")[0]
106
- header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding
107
- header = json.loads(base64.urlsafe_b64decode(header_b64))
108
- kid = header.get("kid")
109
-
110
- if not kid:
111
- raise ValueError("Token missing key ID (kid)")
112
-
113
- return await self._get_jwks_key(kid)
114
-
115
- except Exception as e:
116
- raise ValueError(f"Failed to extract key ID from token: {e}")
117
-
118
- async def _get_jwks_key(self, kid: str) -> str:
119
- """Fetch key from JWKS with simple caching."""
120
- if not self.jwks_uri:
121
- raise ValueError("JWKS URI not configured")
122
-
123
- current_time = time.time()
124
-
125
- # Check cache
126
- if (
127
- current_time - self._jwks_cache_time < self._cache_ttl
128
- and kid in self._jwks_cache
129
- ):
130
- return self._jwks_cache[kid]
131
-
132
- # Fetch JWKS
133
- try:
134
- async with httpx.AsyncClient() as client:
135
- response = await client.get(self.jwks_uri)
136
- response.raise_for_status()
137
- jwks_data = response.json()
138
-
139
- # Cache all keys
140
- self._jwks_cache = {}
141
- for key_data in jwks_data.get("keys", []):
142
- key_kid = key_data.get("kid")
143
- if key_kid:
144
- jwk = JsonWebKey.import_key(key_data)
145
- self._jwks_cache[key_kid] = jwk.get_public_key()
146
-
147
- self._jwks_cache_time = current_time
148
-
149
- if kid not in self._jwks_cache:
150
- raise ValueError(f"Key ID '{kid}' not found in JWKS")
151
-
152
- return self._jwks_cache[kid]
153
-
154
- except Exception as e:
155
- raise ValueError(f"Failed to fetch JWKS: {e}")
156
-
157
- async def load_access_token(self, token: str) -> AccessToken | None:
158
- """
159
- Validates the provided JWT bearer token.
160
-
161
- Args:
162
- token: The JWT token string to validate
163
-
164
- Returns:
165
- AccessToken object if valid, None if invalid or expired
166
- """
167
- try:
168
- # Get verification key (static or from JWKS)
169
- verification_key = await self._get_verification_key(token)
170
-
171
- # Decode and verify the JWT token
172
- claims = self.jwt.decode(token, verification_key)
173
-
174
- # Validate expiration
175
- exp = claims.get("exp")
176
- if exp and exp < time.time():
177
- return None
178
-
179
- # Validate issuer
180
- if claims.get("iss") != self.issuer:
181
- return None
182
-
183
- # Validate audience if configured
184
- if self.audience:
185
- aud = claims.get("aud")
186
- if isinstance(aud, list):
187
- if self.audience not in aud:
188
- return None
189
- elif aud != self.audience:
190
- return None
191
-
192
- # Extract claims
193
- client_id = claims.get("sub") or claims.get("client_id") or "unknown"
194
- scopes = self._extract_scopes(claims)
195
-
196
- return AccessToken(
197
- token=token,
198
- client_id=str(client_id),
199
- scopes=scopes,
200
- expires_at=int(exp) if exp else None,
201
- )
202
-
203
- except JoseError:
204
- return None
205
- except Exception:
206
- return None
207
-
208
- def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
209
- """Extract scopes from JWT claims."""
210
- scope_claim = claims.get("scope", "")
211
- if isinstance(scope_claim, str):
212
- return scope_claim.split()
213
- elif isinstance(scope_claim, list):
214
- return scope_claim
215
- return []
216
-
217
- # --- Unused OAuth server methods ---
218
- async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
219
- raise NotImplementedError("Client management not supported")
220
-
221
- async def register_client(self, client_info: OAuthClientInformationFull) -> None:
222
- raise NotImplementedError("Client registration not supported")
223
-
224
- async def authorize(
225
- self, client: OAuthClientInformationFull, params: AuthorizationParams
226
- ) -> str:
227
- raise NotImplementedError("Authorization flow not supported")
228
-
229
- async def load_authorization_code(
230
- self, client: OAuthClientInformationFull, authorization_code: str
231
- ) -> AuthorizationCode | None:
232
- raise NotImplementedError("Authorization code flow not supported")
233
-
234
- async def exchange_authorization_code(
235
- self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
236
- ) -> OAuthToken:
237
- raise NotImplementedError("Authorization code exchange not supported")
238
-
239
- async def load_refresh_token(
240
- self, client: OAuthClientInformationFull, refresh_token: str
241
- ) -> RefreshToken | None:
242
- raise NotImplementedError("Refresh token flow not supported")
243
-
244
- async def exchange_refresh_token(
245
- self,
246
- client: OAuthClientInformationFull,
247
- refresh_token: RefreshToken,
248
- scopes: list[str],
249
- ) -> OAuthToken:
250
- raise NotImplementedError("Refresh token exchange not supported")
251
-
252
- async def revoke_token(
253
- self,
254
- token: AccessToken | RefreshToken,
255
- ) -> None:
256
- raise NotImplementedError("Token revocation not supported")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/server/auth/providers/bearer.py CHANGED
@@ -6,7 +6,7 @@ and hosted MCP servers validate with the corresponding public key.
6
 
7
  Example usage:
8
  # Static public key
9
- provider = BearerTokenValidatorProvider(
10
  public_key='''-----BEGIN PUBLIC KEY-----
11
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
12
  -----END PUBLIC KEY-----''',
@@ -14,7 +14,7 @@ provider = BearerTokenValidatorProvider(
14
  )
15
 
16
  # Or JWKS URI (recommended for production - allows key rotation)
17
- provider = BearerTokenValidatorProvider(
18
  jwks_uri="https://auth.yourservice.com/.well-known/jwks.json",
19
  issuer="https://auth.yourservice.com"
20
  )
@@ -22,7 +22,7 @@ provider = BearerTokenValidatorProvider(
22
 
23
  import time
24
  from dataclasses import dataclass
25
- from typing import Any
26
 
27
  import httpx
28
  from authlib.jose import JsonWebKey, JsonWebToken
@@ -48,6 +48,25 @@ from fastmcp.server.auth.auth import (
48
  )
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  @dataclass(frozen=True, kw_only=True, repr=False)
52
  class RSAKeyPair:
53
  private_key: SecretStr
@@ -96,6 +115,7 @@ class RSAKeyPair:
96
  scopes: list[str] | None = None,
97
  expires_in_seconds: int = 3600,
98
  additional_claims: dict[str, Any] | None = None,
 
99
  ) -> str:
100
  """
101
  Generate a test JWT token for testing purposes.
@@ -108,6 +128,7 @@ class RSAKeyPair:
108
  scopes: List of scopes to include
109
  expires_in_seconds: Token expiration time in seconds
110
  additional_claims: Any additional claims to include
 
111
 
112
  Returns:
113
  Signed JWT token string
@@ -135,6 +156,8 @@ class RSAKeyPair:
135
 
136
  # Create header
137
  header = {"alg": "RS256"}
 
 
138
 
139
  # Sign and return token
140
  token_bytes = jwt.encode(
@@ -209,27 +232,25 @@ class BearerAuthProvider(OAuthProvider):
209
  header = json.loads(base64.urlsafe_b64decode(header_b64))
210
  kid = header.get("kid")
211
 
212
- if not kid:
213
- raise ValueError("Token missing key ID (kid)")
214
-
215
  return await self._get_jwks_key(kid)
216
 
217
  except Exception as e:
218
  raise ValueError(f"Failed to extract key ID from token: {e}")
219
 
220
- async def _get_jwks_key(self, kid: str) -> str:
221
  """Fetch key from JWKS with simple caching."""
222
  if not self.jwks_uri:
223
  raise ValueError("JWKS URI not configured")
224
 
225
  current_time = time.time()
226
 
227
- # Check cache
228
- if (
229
- current_time - self._jwks_cache_time < self._cache_ttl
230
- and kid in self._jwks_cache
231
- ):
232
- return self._jwks_cache[kid]
 
233
 
234
  # Fetch JWKS
235
  try:
@@ -242,16 +263,32 @@ class BearerAuthProvider(OAuthProvider):
242
  self._jwks_cache = {}
243
  for key_data in jwks_data.get("keys", []):
244
  key_kid = key_data.get("kid")
 
 
 
245
  if key_kid:
246
- jwk = JsonWebKey.import_key(key_data)
247
- self._jwks_cache[key_kid] = jwk.get_public_key()
 
 
248
 
249
  self._jwks_cache_time = current_time
250
 
251
- if kid not in self._jwks_cache:
252
- raise ValueError(f"Key ID '{kid}' not found in JWKS")
253
-
254
- return self._jwks_cache[kid]
 
 
 
 
 
 
 
 
 
 
 
255
 
256
  except Exception as e:
257
  raise ValueError(f"Failed to fetch JWKS: {e}")
 
6
 
7
  Example usage:
8
  # Static public key
9
+ provider = BearerAuthProvider(
10
  public_key='''-----BEGIN PUBLIC KEY-----
11
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
12
  -----END PUBLIC KEY-----''',
 
14
  )
15
 
16
  # Or JWKS URI (recommended for production - allows key rotation)
17
+ provider = Bear(
18
  jwks_uri="https://auth.yourservice.com/.well-known/jwks.json",
19
  issuer="https://auth.yourservice.com"
20
  )
 
22
 
23
  import time
24
  from dataclasses import dataclass
25
+ from typing import Any, TypedDict
26
 
27
  import httpx
28
  from authlib.jose import JsonWebKey, JsonWebToken
 
48
  )
49
 
50
 
51
+ class JWKData(TypedDict, total=False):
52
+ """JSON Web Key data structure."""
53
+
54
+ kty: str # Key type (e.g., "RSA") - required
55
+ kid: str # Key ID (optional but recommended)
56
+ use: str # Usage (e.g., "sig")
57
+ alg: str # Algorithm (e.g., "RS256")
58
+ n: str # Modulus (for RSA keys)
59
+ e: str # Exponent (for RSA keys)
60
+ x5c: list[str] # X.509 certificate chain (for JWKs)
61
+ x5t: str # X.509 certificate thumbprint (for JWKs)
62
+
63
+
64
+ class JWKSData(TypedDict):
65
+ """JSON Web Key Set data structure."""
66
+
67
+ keys: list[JWKData]
68
+
69
+
70
  @dataclass(frozen=True, kw_only=True, repr=False)
71
  class RSAKeyPair:
72
  private_key: SecretStr
 
115
  scopes: list[str] | None = None,
116
  expires_in_seconds: int = 3600,
117
  additional_claims: dict[str, Any] | None = None,
118
+ kid: str | None = None,
119
  ) -> str:
120
  """
121
  Generate a test JWT token for testing purposes.
 
128
  scopes: List of scopes to include
129
  expires_in_seconds: Token expiration time in seconds
130
  additional_claims: Any additional claims to include
131
+ kid: Key ID for JWKS lookup (optional)
132
 
133
  Returns:
134
  Signed JWT token string
 
156
 
157
  # Create header
158
  header = {"alg": "RS256"}
159
+ if kid:
160
+ header["kid"] = kid
161
 
162
  # Sign and return token
163
  token_bytes = jwt.encode(
 
232
  header = json.loads(base64.urlsafe_b64decode(header_b64))
233
  kid = header.get("kid")
234
 
 
 
 
235
  return await self._get_jwks_key(kid)
236
 
237
  except Exception as e:
238
  raise ValueError(f"Failed to extract key ID from token: {e}")
239
 
240
+ async def _get_jwks_key(self, kid: str | None) -> str:
241
  """Fetch key from JWKS with simple caching."""
242
  if not self.jwks_uri:
243
  raise ValueError("JWKS URI not configured")
244
 
245
  current_time = time.time()
246
 
247
+ # Check cache first
248
+ if current_time - self._jwks_cache_time < self._cache_ttl:
249
+ if kid and kid in self._jwks_cache:
250
+ return self._jwks_cache[kid]
251
+ elif not kid and len(self._jwks_cache) == 1:
252
+ # If no kid but only one key cached, use it
253
+ return next(iter(self._jwks_cache.values()))
254
 
255
  # Fetch JWKS
256
  try:
 
263
  self._jwks_cache = {}
264
  for key_data in jwks_data.get("keys", []):
265
  key_kid = key_data.get("kid")
266
+ jwk = JsonWebKey.import_key(key_data)
267
+ public_key = jwk.get_public_key()
268
+
269
  if key_kid:
270
+ self._jwks_cache[key_kid] = public_key
271
+ else:
272
+ # Key without kid - use a default identifier
273
+ self._jwks_cache["_default"] = public_key
274
 
275
  self._jwks_cache_time = current_time
276
 
277
+ # Select the appropriate key
278
+ if kid:
279
+ if kid not in self._jwks_cache:
280
+ raise ValueError(f"Key ID '{kid}' not found in JWKS")
281
+ return self._jwks_cache[kid]
282
+ else:
283
+ # No kid in token - only allow if there's exactly one key
284
+ if len(self._jwks_cache) == 1:
285
+ return next(iter(self._jwks_cache.values()))
286
+ elif len(self._jwks_cache) > 1:
287
+ raise ValueError(
288
+ "Multiple keys in JWKS but no key ID (kid) in token"
289
+ )
290
+ else:
291
+ raise ValueError("No keys found in JWKS")
292
 
293
  except Exception as e:
294
  raise ValueError(f"Failed to fetch JWKS: {e}")
tests/auth/providers/test_bearer.py CHANGED
@@ -3,10 +3,15 @@ from typing import Any
3
 
4
  import httpx
5
  import pytest
 
6
 
7
  from fastmcp import Client, FastMCP
8
  from fastmcp.client.auth import BearerAuth
9
- from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
 
 
 
 
10
  from fastmcp.utilities.tests import run_server_in_process
11
 
12
 
@@ -103,6 +108,194 @@ class TestRSAKeyPair:
103
  # We'll validate the scopes in the BearerToken tests
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  class TestBearerToken:
107
  def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair):
108
  """Test provider initialization with public key."""
@@ -143,7 +336,6 @@ class TestBearerToken:
143
  issuer="https://test.example.com",
144
  )
145
 
146
- @pytest.mark.asyncio
147
  async def test_valid_token_validation(
148
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
149
  ):
@@ -163,7 +355,6 @@ class TestBearerToken:
163
  assert "write" in access_token.scopes
164
  assert access_token.expires_at is not None
165
 
166
- @pytest.mark.asyncio
167
  async def test_expired_token_rejection(
168
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
169
  ):
@@ -178,7 +369,6 @@ class TestBearerToken:
178
  access_token = await bearer_provider.load_access_token(token)
179
  assert access_token is None
180
 
181
- @pytest.mark.asyncio
182
  async def test_invalid_issuer_rejection(
183
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
184
  ):
@@ -192,7 +382,6 @@ class TestBearerToken:
192
  access_token = await bearer_provider.load_access_token(token)
193
  assert access_token is None
194
 
195
- @pytest.mark.asyncio
196
  async def test_invalid_audience_rejection(
197
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
198
  ):
@@ -206,7 +395,6 @@ class TestBearerToken:
206
  access_token = await bearer_provider.load_access_token(token)
207
  assert access_token is None
208
 
209
- @pytest.mark.asyncio
210
  async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair):
211
  """Test that issuer validation is skipped when provider has no issuer configured."""
212
  provider = BearerAuthProvider(
@@ -221,7 +409,6 @@ class TestBearerToken:
221
  access_token = await provider.load_access_token(token)
222
  assert access_token is not None
223
 
224
- @pytest.mark.asyncio
225
  async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair):
226
  """Test that audience validation is skipped when provider has no audience configured."""
227
  provider = BearerAuthProvider(
@@ -239,7 +426,6 @@ class TestBearerToken:
239
  access_token = await provider.load_access_token(token)
240
  assert access_token is not None
241
 
242
- @pytest.mark.asyncio
243
  async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair):
244
  """Test validation with multiple audiences in token."""
245
  provider = BearerAuthProvider(
@@ -259,7 +445,6 @@ class TestBearerToken:
259
  access_token = await provider.load_access_token(token)
260
  assert access_token is not None
261
 
262
- @pytest.mark.asyncio
263
  async def test_scope_extraction_string(
264
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
265
  ):
@@ -276,7 +461,6 @@ class TestBearerToken:
276
  assert access_token is not None
277
  assert set(access_token.scopes) == {"read", "write", "admin"}
278
 
279
- @pytest.mark.asyncio
280
  async def test_scope_extraction_list(
281
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
282
  ):
@@ -293,7 +477,6 @@ class TestBearerToken:
293
  assert access_token is not None
294
  assert set(access_token.scopes) == {"read", "write"}
295
 
296
- @pytest.mark.asyncio
297
  async def test_no_scopes(
298
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
299
  ):
@@ -310,7 +493,6 @@ class TestBearerToken:
310
  assert access_token is not None
311
  assert access_token.scopes == []
312
 
313
- @pytest.mark.asyncio
314
  async def test_malformed_token_rejection(self, bearer_provider: BearerAuthProvider):
315
  """Test rejection of malformed tokens."""
316
  malformed_tokens = [
@@ -325,7 +507,6 @@ class TestBearerToken:
325
  access_token = await bearer_provider.load_access_token(token)
326
  assert access_token is None
327
 
328
- @pytest.mark.asyncio
329
  async def test_invalid_signature_rejection(
330
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
331
  ):
@@ -341,7 +522,6 @@ class TestBearerToken:
341
  access_token = await bearer_provider.load_access_token(token)
342
  assert access_token is None
343
 
344
- @pytest.mark.asyncio
345
  async def test_client_id_fallback(
346
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
347
  ):
@@ -367,9 +547,10 @@ class TestFastMCPBearerAuth:
367
  assert isinstance(mcp.auth, BearerAuthProvider)
368
 
369
  async def test_unauthorized_access(self, mcp_server_url: str):
370
- with pytest.raises(httpx.HTTPStatusError, match="401"):
371
  async with Client(mcp_server_url) as client:
372
  tools = await client.list_tools() # noqa: F841
 
373
  assert "tools" not in locals()
374
 
375
  async def test_authorized_access(self, mcp_server_url: str, bearer_token):
@@ -378,9 +559,10 @@ class TestFastMCPBearerAuth:
378
  assert tools
379
 
380
  async def test_invalid_token_raises_401(self, mcp_server_url: str):
381
- with pytest.raises(httpx.HTTPStatusError, match="401"):
382
  async with Client(mcp_server_url, auth=BearerAuth("invalid")) as client:
383
  tools = await client.list_tools() # noqa: F841
 
384
  assert "tools" not in locals()
385
 
386
  async def test_expired_token(self, mcp_server_url: str, rsa_key_pair: RSAKeyPair):
@@ -391,16 +573,62 @@ class TestFastMCPBearerAuth:
391
  expires_in_seconds=-3600,
392
  )
393
 
394
- with pytest.raises(httpx.HTTPStatusError, match="401"):
395
  async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
396
  tools = await client.list_tools() # noqa: F841
 
397
  assert "tools" not in locals()
398
 
399
  async def test_token_with_bad_signature(self, mcp_server_url: str):
400
  rsa_key_pair = RSAKeyPair.generate()
401
  token = rsa_key_pair.create_token()
402
 
403
- with pytest.raises(httpx.HTTPStatusError, match="401"):
404
  async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
405
  tools = await client.list_tools() # noqa: F841
 
406
  assert "tools" not in locals()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  import httpx
5
  import pytest
6
+ from pytest_httpx import HTTPXMock
7
 
8
  from fastmcp import Client, FastMCP
9
  from fastmcp.client.auth import BearerAuth
10
+ from fastmcp.server.auth.providers.bearer import (
11
+ BearerAuthProvider,
12
+ JWKSData,
13
+ RSAKeyPair,
14
+ )
15
  from fastmcp.utilities.tests import run_server_in_process
16
 
17
 
 
108
  # We'll validate the scopes in the BearerToken tests
109
 
110
 
111
+ class TestBearerTokenJWKS:
112
+ """Tests for JWKS URI functionality."""
113
+
114
+ @pytest.fixture
115
+ def jwks_provider(self, rsa_key_pair: RSAKeyPair) -> BearerAuthProvider:
116
+ """Provider configured with JWKS URI."""
117
+ return BearerAuthProvider(
118
+ jwks_uri="https://test.example.com/.well-known/jwks.json",
119
+ issuer="https://test.example.com",
120
+ audience="https://api.example.com",
121
+ )
122
+
123
+ @pytest.fixture
124
+ def mock_jwks_data(self, rsa_key_pair: RSAKeyPair) -> JWKSData:
125
+ """Create mock JWKS data from RSA key pair."""
126
+ from authlib.jose import JsonWebKey
127
+
128
+ # Create JWK from the RSA public key
129
+ jwk = JsonWebKey.import_key(rsa_key_pair.public_key)
130
+ jwk_data = jwk.as_dict()
131
+ jwk_data["kid"] = "test-key-1"
132
+ jwk_data["alg"] = "RS256"
133
+
134
+ return {"keys": [jwk_data]}
135
+
136
+ async def test_jwks_token_validation(
137
+ self,
138
+ rsa_key_pair: RSAKeyPair,
139
+ jwks_provider: BearerAuthProvider,
140
+ mock_jwks_data: JWKSData,
141
+ httpx_mock: HTTPXMock,
142
+ ):
143
+ """Test token validation using JWKS URI."""
144
+ httpx_mock.add_response(
145
+ url="https://test.example.com/.well-known/jwks.json",
146
+ json=mock_jwks_data,
147
+ )
148
+ token = rsa_key_pair.create_token(
149
+ subject="test-user",
150
+ issuer="https://test.example.com",
151
+ audience="https://api.example.com",
152
+ )
153
+
154
+ access_token = await jwks_provider.load_access_token(token)
155
+ assert access_token is not None
156
+ assert access_token.client_id == "test-user"
157
+
158
+ async def test_jwks_token_validation_with_invalid_key(
159
+ self,
160
+ rsa_key_pair: RSAKeyPair,
161
+ jwks_provider: BearerAuthProvider,
162
+ mock_jwks_data: JWKSData,
163
+ httpx_mock: HTTPXMock,
164
+ ):
165
+ httpx_mock.add_response(
166
+ url="https://test.example.com/.well-known/jwks.json",
167
+ json=mock_jwks_data,
168
+ )
169
+ token = RSAKeyPair.generate().create_token(
170
+ subject="test-user",
171
+ issuer="https://test.example.com",
172
+ audience="https://api.example.com",
173
+ )
174
+
175
+ access_token = await jwks_provider.load_access_token(token)
176
+ assert access_token is None
177
+
178
+ async def test_jwks_token_validation_with_kid(
179
+ self,
180
+ rsa_key_pair: RSAKeyPair,
181
+ jwks_provider: BearerAuthProvider,
182
+ mock_jwks_data: JWKSData,
183
+ httpx_mock: HTTPXMock,
184
+ ):
185
+ mock_jwks_data["keys"][0]["kid"] = "test-key-1"
186
+ httpx_mock.add_response(
187
+ url="https://test.example.com/.well-known/jwks.json",
188
+ json=mock_jwks_data,
189
+ )
190
+ token = rsa_key_pair.create_token(
191
+ subject="test-user",
192
+ issuer="https://test.example.com",
193
+ audience="https://api.example.com",
194
+ kid="test-key-1",
195
+ )
196
+
197
+ access_token = await jwks_provider.load_access_token(token)
198
+ assert access_token is not None
199
+ assert access_token.client_id == "test-user"
200
+
201
+ async def test_jwks_token_validation_with_kid_and_no_kid_in_token(
202
+ self,
203
+ rsa_key_pair: RSAKeyPair,
204
+ jwks_provider: BearerAuthProvider,
205
+ mock_jwks_data: JWKSData,
206
+ httpx_mock: HTTPXMock,
207
+ ):
208
+ mock_jwks_data["keys"][0]["kid"] = "test-key-1"
209
+ httpx_mock.add_response(
210
+ url="https://test.example.com/.well-known/jwks.json",
211
+ json=mock_jwks_data,
212
+ )
213
+ token = rsa_key_pair.create_token(
214
+ subject="test-user",
215
+ issuer="https://test.example.com",
216
+ audience="https://api.example.com",
217
+ )
218
+
219
+ access_token = await jwks_provider.load_access_token(token)
220
+ assert access_token is not None
221
+ assert access_token.client_id == "test-user"
222
+
223
+ async def test_jwks_token_validation_with_no_kid_and_kid_in_jwks(
224
+ self,
225
+ rsa_key_pair: RSAKeyPair,
226
+ jwks_provider: BearerAuthProvider,
227
+ mock_jwks_data: JWKSData,
228
+ httpx_mock: HTTPXMock,
229
+ ):
230
+ mock_jwks_data["keys"][0]["kid"] = "test-key-1"
231
+ httpx_mock.add_response(
232
+ url="https://test.example.com/.well-known/jwks.json",
233
+ json=mock_jwks_data,
234
+ )
235
+ token = rsa_key_pair.create_token(
236
+ subject="test-user",
237
+ issuer="https://test.example.com",
238
+ audience="https://api.example.com",
239
+ )
240
+
241
+ access_token = await jwks_provider.load_access_token(token)
242
+ assert access_token is not None
243
+ assert access_token.client_id == "test-user"
244
+
245
+ async def test_jwks_token_validation_with_kid_mismatch(
246
+ self,
247
+ rsa_key_pair: RSAKeyPair,
248
+ jwks_provider: BearerAuthProvider,
249
+ mock_jwks_data: JWKSData,
250
+ httpx_mock: HTTPXMock,
251
+ ):
252
+ mock_jwks_data["keys"][0]["kid"] = "test-key-1"
253
+ httpx_mock.add_response(
254
+ url="https://test.example.com/.well-known/jwks.json",
255
+ json=mock_jwks_data,
256
+ )
257
+ token = rsa_key_pair.create_token(
258
+ subject="test-user",
259
+ issuer="https://test.example.com",
260
+ audience="https://api.example.com",
261
+ kid="test-key-2",
262
+ )
263
+
264
+ access_token = await jwks_provider.load_access_token(token)
265
+ assert access_token is None
266
+
267
+ async def test_jwks_token_validation_with_multiple_keys_and_no_kid_in_token(
268
+ self,
269
+ rsa_key_pair: RSAKeyPair,
270
+ jwks_provider: BearerAuthProvider,
271
+ mock_jwks_data: JWKSData,
272
+ httpx_mock: HTTPXMock,
273
+ ):
274
+ mock_jwks_data["keys"] = [
275
+ {
276
+ "kid": "test-key-1",
277
+ "alg": "RS256",
278
+ },
279
+ {
280
+ "kid": "test-key-2",
281
+ "alg": "RS256",
282
+ },
283
+ ]
284
+
285
+ httpx_mock.add_response(
286
+ url="https://test.example.com/.well-known/jwks.json",
287
+ json=mock_jwks_data,
288
+ )
289
+ token = rsa_key_pair.create_token(
290
+ subject="test-user",
291
+ issuer="https://test.example.com",
292
+ audience="https://api.example.com",
293
+ )
294
+
295
+ access_token = await jwks_provider.load_access_token(token)
296
+ assert access_token is None
297
+
298
+
299
  class TestBearerToken:
300
  def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair):
301
  """Test provider initialization with public key."""
 
336
  issuer="https://test.example.com",
337
  )
338
 
 
339
  async def test_valid_token_validation(
340
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
341
  ):
 
355
  assert "write" in access_token.scopes
356
  assert access_token.expires_at is not None
357
 
 
358
  async def test_expired_token_rejection(
359
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
360
  ):
 
369
  access_token = await bearer_provider.load_access_token(token)
370
  assert access_token is None
371
 
 
372
  async def test_invalid_issuer_rejection(
373
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
374
  ):
 
382
  access_token = await bearer_provider.load_access_token(token)
383
  assert access_token is None
384
 
 
385
  async def test_invalid_audience_rejection(
386
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
387
  ):
 
395
  access_token = await bearer_provider.load_access_token(token)
396
  assert access_token is None
397
 
 
398
  async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair):
399
  """Test that issuer validation is skipped when provider has no issuer configured."""
400
  provider = BearerAuthProvider(
 
409
  access_token = await provider.load_access_token(token)
410
  assert access_token is not None
411
 
 
412
  async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair):
413
  """Test that audience validation is skipped when provider has no audience configured."""
414
  provider = BearerAuthProvider(
 
426
  access_token = await provider.load_access_token(token)
427
  assert access_token is not None
428
 
 
429
  async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair):
430
  """Test validation with multiple audiences in token."""
431
  provider = BearerAuthProvider(
 
445
  access_token = await provider.load_access_token(token)
446
  assert access_token is not None
447
 
 
448
  async def test_scope_extraction_string(
449
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
450
  ):
 
461
  assert access_token is not None
462
  assert set(access_token.scopes) == {"read", "write", "admin"}
463
 
 
464
  async def test_scope_extraction_list(
465
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
466
  ):
 
477
  assert access_token is not None
478
  assert set(access_token.scopes) == {"read", "write"}
479
 
 
480
  async def test_no_scopes(
481
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
482
  ):
 
493
  assert access_token is not None
494
  assert access_token.scopes == []
495
 
 
496
  async def test_malformed_token_rejection(self, bearer_provider: BearerAuthProvider):
497
  """Test rejection of malformed tokens."""
498
  malformed_tokens = [
 
507
  access_token = await bearer_provider.load_access_token(token)
508
  assert access_token is None
509
 
 
510
  async def test_invalid_signature_rejection(
511
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
512
  ):
 
522
  access_token = await bearer_provider.load_access_token(token)
523
  assert access_token is None
524
 
 
525
  async def test_client_id_fallback(
526
  self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
527
  ):
 
547
  assert isinstance(mcp.auth, BearerAuthProvider)
548
 
549
  async def test_unauthorized_access(self, mcp_server_url: str):
550
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
551
  async with Client(mcp_server_url) as client:
552
  tools = await client.list_tools() # noqa: F841
553
+ assert exc_info.value.response.status_code == 401
554
  assert "tools" not in locals()
555
 
556
  async def test_authorized_access(self, mcp_server_url: str, bearer_token):
 
559
  assert tools
560
 
561
  async def test_invalid_token_raises_401(self, mcp_server_url: str):
562
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
563
  async with Client(mcp_server_url, auth=BearerAuth("invalid")) as client:
564
  tools = await client.list_tools() # noqa: F841
565
+ assert exc_info.value.response.status_code == 401
566
  assert "tools" not in locals()
567
 
568
  async def test_expired_token(self, mcp_server_url: str, rsa_key_pair: RSAKeyPair):
 
573
  expires_in_seconds=-3600,
574
  )
575
 
576
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
577
  async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
578
  tools = await client.list_tools() # noqa: F841
579
+ assert exc_info.value.response.status_code == 401
580
  assert "tools" not in locals()
581
 
582
  async def test_token_with_bad_signature(self, mcp_server_url: str):
583
  rsa_key_pair = RSAKeyPair.generate()
584
  token = rsa_key_pair.create_token()
585
 
586
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
587
  async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
588
  tools = await client.list_tools() # noqa: F841
589
+ assert exc_info.value.response.status_code == 401
590
  assert "tools" not in locals()
591
+
592
+ async def test_token_with_insufficient_scopes(
593
+ self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
594
+ ):
595
+ token = rsa_key_pair.create_token(
596
+ subject="test-user",
597
+ issuer="https://test.example.com",
598
+ audience="https://api.example.com",
599
+ scopes=["read"],
600
+ )
601
+
602
+ with run_server_in_process(
603
+ run_mcp_server,
604
+ public_key=rsa_key_pair.public_key,
605
+ auth_kwargs=dict(required_scopes=["read", "write"]),
606
+ run_kwargs=dict(transport="streamable-http"),
607
+ ) as url:
608
+ mcp_server_url = f"{url}/mcp"
609
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
610
+ async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
611
+ tools = await client.list_tools() # noqa: F841
612
+ assert exc_info.value.response.status_code == 403
613
+ assert "tools" not in locals()
614
+
615
+ async def test_token_with_sufficient_scopes(
616
+ self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
617
+ ):
618
+ token = rsa_key_pair.create_token(
619
+ subject="test-user",
620
+ issuer="https://test.example.com",
621
+ audience="https://api.example.com",
622
+ scopes=["read", "write"],
623
+ )
624
+
625
+ with run_server_in_process(
626
+ run_mcp_server,
627
+ public_key=rsa_key_pair.public_key,
628
+ auth_kwargs=dict(required_scopes=["read", "write"]),
629
+ run_kwargs=dict(transport="streamable-http"),
630
+ ) as url:
631
+ mcp_server_url = f"{url}/mcp"
632
+ async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
633
+ tools = await client.list_tools()
634
+ assert tools
uv.lock CHANGED
@@ -456,6 +456,7 @@ dev = [
456
  { name = "pytest-cov" },
457
  { name = "pytest-env" },
458
  { name = "pytest-flakefinder" },
 
459
  { name = "pytest-report" },
460
  { name = "pytest-timeout" },
461
  { name = "pytest-xdist" },
@@ -490,6 +491,7 @@ dev = [
490
  { name = "pytest-cov", specifier = ">=6.1.1" },
491
  { name = "pytest-env", specifier = ">=1.1.5" },
492
  { name = "pytest-flakefinder" },
 
493
  { name = "pytest-report", specifier = ">=0.2.1" },
494
  { name = "pytest-timeout", specifier = ">=2.4.0" },
495
  { name = "pytest-xdist", specifier = ">=3.6.1" },
@@ -1160,6 +1162,19 @@ wheels = [
1160
  { url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644, upload-time = "2022-10-26T18:27:52.128Z" },
1161
  ]
1162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1163
  [[package]]
1164
  name = "pytest-report"
1165
  version = "0.2.1"
 
456
  { name = "pytest-cov" },
457
  { name = "pytest-env" },
458
  { name = "pytest-flakefinder" },
459
+ { name = "pytest-httpx" },
460
  { name = "pytest-report" },
461
  { name = "pytest-timeout" },
462
  { name = "pytest-xdist" },
 
491
  { name = "pytest-cov", specifier = ">=6.1.1" },
492
  { name = "pytest-env", specifier = ">=1.1.5" },
493
  { name = "pytest-flakefinder" },
494
+ { name = "pytest-httpx", specifier = ">=0.35.0" },
495
  { name = "pytest-report", specifier = ">=0.2.1" },
496
  { name = "pytest-timeout", specifier = ">=2.4.0" },
497
  { name = "pytest-xdist", specifier = ">=3.6.1" },
 
1162
  { url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644, upload-time = "2022-10-26T18:27:52.128Z" },
1163
  ]
1164
 
1165
+ [[package]]
1166
+ name = "pytest-httpx"
1167
+ version = "0.35.0"
1168
+ source = { registry = "https://pypi.org/simple" }
1169
+ dependencies = [
1170
+ { name = "httpx" },
1171
+ { name = "pytest" },
1172
+ ]
1173
+ sdist = { url = "https://files.pythonhosted.org/packages/1f/89/5b12b7b29e3d0af3a4b9c071ee92fa25a9017453731a38f08ba01c280f4c/pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f", size = 54146, upload-time = "2024-11-28T19:16:54.237Z" }
1174
+ wheels = [
1175
+ { url = "https://files.pythonhosted.org/packages/b0/ed/026d467c1853dd83102411a78126b4842618e86c895f93528b0528c7a620/pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744", size = 19442, upload-time = "2024-11-28T19:16:52.787Z" },
1176
+ ]
1177
+
1178
  [[package]]
1179
  name = "pytest-report"
1180
  version = "0.2.1"