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 35d64d2f11a Add jwt auth keycloak provider (#72978)
35d64d2f11a is described below

commit 35d64d2f11a5895b1045671e38bc4536bf439959
Author: Dustin Lactin <[email protected]>
AuthorDate: Mon Sep 14 11:05:46 2026 -0600

    Add jwt auth keycloak provider (#72978)
---
 .../keycloak/docs/auth-manager/setup/config.rst    |   3 +
 providers/keycloak/docs/auth-manager/token.rst     |  72 ++++++++-
 providers/keycloak/provider.yaml                   |  10 ++
 .../providers/keycloak/auth_manager/constants.py   |   3 +
 .../keycloak/auth_manager/datamodels/token.py      |  22 ++-
 .../v2-keycloak-auth-manager-generated.yaml        |  26 ++++
 .../keycloak/auth_manager/services/token.py        |  93 ++++++++++-
 .../providers/keycloak/get_provider_info.py        |   7 +
 .../keycloak/auth_manager/datamodels/test_token.py |  30 ++++
 .../keycloak/auth_manager/routes/test_token.py     |  24 +++
 .../keycloak/auth_manager/services/test_token.py   | 173 +++++++++++++++++++++
 11 files changed, 460 insertions(+), 3 deletions(-)

diff --git a/providers/keycloak/docs/auth-manager/setup/config.rst 
b/providers/keycloak/docs/auth-manager/setup/config.rst
index ef3c6ad275d..57806a6ccd2 100644
--- a/providers/keycloak/docs/auth-manager/setup/config.rst
+++ b/providers/keycloak/docs/auth-manager/setup/config.rst
@@ -72,3 +72,6 @@ Optional config options:
 - ``server_url``. Keycloak server URL. This server URL is used by the Airflow 
API server to communicate with Keycloak.
   If the Airflow API server and Keycloak are running in Docker, set 
"http://host.docker.internal:<PORT>" (default value).
   You do not need to set this configuration option if you are running Keycloak 
with Breeze.
+- ``jwt_federated_client_ids``. Comma-separated allow-list of Keycloak client 
ids (``azp`` claim) permitted to
+  exchange a Keycloak-issued access token for an Airflow token via the 
``urn:ietf:params:oauth:grant-type:jwt-bearer``
+  grant at ``/auth/token``. See :doc:`../token` for details. Unset or empty 
denies every caller.
diff --git a/providers/keycloak/docs/auth-manager/token.rst 
b/providers/keycloak/docs/auth-manager/token.rst
index c775814a5c1..491797e32cc 100644
--- a/providers/keycloak/docs/auth-manager/token.rst
+++ b/providers/keycloak/docs/auth-manager/token.rst
@@ -29,7 +29,7 @@ Several endpoints exist to create tokens depending on the 
authentication method
 
 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``: Create token using username and password, client 
credentials, or a Keycloak-issued JWT assertion, 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.
 
 
@@ -65,3 +65,73 @@ The body can also contain a ``grant_type`` field with value 
``password`` but it
 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.
+
+.. code-block:: bash
+
+    ENDPOINT_URL="http://localhost:8080";
+    curl -X 'POST' \
+        "${ENDPOINT_URL}/auth/token" \
+        -H 'Content-Type: application/json' \
+        -d '{
+        "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
+        "assertion": "<keycloak_access_token>"
+        }'
+
+If a client already authenticated to Keycloak by some other means -- for 
example, a
+"Signed JWT - Federated" client bound to an external OIDC identity provider 
such as a
+Kubernetes ServiceAccount issuer or AWS IAM outbound identity federation -- it 
can
+exchange the resulting Keycloak access token for an Airflow token without
+re-authenticating to Keycloak through Airflow. Airflow does not obtain the 
assertion on
+the client's behalf, it verifies the assertion and calls Keycloak's 
``/userinfo``
+endpoint to validate it and retrieve user information.
+
+The assertion must be a valid, unexpired access token issued by this realm, 
and its
+``aud`` claim must include Airflow's configured client id. The calling client 
(from the
+token's ``azp`` claim) must also appear in the
+``[keycloak_auth_manager]jwt_federated_client_ids`` allow-list -- an ``aud`` 
match alone
+is not sufficient, since it only proves the token was meant for Airflow, not 
that the
+issuing client has been vetted for machine authentication. An unset or empty 
allow-list
+denies every caller.
+
+If the assertion fails JWT validation, is not allow-listed, or Keycloak 
rejects it at
+``/userinfo``, the endpoint returns ``403 Invalid Keycloak assertion``. This 
generic
+response does not reveal which assertion validation check failed.
+
+**Keycloak client requirements:** The federated client that obtains the 
assertion (not
+the ``airflow`` client itself) must
+be configured with:
+
+- **Client authentication**: ON (confidential client), using whatever 
mechanism suits
+  the caller -- a static client secret, or a "Signed JWT - Federated" 
authenticator bound
+  to an external OIDC identity provider (e.g. a Kubernetes ServiceAccount 
issuer or AWS
+  IAM outbound identity federation) for machine auth with no static secret.
+- **Service accounts roles**: ON, so the client can obtain its own access 
token via the
+  ``client_credentials`` grant.
+- **Default client scopes** must include ``openid``. Keycloak's ``/userinfo`` 
endpoint
+  rejects tokens whose ``scope`` claim omits ``openid`` with a bare ``403``, 
and Airflow
+  calls ``/userinfo`` to build the resulting user -- a token issued with only, 
say,
+  ``profile email`` in its ``scope`` claim will fail here even though the JWT 
itself is
+  perfectly valid. This is easy to miss since it is a realm-wide default that 
some
+  client scope configurations exclude for service accounts.
+- Realm/client roles appropriate for whatever Airflow permissions the client 
needs
+  (e.g. ``SuperAdmin``), assigned the same way as any other service-account 
client.
+
+On the Airflow side, the client's id (from its token's ``azp`` claim) must be 
added to
+``[keycloak_auth_manager]jwt_federated_client_ids``.
+
+The response contains an Airflow-minted JWT, not the Keycloak assertion 
itself. Exchange
+the assertion for it first, then use that JWT -- not ``$KEYCLOAK_TOKEN`` -- as 
the Bearer
+token for subsequent public API calls:
+
+.. code-block:: bash
+
+    ENDPOINT_URL="http://airflow-api-server:8080";
+
+    AIRFLOW_TOKEN=$(curl -s -X 'POST' \
+        "${ENDPOINT_URL}/auth/token" \
+        -H 'Content-Type: application/json' \
+        -d "{\"grant_type\": \"urn:ietf:params:oauth:grant-type:jwt-bearer\", 
\"assertion\": \"${KEYCLOAK_TOKEN}\"}" \
+        | python3 -c 'import json, sys; 
print(json.load(sys.stdin)["access_token"])')
+
+    curl -s "${ENDPOINT_URL}/api/v2/dags" \
+        -H "Authorization: Bearer ${AIRFLOW_TOKEN}"
diff --git a/providers/keycloak/provider.yaml b/providers/keycloak/provider.yaml
index 721f0557a77..d6105593bdc 100644
--- a/providers/keycloak/provider.yaml
+++ b/providers/keycloak/provider.yaml
@@ -93,6 +93,16 @@ config:
         version_added: 0.0.1
         example: ~
         default: ~
+      jwt_federated_client_ids:
+        description: |
+          Comma-separated allow-list of Keycloak client ids (``azp`` claim) 
permitted to
+          exchange a Keycloak-issued access token for an Airflow token via the
+          ``urn:ietf:params:oauth:grant-type:jwt-bearer`` grant at 
``/auth/token``. Unset
+          or empty denies every caller.
+        type: string
+        version_added: 0.10.0
+        example: "team-platform-admin-sa,other-team-sa"
+        default: ~
       realm:
         description: |
           Realm configured in Keycloak associated to Airflow.
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/constants.py 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/constants.py
index 169042d1f45..262166de5b6 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/constants.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/constants.py
@@ -27,6 +27,9 @@ CONF_REALM_KEY = "realm"
 CONF_SERVER_URL_KEY = "server_url"
 CONF_REQUESTS_POOL_SIZE_KEY = "requests_pool_size"
 CONF_REQUESTS_RETRIES_KEY = "requests_retries"
+# Comma-separated allow-list of federated Keycloak client ids (azp claim) 
permitted
+# to use the jwt-bearer grant. Empty/unset denies all callers.
+CONF_JWT_FEDERATED_CLIENT_IDS_KEY = "jwt_federated_client_ids"
 
 # Extra Cookie names
 COOKIE_NAME_ACCESS_TOKEN = "_access_token"
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 6d60e7e4020..f61fdd3615c 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
@@ -24,6 +24,7 @@ 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_jwt_federated_token,
     create_token_for,
 )
 
@@ -62,8 +63,27 @@ class TokenClientCredentialsBody(StrictBaseModel):
         )
 
 
