This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new cdc5d9facec Accept only the configured client on the Keycloak 
client_credentials grant (#72205)
cdc5d9facec is described below

commit cdc5d9faceca4d33791692fc679ec38466c6d91c
Author: Jarek Potiuk <[email protected]>
AuthorDate: Fri Aug 28 22:04:44 2026 +0200

    Accept only the configured client on the Keycloak client_credentials grant 
(#72205)
    
    create_client_credentials_token built a Keycloak client from the 
caller-supplied
    client_id and client_secret and minted an Airflow session token for 
whichever
    service account came back. The route reaching it is unauthenticated, so the
    credentials of any confidential client in the realm were usable to obtain an
    Airflow token, not only those of the client Airflow is configured with.
    
    The client_id is now compared against [keycloak_auth_manager] client_id 
before
    anything is sent to Keycloak. The rejection reuses the response of a failed
    credential exchange so the endpoint cannot be used to discover which client 
ids
    exist in the realm, and returns before the exchange so it cannot be used to 
test
    another client's secret either.
    
    The two existing client_credentials tests passed an arbitrary client_id 
with no
    configured value; they now declare one. Added a test that another realm 
client is
    refused without any exchange being attempted, and one asserting a wrong id 
and a
    wrong secret stay indistinguishable.
---
 providers/keycloak/docs/changelog.rst              |  8 +++++
 .../keycloak/auth_manager/services/token.py        | 17 +++++++++
 .../keycloak/auth_manager/services/test_token.py   | 42 ++++++++++++++++++++++
 3 files changed, 67 insertions(+)

diff --git a/providers/keycloak/docs/changelog.rst 
b/providers/keycloak/docs/changelog.rst
index 3b97f613434..822e8a72487 100644
--- a/providers/keycloak/docs/changelog.rst
+++ b/providers/keycloak/docs/changelog.rst
@@ -25,6 +25,14 @@
 Changelog
 ---------
 
+.. note::
+    The unauthenticated ``POST /auth/token`` endpoint now accepts the 
``client_credentials``
+    grant only for the client configured in ``[keycloak_auth_manager] 
client_id``. Previously
+    the credentials of any confidential client in the realm were accepted and 
exchanged for an
+    Airflow token. If a deployment authenticates with a service account 
belonging to a different
+    client, either point ``[keycloak_auth_manager] client_id`` at that client 
or issue the
+    credentials against the configured one -- other clients now receive 
``403``.
+
 0.9.0
 .....
 
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/services/token.py
 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/services/token.py
index 68caa598704..a92ec0e04cc 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/services/token.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/services/token.py
@@ -24,6 +24,10 @@ from keycloak import KeycloakAuthenticationError
 
 from airflow.api_fastapi.app import get_auth_manager
 from airflow.providers.common.compat.sdk import conf
+from airflow.providers.keycloak.auth_manager.constants import (
+    CONF_CLIENT_ID_KEY,
+    CONF_SECTION_NAME,
+)
 from airflow.providers.keycloak.auth_manager.keycloak_auth_manager import 
KeycloakAuthManager
 from airflow.providers.keycloak.auth_manager.user import 
KeycloakAuthManagerUser
 
@@ -71,7 +75,20 @@ def create_client_credentials_token(
     - Client Authentication: ON (confidential client)
 
     The service account must be configured with the appropriate 
roles/permissions.
+
+    Only the client Airflow is configured to use is accepted. The route this 
is reached
+    from is unauthenticated, so without that restriction the credentials of any
+    confidential client in the realm would be usable to obtain an Airflow 
token.
     """
+    if client_id != conf.get(CONF_SECTION_NAME, CONF_CLIENT_ID_KEY):
+        # Deliberately the same response as a failed credential exchange 
below: telling
+        # the caller which of the two checks rejected them would make the 
endpoint a
+        # discovery oracle for client ids in the realm.
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Client credentials authentication failed",
+        )
+
     # Get Keycloak client with service account credentials
     client = KeycloakAuthManager.get_keycloak_client(
         client_id=client_id,
diff --git 
a/providers/keycloak/tests/unit/keycloak/auth_manager/services/test_token.py 
b/providers/keycloak/tests/unit/keycloak/auth_manager/services/test_token.py
index ceaf40b2ffc..a3e365051e4 100644
--- a/providers/keycloak/tests/unit/keycloak/auth_manager/services/test_token.py
+++ b/providers/keycloak/tests/unit/keycloak/auth_manager/services/test_token.py
@@ -83,6 +83,7 @@ class TestTokenService:
     @conf_vars(
         {
             ("api_auth", "jwt_expiration_time"): "10",
+            ("keycloak_auth_manager", "client_id"): "test_client",
         }
     )
     
@patch("airflow.providers.keycloak.auth_manager.services.token.get_auth_manager")
@@ -117,6 +118,7 @@ class TestTokenService:
     @conf_vars(
         {
             ("api_auth", "jwt_expiration_time"): "10",
+            ("keycloak_auth_manager", "client_id"): "invalid_client",
         }
     )
     
@patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client")
@@ -133,3 +135,43 @@ class TestTokenService:
 
         assert exc_info.value.status_code == 403
         assert "Client credentials authentication failed" in 
exc_info.value.detail
+
+    @conf_vars(
+        {
+            ("api_auth", "jwt_expiration_time"): "10",
+            ("keycloak_auth_manager", "client_id"): "airflow",
+        }
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client")
+    def test_create_token_client_credentials_rejects_other_client(self, 
mock_get_keycloak_client):
+        """Only the client Airflow is configured with may exchange credentials 
for a token."""
+        with pytest.raises(fastapi.exceptions.HTTPException) as exc_info:
+            create_client_credentials_token(
+                client_id="some_other_realm_client", 
client_secret="its_own_valid_secret"
+            )
+
+        assert exc_info.value.status_code == 403
+        # No exchange is attempted: the credentials are never sent to 
Keycloak, so the
+        # endpoint cannot be used to test whether another client's secret is 
valid.
+        mock_get_keycloak_client.assert_not_called()
+
+    @conf_vars(
+        {
+            ("api_auth", "jwt_expiration_time"): "10",
+            ("keycloak_auth_manager", "client_id"): "airflow",
+        }
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client")
+    def 
test_create_token_client_credentials_rejection_is_indistinguishable(self, 
mock_get_keycloak_client):
+        """A wrong client id and a wrong secret must not be tellable apart."""
+        mock_keycloak_client = Mock()
+        mock_keycloak_client.token.side_effect = KeycloakAuthenticationError()
+        mock_get_keycloak_client.return_value = mock_keycloak_client
+
+        with pytest.raises(fastapi.exceptions.HTTPException) as wrong_secret:
+            create_client_credentials_token(client_id="airflow", 
client_secret="wrong")
+        with pytest.raises(fastapi.exceptions.HTTPException) as wrong_client:
+            create_client_credentials_token(client_id="other", 
client_secret="wrong")
+
+        assert wrong_secret.value.status_code == wrong_client.value.status_code
+        assert wrong_secret.value.detail == wrong_client.value.detail

Reply via email to