From 42460480de275257348911f1e77643115bc140b2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:13:00 -0400 Subject: [PATCH 1/2] Fix BearerAuthProvider audience type annotations to support List[str] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audience parameter was typed as `str | None` but the implementation already supported `List[str]`. This fix aligns the type annotations with the actual functionality and adds comprehensive validation logic for all audience type combinations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/fastmcp/server/auth/providers/bearer.py | 30 ++++++++++++----- tests/auth/providers/test_bearer.py | 37 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index a4f3a8e48..a18d30b16 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -89,7 +89,7 @@ class RSAKeyPair: self, subject: str = "fastmcp-user", issuer: str = "https://fastmcp.example.com", - audience: str | None = None, + audience: str | list[str] | None = None, scopes: list[str] | None = None, expires_in_seconds: int = 3600, additional_claims: dict[str, Any] | None = None, @@ -102,7 +102,7 @@ class RSAKeyPair: private_key_pem: RSA private key in PEM format subject: Subject claim (usually user ID) issuer: Issuer claim - audience: Audience claim (optional) + audience: Audience claim - can be a string or list of strings (optional) scopes: List of scopes to include expires_in_seconds: Token expiration time in seconds additional_claims: Any additional claims to include @@ -161,7 +161,7 @@ class BearerAuthProvider(OAuthProvider): public_key: str | None = None, jwks_uri: str | None = None, issuer: str | None = None, - audience: str | None = None, + audience: str | list[str] | None = None, required_scopes: list[str] | None = None, ): """ @@ -171,7 +171,7 @@ class BearerAuthProvider(OAuthProvider): public_key: RSA public key in PEM format (for static key) jwks_uri: URI to fetch keys from (for key rotation) issuer: Expected issuer claim (optional) - audience: Expected audience claim (optional) + audience: Expected audience claim - can be a string or list of strings (optional) required_scopes: List of required scopes for access (optional) """ if not (public_key or jwks_uri): @@ -312,11 +312,25 @@ class BearerAuthProvider(OAuthProvider): # Validate audience if configured if self.audience: aud = claims.get("aud") - if isinstance(aud, list): - if self.audience not in aud: + + # Handle different combinations of audience types + if isinstance(self.audience, list): + # self.audience is a list - check if any expected audience is present + if isinstance(aud, list): + # Both are lists - check for intersection + if not any(expected in aud for expected in self.audience): + return None + else: + # aud is a string - check if it's in our expected list + if aud not in self.audience: + return None + else: + # self.audience is a string - use original logic + if isinstance(aud, list): + if self.audience not in aud: + return None + elif aud != self.audience: return None - elif aud != self.audience: - return None # Extract claims - prefer client_id over sub for OAuth application identification client_id = claims.get("client_id") or claims.get("sub") or "unknown" diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index 31d623d2c..ba6692c54 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -446,6 +446,43 @@ class TestBearerToken: access_token = await provider.load_access_token(token) assert access_token is not None + async def test_provider_with_multiple_expected_audiences(self, rsa_key_pair: RSAKeyPair): + """Test provider configured with multiple expected audiences.""" + provider = BearerAuthProvider( + public_key=rsa_key_pair.public_key, + issuer="https://test.example.com", + audience=["https://api.example.com", "https://other-api.example.com"], + ) + + # Token with single audience that matches one of the expected + token1 = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + access_token1 = await provider.load_access_token(token1) + assert access_token1 is not None + + # Token with multiple audiences, one of which matches + token2 = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + additional_claims={ + "aud": ["https://api.example.com", "https://third-party.example.com"] + }, + ) + access_token2 = await provider.load_access_token(token2) + assert access_token2 is not None + + # Token with audience that doesn't match any expected + token3 = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://wrong-api.example.com", + ) + access_token3 = await provider.load_access_token(token3) + assert access_token3 is None + async def test_scope_extraction_string( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): From a2f406faa4bac1b23f449cd5dae0e35d45061a56 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:17:47 -0400 Subject: [PATCH 2/2] Apply pre-commit formatting fixes --- src/fastmcp/server/auth/providers/bearer.py | 2 +- tests/auth/providers/test_bearer.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index a18d30b16..6ffffa909 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -312,7 +312,7 @@ class BearerAuthProvider(OAuthProvider): # Validate audience if configured if self.audience: aud = claims.get("aud") - + # Handle different combinations of audience types if isinstance(self.audience, list): # self.audience is a list - check if any expected audience is present diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index ba6692c54..efed070d4 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -446,7 +446,9 @@ class TestBearerToken: access_token = await provider.load_access_token(token) assert access_token is not None - async def test_provider_with_multiple_expected_audiences(self, rsa_key_pair: RSAKeyPair): + async def test_provider_with_multiple_expected_audiences( + self, rsa_key_pair: RSAKeyPair + ): """Test provider configured with multiple expected audiences.""" provider = BearerAuthProvider( public_key=rsa_key_pair.public_key,