+class TokenJwtFederatedBody(StrictBaseModel):
+    """
+    JWT-bearer grant token serializer for post bodies.
+
+    Accepts a Keycloak access token obtained via any Keycloak-native 
authentication
+    method (e.g. a client federated to an external OIDC identity provider) 
instead of
+    Airflow re-authenticating to Keycloak itself.
+    """
+
+    grant_type: Literal["urn:ietf:params:oauth:grant-type:jwt-bearer"]
+    assertion: str = Field()
+
+    def create_token(self, expiration_time_in_seconds: int) -> str:
+        """Create token by validating a pre-obtained Keycloak assertion."""
+        return create_jwt_federated_token(
+            self.assertion, 
expiration_time_in_seconds=expiration_time_in_seconds
+        )
+
+
 TokenUnion = Annotated[
-    TokenPasswordBody | TokenClientCredentialsBody,
+    TokenPasswordBody | TokenClientCredentialsBody | TokenJwtFederatedBody,
     Field(discriminator="grant_type"),
 ]
 
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 0c3d4fa8c49..2158cc3a65b 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
@@ -164,6 +164,7 @@ components:
       oneOf:
       - $ref: '#/components/schemas/TokenPasswordBody'
       - $ref: '#/components/schemas/TokenClientCredentialsBody'
