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 a37c358ade2 Disable separate Keycloak JWTs for Airflow v3.2- (#70981)
a37c358ade2 is described below
commit a37c358ade2e6399e2470997b94b2cb341f43ca4
Author: stephen-bracken <[email protected]>
AuthorDate: Mon Aug 10 14:53:30 2026 +0100
Disable separate Keycloak JWTs for Airflow v3.2- (#70981)
---
providers/keycloak/docs/changelog.rst | 12 +--
.../keycloak/auth_manager/keycloak_auth_manager.py | 16 +++-
.../keycloak/auth_manager/routes/login.py | 44 +++++-----
.../airflow/providers/keycloak/version_compat.py | 1 +
.../keycloak/auth_manager/routes/test_login.py | 6 +-
.../auth_manager/test_keycloak_auth_manager.py | 98 +++++++++++++++++-----
6 files changed, 130 insertions(+), 47 deletions(-)
diff --git a/providers/keycloak/docs/changelog.rst
b/providers/keycloak/docs/changelog.rst
index 7f1ff247328..64f75516305 100644
--- a/providers/keycloak/docs/changelog.rst
+++ b/providers/keycloak/docs/changelog.rst
@@ -39,12 +39,12 @@ Changelog
.....
.. note::
- Keycloak's access and refresh tokens are now stored in dedicated
``_access_token`` and
- ``_refresh_token`` cookies instead of being carried inside the Airflow JWT
claims. Sessions
- established before this release carry the tokens in the old form, so the
first request after
- the upgrade cannot be refreshed and the session is cleared -- **every
logged-in user is signed
- out once when you upgrade**. No action is required beyond logging back in;
this is a one-time
- effect of the move and does not recur.
+ For Airflow v3.3.0+ Keycloak's access and refresh tokens are now stored in
dedicated
+ ``_access_token`` and ``_refresh_token`` cookies instead of being carried
inside the
+ Airflow JWT claims. Sessions established before this release carry the
tokens in the old form,
+ so the first request after the upgrade cannot be refreshed and the session
is cleared --
+ **every logged-in user is signed out once when you upgrade**. No action is
required
+ beyond logging back in; this is a one-time effect of the move and does not
recur.
Features
~~~~~~~~
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 1bb0c9bda1b..ce0fc933807 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
@@ -64,6 +64,7 @@ from airflow.providers.keycloak.auth_manager.constants import
(
)
from airflow.providers.keycloak.auth_manager.resources import KeycloakResource
from airflow.providers.keycloak.auth_manager.user import
KeycloakAuthManagerUser
+from airflow.providers.keycloak.version_compat import AIRFLOW_V_3_3_PLUS
from airflow.utils.helpers import prune_dict
if TYPE_CHECKING:
@@ -139,13 +140,24 @@ class
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
def deserialize_user(self, token: dict[str, Any]) ->
KeycloakAuthManagerUser:
return KeycloakAuthManagerUser(
- user_id=token["user_id"], name=token["name"], access_token="",
refresh_token=None
+ user_id=token["user_id"],
+ name=token["name"],
+ access_token=token.get("access_token", ""),
+ refresh_token=token.get("refresh_token"),
)
def serialize_user(self, user: KeycloakAuthManagerUser) -> dict[str, Any]:
+ if AIRFLOW_V_3_3_PLUS:
+ # Omit Keycloak JWTs from claims, they are stored in separate
cookies
+ return {
+ "user_id": user.get_id(),
+ "name": user.get_name(),
+ }
return {
"user_id": user.get_id(),
"name": user.get_name(),
+ "access_token": user.access_token,
+ "refresh_token": user.refresh_token,
}
async def get_user_from_token(
@@ -159,6 +171,8 @@ class
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
:param refresh_token: Keycloak refresh JWT
"""
user = cast("KeycloakAuthManagerUser", await
super().get_user_from_token(token))
+ if not AIRFLOW_V_3_3_PLUS:
+ return user
if access_token:
user.access_token = access_token
user.refresh_token = refresh_token
diff --git
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py
index 804733757c4..0728518ab3c 100644
---
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py
+++
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py
@@ -28,7 +28,11 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX,
get_auth_manager
from airflow.api_fastapi.auth.managers.base_auth_manager import
COOKIE_NAME_JWT_TOKEN
-from airflow.providers.keycloak.version_compat import AIRFLOW_V_3_1_1_PLUS,
AIRFLOW_V_3_1_8_PLUS
+from airflow.providers.keycloak.version_compat import (
+ AIRFLOW_V_3_1_1_PLUS,
+ AIRFLOW_V_3_1_8_PLUS,
+ AIRFLOW_V_3_3_PLUS,
+)
if AIRFLOW_V_3_1_8_PLUS:
from airflow.api_fastapi.app import get_cookie_path
@@ -139,13 +143,14 @@ def login_callback(request: Request):
COOKIE_NAME_ID_TOKEN, tokens["id_token"], path=cookie_path,
secure=secure, httponly=True
)
- response.set_cookie(
- COOKIE_NAME_ACCESS_TOKEN, tokens["access_token"], path=cookie_path,
secure=secure, httponly=True
- )
+ if AIRFLOW_V_3_3_PLUS:
+ response.set_cookie(
+ COOKIE_NAME_ACCESS_TOKEN, tokens["access_token"],
path=cookie_path, secure=secure, httponly=True
+ )
- response.set_cookie(
- COOKIE_NAME_REFRESH_TOKEN, tokens["refresh_token"], path=cookie_path,
secure=secure, httponly=True
- )
+ response.set_cookie(
+ COOKIE_NAME_REFRESH_TOKEN, tokens["refresh_token"],
path=cookie_path, secure=secure, httponly=True
+ )
return response
@@ -197,16 +202,17 @@ def logout_callback(request: Request):
secure=secure,
httponly=True,
)
- response.delete_cookie(
- key=COOKIE_NAME_ACCESS_TOKEN,
- path=cookie_path,
- secure=secure,
- httponly=True,
- )
- response.delete_cookie(
- key=COOKIE_NAME_REFRESH_TOKEN,
- path=cookie_path,
- secure=secure,
- httponly=True,
- )
+ if AIRFLOW_V_3_3_PLUS:
+ response.delete_cookie(
+ key=COOKIE_NAME_ACCESS_TOKEN,
+ path=cookie_path,
+ secure=secure,
+ httponly=True,
+ )
+ response.delete_cookie(
+ key=COOKIE_NAME_REFRESH_TOKEN,
+ path=cookie_path,
+ secure=secure,
+ httponly=True,
+ )
return response
diff --git
a/providers/keycloak/src/airflow/providers/keycloak/version_compat.py
b/providers/keycloak/src/airflow/providers/keycloak/version_compat.py
index e97df79b887..d87042a9edb 100644
--- a/providers/keycloak/src/airflow/providers/keycloak/version_compat.py
+++ b/providers/keycloak/src/airflow/providers/keycloak/version_compat.py
@@ -35,3 +35,4 @@ def get_base_airflow_version_tuple() -> tuple[int, int, int]:
AIRFLOW_V_3_1_1_PLUS = get_base_airflow_version_tuple() >= (3, 1, 1)
AIRFLOW_V_3_1_7_PLUS = get_base_airflow_version_tuple() >= (3, 1, 7)
AIRFLOW_V_3_1_8_PLUS = get_base_airflow_version_tuple() >= (3, 1, 8)
+AIRFLOW_V_3_3_PLUS = get_base_airflow_version_tuple() >= (3, 3, 0)
diff --git
a/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_login.py
b/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_login.py
index 058bda6590d..dcfbb3202ae 100644
--- a/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_login.py
+++ b/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_login.py
@@ -23,6 +23,7 @@ import pytest
from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS
class TestLoginRouter:
@@ -79,8 +80,9 @@ class TestLoginRouter:
assert "_token" in response.cookies
assert response.cookies["_token"] == token
assert response.cookies["_id_token"] == "id_token"
- assert response.cookies["_access_token"] == "access_token"
- assert response.cookies["_refresh_token"] == "refresh_token"
+ if AIRFLOW_V_3_3_PLUS:
+ assert response.cookies["_access_token"] == "access_token"
+ assert response.cookies["_refresh_token"] == "refresh_token"
@patch("airflow.providers.keycloak.auth_manager.routes.login.KeycloakAuthManager.get_keycloak_client")
def test_login_sets_secure_state_cookie_behind_tls_proxy(self,
mock_get_keycloak_client, client):
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 44ab60304bd..d20c72e1aea 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
@@ -40,7 +40,11 @@ from
airflow.api_fastapi.auth.managers.models.resource_details import (
)
from tests_common.test_utils.config import conf_vars
-from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_7_PLUS,
AIRFLOW_V_3_2_PLUS
+from tests_common.test_utils.version_compat import (
+ AIRFLOW_V_3_1_7_PLUS,
+ AIRFLOW_V_3_2_PLUS,
+ AIRFLOW_V_3_3_PLUS,
+)
if AIRFLOW_V_3_1_7_PLUS:
from airflow.api_fastapi.auth.managers.exceptions import
AuthManagerRefreshTokenExpiredException
@@ -130,26 +134,47 @@ def _clear_filter_cache():
class TestKeycloakAuthManager:
@pytest.mark.parametrize(
- "token_data",
+ ("token_data", "exp"),
[
- {
- "user_id": "user_id",
- "name": "name",
- },
- {
- "user_id": "user_id",
- "name": "name",
- "access_token": "access_token",
- "refresh_token": "refresh_token",
- },
+ pytest.param(
+ {
+ "user_id": "user_id",
+ "name": "name",
+ },
+ {
+ "user_id": "user_id",
+ "name": "name",
+ "access_token": "",
+ "refresh_token": None,
+ },
+ marks=pytest.mark.skipif(
+ not AIRFLOW_V_3_3_PLUS, reason="Uses KeycloakJWTMiddleware
and separate cookies"
+ ),
+ id="no-Keycloak-tokens",
+ ),
+ pytest.param(
+ {
+ "user_id": "user_id",
+ "name": "name",
+ "access_token": "access_token",
+ "refresh_token": "refresh_token",
+ },
+ {
+ "user_id": "user_id",
+ "name": "name",
+ "access_token": "access_token",
+ "refresh_token": "refresh_token",
+ },
+ id="with-Keycloak-tokens",
+ ),
],
)
- def test_deserialize_user(self, auth_manager, token_data):
+ def test_deserialize_user(self, auth_manager, token_data, exp):
result = auth_manager.deserialize_user(token_data)
- assert result.user_id == "user_id"
- assert result.name == "name"
- assert result.access_token == ""
- assert result.refresh_token is None
+ assert result.user_id == exp["user_id"]
+ assert result.name == exp["name"]
+ assert result.access_token == exp["access_token"]
+ assert result.refresh_token == exp["refresh_token"]
def test_serialize_user(self, auth_manager):
result = auth_manager.serialize_user(
@@ -157,10 +182,19 @@ class TestKeycloakAuthManager:
user_id="user_id", name="name", access_token="access_token",
refresh_token="refresh_token"
)
)
- assert result == {"user_id": "user_id", "name": "name"}
+ if AIRFLOW_V_3_3_PLUS:
+ assert result == {"user_id": "user_id", "name": "name"}
+ else:
+ assert result == {
+ "user_id": "user_id",
+ "name": "name",
+ "access_token": "access_token",
+ "refresh_token": "refresh_token",
+ }
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="Uses
KeycloakJWTMiddleware and separate cookies")
@pytest.mark.asyncio
- async def test_get_user_from_token(self, auth_manager):
+ async def test_get_user_from_token_with_keycloak_tokens(self,
auth_manager):
mock_get_user_from_token = AsyncMock(
return_value=KeycloakAuthManagerUser(
user_id="user_id", name="name", access_token="",
refresh_token=None
@@ -180,6 +214,31 @@ class TestKeycloakAuthManager:
assert user.access_token == "access_token"
assert user.refresh_token == "refresh_token"
+ @pytest.mark.skipif(AIRFLOW_V_3_3_PLUS, reason="Testing Old Keycloak JWT
flow.")
+ @pytest.mark.asyncio
+ async def test_get_user_from_token(self, auth_manager):
+ mock_token_validator = Mock()
+ mock_get_token_validator = Mock(return_value=mock_token_validator)
+ mock_token_validator.avalidated_claims = AsyncMock(
+ return_value=dict(
+ user_id="user_id", name="name", access_token="access_token",
refresh_token="refresh_token"
+ )
+ )
+ with (
+ patch.object(
+ KeycloakAuthManager,
+ "_get_token_validator",
+ mock_get_token_validator,
+ ),
+ ):
+ user = await auth_manager.get_user_from_token("token")
+ mock_token_validator.avalidated_claims.assert_called_with("token")
+ assert user.get_id() == "user_id"
+ assert user.get_name() == "name"
+ assert user.access_token == "access_token"
+ assert user.refresh_token == "refresh_token"
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="Uses
KeycloakJWTMiddleware and separate cookies")
@pytest.mark.asyncio
async def test_get_user_from_token_keycloak_jwts_missing(self,
auth_manager):
mock_get_user_from_token = AsyncMock(
@@ -196,6 +255,7 @@ class TestKeycloakAuthManager:
):
assert await auth_manager.get_user_from_token("token") is None
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="Uses
KeycloakJWTMiddleware and separate cookies")
@pytest.mark.asyncio
async def test_get_user_from_token_keycloak_jwt(self, auth_manager):
mock_get_user_from_token = AsyncMock(