Skip to content

Commit 2b6e818

Browse files
Two-token mechanism for task execution to prevent token expiration while tasks wait in executor queues (#60108)
Tasks waiting in executor queues (Celery, Kubernetes) can have their JWT tokens expire before execution starts, causing auth failures on the Execution API. This is a real problem in production, when queues back up or workers are slow to pick up tasks, the original short-lived token expires and the worker gets a 403 when it finally tries to start the task. Fixes: #53713 Related: #59553 closes: #62129 ## Approach Two-token mechanism: a workload token (lifetime tracks [scheduler] task_queued_timeout) travels with the task through the queue, and a short-lived execution token is issued when the task actually starts running. The workload token carries a scope: "workload" claim and is restricted to the /run endpoint only, enforced via FastAPI SecurityScopes and a custom ExecutionAPIRoute. When /run succeeds, it returns an execution token via Refreshed-API-Token header. The SDK client picks it up and uses it for all subsequent API calls. The existing JWTReissueMiddleware handles refreshing execution tokens near expiry and skips workload tokens. Built on @ashb's SecurityScopes foundation. ### Security considerations Even if a workload token is intercepted, it can only call /run which already guards against running a task more than once (returns 409 if the task isn't in QUEUED/RESTARTING state). All other endpoints reject workload tokens , they require execution scope. The execution token issued by /run is short-lived and automatically refreshed, keeping the existing security posture for all API calls during task execution.
1 parent 5d6f1c1 commit 2b6e818

12 files changed

Lines changed: 282 additions & 86 deletions

File tree

airflow-core/docs/security/jwt_token_authentication.rst

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -201,16 +201,25 @@ Token structure (Execution API)
201201
Token scopes (Execution API)
202202
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
203203

204-
The Execution API defines two token scopes:
204+
The Execution API defines two token scopes with different lifetimes:
205205

206206
**workload**
207-
A restricted scope accepted only on endpoints that explicitly opt in via
208-
``Security(require_auth, scopes=["token:workload"])``. Used for endpoints that
209-
manage task state transitions.
207+
A token embedded in the workload JSON payload when the Scheduler
208+
dispatches a task. The longer lifetime
209+
allows tasks to remain valid while waiting in executor queues before execution
210+
begins. When a worker calls the ``/run`` endpoint with a ``workload`` token, the
211+
server issues a fresh ``execution``-scoped token in the ``Refreshed-API-Token``
212+
response header. Lifetime equals ``[scheduler] task_queued_timeout`` (default
213+
600 seconds) — the same timeout the scheduler uses to reap queue-starved tasks —
214+
so tuning ``task_queued_timeout`` also widens the window a task can wait in a
215+
backed-up queue before its workload token expires.
210216

211217
**execution**
212-
Accepted by all Execution API endpoints. This is the standard scope for worker
213-
communication and allows access
218+
A short-lived token (default 10 minutes) accepted by all Execution API endpoints.
219+
This is the standard scope for worker communication during task execution. Issued
220+
by the server when the worker transitions to running via the ``/run`` endpoint.
221+
The ``JWTReissueMiddleware`` refreshes ``execution`` tokens transparently,
222+
so the worker maintains access for the duration of the task.
214223

215224
Tokens without a ``scope`` claim default to ``"execution"`` for backwards compatibility.
216225

@@ -219,14 +228,19 @@ Token delivery to workers
219228

220229
The token flows through the execution stack as follows:
221230

222-
1. **Scheduler** generates the token and embeds it in the workload JSON payload that it passes to
223-
**Executor**.
231+
1. **Scheduler** generates a ``workload``-scoped token (lifetime equals
232+
``[scheduler] task_queued_timeout``, default 600 seconds) and embeds it in the workload
233+
JSON payload that it passes to **Executor**.
224234
2. The workload JSON is passed to the worker process (via the executor-specific mechanism:
225235
Celery message, Kubernetes Pod spec, local subprocess arguments, etc.).
226236
3. The worker's ``execute_workload()`` function reads the workload JSON and extracts the token.
227237
4. The ``supervise()`` function receives the token and creates an ``httpx.Client`` instance
228238
with ``BearerAuth(token)`` for all Execution API HTTP requests.
229-
5. The token is included in the ``Authorization: Bearer <token>`` header of every request.
239+
5. The worker calls the ``/run`` endpoint with the ``workload``-scoped token to mark the task
240+
as running. The server responds with a fresh ``execution``-scoped token in the
241+
``Refreshed-API-Token`` header.
242+
6. The client's ``_update_auth()`` hook detects the header and transparently updates
243+
the ``BearerAuth`` instance to use the new ``execution`` token for all subsequent requests.
230244

231245
Token validation (Execution API)
232246
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -251,7 +265,8 @@ Route-level enforcement is handled by ``require_auth``:
251265
Token refresh (Execution API)
252266
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
253267

254-
The ``JWTReissueMiddleware`` automatically refreshes valid tokens that are approaching expiry:
268+
The ``JWTReissueMiddleware`` automatically refreshes valid tokens that are approaching
269+
expiry. The token must be valid at the start of the request for refresh to occur:
255270

256271
1. After each response, the middleware checks the token's remaining validity.
257272
2. If less than **20%** of the total validity remains (minimum 30 seconds), the server
@@ -260,16 +275,20 @@ The ``JWTReissueMiddleware`` automatically refreshes valid tokens that are appro
260275
4. The client's ``_update_auth()`` hook detects this header and transparently updates
261276
the ``BearerAuth`` instance for subsequent requests.
262277

263-
This mechanism ensures long-running tasks do not lose API access due to token expiry,
264-
without requiring the worker to re-authenticate.
278+
The middleware only refreshes ``execution``-scoped tokens. ``workload``-scoped tokens are
279+
sized to span the queued-timeout window and are explicitly skipped by the middleware —
280+
they are designed to survive executor queue wait times without needing refresh. This
281+
ensures long-running tasks do not lose API access without requiring the worker to
282+
re-authenticate.
265283

266284
No token revocation (Execution API)
267285
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
268286

269-
Execution API tokens are not subject to revocation. They are short-lived (default 10 minutes)
270-
and automatically refreshed by the ``JWTReissueMiddleware``, so revocation is not part of the
271-
Execution API security model. Once an Execution API token is issued to a worker, it remains
272-
valid until it expires.
287+
Execution API tokens are not subject to revocation. ``execution``-scoped tokens are short-lived
288+
(default 10 minutes) and automatically refreshed by the ``JWTReissueMiddleware``.
289+
``workload``-scoped tokens (tracking ``[scheduler] task_queued_timeout``) are not refreshed —
290+
they expire naturally after their validity period. Revocation is not part of the Execution API
291+
security model.
273292

274293

275294

@@ -284,11 +303,12 @@ Default timings (Execution API)
284303
- Default
285304
* - ``[execution_api] jwt_expiration_time``
286305
- 600 seconds (10 minutes)
306+
* - Workload token lifetime (derived)
307+
- ``[scheduler] task_queued_timeout`` (default 600 seconds)
287308
* - ``[execution_api] jwt_audience``
288309
- ``urn:airflow.apache.org:task``
289310
* - Token refresh threshold
290-
- 20% of validity remaining (minimum 30 seconds, i.e., at ~120 seconds before expiry
291-
with the default 600-second token lifetime)
311+
- 20% of validity remaining (minimum 30 seconds)
292312

293313

294314
Dag File Processor and Triggerer
@@ -386,7 +406,10 @@ All JWT-related configuration parameters:
386406
- JWKS endpoint URL or local file path for token validation. Mutually exclusive with ``jwt_secret``.
387407
* - ``[execution_api] jwt_expiration_time``
388408
- 600 (10 min)
389-
- Execution API token lifetime in seconds.
409+
- Execution API ``execution``-scoped token lifetime in seconds.
410+
* - ``[scheduler] task_queued_timeout``
411+
- 600.0 (10 min)
412+
- Queue-starvation timeout. Also sets the ``workload``-scoped token lifetime to the same value.
390413
* - ``[execution_api] jwt_audience``
391414
- ``urn:airflow.apache.org:task``
392415
- Audience claim for Execution API tokens.

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,15 +447,21 @@ def signing_arg(self) -> AllowedPrivateKeys | str:
447447
assert self._secret_key
448448
return self._secret_key
449449

450-
def generate(self, extras: dict[str, Any] | None = None, headers: dict[str, Any] | None = None) -> str:
450+
def generate(
451+
self,
452+
extras: dict[str, Any] | None = None,
453+
headers: dict[str, Any] | None = None,
454+
valid_for: float | None = None,
455+
) -> str:
451456
"""Generate a signed JWT for the subject."""
452457
now = int(datetime.now(tz=timezone.utc).timestamp())
458+
effective_valid_for = valid_for if valid_for is not None else self.valid_for
453459
claims = {
454460
"jti": uuid.uuid4().hex,
455461
"iss": self.issuer,
456462
"aud": self.audience,
457463
"nbf": now,
458-
"exp": int(now + self.valid_for),
464+
"exp": int(now + effective_valid_for),
459465
"iat": now,
460466
}
461467

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,6 @@ async def dispatch(self, request: Request, call_next):
129129

130130
class JWTReissueMiddleware(BaseHTTPMiddleware):
131131
async def dispatch(self, request: Request, call_next):
132-
from airflow.configuration import conf
133-
134132
response: Response = await call_next(request)
135133

136134
refreshed_token: str | None = None
@@ -142,9 +140,15 @@ async def dispatch(self, request: Request, call_next):
142140
validator: JWTValidator = await services.aget(JWTValidator)
143141
claims = await validator.avalidated_claims(token, {})
144142

143+
# Workload tokens are long-lived and meant to survive queue
144+
# wait times so avoid refreshing them. If avalidated_claims
145+
# raises for a workload token, the outer except handles it.
146+
if claims.get("scope") == "workload":
147+
return response
148+
145149
now = int(time.time())
146-
validity = conf.getint("execution_api", "jwt_expiration_time")
147-
refresh_when_less_than = max(int(validity * 0.20), 30)
150+
token_lifetime = int(claims.get("exp", 0)) - int(claims.get("iat", 0))
151+
refresh_when_less_than = max(int(token_lifetime * 0.20), 30)
148152
valid_left = int(claims.get("exp", 0)) - now
149153
if valid_left <= refresh_when_less_than:
150154
generator: JWTGenerator = await services.aget(JWTGenerator)
@@ -312,7 +316,6 @@ class InProcessExecutionAPI:
312316
def app(self):
313317
if not self._app:
314318
from airflow.api_fastapi.common.dagbag import create_dag_bag
315-
from airflow.api_fastapi.execution_api.app import create_task_execution_api_app
316319
from airflow.api_fastapi.execution_api.datamodels.token import TIClaims, TIToken
317320
from airflow.api_fastapi.execution_api.routes.connections import has_connection_access
318321
from airflow.api_fastapi.execution_api.routes.variables import has_variable_access

airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
import attrs
2929
import structlog
3030
from cadwyn import VersionedAPIRouter
31-
from fastapi import Body, HTTPException, Query, Security, status
31+
from fastapi import Body, HTTPException, Query, Response, Security, status
3232
from opentelemetry import trace
3333
from opentelemetry.trace import StatusCode
3434
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
@@ -42,6 +42,7 @@
4242

4343
from airflow._shared.observability.traces import override_ids
4444
from airflow._shared.timezones import timezone
45+
from airflow.api_fastapi.auth.tokens import JWTGenerator
4546
from airflow.api_fastapi.common.dagbag import DagBagDep, get_latest_version_of_dag
4647
from airflow.api_fastapi.common.db.common import SessionDep
4748
from airflow.api_fastapi.common.types import UtcDateTime
@@ -63,7 +64,9 @@
6364
TISuccessStatePayload,
6465
TITerminalStatePayload,
6566
)
66-
from airflow.api_fastapi.execution_api.security import ExecutionAPIRoute, require_auth
67+
from airflow.api_fastapi.execution_api.datamodels.token import TIToken
68+
from airflow.api_fastapi.execution_api.deps import DepContainer
69+
from airflow.api_fastapi.execution_api.security import CurrentTIToken, ExecutionAPIRoute, require_auth
6770
from airflow.exceptions import TaskNotFound
6871
from airflow.models.asset import AssetActive
6972
from airflow.models.dag import DagModel
@@ -98,6 +101,7 @@
98101
@ti_id_router.patch(
99102
"/{task_instance_id}/run",
100103
status_code=status.HTTP_200_OK,
104+
dependencies=[Security(require_auth, scopes=["token:execution", "token:workload"])],
101105
responses={
102106
status.HTTP_404_NOT_FOUND: {"description": "Task Instance not found"},
103107
status.HTTP_409_CONFLICT: {"description": "The TI is already in the requested state"},
@@ -108,8 +112,11 @@
108112
def ti_run(
109113
task_instance_id: UUID,
110114
ti_run_payload: Annotated[TIEnterRunningPayload, Body()],
115+
response: Response,
111116
session: SessionDep,
112117
dag_bag: DagBagDep,
118+
services=DepContainer,
119+
token: TIToken = CurrentTIToken,
113120
) -> TIRunContext:
114121
"""
115122
Run a TaskInstance.
@@ -293,13 +300,20 @@ def ti_run(
293300
context.next_method = ti.next_method
294301
context.next_kwargs = ti.next_kwargs
295302
context.start_date = ti.start_date
296-
return context
297303
except SQLAlchemyError:
298304
log.exception("Error marking Task Instance state as running")
299305
raise HTTPException(
300306
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database error occurred"
301307
)
302308

309+
# JWTReissueMiddleware also writes Refreshed-API-Token but skips workload tokens, so we set it here for the workload→execution swap.
310+
if token.claims.scope == "workload":
311+
generator: JWTGenerator = services.get(JWTGenerator)
312+
execution_token = generator.generate(extras={"sub": str(task_instance_id), "scope": "execution"})
313+
response.headers["Refreshed-API-Token"] = execution_token
314+
315+
return context
316+
303317

304318
@ti_id_router.patch(
305319
"/{task_instance_id}/state",

airflow-core/src/airflow/config_templates/config.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2586,6 +2586,10 @@ scheduler:
25862586
task_queued_timeout:
25872587
description: |
25882588
Amount of time a task can be in the queued state before being retried or set to failed.
2589+
2590+
This value also sets the lifetime of the workload JWT token that is sent with the task
2591+
to an executor queue, so a task waiting in the queue can still authenticate to the
2592+
Execution API until its queue-starvation deadline.
25892593
version_added: 2.6.0
25902594
type: float
25912595
example: ~

airflow-core/src/airflow/executors/workloads/base.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525

2626
from pydantic import BaseModel, ConfigDict, Field
2727

28+
from airflow.configuration import conf
29+
2830
if TYPE_CHECKING:
2931
from airflow.api_fastapi.auth.tokens import JWTGenerator
3032
from airflow.executors.workloads.types import WorkloadState
@@ -76,7 +78,13 @@ class BaseWorkloadSchema(BaseModel):
7678

7779
@staticmethod
7880
def generate_token(sub_id: str, generator: JWTGenerator | None = None) -> str:
79-
return generator.generate({"sub": sub_id}) if generator else ""
81+
if not generator:
82+
return ""
83+
valid_for = conf.getfloat("scheduler", "task_queued_timeout")
84+
return generator.generate(
85+
extras={"sub": sub_id, "scope": "workload"},
86+
valid_for=valid_for,
87+
)
8088

8189

8290
class BaseDagBundleWorkload(BaseWorkloadSchema, ABC):

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,34 @@ def test_secret_key_with_configured_kid():
160160
assert header["kid"] == "my-custom-kid"
161161

162162

163+
def test_generate_with_custom_valid_for():
164+
"""generate() accepts a valid_for override."""
165+
generator = JWTGenerator(secret_key="test-secret", audience="test", valid_for=60)
166+
token = generator.generate(extras={"sub": "user"}, valid_for=3600)
167+
claims = jwt.decode(token, "test-secret", algorithms=["HS512"], audience="test")
168+
assert claims["exp"] - claims["iat"] == 3600
169+
170+
171+
def test_generate_workload_scope_via_extras():
172+
"""generate() with scope='workload' in extras produces a workload-scoped token."""
173+
generator = JWTGenerator(secret_key="test-secret", audience="test", valid_for=60)
174+
175+
token = generator.generate(extras={"sub": "ti-123", "scope": "workload"}, valid_for=86400)
176+
claims = jwt.decode(token, "test-secret", algorithms=["HS512"], audience="test")
177+
assert claims["sub"] == "ti-123"
178+
assert claims["scope"] == "workload"
179+
assert claims["exp"] - claims["iat"] == 86400
180+
181+
182+
def test_regular_token_has_no_scope():
183+
"""Regular tokens without scope in extras have no scope claim."""
184+
generator = JWTGenerator(secret_key="test-secret", audience="test", valid_for=60)
185+
186+
regular = generator.generate(extras={"sub": "user"})
187+
regular_claims = jwt.decode(regular, "test-secret", algorithms=["HS512"], audience="test")
188+
assert "scope" not in regular_claims
189+
190+
163191
@pytest.fixture
164192
def jwt_generator(ed25519_private_key: Ed25519PrivateKey):
165193
key = ed25519_private_key

airflow-core/tests/unit/api_fastapi/execution_api/conftest.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,16 @@
2222
from starlette.routing import Mount
2323

2424
from airflow.api_fastapi.app import cached_app
25+
from airflow.api_fastapi.execution_api.app import lifespan
2526
from airflow.api_fastapi.execution_api.datamodels.token import TIClaims, TIToken
26-
from airflow.api_fastapi.execution_api.security import _jwt_bearer
27+
from airflow.api_fastapi.execution_api.security import require_auth
28+
29+
30+
@pytest.fixture(autouse=True)
31+
def _restore_lifespan_registry():
32+
snapshot = dict(lifespan.registry._services)
33+
yield
34+
lifespan.registry._services = snapshot
2735

2836

2937
def _get_execution_api_app(root_app: FastAPI) -> FastAPI:
@@ -45,16 +53,15 @@ def client(request: pytest.FixtureRequest):
4553
app = cached_app(apps="execution")
4654
exec_app = _get_execution_api_app(app)
4755

48-
async def mock_jwt_bearer(request: Request):
56+
async def mock_require_auth(request: Request) -> TIToken:
4957
from uuid import UUID
5058

5159
ti_id = UUID(request.path_params.get("task_instance_id", "00000000-0000-0000-0000-000000000000"))
52-
claims = TIClaims(scope="execution")
53-
return TIToken(id=ti_id, claims=claims)
60+
return TIToken(id=ti_id, claims=TIClaims(scope="execution"))
5461

55-
exec_app.dependency_overrides[_jwt_bearer] = mock_jwt_bearer
62+
exec_app.dependency_overrides[require_auth] = mock_require_auth
5663

5764
with TestClient(app, headers={"Authorization": "Bearer fake"}) as client:
5865
yield client
5966

60-
exec_app.dependency_overrides.pop(_jwt_bearer, None)
67+
exec_app.dependency_overrides.pop(require_auth, None)

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@
2525
from airflow.api_fastapi.auth.tokens import JWTValidator
2626
from airflow.api_fastapi.execution_api.app import lifespan
2727

28-
from tests_common.test_utils.config import conf_vars
29-
3028

3129
@pytest.fixture
3230
def exec_app(client):
@@ -53,6 +51,7 @@ def test_expiring_token_is_reissued(
5351
auth = AsyncMock(spec=JWTValidator)
5452
auth.avalidated_claims.return_value = {
5553
"sub": "edb09971-4e0e-4221-ad3f-800852d38085",
54+
"iat": moment,
5655
"exp": moment + validity,
5756
}
5857

@@ -62,8 +61,7 @@ def test_expiring_token_is_reissued(
6261
lifespan.registry.register_value(JWTValidator, auth)
6362
# In order to test this we need any endpoint to hit. The easiest one to use is variable get
6463

65-
with conf_vars({("execution_api", "jwt_expiration_time"): str(validity)}):
66-
response = client.get("/execution/variables/key1", headers={"Authorization": "Bearer dummy"})
64+
response = client.get("/execution/variables/key1", headers={"Authorization": "Bearer dummy"})
6765

6866
if expect_refreshed_token:
6967
assert "Refreshed-API-Token" in response.headers

0 commit comments

Comments
 (0)