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