Skip to content

Commit f56f9d2

Browse files
committed
Fix Dag processor identity exchange edge cases
Processors must stop trusting keys withdrawn by their issuer and recover from temporary identity-provider failures without exhausting retries during its cooldown. Authentication must also remain the boundary for incoming trace context.
1 parent 89180fc commit f56f9d2

9 files changed

Lines changed: 161 additions & 42 deletions

File tree

‎airflow-core/src/airflow/api_fastapi/auth/tokens.py‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -163,18 +163,22 @@ async def fetch_jwks(self, *, force: bool = False) -> None:
163163
return
164164

165165
try:
166-
# PyJWKSet.from_dict raises AttributeError for a JSON array or scalar.
166+
# Older PyJWT versions assume the body and every key are objects.
167167
if not isinstance(data, dict):
168168
raise TypeError(f"JWKS must be a JSON object, not {type(data).__name__}")
169-
keyset = jwt.PyJWKSet.from_dict(data)
169+
keys = data.get("keys")
170+
if not isinstance(keys, list) or any(not isinstance(key, dict) for key in keys):
171+
raise TypeError("JWKS keys must be an array of JSON objects")
172+
# PyJWT rejects empty sets, but an issuer can remove all keys with an empty array.
173+
keyset = jwt.PyJWKSet.from_dict(data) if keys else None
170174
except (jwt.PyJWKError, jwt.PyJWKSetError, KeyError, TypeError, ValueError):
171175
if not self.raise_on_fetch_error:
172176
raise
173177
log.warning("Unable to load JWKS response", url=self.url, exc_info=True)
174178
return
175179
self._jwks = keyset
176180
self._fetch_failed = False
177-
log.debug("Fetched JWKS", url=self.url, keys=len(self._jwks.keys))
181+
log.debug("Fetched JWKS", url=self.url, keys=len(keyset.keys) if keyset is not None else 0)
178182

179183
async def _fetch_remote_jwks(self) -> dict[str, Any] | None:
180184
try:
@@ -251,10 +255,11 @@ async def get_key(self, kid: str) -> jwt.PyJWK:
251255
except KeyError:
252256
if self.refresh_on_unknown_kid:
253257
await self.fetch_jwks(force=True)
254-
try:
255-
return self._jwks[kid]
256-
except KeyError:
257-
pass
258+
if self._jwks is not None:
259+
try:
260+
return self._jwks[kid]
261+
except KeyError:
262+
pass
258263

259264
if self.raise_on_fetch_error and self._fetch_failed:
260265
raise JWKSUnavailableError("Unable to load the required signing key")

‎airflow-core/src/airflow/api_fastapi/execution_api/app.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,13 @@ def _inject_trace_context_dep(routes, mode: str) -> None:
292292
case "unsafe-always":
293293
route.dependencies.insert(0, dep)
294294
case "only-authenticated":
295+
from airflow.api_fastapi.execution_api.routes.session_tokens import require_workload_identity
295296
from airflow.api_fastapi.execution_api.security import require_auth
296297

297-
if any(getattr(d, "dependency", None) is require_auth for d in route.dependencies):
298+
if any(
299+
getattr(d, "dependency", None) in (require_auth, require_workload_identity)
300+
for d in route.dependencies
301+
):
298302
route.dependencies.append(dep)
299303

300304

