Repository: dpkp/kafka-python

Issue:
Kafka application consumer code fails with auth_bytes
Hi ,

I was using Kafka-python version 2.0.3. I got an application written to consume a message and that worked without issue. Now when i upgraded to the latest version 2.2.7 version of Kafka-python, the same application fails with error "ValueError: Unexpected receive auth_bytes after sasl/gssapi completion"

I have mentioned (2,2,6) as api version in my consumer application code but still it fails.

How to resolve this issue? Is there any configuration change that needs to be added in code? Any workaround to make the existing application work with the latest version of Kafka-python?

Regards,
Ganesh


Discussion:
The SASL authentication system was updated between 2.0.3 and 2.2.7, and you may be hitting a bug in one of the new plugins. Can you share your consumer sasl configuration (sasl_mechanism etc)? It would also be helpful if you could share debug logs of the connection failure.
My apologies for not able to respond immediately to your query.
 The following is the code :-
topic = "test_topic"
    sasl_mechanism = "GSSAPI"
    username = "username"
    password = "password"
    security_protocol = "SASL_SSL"


    consumer = KafkaConsumer(topic, bootstrap_servers='KAFKA_BROKER_HOST:9093',
                              security_protocol=security_protocol,
                              ssl_check_hostname=True,
                              sasl_mechanism = sasl_mechanism,
                              sasl_kerberos_domain_name='KAFKA_BROKER_HOST',
                              auto_offset_reset='earliest',
                              enable_auto_commit=False,
                              sasl_plain_username = username,
                              sasl_plain_password = password)

I have attached the debug logs.

