joshua-cogliati-inl opened a new issue, #72351:
URL: https://github.com/apache/airflow/issues/72351

   ### Under which category would you file this issue?
   
   Providers
   
   ### Apache Airflow version
   
   3.3.0
   
   ### What happened and how to reproduce it?
   
   This code worked with airflow 3.3.0 and 
apache-airflow-providers-keycloak==0.8.2 to initialize connections and pools:
   
   ```python
   """Create team-owned pilot Connections and pools through Airflow's public 
API."""
   
   from __future__ import annotations
   
   import json
   import os
   import time
   import urllib.error
   import urllib.parse
   import urllib.request
   
   BASE_URL = "http://airflow-apiserver:8080";
   
   
   def request(method: str, path: str, token: str | None = None, body: dict | 
None = None):
       data = json.dumps(body).encode() if body is not None else None
       headers = {"Content-Type": "application/json"}
       if token:
           headers["Authorization"] = f"Bearer {token}"
       req = urllib.request.Request(BASE_URL + path, data=data, 
headers=headers, method=method)
       with urllib.request.urlopen(req, timeout=15) as response:
           payload = response.read()
           return json.loads(payload) if payload else None
   
   
   def upsert(path: str, item_path: str, token: str, body: dict) -> None:
       try:
           # The auth manager cannot infer a team for a GET on an item that does
           # not exist yet. Create first so authorization can use 
body.team_name;
           # a conflict means this is an idempotent bootstrap rerun.
           request("POST", path, token, body)
       except urllib.error.HTTPError as exc:
           if exc.code != 409:
               raise
           request("PATCH", item_path, token, body)
   
   
   def main() -> None:
       token_body = {
           "grant_type": "client_credentials",
           "client_id": "airflow-bootstrap",
           "client_secret": os.environ["AIRFLOW_BOOTSTRAP_CLIENT_SECRET"],
       }
       for attempt in range(20):
           try:
               token = request("POST", "/auth/token", 
body=token_body)["access_token"]
               break
           except (urllib.error.URLError, KeyError):
               if attempt == 19:
                   raise
               time.sleep(2)
   
       resources = [
           (
               "team-analytics",
               "team-analytics__demo-service",
               os.environ["TEAM_ANALYTICS_DEMO_PASSWORD"],
               "team-analytics__pilot-pool",
           ),
       ]
       for team, connection_id, password, pool_name in resources:
           connection = {
               "connection_id": connection_id,
               "conn_type": "http",
               "host": f"https://{team}.example.invalid";,
               "login": "pilot-service",
               "password": password,
               "extra": json.dumps({"pilot": True}),
               "team_name": team,
           }
           upsert(
               "/api/v2/connections",
               f"/api/v2/connections/{urllib.parse.quote(connection_id, 
safe='')}",
               token,
               connection,
           )
           pool = {
               "name": pool_name,
               "slots": 4,
               "description": f"Local pilot pool for {team}",
               "include_deferred": False,
               "team_name": team,
           }
           upsert(
               "/api/v2/pools",
               f"/api/v2/pools/{urllib.parse.quote(pool_name, safe='')}",
               token,
               pool,
           )
       print("Team-owned Connections and pools are ready.")
   
   
   if __name__ == "__main__":
       main()
   ```
   
   However with both airflow 3.3.0 and 3.3.1 I get errors like:
   ```
   File ".../airflow/api_fastapi/core_api/security.py", line 605, in inner
       _requires_access(is_authorized_callback=_callback)
   File ".../airflow/api_fastapi/core_api/security.py", line 1001, in 
_requires_access
       if not is_authorized_callback():
   File ".../airflow/api_fastapi/core_api/security.py", line 599, in _callback
       return get_auth_manager().is_authorized_connection(
           method=method,
           details=ConnectionDetails(conn_id=connection_id, team_name=tn),
           user=user,
       )
   File ".../airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py", 
line 249, in is_authorized_connection
       return self._is_authorized(
           method=method,
           ...
           team_name=team_name,
       )
   File ".../airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py", 
line 471, in _is_authorized
       headers=self._get_headers(user.access_token),
                                  ^^^^^^^^^^^^^^^^^
   AttributeError: 'NoneType' object has no attribute 'access_token'
   ```
   
   
   
   The initial error is:
   ```
   airflow-apiserver-1    | 2026-08-31T19:49:19.922649Z [info     ] request 
finished               [http.access] client_addr=172.22.0.7:36610 
duration_us=49458 loc=http_access_log.py:130 method=POST path=/auth/token 
query= status_code=201
   airflow-apiserver-1    | 2026-08-31T19:49:19.926767Z [info     ] request 
finished               [http.access] client_addr=172.22.0.7:36626 
duration_us=3211 loc=http_access_log.py:130 method=POST 
path=/api/v2/connections query= status_code=500
   airflow-apiserver-1    | 2026-08-31T19:49:19.926926Z [error    ] Exception 
in ASGI application
   airflow-apiserver-1    |  [uvicorn.error] loc=httptools_impl.py:427
   airflow-apiserver-1    |   + Exception Group Traceback (most recent call 
last):
   airflow-apiserver-1    |   |   File 
"/home/airflow/.local/lib/python3.13/site-packages/starlette/_utils.py", line 
85, in create_collapsing_task_group
   airflow-apiserver-1    |   |     async with anyio.create_task_group() as tg:
   airflow-apiserver-1    |   |                ~~~~~~~~~~~~~~~~~~~~~~~^^
   airflow-apiserver-1    |   |   File 
"/home/airflow/.local/lib/python3.13/site-packages/anyio/_backends/_asyncio.py",
 line 815, in __aexit__
   airflow-apiserver-1    |   |     raise BaseExceptionGroup(
   airflow-apiserver-1    |   |         "unhandled errors in a TaskGroup", 
self._exceptions
   airflow-apiserver-1    |   |     ) from None
   ```
   
   Steps to reproduce:
   1. Configure `KeycloakAuthManager` on Airflow ≥3.3.0 with 