‎airflow-core/src/airflow/api_fastapi/execution_api/routes/session_tokens.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ async def require_workload_identity(
7676

7777
@router.post(
7878
"/dag-processor/session-token",
79+
dependencies=[Depends(require_workload_identity)],
7980
responses=create_openapi_http_exception_doc(
8081
[
8182
(status.HTTP_401_UNAUTHORIZED, "The workload identity is missing, invalid, or expired"),

‎airflow-core/src/airflow/api_fastapi/execution_api/workload_identity.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from typing import Annotated, Literal
2727
from uuid import NAMESPACE_URL, UUID, uuid5
2828

29-
import httpx
29+
import httpx2
3030
import jwt
3131
import structlog
3232
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
@@ -84,7 +84,7 @@ class _IdentityClaims(BaseModel):
8484
class DagProcessorIdentityVerifier:
8585
"""Validate external JWTs independently of Airflow's runtime token trust."""
8686

87-
def __init__(self, config: WorkloadIdentityConfig, *, client: httpx.AsyncClient):
87+
def __init__(self, config: WorkloadIdentityConfig, *, client: httpx2.AsyncClient):
8888
if config.audience in conf.getlist("execution_api", "jwt_audience"):
8989
raise ValueError("Workload identity must use a dedicated audience")
9090
configured = get_configured_bundle_names()
@@ -163,5 +163,5 @@ async def create_dag_processor_identity_verifier() -> AsyncIterator[DagProcessor
163163
yield None
164164
return
165165
config = WorkloadIdentityConfig.model_validate_json(raw)
166-
async with httpx.AsyncClient(timeout=5.0, follow_redirects=False) as client:
166+
async with httpx2.AsyncClient(timeout=5.0, follow_redirects=False) as client:
167167
yield DagProcessorIdentityVerifier(config, client=client)

‎airflow-core/src/airflow/dag_processing/credentials.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from __future__ import annotations
2121

22+
import math
2223
from collections.abc import Callable
2324
from pathlib import Path
2425
from time import monotonic
@@ -55,7 +56,9 @@ def _wait_for_retry_after(retry_state: RetryCallState) -> float:
5556
retry_after = float(error.response.headers["Retry-After"])
5657
except (KeyError, ValueError): # Missing, or an HTTP-date rather than seconds.
5758
return wait
58-
return max(wait, min(retry_after, API_RETRY_WAIT_MAX))
59+
if not math.isfinite(retry_after):
60+
return wait
61+
return max(wait, retry_after)
5962

6063

6164
class SessionTokenProvider(Protocol):

‎airflow-core/tests/unit/api_fastapi/auth/test_tokens.py‎

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ async def test_refresh_unknown_key_before_regular_interval(
7070

7171
def handle(request):
7272
requests.append(request)
73-
return httpx.Response(200, json={"keys": keys})
73+
return httpx2.Response(200, json={"keys": keys})
7474

75-
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
75+
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handle)) as client:
7676
jwks = JWKS(
7777
url="https://example.com/keys", client=client, refresh_on_unknown_kid=refresh_on_unknown_kid
7878
)
@@ -98,9 +98,9 @@ async def test_concurrent_unknown_keys_share_fetch_and_cooldown(
9898
async def handle(request):
9999
requests.append(request)
100100
await asyncio.sleep(0)
101-
return httpx.Response(status, json={"keys": [key_to_jwk_dict(ed25519_private_key, "known")]})
101+
return httpx2.Response(status, json={"keys": [key_to_jwk_dict(ed25519_private_key, "known")]})
102102

103-
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
103+
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handle)) as client:
104104
jwks = JWKS(
105105
url="https://example.com/keys",
106106
client=client,
@@ -135,9 +135,9 @@ async def test_failed_unknown_key_refresh_keeps_cached_keys_and_rate_limits(
135135

136136
def handle(request):
137137
requests.append(request)
138-
return httpx.Response(status, json={"keys": keys})
138+
return httpx2.Response(status, json={"keys": keys})
139139

140-
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
140+
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handle)) as client:
141141
jwks = JWKS(
142142
url="https://example.com/keys",
143143
client=client,
@@ -172,9 +172,9 @@ async def handle(request):
172172
if len(requests) > 1:
173173
fetching.set()
174174
await release.wait()
175-
return httpx.Response(200, json={"keys": [key_to_jwk_dict(ed25519_private_key, "known")]})
175+
return httpx2.Response(200, json={"keys": [key_to_jwk_dict(ed25519_private_key, "known")]})
176176

177-
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
177+
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handle)) as client:
178178
jwks = JWKS(url="https://example.com/keys", client=client, refresh_on_unknown_kid=True)
179179
known = await jwks.get_key("known")
180180
clock.return_value = 111
@@ -195,14 +195,15 @@ async def handle(request):
195195
await refresh
196196
assert len(requests) == 2
197197

198+
@pytest.mark.parametrize("body", [[{"kty": "OKP"}], {"keys": [None]}, {"keys": ["invalid"]}])
198199
@patch("airflow.api_fastapi.auth.tokens.time.monotonic", autospec=True, return_value=100)
199-
async def test_jwks_body_that_is_not_an_object_is_a_failed_fetch(self, clock, ed25519_private_key):
200-
bodies = [[{"kty": "OKP"}]]
200+
async def test_malformed_jwks_is_a_failed_fetch(self, clock, ed25519_private_key, body):
201+
bodies = [body]
201202

202203
def handle(request):
203-
return httpx.Response(200, json=bodies[-1])
204+
return httpx2.Response(200, json=bodies[-1])
204205

205-
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
206+
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handle)) as client:
206207
jwks = JWKS(
207208
url="https://example.com/keys",
208209
client=client,
@@ -216,9 +217,37 @@ def handle(request):
216217
known = await jwks.get_key("known")
217218
# Once the hourly refresh is due, a bad body must not stop the cached key from validating.
218219
clock.return_value = 111 + jwks.refresh_interval_secs + 1
219-
bodies.append([{"kty": "OKP"}])
220+
bodies.append(body)
220221
assert await jwks.get_key("known") is known
221222

223+
@pytest.mark.parametrize("refresh", ["initial", "scheduled", "unknown-key"])
224+
@patch("airflow.api_fastapi.auth.tokens.time.monotonic", autospec=True, return_value=100)
225+
async def test_empty_jwks_removes_cached_keys(self, clock, ed25519_private_key, refresh):
226+
keys = [] if refresh == "initial" else [key_to_jwk_dict(ed25519_private_key, "known")]
227+
requests = []
228+
229+
def handle(request):
230+
requests.append(request)
231+
return httpx2.Response(200, json={"keys": keys})
232+
233+
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handle)) as client:
234+
jwks = JWKS(
235+
url="https://example.com/keys",
236+
client=client,
237+
refresh_on_unknown_kid=True,
238+
raise_on_fetch_error=True,
239+
)
240+
if refresh != "initial":
241+
await jwks.get_key("known")
242+
keys.clear()
243+
clock.return_value += jwks.refresh_interval_secs + 1 if refresh == "scheduled" else 11
244+
if refresh == "unknown-key":
245+
with pytest.raises(KeyError):
246+
await jwks.get_key("unknown")
247+
with pytest.raises(KeyError):
248+
await jwks.get_key("known")
249+
assert len(requests) == (1 if refresh == "initial" else 2)
250+
222251
@pytest.mark.parametrize("private_key", ["rsa_private_key", "ed25519_private_key"], indirect=True)
223252
async def test_fetch_jwks_success(self, private_key):
224253
jwk_content = json.dumps({"keys": [key_to_jwk_dict(private_key, "kid")]})

