Keycloak auth manager: keep API tokens usable with the Authorization - #72381
Merged
Merged
Conversation
vincbeck
reviewed
Sep 2, 2026
vincbeck
left a comment
Contributor
There was a problem hiding this comment.
Nice! Thanks for taking a look. I would do few things:
- Expose
generate_api_jwtinbase_auth_managerwith default implementation callinggenerate_jwt - Then the keycloak auth manager overrides
generate_api_jwtthe same you do it today
That way, if other auth managers have the same issue (which is not impossible), we would have the mechanism in place to handle it
Contributor
Author
|
Thanks for the quick review! I was wondering about pushing it up into the base, but it didn't seem like other providers needed similar behavior? But if that's your instinct I'm good with that. It was nice to remove some of the casting behavior. I've pushed a new commit with that fix, and also corrected a wording problem |
…header On Airflow 3.3+, a token from `POST /auth/token` no longer authorized anything. `serialize_user()` omits the Keycloak JWTs from the claims, and the only code that supplies them again, `KeycloakJWTMiddleware`, reads them from cookies. A client authenticating with the `Authorization` header sends no cookies, so `get_user_from_token()` fell through to `return None` and the request failed -- in practice with a 500, because the `None` reaches the authorization layer and raises `AttributeError: 'NoneType' object has no attribute 'get_id'`. The tokens were moved into cookies to keep the browser session cookie under the 4096 byte limit. That constraint does not apply to a token handed to an API client, which is never stored in a cookie, so the two paths can differ: - `generate_api_jwt()` mints tokens that keep the Keycloak JWTs in their claims, and `POST /auth/token` uses it for both the password and client-credentials grants. It builds on `serialize_user()` so an API token cannot silently miss a claim the browser flow gains later. - The browser paths, `routes/login.py` and the middleware, are unchanged and still mint claim-free tokens backed by the cookies. - `get_user_from_token()` falls back to the claims when no cookie-supplied tokens are present. The fallback does not weaken the subject binding added for the cookie flow. That check exists because cookies are not covered by the Airflow JWT signature, so a caller could pair their own session with somebody else's Keycloak token. In the claims both values come from the same signed payload, and both minting sites derive them from one Keycloak response, so a mismatched pair cannot be constructed. A browser token still resolves to `None` without its cookies: it carries no claims to fall back on. Tested alongside the fix so the two paths stay distinct. Fixes apache#72352
Review feedback: put the mechanism on the base class so any auth manager that
keeps part of its state in cookies can opt into a different token shape for
header-authenticated clients.
`BaseAuthManager.generate_api_jwt()` defaults to `generate_jwt()`, so auth
managers whose tokens are already self-contained are unaffected -- Simple, FAB
and AWS all serialize everything they need for authorization into the claims.
Keycloak is the outlier: it replays the user's own Keycloak access token to
Keycloak at authorization time, so that credential has to reach the request
somehow, and cookies do not reach a header-authenticated client. Its method is
now an override rather than a Keycloak-only addition.
This also removes the `cast("KeycloakAuthManager", ...)` the previous revision
needed in `services/token.py`, since the method is now declared on the base
type.
Also fixes the docs spellcheck failure on this branch: "authorizable" is not in
docs/spelling_wordlist.txt, so the docstring now says "authorized".
Verified: providers/keycloak suite 296 passed (2 pre-existing environment
errors, unchanged); airflow-core base auth manager tests 60 passed; mypy clean
across the provider and the modified core file; ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
edsu
force-pushed
the
fix-keycloak-bearer-api-auth
branch
from
September 2, 2026 14:58
b319ac4 to
f845e33
Compare
vincbeck
approved these changes
Sep 2, 2026
|
Awesome work, congrats on your first merged pull request! You are invited to check our Issue Tracker for additional contributions. |
2 tasks
imrichardwu
pushed a commit
to imrichardwu/airflow
that referenced
this pull request
Sep 11, 2026
…pache#72381) On Airflow 3.3+, a token from `POST /auth/token` no longer authorized anything. `serialize_user()` omits the Keycloak JWTs from the claims, and the only code that supplies them again, `KeycloakJWTMiddleware`, reads them from cookies. A client authenticating with the `Authorization` header sends no cookies, so `get_user_from_token()` fell through to `return None` and the request failed -- in practice with a 500, because the `None` reaches the authorization layer and raises `AttributeError: 'NoneType' object has no attribute 'get_id'`. The tokens were moved into cookies to keep the browser session cookie under the 4096 byte limit. That constraint does not apply to a token handed to an API client, which is never stored in a cookie, so the two paths can differ: - `generate_api_jwt()` mints tokens that keep the Keycloak JWTs in their claims, and `POST /auth/token` uses it for both the password and client-credentials grants. It builds on `serialize_user()` so an API token cannot silently miss a claim the browser flow gains later. - The browser paths, `routes/login.py` and the middleware, are unchanged and still mint claim-free tokens backed by the cookies. - `get_user_from_token()` falls back to the claims when no cookie-supplied tokens are present. The fallback does not weaken the subject binding added for the cookie flow. That check exists because cookies are not covered by the Airflow JWT signature, so a caller could pair their own session with somebody else's Keycloak token. In the claims both values come from the same signed payload, and both minting sites derive them from one Keycloak response, so a mismatched pair cannot be constructed. A browser token still resolves to `None` without its cookies: it carries no claims to fall back on. Tested alongside the fix so the two paths stay distinct. Fixes apache#72352
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.
On Airflow 3.3+, a token from
POST /auth/tokenno longer authorized anything.serialize_user()omits the Keycloak JWTs from the claims, and the only code that supplies them again,KeycloakJWTMiddleware, reads them from cookies. A client authenticating with theAuthorizationheader sends no cookies, soget_user_from_token()fell through toreturn Noneand the request failed -- in practice with a 500, because theNonereaches the authorization layer and raisesAttributeError: 'NoneType' object has no attribute 'get_id'.The tokens were moved into cookies to keep the browser session cookie under the 4096 byte limit. That constraint does not apply to a token handed to an API client, which is never stored in a cookie, so the two paths can differ:
generate_api_jwt()mints tokens that keep the Keycloak JWTs in their claims, andPOST /auth/tokenuses it for both the password and client-credentials grants. It builds onserialize_user()so an API token cannot silently miss a claim the browser flow gains later.routes/login.pyand the middleware, are unchanged and still mint claim-free tokens backed by the cookies.get_user_from_token()falls back to the claims when no cookie-supplied tokens are present.The fallback does not weaken the subject binding added for the cookie flow. That check exists because cookies are not covered by the Airflow JWT signature, so a caller could pair their own session with somebody else's Keycloak token. In the claims both values come from the same signed payload, and both minting sites derive them from one Keycloak response, so a mismatched pair cannot be constructed.
A browser token still resolves to
Nonewithout its cookies: it carries no claims to fall back on. Tested alongside the fix so the two paths stay distinct.Fixes #72352
Generated-by: Claude Code (Opus 5) following the guidelines