This is an automated email from the ASF dual-hosted git repository.

ashb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 0aa1b9a3330 Add OIDC worker token verification to Edge3 (#72262)
0aa1b9a3330 is described below

commit 0aa1b9a333065e9b7f52cf502f89209bf76ba8ab
Author: Robert Pofuk RBI <[email protected]>
AuthorDate: Thu Sep 24 13:46:52 2026 +0200

    Add OIDC worker token verification to Edge3 (#72262)
    
    * Add OIDC worker token verification to Edge3
    
    Edge workers could only authenticate with tokens signed by the shared
    [api_auth] jwt_secret, forcing every deployment to distribute that secret
    to each worker. Deployments that already run a central OpenID Connect
    provider want their workers to authenticate with provider-minted tokens
    instead, so the secret never has to leave the API server.
    
    Because an externally issued token proves only who signed it, a trusted
    signature alone is not authorization to act as a worker: when issuer
    verification is skipped the configuration is rejected unless an explicit
    jwt_verifier decides which identities are allowed. Verification stays
    opt-in behind [edge] trusted_jwks_url, leaving the shared-secret path
    untouched for existing deployments.
    
    * Await OIDC JWKS validation and pass request context to edge worker 
verifier
    
    Edge worker token validation ran through the synchronous validated_claims
    path, which does network I/O across per-request event loops once a JWKS URL
    is configured. Switch the authorization entry points to await the async
    validator like the rest of the codebase, resolve the jwt_verifier import 
once
    when the OIDC authenticator is built so a bad path fails fast, and hand the
    verifier a WorkerTokenContext carrying the request method so the input can
    grow without a breaking signature change while jwt_verifier is unreleased.
---
 .../src/airflow/api_fastapi/auth/tokens.py         |   3 +-
 providers/edge3/provider.yaml                      |  80 +++++
 .../airflow/providers/edge3/get_provider_info.py   |  42 +++
 .../src/airflow/providers/edge3/worker_api/auth.py | 195 ++++++++++-
 .../edge3/tests/unit/edge3/worker_api/test_auth.py | 358 +++++++++++++++++++--
 5 files changed, 643 insertions(+), 35 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/auth/tokens.py 
b/airflow-core/src/airflow/api_fastapi/auth/tokens.py
index 4ba1943c577..91518d5c72a 100644
--- a/airflow-core/src/airflow/api_fastapi/auth/tokens.py
+++ b/airflow-core/src/airflow/api_fastapi/auth/tokens.py
@@ -279,7 +279,8 @@ class JWTValidator:
     )
     # By default, we just validate these
     required_claims: frozenset[str] = frozenset({"exp", "iat", "nbf"})
-    audience: str | Sequence[str]
+    # ``None`` is passed straight to ``jwt.decode`` and accepts only tokens 
without an ``aud`` claim.
+    audience: str | Sequence[str] | None
     algorithm: list[str] = attrs.field(
         factory=_conf_list_factory("api_auth", "jwt_algorithm", 
fallback="GUESS"), converter=_to_list
     )
diff --git a/providers/edge3/provider.yaml b/providers/edge3/provider.yaml
index 79abba5aa3a..39334a8315a 100644
--- a/providers/edge3/provider.yaml
+++ b/providers/edge3/provider.yaml
@@ -108,6 +108,86 @@ config:
         type: string
         example: https://airflow.hosting.org/edge_worker/v1/rpcapi
         default: ~
+      trusted_jwks_url:
+        description: |
+          When set, edge worker tokens are verified against this JSON Web Key 
Set
+          (JWKS) URL of a trusted OpenID Connect provider, using asymmetric
+          signatures, instead of the shared ``[api_auth] jwt_secret``. This 
lets
+          edge workers authenticate with tokens minted by an external identity
+          provider.
+
+          When empty (the default) the shared-secret (symmetric) verification 
is
+          used, so existing deployments are unaffected.
+
+          Security note: enabling this disables the per-request ``method`` 
claim
+          check that the shared-secret path uses to scope a token to a single
+          endpoint (externally issued tokens do not carry it). A valid 
signature
+          then only proves who signed the token, not that the identity may act 
as
+          an edge worker. If the issuer serves other workloads, configure
+          ``jwt_issuer`` and a ``jwt_verifier`` to restrict which identities 
are
+          accepted; otherwise any validly signed token from the issuer is 
accepted.
+        version_added: 4.4.0
+        type: string
+        example: https://idp.example.com/oauth2/v1/keys
+        default: ~
+      jwt_issuer:
+        description: |
+          Expected ``iss`` claim of edge worker tokens when 
``trusted_jwks_url`` is
+          set. Leaving it empty skips issuer verification, so a token from any 
issuer
+          whose key is in the JWKS is accepted; in that case ``jwt_verifier`` 
must be
+          configured to decide which identities may act as edge workers 
(requests are
+          rejected otherwise). Set it to bind tokens to your provider. Ignored 
when
+          ``trusted_jwks_url`` is empty.
+        version_added: 4.4.0
+        type: string
+        example: https://idp.example.com
+        default: ~
+      jwt_audience:
+        description: |
+          Expected ``aud`` claim of edge worker tokens when 
``trusted_jwks_url`` is
+          set. When set, the token must carry a matching ``aud``. When empty, 
only
+          tokens that carry no ``aud`` claim are accepted (a token that does 
carry
+          an ``aud`` is rejected). Ignored when ``trusted_jwks_url`` is empty.
+        version_added: 4.4.0
+        type: string
+        example: api
+        default: ~
+      jwt_algorithm:
+        description: |
+          Comma-separated list of JWS signing algorithms accepted for edge 
worker
+          tokens when ``trusted_jwks_url`` is set. Set it to match the 
algorithm the
+          trusted provider signs with. Ignored when ``trusted_jwks_url`` is 
empty.
+        version_added: 4.4.0
+        type: string
+        example: RS256,RS512
+        default: RS256
+      jwt_leeway:
+        description: |
+          Number of seconds of leeway allowed when validating the expiry and 
issued-at
+          claims of edge worker tokens issued by a trusted OIDC provider, to 
account for
+          clock skew between the identity provider and the API server. Ignored 
when
+          ``trusted_jwks_url`` is empty.
+        version_added: 4.4.0
+        type: integer
+        example: ~
+        default: "30"
+      jwt_verifier:
+        description: |
+          Import path of a callable that authorizes edge worker tokens issued 
by a
+          trusted OIDC provider, run after signature, issuer and audience 
checks pass.
+          It receives the validated claims (``dict``) and a request context 
object
+          carrying the request ``method``, and returns a mapping with an
+          ``authorized`` boolean (e.g. ``{"authorized": True}``); a falsy 
result or a
+          raised exception rejects the request. Use it to restrict which 
identities
+          (for example by ``sub``, ``client_id`` or ``scope``) may act as edge 
workers.
+          Required when ``trusted_jwks_url`` is set but ``jwt_issuer`` is 
empty, since
+          issuer verification is then skipped. When empty (and ``jwt_issuer`` 
is set)
+          any token that passes signature, issuer and audience verification is
+          authorized. Ignored when ``trusted_jwks_url`` is empty.
+        version_added: 4.4.0
+        type: string
+        example: my_company.edge_auth.verify_worker_token
+        default: ~
       job_poll_interval:
         description: |
           Edge Worker currently polls for new jobs via HTTP. This parameter 
defines the number
diff --git a/providers/edge3/src/airflow/providers/edge3/get_provider_info.py 
b/providers/edge3/src/airflow/providers/edge3/get_provider_info.py
index 86375fece35..58488f5c327 100644
--- a/providers/edge3/src/airflow/providers/edge3/get_provider_info.py
+++ b/providers/edge3/src/airflow/providers/edge3/get_provider_info.py
@@ -53,6 +53,48 @@ def get_provider_info():
                         "example": 
"https://airflow.hosting.org/edge_worker/v1/rpcapi";,
                         "default": None,
                     },