[debug_logs_kafka_python229.txt](https://github.com/user-attachments/files/20391317/debug_logs_kafka_python229.txt)

Please let me know if you need more information 
I have the same problem and did some debugging, finding the direct cause - but not a fix.

In `SaslMechanismGSSAPI.receive`, the last part after the `else` is supposed to build the final message we want to send to the server, when the `_client_ctx` is `complete` but `SaslMechanismGSSAPI` is not `done`, yet. However, that part is never entered, and the message never sent, because `auth_bytes` gets called by `BrokerConnection._sasl_authenticate` before `receive` has a chance to do its work. As a result, `SaslMechanismGSSAPI` is already set to done, when `receive` gets called, and we end up in the `elif` branch, that raises the error, because we think we are done, but we actually are not done, yet.

The old SASL authentication was structured like this (pseudo-code):
```python
while not client_ctx.complete:
    create_next_token()
    send_next_token()
    receive_answer()

create_last_message()
send_last_message()
```
Those last two steps do not happen after the refactor in version 2.1.0. To fix that, one has to untangle the interactions between `SaslMechanismGSSAPI` and `BrokerConnection`. I could probably do that, but only in a way that effectively undoes the new SASL mechanism, thus I must pass the ball to someone who is more familiar with the intent behind the refactor.
Thanks! I dont have a good gssapi test setup, but from this it looks like `client_ctx.complete` is set on the final `create_next_token()` step. The current code assumes that `client_ctx.complete` is set on the final `receive_answer()`. The fix for this should be pretty easy (doesn't require changing the connection / sasl plugin interface).



Patch:
diff --git a/kafka/sasl/gssapi.py b/kafka/sasl/gssapi.py
index be84269da..6a4896585 100644
--- a/kafka/sasl/gssapi.py
+++ b/kafka/sasl/gssapi.py
@@ -26,14 +26,15 @@ def __init__(self, **config):
             raise ValueError('sasl_kerberos_service_name or sasl_kerberos_name required for GSSAPI sasl configuration')
         self._is_done = False
         self._is_authenticated = False
+        self.gssapi_name = None
         if config.get('sasl_kerberos_name', None) is not None:
             self.auth_id = str(config['sasl_kerberos_name'])
+            if isinstance(config['sasl_kerberos_name'], gssapi.Name):
+                self.gssapi_name = config['sasl_kerberos_name']
         else:
             kerberos_domain_name = config.get('sasl_kerberos_domain_name', '') or config.get('host', '')
             self.auth_id = config['sasl_kerberos_service_name'] + '@' + kerberos_domain_name
-        if isinstance(config.get('sasl_kerberos_name', None), gssapi.Name):
-            self.gssapi_name = config['sasl_kerberos_name']
-        else:
+        if self.gssapi_name is None:
             self.gssapi_name = gssapi.Name(self.auth_id, name_type=gssapi.NameType.hostbased_service).canonicalize(gssapi.MechType.kerberos)
         self._client_ctx = gssapi.SecurityContext(name=self.gssapi_name, usage='initiate')
         self._next_token = self._client_ctx.step(None)
@@ -43,9 +44,8 @@ def auth_bytes(self):
         # so mark is_done after the final auth_bytes are provided
         # in practice we'll still receive a response when using SaslAuthenticate
         # but not when using the prior unframed approach.
-        if self._client_ctx.complete:
+        if self._is_authenticated:
             self._is_done = True
-            self._is_authenticated = True
         return self._next_token or b''
 
     def receive(self, auth_bytes):
@@ -74,6 +74,13 @@ def receive(self, auth_bytes):
             ]
             # add authorization identity to the response, and GSS-wrap
             self._next_token = self._client_ctx.wrap(b''.join(message_parts), False).message
+            # We need to identify the last token in auth_bytes();
+            # we can't rely on client_ctx.complete because it becomes True after generating
+            # the second-to-last token (after calling .step(auth_bytes) for the final time)
+            # We could introduce an additional state variable (i.e., self._final_token),
+            # but instead we just set _is_authenticated. Since the plugin interface does
+            # not read is_authenticated() until after is_done() is True, this should be fine.
+            self._is_authenticated = True
 
     def is_done(self):
         return self._is_done


Test patch:
diff --git a/test/sasl/test_gssapi.py b/test/sasl/test_gssapi.py
new file mode 100644
index 000000000..893414e37
--- /dev/null
+++ b/test/sasl/test_gssapi.py
@@ -0,0 +1,42 @@
+from __future__ import absolute_import
+
+try:
+    from unittest import mock
+except ImportError:
+    import mock
+
+from kafka.sasl import get_sasl_mechanism
+import kafka.sasl.gssapi
+
+
+def test_gssapi():
+    config = {
+        'sasl_kerberos_domain_name': 'foo',
+        'sasl_kerberos_service_name': 'bar',
+    }
+    client_ctx = mock.Mock()
+    client_ctx.step.side_effect = [b'init', b'exchange', b'complete', b'xxxx']
+    client_ctx.complete = False
+    def mocked_message_wrapper(msg, *args):
+        wrapped = mock.Mock()
+        type(wrapped).message = mock.PropertyMock(return_value=msg)
+        return wrapped
+    client_ctx.unwrap.side_effect = mocked_message_wrapper
+    client_ctx.wrap.side_effect = mocked_message_wrapper
+    kafka.sasl.gssapi.gssapi = mock.Mock()
+    kafka.sasl.gssapi.gssapi.SecurityContext.return_value = client_ctx
+    gssapi = get_sasl_mechanism('GSSAPI')(**config)
+    assert isinstance(gssapi, kafka.sasl.gssapi.SaslMechanismGSSAPI)
+    client_ctx.step.assert_called_with(None)
+
+    while not gssapi.is_done():
+        send_token = gssapi.auth_bytes()
+        receive_token = send_token # not realistic, but enough for testing
+        if send_token == b'\x00cbar@foo': # final wrapped message
+            receive_token = b'' # final message gets an empty response
+        gssapi.receive(receive_token)
+        if client_ctx.step.call_count == 3:
+            client_ctx.complete = True
+
+    assert gssapi.is_done()
+    assert gssapi.is_authenticated()