`apache-airflow-providers-keycloak==0.9.0`.
   2. Create a confidential Keycloak client (e.g. `airflow-bootstrap`) with a 
service account enabled (client-credentials grant), with a role/permission 
grant sufficient to create Connections and Pools for a team.
   3. Run the script above against a running Airflow API server with 
`AIRFLOW_BOOTSTRAP_CLIENT_SECRET` and `TEAM_ANALYTICS_DEMO_PASSWORD` set 
appropriately.
   4. The `POST /auth/token` call succeeds (HTTP 201) and returns a token.
   5. The subsequent `POST /api/v2/connections` call fails with HTTP 500 / 
`AttributeError: 'NoneType' object has no attribute 'access_token'`.
   
   Downgrading only `apache-airflow-providers-keycloak` to `0.8.2` (same 
Airflow core version, same script, same Keycloak client config) resolves the 
issue, isolating the regression to the provider package.
   
   ### What you think should happen instead?
   
   Ideally, it would continue to work like the 0.8.2 version.
   
   The rest is Claude's analysis of what went wrong (Claude was concerned 
"there's context I'm missing about why the `None` return is intentional for 
this case."):
   
   ## What do you think went wrong?
   
   This is a conflict between two independent authentication code paths that 
both call `KeycloakAuthManager.get_user_from_token`, with incompatible 
expectations introduced in #70800 and refined in #70981.
   
   **Path 1 — generic bearer/OAuth2 token auth** 