+      - $ref: '#/components/schemas/TokenJwtFederatedBody'
       title: TokenBody
       description: Token request body.
       discriminator:
@@ -171,6 +172,7 @@ components:
         mapping:
           client_credentials: '#/components/schemas/TokenClientCredentialsBody'
           password: '#/components/schemas/TokenPasswordBody'
+          urn:ietf:params:oauth:grant-type:jwt-bearer: 
'#/components/schemas/TokenJwtFederatedBody'
     TokenClientCredentialsBody:
       properties:
         grant_type:
@@ -191,6 +193,30 @@ components:
       - client_secret
       title: TokenClientCredentialsBody
       description: Client credentials grant token serializer for post bodies.
+    TokenJwtFederatedBody:
+      properties:
+        grant_type:
+          type: string
+          const: urn:ietf:params:oauth:grant-type:jwt-bearer
+          title: Grant Type
+        assertion:
+          type: string
+          title: Assertion
+      additionalProperties: false
+      type: object
+      required:
+      - grant_type
+      - assertion
+      title: TokenJwtFederatedBody
+      description: 'JWT-bearer grant token serializer for post bodies.
+
+
+        Accepts a Keycloak access token obtained via any Keycloak-native 
authentication
+
+        method (e.g. a client federated to an external OIDC identity provider) 
instead
+        of
+
+        Airflow re-authenticating to Keycloak itself.'
     TokenPasswordBody:
       properties:
         grant_type:
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 94da35310f7..6a0566bfce0 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
@@ -18,20 +18,32 @@
 from __future__ import annotations
 
 import json
+from functools import cache
 
+import jwt
 from fastapi import HTTPException, status
-from keycloak import KeycloakAuthenticationError
+from jwt import PyJWKClient
+from keycloak import KeycloakAuthenticationError, KeycloakError
 
 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_JWT_FEDERATED_CLIENT_IDS_KEY,
+    CONF_REALM_KEY,
     CONF_SECTION_NAME,
+    CONF_SERVER_URL_KEY,
 )
 from airflow.providers.keycloak.auth_manager.keycloak_auth_manager import 
KeycloakAuthManager
 from airflow.providers.keycloak.auth_manager.user import 
