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

vincbeck 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 21a2fa55453 Keycloak: implement client_credentials grant flow (#59411)
21a2fa55453 is described below

commit 21a2fa554533accfff8e25258f265528a0d1db4a
Author: ecodina <[email protected]>
AuthorDate: Mon Jan 5 20:01:48 2026 +0100

    Keycloak: implement client_credentials grant flow (#59411)
    
    * kc: implement client_credentials grant
    
    * Improve error log
    
    Co-authored-by: Vincent <[email protected]>
    
    * change client credentials service func name
    
    Co-authored-by: Vincent <[email protected]>
    
    * update client credentials service usage
    
    * refresh token can be None
    
    * add docs for client credentials
    
    * implement pydantic union
    
    * add tests for the new token route
    
    * add docs for new token endpoint
    
    * fix mypy error
    
    * refactor token creation logic to use methods in data models
    
    * lowercase
    
    Co-authored-by: Vincent <[email protected]>
    
    * lowercase
    
    * remove unused import
    
    ---------
    
    Co-authored-by: Vincent <[email protected]>
---
 providers/keycloak/docs/auth-manager/token.rst     | 25 ++++++++
 .../keycloak/auth_manager/datamodels/token.py      | 51 +++++++++++++++-
 .../keycloak/auth_manager/keycloak_auth_manager.py | 30 +++++++++-
 .../v2-keycloak-auth-manager-generated.yaml        | 42 ++++++++++++-
 .../keycloak/auth_manager/routes/token.py          | 19 +++---
 .../keycloak/auth_manager/services/token.py        | 44 ++++++++++++++
 .../providers/keycloak/auth_manager/user.py        |  2 +-
 .../keycloak/auth_manager/routes/test_token.py     | 68 ++++++++++++++++++++--
 .../keycloak/auth_manager/services/test_token.py   | 59 ++++++++++++++++++-
 .../auth_manager/test_keycloak_auth_manager.py     | 54 +++++++++++++++++
 10 files changed, 371 insertions(+), 23 deletions(-)

diff --git a/providers/keycloak/docs/auth-manager/token.rst 
b/providers/keycloak/docs/auth-manager/token.rst
index b38904b3516..c775814a5c1 100644
--- a/providers/keycloak/docs/auth-manager/token.rst
+++ b/providers/keycloak/docs/auth-manager/token.rst
@@ -25,6 +25,14 @@ In order to use the :doc:`Airflow public API 
<apache-airflow:stable-rest-api-ref
 You can then include this token in your Airflow public API requests.
 To generate a JWT token, use the ``Create Token`` API in 
:doc:`/api-ref/token-api-ref`.
 
+Several endpoints exist to create tokens depending on the authentication 
method you want to use.
+
+If a user or service needs to interact with the Airflow public API, they can 
create a token using their credentials.
+
+- ``/auth/token``: Create token using username and password or client 
credentials with a ``[config][api_auth]jwt_expiration_time`` expiration time.
+- ``/auth/token/cli``: Create token for Airflow CLI using username and 
password with a ``[config][api_auth]jwt_cli_expiration_time`` expiration time.
+
+
 Example
 '''''''
 
@@ -40,3 +48,20 @@ Example
         }'
 
 This process will return a token that you can use in the Airflow public API 
requests.
+The body can also contain a ``grant_type`` field with value ``password`` but 
it is optional since it is the default value.
+
+.. code-block:: bash
+
+    ENDPOINT_URL="http://localhost:8080 "
+    curl -X 'POST' \
+        "${ENDPOINT_URL}/auth/token" \
+        -H 'Content-Type: application/json' \
+        -d '{
+        "grant_type": "client_credentials",
+        "client_id": "<client_id>",
+        "client_secret": "<client_secret>"
+        }'
+
+If other services need to interact with the Airflow public API, they can 
create a token using the client credentials grant flow.
+The client must live in the same realm the Auth Manager is configured to use. 
Its service account must have the appropriate roles / permissions to access the 
Airflow public API.
+This process will return a token obtained using client credentials grant flow.
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/datamodels/token.py
 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/datamodels/token.py
index 16670f64356..6d60e7e4020 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/datamodels/token.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/datamodels/token.py
@@ -17,9 +17,15 @@
 # under the License.
 from __future__ import annotations
 
-from pydantic import Field
+from typing import Annotated, Literal
+
+from pydantic import Field, RootModel, model_validator
 
 from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel
+from airflow.providers.keycloak.auth_manager.services.token import (
+    create_client_credentials_token,
+    create_token_for,
+)
 
 
 class TokenResponse(BaseModel):
@@ -28,8 +34,47 @@ class TokenResponse(BaseModel):
     access_token: str
 
 
-class TokenBody(StrictBaseModel):
-    """Token serializer for post bodies."""
+class TokenPasswordBody(StrictBaseModel):
+    """Password grant token serializer for post bodies."""
 
+    grant_type: Literal["password"] = "password"
     username: str = Field()
     password: str = Field()
+
+    def create_token(self, expiration_time_in_seconds: int) -> str:
+        """Create token using password grant."""
+        return create_token_for(
+            self.username, self.password, 
expiration_time_in_seconds=expiration_time_in_seconds
+        )
+
+
+class TokenClientCredentialsBody(StrictBaseModel):
+    """Client credentials grant token serializer for post bodies."""
+
+    grant_type: Literal["client_credentials"]
+    client_id: str = Field()
+    client_secret: str = Field()
+
+    def create_token(self, expiration_time_in_seconds: int) -> str:
+        """Create token using client credentials grant."""
+        return create_client_credentials_token(
+            self.client_id, self.client_secret, 
expiration_time_in_seconds=expiration_time_in_seconds
+        )
+
+
+TokenUnion = Annotated[
+    TokenPasswordBody | TokenClientCredentialsBody,
+    Field(discriminator="grant_type"),
+]
+
+
+class TokenBody(RootModel[TokenUnion]):
+    """Token request body."""
+
+    @model_validator(mode="before")
+    @classmethod
+    def default_grant_type(cls, data):
+        """Add default grant_type for discrimination."""
+        if "grant_type" not in data:
+            data["grant_type"] = "password"
+        return data
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py
 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py
index 9ad07ffbabb..24a1a60f44b 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py
@@ -147,6 +147,13 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
         return urljoin(base_url, f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/logout")
 
     def refresh_user(self, *, user: KeycloakAuthManagerUser) -> 
KeycloakAuthManagerUser | None:
+        # According to RFC6749 section 4.4.3, a refresh token should not be 
included when using
+        # the Service accounts/client_credentials flow.
+        # We check whether the user has a refresh token; if not, we assume 
it's a service account
+        # and return None.
+        if not user.refresh_token:
+            return None
+
         if self._token_expired(user.access_token):
             tokens = self.refresh_tokens(user=user)
 
@@ -158,6 +165,10 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
         return None
 
     def refresh_tokens(self, *, user: KeycloakAuthManagerUser) -> dict[str, 
str]:
+        if not user.refresh_token:
+            # It is a service account. It used the client credentials flow and 
no refresh token is issued.
+            return {}
+
         try:
             log.debug("Refreshing the token")
             client = self.get_keycloak_client()
@@ -316,9 +327,22 @@ class 
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
         ]
 
     @staticmethod
-    def get_keycloak_client() -> KeycloakOpenID:
-        client_id = conf.get(CONF_SECTION_NAME, CONF_CLIENT_ID_KEY)
-        client_secret = conf.get(CONF_SECTION_NAME, CONF_CLIENT_SECRET_KEY)
+    def get_keycloak_client(client_id: str | None = None, client_secret: str | 
None = None) -> KeycloakOpenID:
+        """
+        Get a KeycloakOpenID client instance.
+
+        :param client_id: Optional client ID to override config. If provided, 
client_secret must also be provided.
+        :param client_secret: Optional client secret to override config. If 
provided, client_id must also be provided.
+        """
+        if (client_id is None) != (client_secret is None):
+            raise ValueError(
+                "Both `client_id` and `client_secret` must be provided 
together, or both must be None"
+            )
+
+        if client_id is None:
+            client_id = conf.get(CONF_SECTION_NAME, CONF_CLIENT_ID_KEY)
+            client_secret = conf.get(CONF_SECTION_NAME, CONF_CLIENT_SECRET_KEY)
+
         realm = conf.get(CONF_SECTION_NAME, CONF_REALM_KEY)
         server_url = conf.get(CONF_SECTION_NAME, CONF_SERVER_URL_KEY)
 
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/openapi/v2-keycloak-auth-manager-generated.yaml
 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/openapi/v2-keycloak-auth-manager-generated.yaml
index 011a57b4485..2f72b64ee0f 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/openapi/v2-keycloak-auth-manager-generated.yaml
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/openapi/v2-keycloak-auth-manager-generated.yaml
@@ -128,7 +128,7 @@ paths:
         content:
           application/json:
             schema:
-              $ref: '#/components/schemas/TokenBody'
+              $ref: '#/components/schemas/TokenPasswordBody'
         required: true
       responses:
         '201':
@@ -180,7 +180,43 @@ components:
       type: object
       title: HTTPValidationError
     TokenBody:
+      oneOf:
+      - $ref: '#/components/schemas/TokenPasswordBody'
+      - $ref: '#/components/schemas/TokenClientCredentialsBody'
+      title: TokenBody
+      description: Token request body.
+      discriminator:
+        propertyName: grant_type
+        mapping:
+          client_credentials: '#/components/schemas/TokenClientCredentialsBody'
+          password: '#/components/schemas/TokenPasswordBody'
+    TokenClientCredentialsBody:
       properties:
+        grant_type:
+          type: string
+          const: client_credentials
+          title: Grant Type
+        client_id:
+          type: string
+          title: Client Id
+        client_secret:
+          type: string
+          title: Client Secret
+      additionalProperties: false
+      type: object
+      required:
+      - grant_type
+      - client_id
+      - client_secret
+      title: TokenClientCredentialsBody
+      description: Client credentials grant token serializer for post bodies.
+    TokenPasswordBody:
+      properties:
+        grant_type:
+          type: string
+          const: password
+          title: Grant Type
+          default: password
         username:
           type: string
           title: Username
@@ -192,8 +228,8 @@ components:
       required:
       - username
       - password
-      title: TokenBody
-      description: Token serializer for post bodies.
+      title: TokenPasswordBody
+      description: Password grant token serializer for post bodies.
     TokenResponse:
       properties:
         access_token:
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/token.py
 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/token.py
index 72c16388495..4514531c648 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/token.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/token.py
@@ -24,8 +24,11 @@ from starlette import status
 from airflow.api_fastapi.common.router import AirflowRouter
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.providers.common.compat.sdk import conf
-from airflow.providers.keycloak.auth_manager.datamodels.token import 
TokenBody, TokenResponse
-from airflow.providers.keycloak.auth_manager.services.token import 
create_token_for
+from airflow.providers.keycloak.auth_manager.datamodels.token import (
+    TokenBody,
+    TokenPasswordBody,
+    TokenResponse,
+)
 
 log = logging.getLogger(__name__)
 token_router = AirflowRouter(tags=["KeycloakAuthManagerToken"])
@@ -37,7 +40,9 @@ token_router = 
AirflowRouter(tags=["KeycloakAuthManagerToken"])
     responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST, 
status.HTTP_401_UNAUTHORIZED]),
 )
 def create_token(body: TokenBody) -> TokenResponse:
-    token = create_token_for(body.username, body.password)
+    token = body.root.create_token(
+        expiration_time_in_seconds=int(conf.getint("api_auth", 
"jwt_expiration_time"))
+    )
     return TokenResponse(access_token=token)
 
 
@@ -46,10 +51,8 @@ def create_token(body: TokenBody) -> TokenResponse:
     status_code=status.HTTP_201_CREATED,
     responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST, 
status.HTTP_401_UNAUTHORIZED]),
 )
-def create_token_cli(body: TokenBody) -> TokenResponse:
-    token = create_token_for(
-        body.username,
-        body.password,
-        expiration_time_in_seconds=int(conf.getint("api_auth", 
"jwt_cli_expiration_time")),
+def create_token_cli(body: TokenPasswordBody) -> TokenResponse:
+    token = body.create_token(
+        expiration_time_in_seconds=int(conf.getint("api_auth", 
"jwt_cli_expiration_time"))
     )
     return TokenResponse(access_token=token)
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 579e8de9431..f1d4a180de1 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
@@ -51,3 +51,47 @@ def create_token_for(
     )
 
     return get_auth_manager().generate_jwt(user, 
expiration_time_in_seconds=expiration_time_in_seconds)
+
+
+def create_client_credentials_token(
+    client_id: str,
+    client_secret: str,
+    expiration_time_in_seconds: int = conf.getint("api_auth", 
"jwt_expiration_time"),
+) -> str:
+    """
+    Create token using OAuth2 client_credentials grant type.
+
+    This authentication flow uses the provided client_id and client_secret
+    to obtain a token for a service account. The Keycloak client must have:
+    - Service accounts roles: ON
+    - Client Authentication: ON (confidential client)
+
+    The service account must be configured with the appropriate 
roles/permissions.
+    """
+    # Get Keycloak client with service account credentials
+    client = KeycloakAuthManager.get_keycloak_client(
+        client_id=client_id,
+        client_secret=client_secret,
+    )
+
+    try:
+        tokens = client.token(grant_type="client_credentials")
+    except KeycloakAuthenticationError:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Client credentials authentication failed",
+        )
+
+    # For client_credentials, get the service account user info
+    # The token represents the service account associated with the client
+    userinfo = client.userinfo(tokens["access_token"])
+    user = KeycloakAuthManagerUser(
+        user_id=userinfo["sub"],
+        name=userinfo.get("preferred_username", userinfo.get("clientId", 
"service-account")),
+        access_token=tokens["access_token"],
+        refresh_token=tokens.get(
+            "refresh_token"
+        ),  # client_credentials may not return refresh_token (RFC6749 section 
4.4.3)
+    )
+
+    return get_auth_manager().generate_jwt(user, 
expiration_time_in_seconds=expiration_time_in_seconds)
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/user.py 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/user.py
index 460149f5603..c2ec7d59c88 100644
--- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/user.py
+++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/user.py
@@ -22,7 +22,7 @@ from airflow.api_fastapi.auth.managers.models.base_user 
import BaseUser
 class KeycloakAuthManagerUser(BaseUser):
     """User model for users managed by Keycloak auth manager."""
 
-    def __init__(self, *, user_id: str, name: str, access_token: str, 
refresh_token: str) -> None:
+    def __init__(self, *, user_id: str, name: str, access_token: str, 
refresh_token: str | None) -> None:
         self.user_id = user_id
         self.name = name
         self.access_token = access_token
diff --git 
a/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_token.py 
b/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_token.py
index b05b45ff485..064288152ff 100644
--- a/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_token.py
+++ b/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_token.py
@@ -18,6 +18,8 @@ from __future__ import annotations
 
 from unittest.mock import patch
 
+import pytest
+
 from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
 
 from tests_common.test_utils.config import conf_vars
@@ -27,17 +29,24 @@ class TestTokenRouter:
     token = "token"
     token_body_dict = {"username": "username", "password": "password"}
 
+    @pytest.mark.parametrize(
+        "body",
+        [
+            {"username": "username", "password": "password"},
+            {"grant_type": "password", "username": "username", "password": 
"password"},
+        ],
+    )
     @conf_vars(
         {
             ("api_auth", "jwt_expiration_time"): "10",
         }
     )
-    
@patch("airflow.providers.keycloak.auth_manager.routes.token.create_token_for")
-    def test_create_token(self, mock_create_token_for, client):
+    
@patch("airflow.providers.keycloak.auth_manager.datamodels.token.create_token_for")
+    def test_create_token_password_grant(self, mock_create_token_for, client, 
body):
         mock_create_token_for.return_value = self.token
         response = client.post(
             AUTH_MANAGER_FASTAPI_APP_PREFIX + "/token",
-            json=self.token_body_dict,
+            json=body,
         )
 
         assert response.status_code == 201
@@ -49,7 +58,7 @@ class TestTokenRouter:
             ("api_auth", "jwt_expiration_time"): "10",
         }
     )
-    
@patch("airflow.providers.keycloak.auth_manager.routes.token.create_token_for")
+    
@patch("airflow.providers.keycloak.auth_manager.datamodels.token.create_token_for")
     def test_create_token_cli(self, mock_create_token_for, client):
         mock_create_token_for.return_value = self.token
         response = client.post(
@@ -59,3 +68,54 @@ class TestTokenRouter:
 
         assert response.status_code == 201
         assert response.json() == {"access_token": self.token}
+
+    @conf_vars(
+        {
+            ("api_auth", "jwt_expiration_time"): "10",
+        }
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.datamodels.token.create_client_credentials_token")
+    def test_create_token_client_credentials(self, 
mock_create_client_credentials_token, client):
+        mock_create_client_credentials_token.return_value = self.token
+        response = client.post(
+            AUTH_MANAGER_FASTAPI_APP_PREFIX + "/token",
+            json={
+                "grant_type": "client_credentials",
+                "client_id": "client_id",
+                "client_secret": "client_secret",
+            },
+        )
+
+        assert response.status_code == 201
+        assert response.json() == {"access_token": self.token}
+        mock_create_client_credentials_token.assert_called_once_with(
+            "client_id", "client_secret", expiration_time_in_seconds=10
+        )
+
+    @pytest.mark.parametrize(
+        "body",
+        [
+            {"client_id": "client_id", "client_secret": "client_secret"},
+            {"grant_type": "password", "client_id": "client_id", 
"client_secret": "client_secret"},
+            {"grant_type": "password", "client_id": "client_id", "password": 
"password"},
+            {"grant_type": "password", "username": "username", 
"client_secret": "client_secret"},
+            {"grant_type": "client_credentials", "username": "username", 
"password": "password"},
+            {"grant_type": "client_credentials", "client_id": "client_id", 
"password": "password"},
+            {"grant_type": "client_credentials", "username": "username", 
"client_secret": "client_secret"},
+        ],
+    )
+    @conf_vars(
+        {
+            ("api_auth", "jwt_expiration_time"): "10",
+        }
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.datamodels.token.create_client_credentials_token")
+    def test_create_token_invalid_body(self, 
mock_create_client_credentials_token, client, body):
+        mock_create_client_credentials_token.return_value = self.token
+        response = client.post(
+            AUTH_MANAGER_FASTAPI_APP_PREFIX + "/token",
+            json=body,
+        )
+
+        assert response.status_code == 422
+        mock_create_client_credentials_token.assert_not_called()
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 8f7ce72d4a8..8dbcf2b8ac9 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
@@ -23,7 +23,10 @@ import pytest
 from keycloak import KeycloakAuthenticationError
 
 from airflow.providers.common.compat.sdk import conf
-from airflow.providers.keycloak.auth_manager.services.token import 
create_token_for
+from airflow.providers.keycloak.auth_manager.services.token import (
+    create_client_credentials_token,
+    create_token_for,
+)
 
 from tests_common.test_utils.config import conf_vars
 
@@ -76,3 +79,57 @@ class TestTokenService:
                 password=self.test_password,
                 expiration_time_in_seconds=conf.getint("api_auth", 
"jwt_cli_expiration_time"),
             )
+
+    @conf_vars(
+        {
+            ("api_auth", "jwt_expiration_time"): "10",
+        }
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.services.token.get_auth_manager")
+    
@patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client")
+    def test_create_token_client_credentials(self, mock_get_keycloak_client, 
mock_get_auth_manager):
+        test_client_id = "test_client"
+        test_client_secret = "test_secret"
+        test_access_token = "access_token"
+
+        mock_keycloak_client = Mock()
+        mock_keycloak_client.token.return_value = {
+            "access_token": test_access_token,
+        }
+        mock_keycloak_client.userinfo.return_value = {
+            "sub": "service-account-sub",
+            "preferred_username": "service-account-test_client",
+        }
+        mock_get_keycloak_client.return_value = mock_keycloak_client
+        mock_auth_manager = Mock()
+        mock_get_auth_manager.return_value = mock_auth_manager
+        mock_auth_manager.generate_jwt.return_value = self.token
+
+        result = create_client_credentials_token(client_id=test_client_id, 
client_secret=test_client_secret)
+
+        assert result == self.token
+        mock_get_keycloak_client.assert_called_once_with(
+            client_id=test_client_id, client_secret=test_client_secret
+        )
+        
mock_keycloak_client.token.assert_called_once_with(grant_type="client_credentials")
+        
mock_keycloak_client.userinfo.assert_called_once_with(test_access_token)
+
+    @conf_vars(
+        {
+            ("api_auth", "jwt_expiration_time"): "10",
+        }
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client")
+    def test_create_token_client_credentials_with_invalid_credentials(self, 
mock_get_keycloak_client):
+        test_client_id = "invalid_client"
+        test_client_secret = "invalid_secret"
+
+        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 exc_info:
+            create_client_credentials_token(client_id=test_client_id, 
client_secret=test_client_secret)
+
+        assert exc_info.value.status_code == 401
+        assert "Client credentials authentication failed" in 
exc_info.value.detail
diff --git 
a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py
 
b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py
index 2902e7d0687..8e7fb924333 100644
--- 
a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py
+++ 
b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py
@@ -39,6 +39,7 @@ from airflow.api_fastapi.common.types import MenuItem
 from airflow.providers.common.compat.sdk import AirflowException
 from airflow.providers.keycloak.auth_manager.constants import (
     CONF_CLIENT_ID_KEY,
+    CONF_CLIENT_SECRET_KEY,
     CONF_REALM_KEY,
     CONF_SECTION_NAME,
     CONF_SERVER_URL_KEY,
@@ -57,6 +58,7 @@ def auth_manager():
     with conf_vars(
         {
             (CONF_SECTION_NAME, CONF_CLIENT_ID_KEY): "client_id",
+            (CONF_SECTION_NAME, CONF_CLIENT_SECRET_KEY): "client_secret",
             (CONF_SECTION_NAME, CONF_REALM_KEY): "realm",
             (CONF_SECTION_NAME, CONF_SERVER_URL_KEY): "server_url",
         }
@@ -116,6 +118,16 @@ class TestKeycloakAuthManager:
 
         assert result is None
 
+    def test_refresh_user_no_refresh_token(self, auth_manager):
+        """Test that refresh_user returns None when refresh_token is empty 
(client_credentials case)."""
+        user_without_refresh = Mock()
+        user_without_refresh.refresh_token = None
+        user_without_refresh.access_token = "access_token"
+
+        result = auth_manager.refresh_user(user=user_without_refresh)
+
+        assert result is None
+
     @patch.object(KeycloakAuthManager, "get_keycloak_client")
     @patch.object(KeycloakAuthManager, "_token_expired")
     def test_refresh_user_expired(self, mock_token_expired, 
mock_get_keycloak_client, auth_manager, user):
@@ -497,3 +509,45 @@ class TestKeycloakAuthManager:
         token = 
auth_manager._get_token_signer(expiration_time_in_seconds=expiration).generate({})
 
         assert KeycloakAuthManager._token_expired(token) is expected
+
+    @pytest.mark.parametrize(
+        ("client_id", "client_secret"),
+        [
+            ("test_client", None),
+            (None, "test_secret"),
+        ],
+    )
+    def test_get_keycloak_client_with_partial_credentials_raises_error(
+        self, auth_manager, client_id, client_secret
+    ):
+        """Test that providing only client_id or only client_secret raises 
ValueError."""
+        with pytest.raises(
+            ValueError, match="Both `client_id` and `client_secret` must be 
provided together"
+        ):
+            auth_manager.get_keycloak_client(client_id=client_id, 
client_secret=client_secret)
+
+    
@patch("airflow.providers.keycloak.auth_manager.keycloak_auth_manager.KeycloakOpenID")
+    def test_get_keycloak_client_with_both_credentials(self, 
mock_keycloak_openid, auth_manager):
+        """Test that providing both client_id and client_secret works 
correctly."""
+        client = auth_manager.get_keycloak_client(client_id="test_client", 
client_secret="test_secret")
+
+        mock_keycloak_openid.assert_called_once_with(
+            server_url="server_url",
+            realm_name="realm",
+            client_id="test_client",
+            client_secret_key="test_secret",
+        )
+        assert client == mock_keycloak_openid.return_value
+
+    
@patch("airflow.providers.keycloak.auth_manager.keycloak_auth_manager.KeycloakOpenID")
+    def test_get_keycloak_client_with_no_credentials(self, 
mock_keycloak_openid, auth_manager):
+        """Test that providing neither credential uses config defaults."""
+        client = auth_manager.get_keycloak_client()
+
+        mock_keycloak_openid.assert_called_once_with(
+            server_url="server_url",
+            realm_name="realm",
+            client_id="client_id",
+            client_secret_key="client_secret",
+        )
+        assert client == mock_keycloak_openid.return_value

Reply via email to