Skip to content

Keycloak auth manager: keep API tokens usable with the Authorization - #72381

Merged
vincbeck merged 2 commits into
apache:mainfrom
edsu:fix-keycloak-bearer-api-auth
Sep 4, 2026
Merged

vincbeck merged 2 commits into
apache:mainfrom
edsu:fix-keycloak-bearer-api-auth

Conversation

@edsu

@edsu edsu commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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 #72352

Generated-by: Claude Code (Opus 5) following the guidelines

@vincbeck vincbeck left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Thanks for taking a look. I would do few things:

  • Expose generate_api_jwt in base_auth_manager with default implementation calling generate_jwt
  • Then the keycloak auth manager overrides generate_api_jwt the 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

@edsu

edsu commented Sep 2, 2026 •

Copy link
Copy Markdown
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

edsu and others added 2 commits September 2, 2026 10:57
…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
edsu force-pushed the fix-keycloak-bearer-api-auth branch from b319ac4 to f845e33 Compare September 2, 2026 14:58
@edsu
edsu requested a review from vincbeck September 2, 2026 15:03
@vincbeck
vincbeck merged commit dc529a1 into apache:main Sep 4, 2026
79 checks passed
@boring-cyborg

boring-cyborg Bot commented Sep 4, 2026

Copy link
Copy Markdown

Awesome work, congrats on your first merged pull request! You are invited to check our Issue Tracker for additional contributions.

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
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.

Keycloak auth manager: is header-based API authentication still supported in Airflow 3.3+?

2 participants