‎airflow-core/tests/unit/api_fastapi/execution_api/test_app.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@
2121
import json
2222
import threading
2323
from contextlib import asynccontextmanager
24+
from datetime import UTC, datetime
2425
from unittest import mock
2526
from uuid import UUID
2627

2728
import httpx2
29+
import jwt
2830
import pytest
2931
from fastapi import FastAPI, Request, status
3032
from fastapi.params import Security as SecurityParam
@@ -46,6 +48,10 @@
4648
from airflow.api_fastapi.execution_api.datamodels.token import TIClaims, TIToken
4749
from airflow.api_fastapi.execution_api.security import require_auth
4850
from airflow.api_fastapi.execution_api.versions import bundle
51+
from airflow.api_fastapi.execution_api.workload_identity import (
52+
DagProcessorIdentity,
53+
DagProcessorIdentityVerifier,
54+
)
4955
from airflow.models.xcom import XComModel
5056
from airflow.utils.session import create_session_async
5157

@@ -477,6 +483,47 @@ async def mock_require_auth(request: Request) -> TIToken:
477483
if expect_status is not None:
478484
assert response.status_code == expect_status
479485

486+
@pytest.mark.parametrize("mode", ["only-authenticated", "unsafe-always", "never"])
487+
@pytest.mark.parametrize("valid_identity", [True, False])
488+
@mock.patch.object(otel_propagate, "extract", autospec=True)
489+
def test_workload_identity_trace_context_extraction(self, extract, mode, valid_identity, time_machine):
490+
now = datetime(2026, 10, 5, 12, 0, tzinfo=UTC)
491+
time_machine.move_to(now, tick=False)
492+
events = []
493+
verifier = mock.create_autospec(DagProcessorIdentityVerifier, instance=True)
494+
495+
async def authenticate(token):
496+
events.append("authenticate")
497+
if not valid_identity:
498+
raise jwt.InvalidTokenError("Invalid identity")
499+
return DagProcessorIdentity(UUID(int=1), frozenset({"dags-folder"}), now.timestamp() + 600)
500+
501+
def extract_context(headers):
502+
events.append("extract")
503+
return otel_context.get_current()
504+
505+
verifier.authenticate.side_effect = authenticate
506+
extract.side_effect = extract_context
507+
app = self._build_app(mode)
508+
with TestClient(app) as client:
509+
app.state.svcs_registry.register_value(DagProcessorIdentityVerifier, verifier)
510+
response = client.post(
511+
"/dag-processor/session-token",
512+
headers={
513+
"Authorization": "Bearer workload-identity",
514+
"traceparent": "00-12345678901234567890123456789012-1234567890123456-01",
515+
},
516+
)
517+
518+
assert response.status_code == (200 if valid_identity else 401)
519+
expect_extract = mode == "unsafe-always" or (mode == "only-authenticated" and valid_identity)
520+
assert extract.call_count == int(expect_extract)
521+
assert events[0] == ("extract" if mode == "unsafe-always" else "authenticate")
522+
if expect_extract:
523+
assert extract.call_args.args[0]["traceparent"] == (
524+
"00-12345678901234567890123456789012-1234567890123456-01"
525+
)
526+
480527
def test_trace_context_dep_cleans_up_on_route_exception(self):
481528
"""Verify extract and cleanup run correctly when a route handler raises."""
482529
app = self._build_app("unsafe-always")

