Skip to content

Bind Keycloak cookie tokens to the Airflow session identity - #72207

Merged
potiuk merged 3 commits into
apache:mainfrom
potiuk:fix-keycloak-bind-token-identity
Aug 29, 2026
Merged

potiuk merged 3 commits into
apache:mainfrom
potiuk:fix-keycloak-bind-token-identity

Conversation

@potiuk

@potiuk potiuk commented Aug 28, 2026

Copy link
Copy Markdown
Member

For Airflow 3.3+ the Keycloak access and refresh tokens are no longer carried in the signed Airflow JWT — serialize_user deliberately omits them, because Keycloak tokens can exceed the 4k cookie limit — and they travel in separate _access_token / _refresh_token cookies instead.

get_user_from_token validated the Airflow JWT and then attached whatever those cookies contained:

user = cast("KeycloakAuthManagerUser", await super().get_user_from_token(token))
...
if access_token:
    user.access_token = access_token
    user.refresh_token = refresh_token
    return user

Nothing checked that the cookies described the same subject as the JWT. A caller could pair their own Airflow session with another subject's Keycloak token: every authorization decision is sent to Keycloak carrying user.access_token, so the effective privileges are that token's, while get_id() and get_name() — used for the session identity, audit records and logging — remain those of the Airflow JWT.

The fix

The access token's sub is 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"] — the interactive login (routes/login.py), the password grant and the client-credentials grant (services/token.py) — so the comparison is direct rather than a mapping.

A token whose payload cannot be read yields no subject and therefore matches nothing.

On reading the subject unverified

_token_subject decodes the payload without verifying the signature, mirroring the existing _token_expired helper alongside it. That is sufficient for this purpose, and deliberately so:

  • the value is only ever compared against an identity the signed Airflow JWT has already established, so it is never trusted on its own;
  • a forged token is rejected by Keycloak when it is presented for an authorization decision;
  • a genuine token belonging to somebody else is precisely what this comparison exists to catch, and its sub is authentic.

Verifying the Keycloak signature here would add a round trip or key handling on every request without changing which tokens are accepted.

Tests

The two existing tests passed the literal string "access_token" as a cookie value, which carries no subject at all; they now build a JWT-shaped token naming the same subject as the session. Added coverage for a token naming a different subject and for one that cannot be parsed. Reverting the source change fails both new tests.

Local: 290 passed, 1 skipped across the Keycloak provider; ruff and mypy clean.

The provider changelog gains a note at the top, since a session whose cookies disagree will now be refused rather than served.

🤖 Generated with Claude Code

potiuk added 2 commits August 28, 2026 19:35
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.
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.
@potiuk
potiuk force-pushed the fix-keycloak-bind-token-identity branch from 1d585f0 to 316f7b7 Compare August 28, 2026 17:35
Comment thread providers/keycloak/docs/changelog.rst Outdated
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.
@potiuk
potiuk merged commit 1f51acb into apache:main Aug 29, 2026
79 checks passed
@potiuk
potiuk deleted the fix-keycloak-bind-token-identity branch August 29, 2026 16:08
imrichardwu pushed a commit to imrichardwu/airflow that referenced this pull request Sep 11, 2026
…2207)

* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants