Repository navigation
Bind Keycloak cookie tokens to the Airflow session identity - #72207
Merged
Merged
Conversation
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
force-pushed
the
fix-keycloak-bind-token-identity
branch
from
August 28, 2026 17:35
1d585f0 to
316f7b7
Compare
vincbeck
reviewed
Aug 28, 2026
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.
vincbeck
approved these changes
Aug 28, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
For Airflow 3.3+ the Keycloak access and refresh tokens are no longer carried in the signed Airflow JWT —
serialize_userdeliberately omits them, because Keycloak tokens can exceed the 4k cookie limit — and they travel in separate_access_token/_refresh_tokencookies instead.get_user_from_tokenvalidated the Airflow JWT and then attached whatever those cookies contained: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, whileget_id()andget_name()— used for the session identity, audit records and logging — remain those of the Airflow JWT.The fix
The access token's
subis compared against the user id the signed JWT established, before the token is attached.Both are the Keycloak subject. Every place a
KeycloakAuthManagerUseris constructed setsuser_idfromuserinfo["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_subjectdecodes the payload without verifying the signature, mirroring the existing_token_expiredhelper alongside it. That is sufficient for this purpose, and deliberately so:subis 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