‎airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_session_tokens.py‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from datetime import UTC, datetime
2121
from unittest import mock
2222

23-
import httpx
2423
import httpx2
2524
import jwt
2625
import pytest
@@ -125,8 +124,8 @@ def verifier(client, exec_app, signing_key, identity_algorithm, monkeypatch):
125124
}
126125
else:
127126
public_key = key_to_jwk_dict(signing_key, kid="key-1")
128-
jwks_client = httpx.AsyncClient(
129-
transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"keys": [public_key]}))
127+
jwks_client = httpx2.AsyncClient(
128+
transport=httpx2.MockTransport(lambda request: httpx2.Response(200, json={"keys": [public_key]}))
130129
)
131130
verifier = svcs.Container(exec_app.state.svcs_registry).get(DagProcessorIdentityVerifier)
132131
monkeypatch.setattr(verifier.validator.jwks, "client", jwks_client)
@@ -305,7 +304,7 @@ def test_rotated_signing_key_refreshes_after_cooldown(
305304

306305
async def get_keys(url):
307306
requests.append(url)
308-
return httpx.Response(200, json={"keys": keys}, request=httpx.Request("GET", url))
307+
return httpx2.Response(200, json={"keys": keys}, request=httpx2.Request("GET", url))
309308

310309
monkeypatch.setattr(verifier.validator.jwks.client, "get", get_keys)
311310
original = decode(exchange(client, identity_token()))
@@ -371,15 +370,15 @@ async def expire(token):
371370
def test_jwks_outage_is_retryable(client, verifier, identity_token, monkeypatch, failure):
372371
async def unavailable(url):
373372
if failure == "connection":
374-
raise httpx.ConnectError("offline")
375-
request = httpx.Request("GET", url)
373+
raise httpx2.ConnectError("offline")
374+
request = httpx2.Request("GET", url)
376375
if failure == "503":
377-
return httpx.Response(503, request=request)
376+
return httpx2.Response(503, request=request)
378377
if failure == "invalid-json":
379-
return httpx.Response(200, content=b"unavailable", request=request)
378+
return httpx2.Response(200, content=b"unavailable", request=request)
380379
if failure == "json-array":
381-
return httpx.Response(200, json=[{"kty": "OKP"}], request=request)
382-
return httpx.Response(200, json={"keys": []}, request=request)
380+
return httpx2.Response(200, json=[{"kty": "OKP"}], request=request)
381+
return httpx2.Response(200, json={"keys": [None]}, request=request)
383382

384383
monkeypatch.setattr(verifier.jwks.client, "get", unavailable)
385384
response = exchange(client, identity_token())
@@ -388,12 +387,32 @@ async def unavailable(url):
388387
assert response.headers["Retry-After"] == "10"
389388

390389

390+
@pytest.mark.parametrize("cached", [False, True])
391+
@mock.patch("airflow.api_fastapi.auth.tokens.time.monotonic", autospec=True, return_value=100)
392+
def test_empty_jwks_rejects_identities_with_or_without_cached_keys(
393+
clock, client, verifier, identity_token, monkeypatch, cached
394+
):
395+
if cached:
396+
assert exchange(client, identity_token()).status_code == 200
397+
clock.return_value = 3701
398+
399+
async def get_keys(url):
400+
return httpx2.Response(200, json={"keys": []}, request=httpx2.Request("GET", url))
401+
402+
monkeypatch.setattr(verifier.jwks.client, "get", get_keys)
403+
response = exchange(client, identity_token())
404+
assert response.status_code == 401
405+
assert response.json() == {"detail": "Invalid workload identity"}
406+
assert response.headers["WWW-Authenticate"] == "Bearer"
407+
assert "Retry-After" not in response.headers
408+
409+
391410
@mock.patch("airflow.api_fastapi.auth.tokens.time.monotonic", autospec=True, return_value=100)
392411
def test_jwks_outage_preserves_cached_key_validation(clock, client, verifier, identity_token, monkeypatch):
393412
assert exchange(client, identity_token()).status_code == 200
394413

395414
async def unavailable(url):
396-
raise httpx.ConnectError("offline")
415+
raise httpx2.ConnectError("offline")
397416

398417
monkeypatch.setattr(verifier.jwks.client, "get", unavailable)
399418
clock.return_value = 3701
@@ -408,10 +427,10 @@ def test_registration_recovers_from_jwks_outage(clock, make_processor, verifier,
408427

409428
async def get_keys(url):
410429
attempts.append(url)
411-
return httpx.Response(
430+
return httpx2.Response(
412431
503 if len(attempts) == 1 else 200,
413432
json={"keys": [key_to_jwk_dict(signing_key, kid="key-1")]},
414-
request=httpx.Request("GET", url),
433+
request=httpx2.Request("GET", url),
415434
)
416435

417436
waits = []

0 commit comments

Comments
 (0)