KeycloakAuthManagerUser
 
 
+@cache
+def _get_jwks_client(issuer: str) -> PyJWKClient:
+    """Return the cached JWK client for a Keycloak realm issuer."""
+    return PyJWKClient(f"{issuer}/protocol/openid-connect/certs")
+
+
 def create_token_for(
     username: str,
     password: str,
@@ -61,6 +73,85 @@ def create_token_for(
     return get_auth_manager().generate_api_jwt(user, 
expiration_time_in_seconds=expiration_time_in_seconds)
 
 
+def create_jwt_federated_token(
+    assertion: str,
+    expiration_time_in_seconds: int = conf.getint("api_auth", 
"jwt_expiration_time"),
+) -> str:
+    """
+    Create a token from a Keycloak access token obtained outside of Airflow.
+
+    This authentication flow accepts an access token issued by Keycloak 
through any
+    Keycloak-native mechanism (e.g. a "Signed JWT - Federated" client bound to 
an
+    external OIDC identity provider such as a Kubernetes ServiceAccount 
issuer, or AWS
+    IAM outbound identity federation). Airflow does not obtain the token on 
the caller's
+    behalf, the caller must obtain it directly from Keycloak's token endpoint. 
Airflow
+    verifies the token and calls Keycloak's UserInfo endpoint to validate it 
and retrieve
+    user information.
+
+    The token's signature, issuer, and audience are verified against this 
realm's JWKS.
+    The ``aud`` claim (a string or a list) must include this Airflow client's 
id, which
+    requires an Audience mapper on the federated client's scope in Keycloak. 
The calling
+    client (``azp``) must also appear in the ``jwt_federated_client_ids`` 
allow-list
+    below -- an ``aud`` match alone only proves the token was meant for 
Airflow, not
+    that the issuing client has been vetted for machine auth. Any Keycloak 
validation
+    error is returned as a generic ``403 Invalid Keycloak assertion`` response.
+    """
+    realm = conf.get(CONF_SECTION_NAME, CONF_REALM_KEY)
+    server_url = conf.get(CONF_SECTION_NAME, CONF_SERVER_URL_KEY)
+    client_id = conf.get(CONF_SECTION_NAME, CONF_CLIENT_ID_KEY)
+    issuer = f"{server_url.rstrip('/')}/realms/{realm}"
+
+    try:
+        jwks_client = _get_jwks_client(issuer)
+        signing_key = jwks_client.get_signing_key_from_jwt(assertion)
+        claims = jwt.decode(
+            assertion,
+            signing_key.key,
+            algorithms=["RS256"],
+            audience=client_id,
+            issuer=issuer,
+        )
+    except jwt.PyJWTError:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Invalid Keycloak assertion",
+        )
+
+    allowed_client_ids = {
+        allowed.strip()
+        for allowed in conf.get(CONF_SECTION_NAME, 
CONF_JWT_FEDERATED_CLIENT_IDS_KEY, fallback="").split(",")
+        if allowed.strip()
+    }
+    federated_client_id = claims.get("azp") or claims.get("client_id")
+    if not federated_client_id or federated_client_id not in 
allowed_client_ids:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Invalid Keycloak assertion",
+        )
+
+    # Confirm the assertion is still live (not revoked) and fetch the same 
shape of
+    # user info create_client_credentials_token uses, rather than trusting the 
JWT's
+    # own claims alone.
+    client = KeycloakAuthManager.get_keycloak_client()
+    try:
+        userinfo_raw: dict | bytes = client.userinfo(assertion)
+    except KeycloakError:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Invalid Keycloak assertion",
+        )
+    userinfo: dict = json.loads(userinfo_raw) if isinstance(userinfo_raw, 
bytes) else userinfo_raw
+
+    user = KeycloakAuthManagerUser(
+        user_id=userinfo["sub"],
+        name=userinfo.get("preferred_username", userinfo.get("clientId", 
"service-account")),
+        access_token=assertion,
+        refresh_token=None,
+    )
+
+    return get_auth_manager().generate_api_jwt(user, 
expiration_time_in_seconds=expiration_time_in_seconds)
+
+
 def create_client_credentials_token(
     client_id: str,
     client_secret: str,
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/get_provider_info.py 
b/providers/keycloak/src/airflow/providers/keycloak/get_provider_info.py
index d186cf2b868..f4b0ae3a890 100644
--- a/providers/keycloak/src/airflow/providers/keycloak/get_provider_info.py
+++ b/providers/keycloak/src/airflow/providers/keycloak/get_provider_info.py
@@ -71,6 +71,13 @@ def get_provider_info():
                         "example": None,
                         "default": None,
                     },
