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