Skip to content

Commit 1f51acb

Browse files
authored
Bind Keycloak cookie tokens to the Airflow session identity (#72207)
* Bind Keycloak cookie tokens to the Airflow session identity For Airflow 3.3+ the Keycloak access and refresh tokens are no longer carried in the signed Airflow JWT; they travel in separate _access_token and _refresh_token cookies. get_user_from_token validated the Airflow JWT and then attached whatever those cookies contained, without checking that they described the same subject. A caller could therefore pair their own Airflow session with another subject's Keycloak token. Every authorization decision goes to Keycloak carrying that token, so the effective privileges were the token's, while get_id() and get_name() - used for the session identity, audit records and logging - stayed those of the Airflow JWT. The access token's sub is now compared against the user id the signed JWT established before the token is attached. Both are the Keycloak subject: every place a KeycloakAuthManagerUser is constructed sets user_id from userinfo[sub], in the interactive login, the password grant and the client_credentials grant alike. A token whose payload cannot be read yields no subject and so matches nothing. The subject is read without signature verification, which is sufficient here: the value is only ever compared against an identity the signed Airflow JWT has already established, a forged token is refused by Keycloak when presented, and a genuine token belonging to somebody else is what the comparison exists to catch. The two existing tests passed the literal string "access_token" as a cookie value; they now build a JWT-shaped token naming the same subject. Adds coverage for a token naming another subject and for one that cannot be parsed. * Refuse malformed Keycloak access-token cookies with 403, not 500 A Keycloak access-token cookie whose payload decodes to valid JSON that is not an object reached the subject lookup as a non-mapping, so reading the claim raised an error the middleware does not translate. The cookie is attacker-supplied, so any shape it can take has to end in the same refusal as a token naming the wrong subject. * Drop the Keycloak changelog note about token-to-session binding Every place a session is established sets the Airflow user id from the Keycloak subject, so the two can only disagree in a request whose cookies were assembled by hand. No deployment reaches the new refusal by ordinary use, which leaves the note describing a change nobody observes.
1 parent f8957f6 commit 1f51acb

2 files changed

Lines changed: 94 additions & 4 deletions

File tree

providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
import requests
3131
from fastapi import FastAPI
32+
from jwt import InvalidTokenError
3233
from keycloak import KeycloakOpenID
3334
from keycloak.exceptions import KeycloakPostError
3435
from requests.adapters import HTTPAdapter
@@ -174,6 +175,14 @@ async def get_user_from_token(
174175
if not AIRFLOW_V_3_3_PLUS:
175176
return user
176177
if access_token:
178+
# The Airflow JWT is signed and establishes who the caller is. The Keycloak
179+
# tokens arrive in separate cookies that the signature does not cover, so
180+
# pairing them unchecked would let a caller combine their own Airflow session
181+
# with somebody else's Keycloak token: every authorization decision is then
182+
# made for that subject, while the session identity, audit trail and logs
183+
# continue to show this one.
184+
if self._token_subject(access_token) != user.get_id():
185+
raise InvalidTokenError("Keycloak access token does not belong to this Airflow session")
177186
user.access_token = access_token
178187
user.refresh_token = refresh_token
179188
return user
@@ -818,6 +827,29 @@ def _get_headers(access_token):
818827
"Content-Type": "application/x-www-form-urlencoded",
819828
}
820829

830+
@staticmethod
831+
def _token_subject(token: str) -> str | None:
832+
"""
833+
Return the ``sub`` claim of a JWT without verifying its signature.
834+
835+
:meta private:
836+
837+
The value is only ever compared against an identity the signed Airflow JWT has
838+
already established, so it is never trusted on its own. A forged token is
839+
rejected by Keycloak when it is presented; a genuine token belonging to somebody
840+
else is exactly what this comparison exists to catch. A token that cannot be
841+
parsed yields ``None``, which matches no user id.
842+
843+
:param token: the token
844+
"""
845+
try:
846+
payload_b64 = token.split(".")[1] + "=="
847+
payload = json.loads(urlsafe_b64decode(payload_b64))
848+
subject = payload["sub"]
849+
except (IndexError, KeyError, TypeError, ValueError):
850+
return None
851+
return str(subject) if subject is not None else None
852+
821853
@staticmethod
822854
def _token_expired(token: str) -> bool:
823855
"""

providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from unittest.mock import AsyncMock, Mock, patch
2424

2525
import pytest
26+
from jwt import InvalidTokenError
2627
from keycloak import KeycloakPostError
2728

2829
from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
@@ -132,6 +133,21 @@ def _clear_filter_cache():
132133
cache_module._pending_requests.clear()
133134

134135

136+
def token_with_payload(payload: str) -> str:
137+
"""Build a JWT-shaped token whose payload segment is the given raw text.
138+
139+
Only the payload segment is read when the token is bound to the Airflow session
140+
identity, so the header and signature are placeholders.
141+
"""
142+
encoded = base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
143+
return f"header.{encoded}.signature"
144+
145+
146+
def keycloak_token(subject: str) -> str:
147+
"""Build a JWT-shaped Keycloak token carrying ``sub``."""
148+
return token_with_payload(json.dumps({"sub": subject}))
149+
150+
135151
class TestKeycloakAuthManager:
136152
@pytest.mark.parametrize(
137153
("token_data", "exp"),
@@ -207,11 +223,12 @@ async def test_get_user_from_token_with_keycloak_tokens(self, auth_manager):
207223
mock_get_user_from_token,
208224
),
209225
):
210-
user = await auth_manager.get_user_from_token("token", "access_token", "refresh_token")
226+
access_token = keycloak_token("user_id")
227+
user = await auth_manager.get_user_from_token("token", access_token, "refresh_token")
211228
mock_get_user_from_token.assert_called_with("token")
212229
assert user.get_id() == "user_id"
213230
assert user.get_name() == "name"
214-
assert user.access_token == "access_token"
231+
assert user.access_token == access_token
215232
assert user.refresh_token == "refresh_token"
216233

217234
@pytest.mark.skipif(AIRFLOW_V_3_3_PLUS, reason="Testing Old Keycloak JWT flow.")
@@ -270,13 +287,54 @@ async def test_get_user_from_token_keycloak_jwt(self, auth_manager):
270287
mock_get_user_from_token,
271288
),
272289
):
273-
user = await auth_manager.get_user_from_token("token", "access_token", "refresh_token")
290+
access_token = keycloak_token("user_id")
291+
user = await auth_manager.get_user_from_token("token", access_token, "refresh_token")
274292
mock_get_user_from_token.assert_called_with("token")
275293
assert user.get_id() == "user_id"
276294
assert user.get_name() == "name"
277-
assert user.access_token == "access_token"
295+
assert user.access_token == access_token
278296
assert user.refresh_token == "refresh_token"
279297

298+
@pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="Uses KeycloakJWTMiddleware and separate cookies")
299+
@pytest.mark.asyncio
300+
async def test_get_user_from_token_rejects_another_subjects_token(self, auth_manager):
301+
"""A Keycloak token naming a different subject must not attach to this session."""
302+
mock_get_user_from_token = AsyncMock(
303+
return_value=KeycloakAuthManagerUser(
304+
user_id="user_id", name="name", access_token="", refresh_token=None
305+
)
306+
)
307+
with (
308+
patch.object(BaseAuthManager, "get_user_from_token", mock_get_user_from_token),
309+
pytest.raises(InvalidTokenError, match="does not belong to this Airflow session"),
310+
):
311+
await auth_manager.get_user_from_token("token", keycloak_token("someone_else"), "refresh_token")
312+
313+
@pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="Uses KeycloakJWTMiddleware and separate cookies")
314+
@pytest.mark.asyncio
315+
@pytest.mark.parametrize(
316+
"access_token",
317+
[
318+
pytest.param("not-a-jwt", id="no-payload-segment"),
319+
pytest.param(token_with_payload("not json"), id="payload-not-json"),
320+
pytest.param(token_with_payload("1"), id="payload-not-an-object"),
321+
pytest.param(token_with_payload('{"other": "user_id"}'), id="payload-without-sub"),
322+
pytest.param(token_with_payload('{"sub": null}'), id="payload-with-null-sub"),
323+
],
324+
)
325+
async def test_get_user_from_token_rejects_unparsable_token(self, auth_manager, access_token):
326+
"""A token whose subject cannot be read matches no user and is refused."""
327+
mock_get_user_from_token = AsyncMock(
328+
return_value=KeycloakAuthManagerUser(
329+
user_id="user_id", name="name", access_token="", refresh_token=None
330+
)
331+
)
332+
with (
333+
patch.object(BaseAuthManager, "get_user_from_token", mock_get_user_from_token),
334+
pytest.raises(InvalidTokenError, match="does not belong to this Airflow session"),
335+
):
336+
await auth_manager.get_user_from_token("token", access_token, "refresh_token")
337+
280338
def test_get_url_login(self, auth_manager):
281339
result = auth_manager.get_url_login()
282340
assert result == f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login"

0 commit comments

Comments
 (0)