+                    "jwt_federated_client_ids": {
+                        "description": "Comma-separated allow-list of Keycloak 
client ids (``azp`` claim) permitted to\nexchange a Keycloak-issued access 
token for an Airflow token via 
the\n``urn:ietf:params:oauth:grant-type:jwt-bearer`` grant at ``/auth/token``. 
Unset\nor empty denies every caller.\n",
+                        "type": "string",
+                        "version_added": "0.10.0",
+                        "example": "team-platform-admin-sa,other-team-sa",
+                        "default": None,
+                    },
                     "realm": {
                         "description": "Realm configured in Keycloak 
associated to Airflow.\nThis realm define all users, roles and groups used in 
Airflow.\n",
                         "type": "string",
diff --git 
a/providers/keycloak/tests/unit/keycloak/auth_manager/datamodels/test_token.py 
b/providers/keycloak/tests/unit/keycloak/auth_manager/datamodels/test_token.py
index 378a5d7da55..50c82c4a5bd 100644
--- 
a/providers/keycloak/tests/unit/keycloak/auth_manager/datamodels/test_token.py
+++ 
b/providers/keycloak/tests/unit/keycloak/auth_manager/datamodels/test_token.py
@@ -24,6 +24,7 @@ from pydantic import ValidationError
 from airflow.providers.keycloak.auth_manager.datamodels.token import (
     TokenBody,
     TokenClientCredentialsBody,
+    TokenJwtFederatedBody,
     TokenPasswordBody,
     TokenResponse,
 )
@@ -68,6 +69,17 @@ class TestTokenBody:
                     "client_secret": "client_secret",
                 },
             ),
+            (
+                {
+                    "grant_type": 
"urn:ietf:params:oauth:grant-type:jwt-bearer",
+                    "assertion": "assertion",
+                },
+                TokenJwtFederatedBody,
+                {
+                    "grant_type": 
"urn:ietf:params:oauth:grant-type:jwt-bearer",
+                    "assertion": "assertion",
+                },
+            ),
         ],
     )
     def test_model_validate_and_dump(self, payload, expected_type, 
expected_dump):
@@ -84,6 +96,8 @@ class TestTokenBody:
             {"grant_type": "unsupported", "username": "username", "password": 
"password"},
             {"username": "username", "password": "password", "extra": "value"},
             {"grant_type": "client_credentials", "client_secret": 
"client_secret"},
+            {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer"},
+            {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", 
"assertion": "a", "extra": "x"},
         ],
     )
     def test_rejects_invalid_payload(self, payload):
@@ -118,3 +132,19 @@ class TestTokenClientCredentialsBody:
         mock_create_client_credentials_token.assert_called_once_with(
             "client_id", "client_secret", expiration_time_in_seconds=60
         )
+
+
+class TestTokenJwtFederatedBody:
+    @mock.patch(
+        
"airflow.providers.keycloak.auth_manager.datamodels.token.create_jwt_federated_token",
+        autospec=True,
+    )
+    def test_create_token(self, mock_create_jwt_federated_token):
+        mock_create_jwt_federated_token.return_value = "token"
+        body = TokenJwtFederatedBody(
+            grant_type="urn:ietf:params:oauth:grant-type:jwt-bearer",
+            assertion="assertion",
+        )
+
+        assert body.create_token(expiration_time_in_seconds=60) == "token"
+        mock_create_jwt_federated_token.assert_called_once_with("assertion", 
expiration_time_in_seconds=60)
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 064288152ff..d7903ca4879 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
@@ -92,6 +92,28 @@ class TestTokenRouter:
             "client_id", "client_secret", expiration_time_in_seconds=10
         )
 