+                    "trusted_jwks_url": {
+                        "description": "When set, edge worker tokens are 
verified against this JSON Web Key Set\n(JWKS) URL of a trusted OpenID Connect 
provider, using asymmetric\nsignatures, instead of the shared ``[api_auth] 
jwt_secret``. This lets\nedge workers authenticate with tokens minted by an 
external identity\nprovider.\n\nWhen empty (the default) the shared-secret 
(symmetric) verification is\nused, so existing deployments are 
unaffected.\n\nSecurity note: enabling this disable [...]
+                        "version_added": "4.4.0",
+                        "type": "string",
+                        "example": "https://idp.example.com/oauth2/v1/keys";,
+                        "default": None,
+                    },
+                    "jwt_issuer": {
+                        "description": "Expected ``iss`` claim of edge worker 
tokens when ``trusted_jwks_url`` is\nset. Leaving it empty skips issuer 
verification, so a token from any issuer\nwhose key is in the JWKS is accepted; 
in that case ``jwt_verifier`` must be\nconfigured to decide which identities 
may act as edge workers (requests are\nrejected otherwise). Set it to bind 
tokens to your provider. Ignored when\n``trusted_jwks_url`` is empty.\n",
+                        "version_added": "4.4.0",
+                        "type": "string",
+                        "example": "https://idp.example.com";,
+                        "default": None,
+                    },
+                    "jwt_audience": {
+                        "description": "Expected ``aud`` claim of edge worker 
tokens when ``trusted_jwks_url`` is\nset. When set, the token must carry a 
matching ``aud``. When empty, only\ntokens that carry no ``aud`` claim are 
accepted (a token that does carry\nan ``aud`` is rejected). Ignored when 
``trusted_jwks_url`` is empty.\n",
+                        "version_added": "4.4.0",
+                        "type": "string",
+                        "example": "api",
+                        "default": None,
+                    },
+                    "jwt_algorithm": {
+                        "description": "Comma-separated list of JWS signing 
algorithms accepted for edge worker\ntokens when ``trusted_jwks_url`` is set. 
Set it to match the algorithm the\ntrusted provider signs with. Ignored when 
``trusted_jwks_url`` is empty.\n",
+                        "version_added": "4.4.0",
+                        "type": "string",
+                        "example": "RS256,RS512",
+                        "default": "RS256",
+                    },
+                    "jwt_leeway": {
+                        "description": "Number of seconds of leeway allowed 
when validating the expiry and issued-at\nclaims of edge worker tokens issued 
by a trusted OIDC provider, to account for\nclock skew between the identity 
provider and the API server. Ignored when\n``trusted_jwks_url`` is empty.\n",
+                        "version_added": "4.4.0",
+                        "type": "integer",
+                        "example": None,
+                        "default": "30",
+                    },
+                    "jwt_verifier": {
+                        "description": 'Import path of a callable that 
authorizes edge worker tokens issued by a\ntrusted OIDC provider, run after 
signature, issuer and audience checks pass.\nIt receives the validated claims 
(``dict``) and a request context object\ncarrying the request ``method``, and 
returns a mapping with an\n``authorized`` boolean (e.g. ``{"authorized": 
True}``); a falsy result or a\nraised exception rejects the request. Use it to 
restrict which identities\n(for examp [...]
+                        "version_added": "4.4.0",
+                        "type": "string",
+                        "example": "my_company.edge_auth.verify_worker_token",
+                        "default": None,
+                    },
                     "job_poll_interval": {
                         "description": "Edge Worker currently polls for new 
jobs via HTTP. This parameter defines the number\nof seconds it should sleep 
between polls for new jobs.\nJob polling only happens if the Edge Worker seeks 
for new work. Not if busy.\n",
                         "version_added": None,
diff --git a/providers/edge3/src/airflow/providers/edge3/worker_api/auth.py 
b/providers/edge3/src/airflow/providers/edge3/worker_api/auth.py
index 090f65bfc92..a2b6fc64495 100644
--- a/providers/edge3/src/airflow/providers/edge3/worker_api/auth.py
+++ b/providers/edge3/src/airflow/providers/edge3/worker_api/auth.py
@@ -17,7 +17,9 @@
 from __future__ import annotations
 
 import logging
+from dataclasses import dataclass
 from functools import cache
+from typing import TYPE_CHECKING, TypedDict
 from uuid import uuid4
 
 from fastapi import Header, HTTPException, Request, status
@@ -30,14 +32,94 @@ from jwt import (
     InvalidSignatureError,
 )
 
-from airflow.api_fastapi.auth.tokens import JWTValidator
-from airflow.providers.common.compat.sdk import conf
+from airflow.api_fastapi.auth.tokens import JWKS, JWTValidator
+from airflow.providers.common.compat.sdk import AirflowConfigException, conf
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
 
 log = logging.getLogger(__name__)
 
 
+class WorkerTokenAuthorization(TypedDict, total=False):
+    """
+    Result of authorizing an OIDC worker token beyond signature verification.
+
+    Returned by a ``[edge] jwt_verifier`` callable to answer "may this token 
act
+    as an edge worker?". ``authorized`` must be ``True`` for the request to
+    proceed; a falsy result (or a raised exception) rejects it.
+    """
+
+    authorized: bool
+
+
+@dataclass(frozen=True)
+class WorkerTokenContext:
+    """Request context passed to a ``[edge] jwt_verifier`` alongside the token 
claims."""
+
+    method: str
+    """The requested Edge worker API path after the ``/edge_worker/v1/`` 
prefix."""
+
+
+def _default_jwt_verifier(claims: dict, context: WorkerTokenContext) -> 
WorkerTokenAuthorization:
+    """Authorize any token that passed signature, issuer and audience 
verification."""
+    return {"authorized": True}
+
+
+def _trusted_jwks_url() -> str:
+    """Return the configured trusted JWKS URL, or an empty string when 
unset."""
+    return conf.get("edge", "trusted_jwks_url", fallback="") or ""
+
+
+def _jwt_algorithms() -> list[str]:
+    """Return the accepted signing algorithms for OIDC worker tokens."""
+    configured = conf.get("edge", "jwt_algorithm", fallback="RS256") or "RS256"
+    return [algorithm.strip() for algorithm in configured.split(",") if 
algorithm.strip()]
+
+
+def _jwt_audience() -> str | None:
+    """Return the configured audience, or None to accept only tokens without 
an ``aud`` claim."""
+    return conf.get("edge", "jwt_audience", fallback="") or None
+
+
+def _jwt_issuer() -> str | None:
+    """Return the expected issuer, or None to skip issuer verification when 
left empty."""
+    return conf.get("edge", "jwt_issuer", fallback="") or None
+
+
+def _jwt_leeway() -> int:
+    """Return the clock-skew leeway (seconds) for OIDC worker tokens."""
+    return conf.getint("edge", "jwt_leeway", fallback=30)
+
+
+def _resolve_jwt_verifier() -> Callable[[dict, WorkerTokenContext], 
WorkerTokenAuthorization | None]:
+    """
+    Import the configured worker-authorization callable, or the permissive 
default.
+
+    Resolved once when the OIDC authenticator is built (not per request).
+    """
+    return conf.getimport("edge", "jwt_verifier", fallback=None) or 
_default_jwt_verifier
+
+
+def _jwt_verifier_configured() -> bool:
+    """Return whether an explicit ``[edge] jwt_verifier`` is set."""
+    return bool(conf.get("edge", "jwt_verifier", fallback=""))
+
+
 @cache
-def jwt_validator() -> JWTValidator:
+def _oidc_enabled() -> bool:
+    """
+    Return whether OIDC verification is enabled, decided once and cached.
+
+    The validator is also cached, so the mode must be read from a single place;
+    otherwise a request-time re-read could disagree with the cached validator 
and
+    skip the ``method``-claim check for a shared-secret token.
+    """
+    return bool(_trusted_jwks_url())
+
+
+def _shared_secret_validator() -> JWTValidator:
+    """Build a validator for worker tokens signed with the shared ``[api_auth] 
jwt_secret``."""
     return JWTValidator(
         secret_key=conf.get("api_auth", "jwt_secret"),
         leeway=conf.getint("api_auth", "jwt_leeway", fallback=30),
@@ -45,8 +127,74 @@ def jwt_validator() -> JWTValidator:
     )
 
 
-def jwt_validate(authorization: str) -> dict:
-    return jwt_validator().validated_claims(authorization)
+@dataclass(frozen=True)
+class _OidcAuthenticator:
+    """A JWKS-backed validator paired with its worker-authorization 
callable."""
+
+    validator: JWTValidator
+    verifier: Callable[[dict, WorkerTokenContext], WorkerTokenAuthorization | 
None]
+
+
+def _build_oidc_authenticator(jwks_url: str) -> _OidcAuthenticator:
+    """
+    Build the validator and worker-authorization callable for a trusted OIDC 
provider.
+
+    Verifies the token signature against the provider JWKS and checks the
+    ``iss`` and (optionally) ``aud`` claims. Used when ``[edge] 
trusted_jwks_url``
+    is configured, so workers can authenticate with tokens minted by an
+    external identity provider instead of the shared secret.
+
+    Raises when issuer verification is skipped (empty ``jwt_issuer``) without a
+    ``jwt_verifier``, since that combination would accept any token signed by a
+    key in the JWKS.
+    """
+    if not _jwt_issuer() and not _jwt_verifier_configured():
+        raise AirflowConfigException(
+            "[edge] jwt_verifier must be set when trusted_jwks_url is 
configured "
+            "without jwt_issuer, otherwise any token signed by the JWKS is 
accepted."
+        )
+    validator = JWTValidator(
+        jwks=JWKS(url=jwks_url),
+        issuer=_jwt_issuer(),
+        audience=_jwt_audience(),
+        algorithm=_jwt_algorithms(),
+        required_claims=frozenset({"iat", "exp"}),
+        leeway=_jwt_leeway(),
+    )
+    return _OidcAuthenticator(validator=validator, 
verifier=_resolve_jwt_verifier())
+
+
+@cache
+def _oidc_authenticator() -> _OidcAuthenticator:
+    """Return the cached OIDC validator and verifier for the configured JWKS 
URL."""
+    return _build_oidc_authenticator(_trusted_jwks_url())
+
+
+@cache
+def jwt_validator() -> JWTValidator:
+    if _oidc_enabled():
+        return _oidc_authenticator().validator
+    return _shared_secret_validator()
+
+
+async def jwt_validate(authorization: str) -> dict:
+    return await jwt_validator().avalidated_claims(authorization)
+
+
+def _check_worker_authorization(method: str, payload: dict) -> None:
+    """
+    Verify the token identity is allowed to act as an edge worker in OIDC mode.
+
+    A valid signature proves who signed the token, not that the identity may 
act
+    as a worker. The configured ``[edge] jwt_verifier`` answers the second
+    question; a falsy result rejects the request.
+    """
+    if not _oidc_enabled():
+        return
+
+    result = _oidc_authenticator().verifier(payload, 
WorkerTokenContext(method=method))
+    if not result or not result.get("authorized"):
+        _forbidden_response("Token is not authorized to act as an edge 
worker.")
 
 
 def _forbidden_response(message: str):
@@ -59,17 +207,32 @@ def _forbidden_response(message: str):
     )
 
 
-def jwt_token_authorization(method: str, authorization: str):
+def _check_method_claim(method: str, payload: dict) -> None:
+    """
+    Verify the signed ``method`` claim for shared-secret tokens.
+
+    Tokens minted by the Edge API carry the request ``method`` they are valid
+    for. Tokens issued by an external OIDC provider do not, so the check is
+    skipped when OIDC verification is enabled.
+    """
+    if _oidc_enabled():
+        return
+
+    signed_method = payload.get("method")
+    if not signed_method or signed_method != method:
+        _forbidden_response(
+            "Invalid method in token authorization. "
+            f"signed method='{signed_method}' "
+            f"called method='{method}'",
+        )
+
+
+async def jwt_token_authorization(method: str, authorization: str):
     """Check if the JWT token is correct."""
     try:
-        payload = jwt_validate(authorization)
-        signed_method = payload.get("method")
-        if not signed_method or signed_method != method:
-            _forbidden_response(
-                "Invalid method in token authorization. "
-                f"signed method='{signed_method}' "
-                f"called method='{method}'",
-            )
+        payload = await jwt_validate(authorization)
+        _check_method_claim(method, payload)
+        _check_worker_authorization(method, payload)
     except BadSignature:
         _forbidden_response("Bad Signature. Please use only the tokens 
provided by the API.")
     except InvalidAudienceError:
@@ -92,11 +255,11 @@ def jwt_token_authorization(method: str, authorization: 
str):
         _forbidden_response("Unable to authenticate API via token.")
 
 
-def jwt_token_authorization_rest(
+async def jwt_token_authorization_rest(
     request: Request, authorization: str = Header(description="JWT 
Authorization Token")
 ):
     """Check if the JWT token is correct for REST API requests."""
     PREFIX = "/edge_worker/v1/"
     path = request.url.path
     method_path = path[path.find(PREFIX) + len(PREFIX) :] if PREFIX in path 
else path
-    jwt_token_authorization(method_path, authorization)
+    await jwt_token_authorization(method_path, authorization)
diff --git a/providers/edge3/tests/unit/edge3/worker_api/test_auth.py 
b/providers/edge3/tests/unit/edge3/worker_api/test_auth.py
index 3d4263be855..ed7ce8e7d02 100644
--- a/providers/edge3/tests/unit/edge3/worker_api/test_auth.py
+++ b/providers/edge3/tests/unit/edge3/worker_api/test_auth.py
@@ -23,7 +23,9 @@ import pytest
 from fastapi import HTTPException, Request
 from itsdangerous import BadSignature
 
-from airflow.api_fastapi.auth.tokens import JWTGenerator
+from airflow.api_fastapi.auth.tokens import JWKS, JWTGenerator, 
generate_private_key
+from airflow.providers.common.compat.sdk import AirflowConfigException
+from airflow.providers.edge3.worker_api import auth
 from airflow.providers.edge3.worker_api.auth import (
     jwt_token_authorization,
     jwt_token_authorization_rest,
@@ -33,6 +35,11 @@ from airflow.providers.edge3.worker_api.auth import (
 from tests_common.test_utils.config import conf_vars
 
 JWT_SECRET = "test-jwt-secret"
+OIDC_JWKS_URL = "https://idp.example.com/keys";
+OIDC_ISSUER = "https://idp.example.com";
+OIDC_KID = "kid1"
+
+pytestmark = [pytest.mark.asyncio]
 
 
 def _token(method: str | None = "test.method", secret: str = JWT_SECRET) -> 
str:
@@ -45,28 +52,32 @@ def _token(method: str | None = "test.method", secret: str 
= JWT_SECRET) -> str:
 
 @pytest.fixture(autouse=True)
 def _reset_jwt_validator_cache():
-    # jwt_validator() is cached: make sure config overrides in one test can 
never leak
-    # a stale validator into another.
+    # jwt_validator(), _oidc_enabled() and _oidc_authenticator() are cached: 
make sure
+    # config overrides in one test can never leak a stale validator or mode 
into another.
     jwt_validator.cache_clear()
+    auth._oidc_enabled.cache_clear()
+    auth._oidc_authenticator.cache_clear()
     yield
     jwt_validator.cache_clear()
+    auth._oidc_enabled.cache_clear()
+    auth._oidc_authenticator.cache_clear()
 
 
 class TestJwtTokenAuthorization:
-    @conf_vars({("api_auth", "jwt_secret"): JWT_SECRET, ("api_auth", 
"jwt_leeway"): "5"})
-    def test_matching_method_claim_is_authorized(self):
-        jwt_token_authorization("test.method", _token("test.method"))
+    async def test_matching_method_claim_is_authorized(self):
+        with conf_vars({("api_auth", "jwt_secret"): JWT_SECRET, ("api_auth", 
"jwt_leeway"): "5"}):
+            await jwt_token_authorization("test.method", _token("test.method"))
 
-    @conf_vars({("api_auth", "jwt_secret"): JWT_SECRET, ("api_auth", 
"jwt_leeway"): "5"})
-    def test_missing_method_claim_is_forbidden(self):
-        with pytest.raises(HTTPException) as exc_info:
-            jwt_token_authorization("test.method", _token(method=None))
+    async def test_missing_method_claim_is_forbidden(self):
+        with conf_vars({("api_auth", "jwt_secret"): JWT_SECRET, ("api_auth", 
"jwt_leeway"): "5"}):
+            with pytest.raises(HTTPException) as exc_info:
+                await jwt_token_authorization("test.method", 
_token(method=None))
         assert exc_info.value.status_code == 403
 
-    @conf_vars({("api_auth", "jwt_secret"): JWT_SECRET, ("api_auth", 
"jwt_leeway"): "5"})
-    def test_mismatched_method_claim_is_forbidden(self):
-        with pytest.raises(HTTPException) as exc_info:
-            jwt_token_authorization("test.method", _token("other.method"))
+    async def test_mismatched_method_claim_is_forbidden(self):
+        with conf_vars({("api_auth", "jwt_secret"): JWT_SECRET, ("api_auth", 
"jwt_leeway"): "5"}):
+            with pytest.raises(HTTPException) as exc_info:
+                await jwt_token_authorization("test.method", 
_token("other.method"))
         assert exc_info.value.status_code == 403
 
 
@@ -90,11 +101,11 @@ class TestJwtTokenAuthorizationForbiddenResponse:
         ],
     )
     @mock.patch("airflow.providers.edge3.worker_api.auth.jwt_validate", 
autospec=True)
-    def test_each_handled_failure_is_forbidden_and_anonymized(self, 
mock_jwt_validate, error):
+    async def test_each_handled_failure_is_forbidden_and_anonymized(self, 
mock_jwt_validate, error):
         mock_jwt_validate.side_effect = error
 
         with pytest.raises(HTTPException) as exc_info:
-            jwt_token_authorization("test.method", "some-token")
+            await jwt_token_authorization("test.method", "some-token")
 
         assert exc_info.value.status_code == 403
         assert "error_id=" in exc_info.value.detail
@@ -141,12 +152,323 @@ class TestJwtTokenAuthorizationRest:
         ],
     )
     
@mock.patch("airflow.providers.edge3.worker_api.auth.jwt_token_authorization", 
autospec=True)
-    def test_strips_edge_worker_v1_prefix_and_falls_back_to_full_path(
+    async def test_strips_edge_worker_v1_prefix_and_falls_back_to_full_path(
         self, mock_jwt_token_authorization, path, expected_method
     ):
         request = mock.MagicMock(spec=Request)
         request.url.path = path
 
-        jwt_token_authorization_rest(request, authorization="some-token")
+        await jwt_token_authorization_rest(request, authorization="some-token")
 
         mock_jwt_token_authorization.assert_called_once_with(expected_method, 
"some-token")
+
+
+class TestOidcConfig:
+    """Config readers translate raw ``[edge]`` options into validator 
inputs."""
+
+    @pytest.mark.parametrize(
+        ("configured", "expected"),
+        [
+            pytest.param(None, ["RS256"], id="default-rs256"),
+            pytest.param("RS512", ["RS512"], id="single-algorithm"),
+            pytest.param("RS256, RS512", ["RS256", "RS512"], 
id="comma-list-is-split-and-stripped"),
+        ],
+    )
+    def test_oidc_algorithms_parsing(self, configured, expected):
+        """Unset falls back to RS256; a configured value is split on commas 
and stripped."""
+        overrides = {} if configured is None else {("edge", "jwt_algorithm"): 
configured}
+        with conf_vars(overrides):
+            assert auth._jwt_algorithms() == expected
+
+    @pytest.mark.parametrize(
+        ("configured", "expected"),
+        [
+            pytest.param(None, None, 
id="empty-is-none-accepts-only-aud-less-tokens"),
+            pytest.param("api", "api", id="configured-value-passed-through"),
+        ],
+    )
+    def test_oidc_audience_parsing(self, configured, expected):
+        """An empty audience becomes ``None`` (accept only aud-less tokens); a 
set value is forwarded."""
+        overrides = {} if configured is None else {("edge", "jwt_audience"): 
configured}
+        with conf_vars(overrides):
+            assert auth._jwt_audience() == expected
+
+    @pytest.mark.parametrize(
+        ("configured", "expected"),
+        [
+            pytest.param(None, None, id="empty-is-none-skips-issuer-check"),
+            pytest.param(
+                "https://idp.example.com";, "https://idp.example.com";, 
id="configured-passed-through"
+            ),
+        ],
+    )
+    def test_oidc_issuer_parsing(self, configured, expected):
+        """An empty issuer becomes ``None`` (skip issuer check); a set value 
is forwarded verbatim."""
+        overrides = {} if configured is None else {("edge", "jwt_issuer"): 
configured}
+        with conf_vars(overrides):
+            assert auth._jwt_issuer() == expected
+
+    @pytest.mark.parametrize(
+        ("configured", "expected"),
+        [
+            pytest.param(None, 30, id="default-30"),
+            pytest.param("90", 90, id="configured-value-parsed-as-int"),
+        ],
+    )
+    def test_oidc_leeway_parsing(self, configured, expected):
+        """Unset falls back to 30 seconds; a configured value is read as an 
integer."""
+        overrides = {} if configured is None else {("edge", "jwt_leeway"): 
configured}
+        with conf_vars(overrides):
+            assert auth._jwt_leeway() == expected
+
+
+class TestJwtValidatorSelection:
+    """``jwt_validator`` picks shared-secret vs OIDC based on 
``trusted_jwks_url``."""
+
+    @conf_vars({("api_auth", "jwt_secret"): "secret"})
+    def test_uses_shared_secret_validator_when_oidc_jwks_url_unset(self):
+        """Default path is unchanged: a shared-secret validator with no JWKS 
is built."""
+        validator = auth.jwt_validator()
+
+        assert validator.secret_key == "secret"
+
+        assert validator.jwks is None
+
+    @conf_vars(
+        {
+            ("edge", "trusted_jwks_url"): OIDC_JWKS_URL,
+            ("edge", "jwt_issuer"): "https://idp.example.com";,
+            ("edge", "jwt_algorithm"): "RS512",
+            ("edge", "jwt_leeway"): "90",
+        }
+    )
+    def test_uses_oidc_validator_when_jwks_url_set(self):
+        """Setting ``trusted_jwks_url`` builds a JWKS-backed validator wired 
from ``[edge]`` config."""
+        validator = auth.jwt_validator()
+
+        assert validator.jwks is not None
+
+        assert validator.algorithm == ["RS512"]
+
+        assert validator.issuer == "https://idp.example.com";
+
+        assert validator.audience is None
+
+        assert validator.leeway == 90
+
+    @conf_vars({("edge", "trusted_jwks_url"): OIDC_JWKS_URL})
+    def test_empty_issuer_without_verifier_fails_closed(self):
+        """Skipping issuer verification without a ``jwt_verifier`` is a hard 
config error."""
+        with pytest.raises(AirflowConfigException, match="jwt_verifier"):
+            auth.jwt_validator()
+
+    @conf_vars(
+        {
+            ("edge", "trusted_jwks_url"): OIDC_JWKS_URL,
+            ("edge", "jwt_verifier"): 
"airflow.providers.edge3.worker_api.auth._default_jwt_verifier",
+        }
+    )
+    def test_empty_issuer_with_verifier_is_allowed(self):
+        """A configured ``jwt_verifier`` permits skipping issuer 
verification."""
+        validator = auth.jwt_validator()
+
+        assert validator.jwks is not None
+
+        assert validator.issuer is None
+
+    @conf_vars(
+        {
+            ("edge", "trusted_jwks_url"): OIDC_JWKS_URL,
+            ("edge", "jwt_issuer"): OIDC_ISSUER,
+            ("edge", "jwt_verifier"): "no.such.module.verify",
+        }
+    )
+    def test_unimportable_verifier_fails_closed_at_construction(self):
+        """A typo'd ``jwt_verifier`` path fails when the authenticator is 
built, not per request."""
+        with pytest.raises(AirflowConfigException):
+            auth.jwt_validator()
+
+
+class TestMethodClaimCheck:
+    """``_check_method_claim`` enforces the signed ``method`` only for 
shared-secret tokens."""
+
+    @conf_vars({("api_auth", "jwt_secret"): "secret"})
+    def test_shared_secret_rejects_mismatched_method(self):
+        """A shared-secret token minted for another endpoint is forbidden 
(403)."""
+        with pytest.raises(HTTPException) as exc_info:
+            auth._check_method_claim("worker/register", {"method": 
"worker/other"})
+
+        assert exc_info.value.status_code == 403
+
+    @conf_vars({("api_auth", "jwt_secret"): "secret"})
+    def test_shared_secret_accepts_matching_method(self):
+        """A shared-secret token whose ``method`` matches the request passes 
without raising."""
+        auth._check_method_claim("worker/register", {"method": 
"worker/register"})
+
+    @conf_vars({("edge", "trusted_jwks_url"): OIDC_JWKS_URL})
+    def test_oidc_skips_method_claim(self):
+        """OIDC tokens carry no ``method`` claim, so the check is skipped 
rather than 403."""
+        auth._check_method_claim("worker/register", {})
+
+
[email protected](scope="session")
+def oidc_private_key():
+    return generate_private_key()
+
+
[email protected]
+def in_memory_jwks(oidc_private_key):
+    # Replace the network-fetching JWKS(url=...) the OIDC validator builds 
with an
+    # in-memory keyset, so signed tokens can be driven through the real 
validator.
+    jwks = JWKS.from_private_key((oidc_private_key, OIDC_KID))
+    with mock.patch.object(auth, "JWKS", return_value=jwks):
+        yield
+
+
+def _oidc_token(oidc_private_key, *, audience: str = "", issuer: str = 
OIDC_ISSUER, sub: str = "") -> str:
+    generator = JWTGenerator(
+        private_key=oidc_private_key,
+        kid=OIDC_KID,
+        valid_for=300,
+        audience=audience,
+        issuer=issuer,
+        algorithm="RS256",
+    )
+    return generator.generate(extras={"sub": sub} if sub else {})
+
+
+def verify_only_known_sub(claims: dict, context: auth.WorkerTokenContext) -> 
auth.WorkerTokenAuthorization:
+    """Test ``jwt_verifier``: authorize only a known service-account 
``sub``."""
+    return {"authorized": claims.get("sub") == "dc-service-account"}
+
+
+VERIFY_ONLY_KNOWN_SUB = f"{__name__}.verify_only_known_sub"
+
+
+class TestOidcAudienceVerification:
+    """
+    Pin the real audience semantics by driving signed tokens through the OIDC 
validator.
+
+    ``jwt_audience`` empty means ``audience=None``, which PyJWT accepts only 
for
+    tokens that carry no ``aud`` claim and rejects for tokens that do. A 
configured
+    value requires a matching ``aud``.
+    """
+
+    async def test_empty_audience_accepts_aud_less_token(self, 
oidc_private_key, in_memory_jwks):
+        with conf_vars({("edge", "trusted_jwks_url"): OIDC_JWKS_URL, ("edge", 
"jwt_issuer"): OIDC_ISSUER}):
+            await jwt_token_authorization("worker/register", 
_oidc_token(oidc_private_key, audience=""))
+
+    async def test_empty_audience_rejects_token_with_aud(self, 
oidc_private_key, in_memory_jwks):
+        with conf_vars({("edge", "trusted_jwks_url"): OIDC_JWKS_URL, ("edge", 
"jwt_issuer"): OIDC_ISSUER}):
+            with pytest.raises(HTTPException) as exc_info:
+                await jwt_token_authorization(
+                    "worker/register", _oidc_token(oidc_private_key, 
audience="api")
+                )
+        assert exc_info.value.status_code == 403
+
+    async def test_configured_audience_requires_match(self, oidc_private_key, 
in_memory_jwks):
+        with conf_vars(
+            {
+                ("edge", "trusted_jwks_url"): OIDC_JWKS_URL,
+                ("edge", "jwt_issuer"): OIDC_ISSUER,
+                ("edge", "jwt_audience"): "api",
+            }
+        ):
+            await jwt_token_authorization("worker/register", 
_oidc_token(oidc_private_key, audience="api"))
+            with pytest.raises(HTTPException) as exc_info:
+                await jwt_token_authorization(
+                    "worker/register", _oidc_token(oidc_private_key, 
audience="other")
+                )
+        assert exc_info.value.status_code == 403
+
+
+class TestOidcTokenValidation:
+    """Drive signed tokens through the real ``_oidc_validator()`` and JWKS 
path."""
+
+    async def test_trusted_issuer_but_unrelated_identity_is_rejected(self, 
oidc_private_key, in_memory_jwks):
+        with conf_vars(
+            {
+                ("edge", "trusted_jwks_url"): OIDC_JWKS_URL,
+                ("edge", "jwt_issuer"): OIDC_ISSUER,
+                ("edge", "jwt_verifier"): VERIFY_ONLY_KNOWN_SUB,
+            }
+        ):
+            token = _oidc_token(oidc_private_key, issuer=OIDC_ISSUER, 
sub="someone-else")
+            with pytest.raises(HTTPException) as exc_info:
+                await jwt_token_authorization("worker/register", token)
+        assert exc_info.value.status_code == 403
+
+    async def test_authorized_identity_from_trusted_issuer_is_accepted(
+        self, oidc_private_key, in_memory_jwks
+    ):
+        with conf_vars(
+            {
+                ("edge", "trusted_jwks_url"): OIDC_JWKS_URL,
+                ("edge", "jwt_issuer"): OIDC_ISSUER,
+                ("edge", "jwt_verifier"): VERIFY_ONLY_KNOWN_SUB,
+            }
+        ):
+            token = _oidc_token(oidc_private_key, issuer=OIDC_ISSUER, 
sub="dc-service-account")
+            await jwt_token_authorization("worker/register", token)
+
+    async def test_token_from_untrusted_issuer_is_rejected(self, 
oidc_private_key, in_memory_jwks):
+        with conf_vars({("edge", "trusted_jwks_url"): OIDC_JWKS_URL, ("edge", 
"jwt_issuer"): OIDC_ISSUER}):
+            token = _oidc_token(oidc_private_key, 
issuer="https://evil.example.com";)
+            with pytest.raises(HTTPException) as exc_info:
+                await jwt_token_authorization("worker/register", token)
+        assert exc_info.value.status_code == 403
+
+
+class TestWorkerAuthorization:
+    """
+    ``_check_worker_authorization`` gates OIDC tokens on the ``jwt_verifier``.
+
+    A valid signature is not authorization: the verifier answers whether the
+    identity may act as an edge worker.
+    """
+
+    def test_no_check_in_shared_secret_mode(self):
+        """The verifier is not consulted when OIDC is disabled."""
+        with mock.patch.object(auth, "_oidc_authenticator") as authenticator:
+            auth._check_worker_authorization("worker/register", {"sub": 
"anyone"})
+        authenticator.assert_not_called()
+
+    @conf_vars({("edge", "trusted_jwks_url"): OIDC_JWKS_URL, ("edge", 
"jwt_verifier"): VERIFY_ONLY_KNOWN_SUB})
+    def test_configured_verifier_rejects_unauthorized_identity(self):
+        """A token from the trusted issuer but an unrelated identity is 
rejected."""
+        with pytest.raises(HTTPException) as exc_info:
+            auth._check_worker_authorization("worker/register", {"sub": 
"someone-else"})
+        assert exc_info.value.status_code == 403
+
+    @conf_vars({("edge", "trusted_jwks_url"): OIDC_JWKS_URL, ("edge", 
"jwt_verifier"): VERIFY_ONLY_KNOWN_SUB})
+    def test_configured_verifier_authorizes_known_identity(self):
+        """A token whose identity the verifier accepts passes without 
raising."""
+        auth._check_worker_authorization("worker/register", {"sub": 
"dc-service-account"})
+
+    @mock.patch.object(
+        auth, "jwt_validate", new_callable=mock.AsyncMock, 
return_value={"sub": "dc-service-account"}
+    )
+    @mock.patch.object(auth, "_oidc_authenticator")
+    async def test_verifier_raising_is_forbidden(self, mock_authenticator, 
_mock_validate):
+        """A verifier that raises is caught by the entry point and collapsed 
to a 403."""
+        mock_authenticator.return_value.verifier = 
mock.Mock(side_effect=ValueError("nope"))
+        with conf_vars({("edge", "trusted_jwks_url"): OIDC_JWKS_URL, ("edge", 
"jwt_issuer"): OIDC_ISSUER}):
+            with pytest.raises(HTTPException) as exc_info:
+                await auth.jwt_token_authorization("worker/register", 
"some-token")
+        assert exc_info.value.status_code == 403
+
+    def test_default_verifier_returns_authorized(self):
+        assert auth._default_jwt_verifier(
+            {"sub": "anyone"}, 
auth.WorkerTokenContext(method="worker/register")
+        ) == {"authorized": True}
+
+    @conf_vars({("edge", "trusted_jwks_url"): OIDC_JWKS_URL, ("edge", 
"jwt_verifier"): VERIFY_ONLY_KNOWN_SUB})
+    @mock.patch.object(auth, "_oidc_authenticator")
+    def test_verifier_receives_request_context(self, mock_authenticator):
+        """The verifier is handed the request method via 
``WorkerTokenContext``."""
+        verifier = mock.Mock(return_value={"authorized": True})
+        mock_authenticator.return_value.verifier = verifier
+        auth._check_worker_authorization("jobs/fetch/worker1", {"sub": 
"dc-service-account"})
+        verifier.assert_called_once_with(
+            {"sub": "dc-service-account"}, 
auth.WorkerTokenContext(method="jobs/fetch/worker1")
+        )

Reply via email to