(`airflow/api_fastapi/core_api/security.py`, core Airflow, not 
keycloak-specific):
   
   ```python
   async def resolve_user_from_token(token_str: str | None) -> BaseUser:
       if not token_str:
           raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, 
detail="Not authenticated")
       try:
           return await get_auth_manager().get_user_from_token(token_str)
       ...
   ```
   
   This is called from `get_user()`, which is what handles any `Authorization: 
Bearer <token>` or OAuth2 client-credentials caller — including the 
`/auth/token` client-credentials flow the keycloak provider's own docs describe 
for service-to-service API access. It calls `get_user_from_token` with a single 
positional argument.
   
   **Path 2 — browser-cookie session refresh** (`KeycloakJWTMiddleware`, added 
in #70800):
   
   ```python
   @staticmethod
   async def _refresh_user(request: Request) -> tuple[...]:
       jwt_token = request.cookies.get(COOKIE_NAME_JWT_TOKEN)
       access_token = request.cookies.get(COOKIE_NAME_ACCESS_TOKEN)
       refresh_token = request.cookies.get(COOKIE_NAME_REFRESH_TOKEN)
       ...
       user = await auth_manager.get_user_from_token(jwt_token, access_token, 
refresh_token)
   ```
   
   This path always supplies `access_token`/`refresh_token` sourced from 
dedicated cookies (`_access_token`, `_refresh_token`), per the cookie-splitting 
scheme introduced in #70800 and scoped to Airflow ≥3.3.0 in #70981.
   
   **The override in `KeycloakAuthManager.get_user_from_token` only accounts 
for Path 2 once `AIRFLOW_V_3_3_PLUS` is true:**
   
   ```python
   async def get_user_from_token(
       self, token: str, access_token: str | None = None, refresh_token: str | 
None = None
   ):
       user = cast("KeycloakAuthManagerUser", await 
super().get_user_from_token(token))
       if not AIRFLOW_V_3_3_PLUS:
           return user
       if access_token:
           user.access_token = access_token
           user.refresh_token = refresh_token
           return user
       # Skip refreshing JWT if Keycloak JWTs are not included.
       return None
   ```
   
   When called via Path 1 (bearer/OAuth2 token, no separate cookies exist), 
`access_token` is `None` by default. `super().get_user_from_token(token)` 
successfully resolves a valid `user` object on the line above — but the method 
then discards it and returns `None` anyway, because it assumes the *absence* of 
a separate Keycloak-cookie access token means the caller isn't authenticated, 
rather than considering that the caller may simply be using a different, 
equally valid auth path that was never designed to carry that cookie in the 
first place.
   
   `None` then propagates through `get_user` → `_requires_access` → 
`is_authorized_connection` → `_is_authorized`, which unconditionally does 
`user.access_token`, causing the `AttributeError`.
   
   ## What you think should happen instead?
   
   `get_user_from_token` should not discard a validly-resolved `user` solely 
because the request didn't include a separate Keycloak-cookie `access_token`. A 
bearer-token/client-credentials caller that authenticates successfully via 
`super().get_user_from_token(token)` should remain authenticated; the 
cookie-splitting logic should only apply refresh-token bookkeeping when those 
cookies are present, not gate authentication success on their presence.
   
   A minimal fix might look like:
   
   ```python
   async def get_user_from_token(
       self, token: str, access_token: str | None = None, refresh_token: str | 
None = None
   ):
       user = cast("KeycloakAuthManagerUser", await 
super().get_user_from_token(token))
       if not AIRFLOW_V_3_3_PLUS:
           return user
       if access_token:
           user.access_token = access_token
           user.refresh_token = refresh_token
           return user
       # No separate Keycloak cookies (e.g. bearer/client-credentials caller 
rather than
       # a browser session) — the user is still authenticated, just not 
refreshable here.
       return user
   ```
   
   
   ### Operating System
   
   Debian GNU/Linux 12 (bookworm)
   
   ### Deployment
   
   Docker-Compose
   
   ### Apache Airflow Provider(s)
   
   keycloak
   
   ### Versions of Apache Airflow Providers
   
   apache-airflow-providers-keycloak==0.9.0
   
   ### Official Helm Chart version
   
   Not Applicable
   
   ### Kubernetes Version
   
   _No response_
   
   ### Helm Chart configuration
   
   _No response_
   
   ### Docker Image customizations
   
   Switched to KeycloakAuthManager and using multi-teams.
   
   ### Anything else?
   
   _No response_
   
   ### Are you willing to submit PR?
   
   - [ ] Yes I am willing to submit a PR!
   
   ### Code of Conduct
   
   - [x] I agree to follow this project's [Code of 
Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to