+    @conf_vars(
+        {
+            ("api_auth", "jwt_expiration_time"): "10",
+        }
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.datamodels.token.create_jwt_federated_token")
+    def test_create_token_jwt_bearer_grant(self, 
mock_create_jwt_federated_token, client):
+        mock_create_jwt_federated_token.return_value = self.token
+        response = client.post(
+            AUTH_MANAGER_FASTAPI_APP_PREFIX + "/token",
+            json={
+                "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
+                "assertion": "a-keycloak-access-token",
+            },
+        )
+
+        assert response.status_code == 201
+        assert response.json() == {"access_token": self.token}
+        mock_create_jwt_federated_token.assert_called_once_with(
+            "a-keycloak-access-token", expiration_time_in_seconds=10
+        )
+
     @pytest.mark.parametrize(
         "body",
         [
@@ -102,6 +124,8 @@ class TestTokenRouter:
             {"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"},
+            {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer"},
+            {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", 
"username": "username"},
         ],
     )
     @conf_vars(
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 b02bcc1c95a..1a5b8aa5a8a 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
@@ -19,12 +19,15 @@ from __future__ import annotations
 from unittest.mock import Mock, patch
 
 import fastapi
+import jwt
 import pytest
 from keycloak import KeycloakAuthenticationError
+from keycloak.exceptions import KeycloakGetError
 
 from airflow.providers.common.compat.sdk import conf
 from airflow.providers.keycloak.auth_manager.services.token import (
     create_client_credentials_token,
+    create_jwt_federated_token,
     create_token_for,
 )
 
@@ -179,3 +182,173 @@ class TestTokenService:
 
         assert wrong_secret.value.status_code == wrong_client.value.status_code
         assert wrong_secret.value.detail == wrong_client.value.detail
+
+
+class TestCreateJwtFederatedToken:
+    token = "token"
+    test_assertion = "assertion"
+    test_access_token = "assertion"
+
+    @conf_vars(
+        {
+            ("api_auth", "jwt_expiration_time"): "10",
+            ("keycloak_auth_manager", "client_id"): "airflow",
+            ("keycloak_auth_manager", "realm"): "airflow",
+            ("keycloak_auth_manager", "server_url"): 
"https://keycloak.example.com";,
+            ("keycloak_auth_manager", "jwt_federated_client_ids"): 
"team-platform-admin-sa, other-sa",
+        }
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.services.token.get_auth_manager")
+    
@patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client")
+    @patch("airflow.providers.keycloak.auth_manager.services.token.jwt.decode")
+    
@patch("airflow.providers.keycloak.auth_manager.services.token._get_jwks_client")
+    def test_create_jwt_federated_token(
+        self, mock_jwks_client_cls, mock_jwt_decode, mock_get_keycloak_client, 
mock_get_auth_manager
+    ):
+        
mock_jwks_client_cls.return_value.get_signing_key_from_jwt.return_value = 
Mock(key="fake-key")
+        mock_jwt_decode.return_value = {
+            "sub": "service-account-sub",
+            "azp": "team-platform-admin-sa",
+            "aud": ["airflow"],
+        }
+        mock_keycloak_client = Mock()
+        mock_keycloak_client.userinfo.return_value = {
+            "sub": "service-account-sub",
+            "preferred_username": "service-account-team-platform-admin-sa",
+        }
+        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_api_jwt.return_value = self.token
+
+        result = create_jwt_federated_token(assertion=self.test_assertion)
+
+        assert result == self.token
+        mock_jwt_decode.assert_called_once_with(
+            self.test_assertion,
+            "fake-key",
+            algorithms=["RS256"],
+            audience="airflow",
+            issuer="https://keycloak.example.com/realms/airflow";,
+        )
+        
mock_keycloak_client.userinfo.assert_called_once_with(self.test_assertion)
+        mock_auth_manager.generate_api_jwt.assert_called_once()
+
+    @conf_vars(
+        {
+            ("api_auth", "jwt_expiration_time"): "10",
+            ("keycloak_auth_manager", "client_id"): "airflow",
+            ("keycloak_auth_manager", "realm"): "airflow",
+            ("keycloak_auth_manager", "server_url"): 
"https://keycloak.example.com";,
+            ("keycloak_auth_manager", "jwt_federated_client_ids"): 
"team-platform-admin-sa",
+        }
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client")
+    @patch("airflow.providers.keycloak.auth_manager.services.token.jwt.decode")
+    
@patch("airflow.providers.keycloak.auth_manager.services.token._get_jwks_client")
+    def test_create_jwt_federated_token_invalid_signature(
+        self, mock_jwks_client_cls, mock_jwt_decode, mock_get_keycloak_client
+    ):
+        
mock_jwks_client_cls.return_value.get_signing_key_from_jwt.return_value = 
Mock(key="fake-key")
+        mock_jwt_decode.side_effect = jwt.InvalidSignatureError()
+
+        with pytest.raises(fastapi.exceptions.HTTPException) as exc_info:
+            create_jwt_federated_token(assertion=self.test_assertion)
+
+        assert exc_info.value.status_code == 403
+
+    @conf_vars(
+        {
+            ("api_auth", "jwt_expiration_time"): "10",
+            ("keycloak_auth_manager", "client_id"): "airflow",
+            ("keycloak_auth_manager", "realm"): "airflow",
+            ("keycloak_auth_manager", "server_url"): 
"https://keycloak.example.com";,
+            ("keycloak_auth_manager", "jwt_federated_client_ids"): "other-sa",
+        }
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client")
+    @patch("airflow.providers.keycloak.auth_manager.services.token.jwt.decode")
+    
@patch("airflow.providers.keycloak.auth_manager.services.token._get_jwks_client")
+    def test_create_jwt_federated_token_client_not_allowlisted(
+        self, mock_jwks_client_cls, mock_jwt_decode, mock_get_keycloak_client
+    ):
+        """A validly-signed token for an un-allow-listed client must still be 
rejected."""
+        
mock_jwks_client_cls.return_value.get_signing_key_from_jwt.return_value = 
Mock(key="fake-key")
+        mock_jwt_decode.return_value = {
+            "sub": "service-account-sub",
+            "azp": "team-platform-admin-sa",
+            "aud": ["airflow"],
+        }
+
+        with pytest.raises(fastapi.exceptions.HTTPException) as exc_info:
+            create_jwt_federated_token(assertion=self.test_assertion)
+
+        assert exc_info.value.status_code == 403
+        # No userinfo call is made once the client is rejected.
+        mock_get_keycloak_client.assert_not_called()
+
+    @conf_vars(
+        {
+            ("api_auth", "jwt_expiration_time"): "10",
+            ("keycloak_auth_manager", "client_id"): "airflow",
+            ("keycloak_auth_manager", "realm"): "airflow",
+            ("keycloak_auth_manager", "server_url"): 
"https://keycloak.example.com";,
+        }
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client")
+    @patch("airflow.providers.keycloak.auth_manager.services.token.jwt.decode")
+    
@patch("airflow.providers.keycloak.auth_manager.services.token._get_jwks_client")
+    def test_create_jwt_federated_token_allowlist_unset_fails_closed(
+        self, mock_jwks_client_cls, mock_jwt_decode, mock_get_keycloak_client
+    ):
+        """An unconfigured jwt_federated_client_ids denies every caller, not 
just none."""
+        
mock_jwks_client_cls.return_value.get_signing_key_from_jwt.return_value = 
Mock(key="fake-key")
+        mock_jwt_decode.return_value = {
+            "sub": "service-account-sub",
+            "azp": "team-platform-admin-sa",
+            "aud": ["airflow"],
+        }
+
+        with pytest.raises(fastapi.exceptions.HTTPException) as exc_info:
+            create_jwt_federated_token(assertion=self.test_assertion)
+
+        assert exc_info.value.status_code == 403
+        mock_get_keycloak_client.assert_not_called()
+
+    @pytest.mark.parametrize(
+        "userinfo_error",
+        [
+            KeycloakAuthenticationError(),
+            KeycloakGetError(response_code=403, response_body=b""),
+        ],
+    )
+    @conf_vars(
+        {
+            ("api_auth", "jwt_expiration_time"): "10",
+            ("keycloak_auth_manager", "client_id"): "airflow",
+            ("keycloak_auth_manager", "realm"): "airflow",
+            ("keycloak_auth_manager", "server_url"): 
"https://keycloak.example.com";,
+            ("keycloak_auth_manager", "jwt_federated_client_ids"): 
"team-platform-admin-sa",
+        }
+    )
+    
@patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client")
+    @patch("airflow.providers.keycloak.auth_manager.services.token.jwt.decode")
+    
@patch("airflow.providers.keycloak.auth_manager.services.token._get_jwks_client")
+    def test_create_jwt_federated_token_userinfo_rejected(
+        self, mock_jwks_client_cls, mock_jwt_decode, mock_get_keycloak_client, 
userinfo_error
+    ):
+        """A Keycloak userinfo rejection returns a generic invalid-assertion 
response."""
+        
mock_jwks_client_cls.return_value.get_signing_key_from_jwt.return_value = 
Mock(key="fake-key")
+        mock_jwt_decode.return_value = {
+            "sub": "service-account-sub",
+            "azp": "team-platform-admin-sa",
+            "aud": ["airflow"],
+        }
+        mock_keycloak_client = Mock()
+        mock_keycloak_client.userinfo.side_effect = userinfo_error
+        mock_get_keycloak_client.return_value = mock_keycloak_client
+
+        with pytest.raises(fastapi.exceptions.HTTPException) as exc_info:
+            create_jwt_federated_token(assertion=self.test_assertion)
+
+        assert exc_info.value.status_code == 403

Reply via email to