diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 2820fd513..7ac82c4c4 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -195,15 +195,17 @@ class AzureProvider(OAuthProxy): # NOT standard OIDC scopes (openid, profile, email, offline_access). # Filter out OIDC scopes from validation - they'll still be sent to Azure # during authorization (handled by _prefix_scopes_for_azure). - if parsed_required_scopes: - validation_scopes = [ - s for s in parsed_required_scopes if s not in OIDC_SCOPES - ] - # If all scopes were OIDC scopes, use None (no scope validation) - if not validation_scopes: - validation_scopes = None - else: - validation_scopes = None + validation_scopes = [ + s for s in (parsed_required_scopes or []) if s not in OIDC_SCOPES + ] + if not validation_scopes: + raise ValueError( + "AzureProvider requires at least one non-OIDC scope in " + "required_scopes (e.g., 'read', 'write'). OIDC scopes like " + "'openid', 'profile', 'email', and 'offline_access' are not " + "included in Azure access token claims and cannot be used for " + "scope enforcement." + ) token_verifier = JWTVerifier( jwks_uri=jwks_uri, diff --git a/tests/server/auth/providers/test_azure_scopes.py b/tests/server/auth/providers/test_azure_scopes.py index 9f90def35..9b518a1ff 100644 --- a/tests/server/auth/providers/test_azure_scopes.py +++ b/tests/server/auth/providers/test_azure_scopes.py @@ -147,23 +147,60 @@ class TestOIDCScopeHandling: # Token validator should only require non-OIDC scopes assert provider._token_validator.required_scopes == ["read"] - def test_required_scopes_all_oidc_results_in_no_validation( + def test_required_scopes_all_oidc_raises_value_error( self, memory_storage: MemoryStore ): - """Test that if all required_scopes are OIDC, no scope validation occurs.""" - provider = AzureProvider( - client_id="test_client", - client_secret="test_secret", - tenant_id="test-tenant", - base_url="https://myserver.com", - identifier_uri="api://my-api", - required_scopes=["openid", "profile"], - jwt_signing_key="test-secret", - client_storage=memory_storage, - ) + """Test that providing only OIDC scopes raises ValueError.""" + with pytest.raises(ValueError, match="at least one non-OIDC scope"): + AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + base_url="https://myserver.com", + identifier_uri="api://my-api", + required_scopes=["openid", "profile"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) - # Token validator should have empty required scopes (all were OIDC) - assert provider._token_validator.required_scopes == [] + def test_empty_required_scopes_raises_value_error( + self, memory_storage: MemoryStore + ): + """Test that providing empty required_scopes raises ValueError.""" + with pytest.raises(ValueError, match="at least one non-OIDC scope"): + AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + base_url="https://myserver.com", + identifier_uri="api://my-api", + required_scopes=[], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + @pytest.mark.parametrize( + "scopes", + [ + ["offline_access"], + ["openid", "email", "profile", "offline_access"], + ["email"], + ], + ) + def test_only_oidc_scopes_raises_value_error( + self, memory_storage: MemoryStore, scopes: list[str] + ): + """Test that various OIDC-only scope combinations raise ValueError.""" + with pytest.raises(ValueError, match="at least one non-OIDC scope"): + AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + base_url="https://myserver.com", + required_scopes=scopes, + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) def test_valid_scopes_includes_oidc_scopes(self, memory_storage: MemoryStore): """Test that valid_scopes advertises OIDC scopes to clients."""