From 4b349fc07ef019ec3d33e9a8a6f44d96d8c96c3a Mon Sep 17 00:00:00 2001 From: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:16:21 -0500 Subject: [PATCH 1/4] fix: Reuse the OIDC JWKS client across requests _decode_token constructed a fresh PyJWKClient on every call. The JWK-set cache lives on the instance, so every authenticated request started cold and paid a full HTTPS fetch of the JWKS document (TLS handshake included) before signature verification. That blocking I/O also runs on the event loop, so concurrent requests serialized behind it. Build the client lazily once per parser instead (the parser is constructed once per process by init_auth_manager), letting PyJWT's built-in JWK-set cache (default lifespan 300s) do its job. IdP key rotation stays safe: PyJWKClient.get_signing_key refreshes the set and retries once whenever it sees an unknown kid. Local benchmark, 25 authenticated decodes against a mock JWKS endpoint: 25 JWKS fetches before, 1 after. Signed-off-by: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> --- .../permissions/auth/oidc_token_parser.py | 44 ++++++++++++------- .../permissions/auth/test_token_parser.py | 32 ++++++++++++++ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index c02ec2b81ef..015e20b8928 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -41,6 +41,34 @@ def __init__(self, auth_config: OidcAuthConfig): ca_cert_path=self._auth_config.ca_cert_path, ) self._k8s_auth_api = None + self._jwks_client: Optional[PyJWKClient] = None # Initialize it lazily. + + def _get_jwks_client(self) -> PyJWKClient: + """Lazily build and cache a parser-lifetime ``PyJWKClient``. + + A per-request client starts with a cold JWK-set cache, so every + authenticated request pays a full HTTPS fetch of the JWKS document + (TLS handshake included) before signature verification, and + concurrent requests serialize behind that blocking I/O. Reusing one + client lets PyJWT's built-in JWK-set cache do its job; on IdP key + rotation the client refetches automatically when it sees an unknown + ``kid`` (``PyJWKClient.get_signing_key`` refreshes and retries once). + """ + if self._jwks_client is None: + ssl_ctx = ssl.create_default_context() + if not self._auth_config.verify_ssl: + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + elif self._auth_config.ca_cert_path and os.path.exists( + self._auth_config.ca_cert_path + ): + ssl_ctx.load_verify_locations(self._auth_config.ca_cert_path) + self._jwks_client = PyJWKClient( + self.oidc_discovery_service.get_jwks_url(), + headers={"User-agent": "custom-user-agent"}, + ssl_context=ssl_ctx, + ) + return self._jwks_client async def _validate_token(self, access_token: str): """ @@ -125,21 +153,7 @@ def _decode_token(self, access_token: str) -> dict: metadata (e.g. Entra ID v1.0 tokens validated against a v2.0 discovery document). """ - optional_custom_headers = {"User-agent": "custom-user-agent"} - ssl_ctx = ssl.create_default_context() - if not self._auth_config.verify_ssl: - ssl_ctx.check_hostname = False - ssl_ctx.verify_mode = ssl.CERT_NONE - elif self._auth_config.ca_cert_path and os.path.exists( - self._auth_config.ca_cert_path - ): - ssl_ctx.load_verify_locations(self._auth_config.ca_cert_path) - jwks_client = PyJWKClient( - self.oidc_discovery_service.get_jwks_url(), - headers=optional_custom_headers, - ssl_context=ssl_ctx, - ) - signing_key = jwks_client.get_signing_key_from_jwt(access_token) + signing_key = self._get_jwks_client().get_signing_key_from_jwt(access_token) expected_audience = self._auth_config.audience expected_issuer = self._auth_config.issuer return jwt.decode( diff --git a/sdk/python/tests/unit/permissions/auth/test_token_parser.py b/sdk/python/tests/unit/permissions/auth/test_token_parser.py index 8f0c82367d5..dd59433934c 100644 --- a/sdk/python/tests/unit/permissions/auth/test_token_parser.py +++ b/sdk/python/tests/unit/permissions/auth/test_token_parser.py @@ -671,6 +671,38 @@ def test_oidc_default_supports_v1_tokens_against_v2_discovery( assertpy.assert_that(user.roles).is_equal_to(["reader"]) +@patch( + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" +) +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient") +def test_oidc_jwks_client_is_reused_across_requests( + mock_jwks_client_cls, + mock_discovery_data, + mock_jwt, + mock_oauth2, + oidc_config, + discovery_data, +): + """The JWKS client must be built once per parser, not once per request: + a fresh client starts with a cold JWK-set cache, which forces a full + HTTPS fetch of the JWKS document on every authenticated call.""" + mock_discovery_data.return_value = discovery_data + mock_jwt.return_value = {"preferred_username": "my-name"} + + token_parser = OidcTokenParser(auth_config=oidc_config) + for _ in range(3): + asyncio.run( + token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc") + ) + + assertpy.assert_that(mock_jwks_client_cls.call_count).is_equal_to(1) + assertpy.assert_that( + mock_jwks_client_cls.return_value.get_signing_key_from_jwt.call_count + ).is_equal_to(3) + + # TODO RBAC: Move role bindings to a reusable fixture @patch("feast.permissions.auth.kubernetes_token_parser.config.load_incluster_config") @patch("feast.permissions.auth.kubernetes_token_parser.jwt.decode") From 602feb009b7dc468765af85bdc744c3c51d71dc9 Mon Sep 17 00:00:00 2001 From: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:59:26 -0500 Subject: [PATCH 2/4] fix: Harden JWKS client reuse from review findings Floor pyjwt at 2.13.0: the reuse makes PyJWT's JWK-set cache semantics load-bearing, and only >=2.13 keeps the cache through transient fetch failures (2.5-2.12 wipe it; pre-2.5 has no cache, silently reverting the fix). Pass lifespan and timeout explicitly so upgrades cannot change the documented staleness window and a hung IdP bounds how long a fetch can block the serving path. Scope the rotation docstring honestly: new-kid rotations recover immediately, but a removed key keeps validating and a reused-kid rotation keeps failing for up to the cache lifespan. Test hardening from mutation results: pin the SSL context per verify_ssl config (both dropping ssl_context and inverting the guard previously left all tests green), pin laziness (no construction before the first request), pin per-parser scoping (second parser builds its own client), and pin construction-failure recovery (a failed first build must not wedge the process-singleton parser). Signed-off-by: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> --- pyproject.toml | 4 +- .../permissions/auth/oidc_token_parser.py | 21 ++- .../permissions/auth/test_token_parser.py | 143 ++++++++++++++---- 3 files changed, 128 insertions(+), 40 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e4623b51509..e2441730106 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,9 @@ dependencies = [ "prometheus_client>=0.20.0,<0.25.0", "psutil", "bigtree>=0.19.2", - "pyjwt", + # >=2.13 for PyJWKClient's JWK-set cache surviving transient fetch + # failures; the OIDC token parser reuses one client and relies on it. + "pyjwt>=2.13.0", ] [project.optional-dependencies] diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 015e20b8928..ba145487dc8 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -46,13 +46,15 @@ def __init__(self, auth_config: OidcAuthConfig): def _get_jwks_client(self) -> PyJWKClient: """Lazily build and cache a parser-lifetime ``PyJWKClient``. - A per-request client starts with a cold JWK-set cache, so every - authenticated request pays a full HTTPS fetch of the JWKS document - (TLS handshake included) before signature verification, and - concurrent requests serialize behind that blocking I/O. Reusing one - client lets PyJWT's built-in JWK-set cache do its job; on IdP key - rotation the client refetches automatically when it sees an unknown - ``kid`` (``PyJWKClient.get_signing_key`` refreshes and retries once). + A per-request client starts with a cold JWK-set cache, forcing a + full HTTPS fetch of the JWKS document on every authenticated + request. Reusing one client lets PyJWT cache the JWK set for + ``lifespan`` seconds, which also bounds two staleness windows: a + key the IdP has removed keeps validating, and a rotation that + reuses an existing ``kid`` keeps failing, for at most that long. + Rotations that introduce a new ``kid`` recover immediately + (``PyJWKClient.get_signing_key`` refreshes and retries once on a + cache miss). """ if self._jwks_client is None: ssl_ctx = ssl.create_default_context() @@ -67,6 +69,11 @@ def _get_jwks_client(self) -> PyJWKClient: self.oidc_discovery_service.get_jwks_url(), headers={"User-agent": "custom-user-agent"}, ssl_context=ssl_ctx, + # Explicit so upgrades cannot silently change the staleness + # window documented above, and so a hung IdP bounds how long + # a fetch can block the serving path. + lifespan=300, + timeout=10, ) return self._jwks_client diff --git a/sdk/python/tests/unit/permissions/auth/test_token_parser.py b/sdk/python/tests/unit/permissions/auth/test_token_parser.py index dd59433934c..e97d9476cdd 100644 --- a/sdk/python/tests/unit/permissions/auth/test_token_parser.py +++ b/sdk/python/tests/unit/permissions/auth/test_token_parser.py @@ -1,5 +1,6 @@ import asyncio import os +import ssl import time from unittest import mock from unittest.mock import MagicMock, patch @@ -472,6 +473,116 @@ async def mock_oath2(self, request): assertpy.assert_that(user.has_matching_role(["updater"])).is_false() +# --------------------------------------------------------------------------- +# JWKS client lifecycle (one lazy client per parser) +# --------------------------------------------------------------------------- + + +@patch( + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" +) +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient") +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") +def test_oidc_jwks_client_is_lazy_and_reused_per_parser( + mock_discovery_data, + mock_jwks_client_cls, + mock_jwt, + mock_oauth2, + oidc_config, + discovery_data, +): + """One JWKS client per parser: built on the first request (not at + construction, which would move a blocking discovery fetch into server + startup), reused across requests, and scoped to the parser instance.""" + mock_discovery_data.return_value = discovery_data + mock_jwt.return_value = {"preferred_username": "my-name"} + + token_parser = OidcTokenParser(auth_config=oidc_config) + assertpy.assert_that(mock_jwks_client_cls.call_count).is_equal_to(0) + + for _ in range(3): + asyncio.run( + token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc") + ) + assertpy.assert_that(mock_jwks_client_cls.call_count).is_equal_to(1) + + # A second parser must not see the first parser's client: each parser + # verifies against the JWKS of its own configured provider. + other_parser = OidcTokenParser(auth_config=oidc_config) + asyncio.run(other_parser.user_details_from_access_token(access_token="aaa-bbb-ccc")) + assertpy.assert_that(mock_jwks_client_cls.call_count).is_equal_to(2) + + +@pytest.mark.parametrize("verify_ssl", [True, False]) +@patch( + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" +) +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient") +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") +def test_oidc_jwks_client_ssl_context_follows_config( + mock_discovery_data, + mock_jwks_client_cls, + mock_jwt, + mock_oauth2, + verify_ssl, + discovery_data, +): + """The client is built with the discovery JWKS URL and an SSL context + matching verify_ssl: default configs must keep certificate verification + on, and verify_ssl=False must be the only way to turn it off.""" + mock_discovery_data.return_value = discovery_data + mock_jwt.return_value = {"preferred_username": "my-name"} + + token_parser = OidcTokenParser(auth_config=_oidc_config_with(verify_ssl=verify_ssl)) + asyncio.run(token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc")) + + call = mock_jwks_client_cls.call_args + assertpy.assert_that(call.args[0]).is_equal_to(discovery_data["jwks_uri"]) + ssl_ctx = call.kwargs["ssl_context"] + if verify_ssl: + assertpy.assert_that(ssl_ctx.verify_mode).is_equal_to(ssl.CERT_REQUIRED) + assertpy.assert_that(ssl_ctx.check_hostname).is_true() + else: + assertpy.assert_that(ssl_ctx.verify_mode).is_equal_to(ssl.CERT_NONE) + assertpy.assert_that(ssl_ctx.check_hostname).is_false() + + +@patch( + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" +) +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient") +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") +def test_oidc_jwks_client_construction_failure_is_retried( + mock_discovery_data, + mock_jwks_client_cls, + mock_jwt, + mock_oauth2, + oidc_config, + discovery_data, +): + """A failed first construction must leave the parser able to retry on + the next request: the parser is a process singleton, so caching a failed + or half-built client would wedge authentication until restart.""" + mock_discovery_data.return_value = discovery_data + mock_jwt.return_value = {"preferred_username": "my-name"} + mock_jwks_client_cls.side_effect = [RuntimeError("IdP unreachable"), MagicMock()] + + token_parser = OidcTokenParser(auth_config=oidc_config) + with pytest.raises(RuntimeError): + asyncio.run( + token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc") + ) + + user = asyncio.run( + token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc") + ) + assertpy.assert_that(user.username).is_equal_to("my-name") + assertpy.assert_that(mock_jwks_client_cls.call_count).is_equal_to(2) + + # --------------------------------------------------------------------------- # Optional audience / issuer verification (opt-in via OidcAuthConfig) # --------------------------------------------------------------------------- @@ -671,38 +782,6 @@ def test_oidc_default_supports_v1_tokens_against_v2_discovery( assertpy.assert_that(user.roles).is_equal_to(["reader"]) -@patch( - "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" -) -@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") -@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") -@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient") -def test_oidc_jwks_client_is_reused_across_requests( - mock_jwks_client_cls, - mock_discovery_data, - mock_jwt, - mock_oauth2, - oidc_config, - discovery_data, -): - """The JWKS client must be built once per parser, not once per request: - a fresh client starts with a cold JWK-set cache, which forces a full - HTTPS fetch of the JWKS document on every authenticated call.""" - mock_discovery_data.return_value = discovery_data - mock_jwt.return_value = {"preferred_username": "my-name"} - - token_parser = OidcTokenParser(auth_config=oidc_config) - for _ in range(3): - asyncio.run( - token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc") - ) - - assertpy.assert_that(mock_jwks_client_cls.call_count).is_equal_to(1) - assertpy.assert_that( - mock_jwks_client_cls.return_value.get_signing_key_from_jwt.call_count - ).is_equal_to(3) - - # TODO RBAC: Move role bindings to a reusable fixture @patch("feast.permissions.auth.kubernetes_token_parser.config.load_incluster_config") @patch("feast.permissions.auth.kubernetes_token_parser.jwt.decode") From c62e9467ebc13f71f3cf046bc882af2659e146b3 Mon Sep 17 00:00:00 2001 From: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:31:56 -0500 Subject: [PATCH 3/4] feat: Make the JWKS cache lifespan and fetch timeout configurable Review feedback on #6683: expose the two PyJWKClient tunables as OidcAuthConfig options rather than hardcoding them. jwks_cache_lifespan_seconds (default 300) also bounds how long a key the IdP has revoked keeps validating tokens, so operators whose provider rotates or revokes aggressively can tighten it at the cost of more JWKS fetches. jwks_request_timeout_seconds (default 10) bounds how long an unresponsive IdP blocks the serving path. Both reject non-positive values at config load: a zero or negative lifespan would expire the cache immediately and silently restore a JWKS fetch per request, undoing this PR. Signed-off-by: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> --- .../components/authz_manager.md | 13 ++++ .../permissions/auth/oidc_token_parser.py | 16 ++--- sdk/python/feast/permissions/auth_model.py | 11 +++- .../permissions/auth/test_token_parser.py | 62 +++++++++++++++++++ 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/docs/getting-started/components/authz_manager.md b/docs/getting-started/components/authz_manager.md index abc97594e61..12fdf2f39e5 100644 --- a/docs/getting-started/components/authz_manager.md +++ b/docs/getting-started/components/authz_manager.md @@ -122,6 +122,19 @@ A token whose `aud` (or `iss`) claim does not match is rejected at authenticatio Set these to the values your IdP puts **in the token itself**, which are not always the ones in the discovery document. For example, Microsoft Entra ID commonly issues v1.0 tokens (`iss: https://sts.windows.net//`, `aud: api://`) even when `auth_discovery_url` points at the v2.0 endpoint. That setup keeps working with these options unset, or set to the v1.0 values — but copying the v2.0 issuer from the discovery document would reject every v1.0 token. {% endhint %} +To validate token signatures the server fetches the provider's JWKS document and caches it, refetching when the cache expires or when a token presents an unknown key id. Two options tune that behavior: + +```yaml +auth: + type: oidc + client_id: _CLIENT_ID_ + auth_discovery_url: https://login.example.com/.well-known/openid-configuration + jwks_cache_lifespan_seconds: 300 # default; how long the fetched key set is reused + jwks_request_timeout_seconds: 10 # default; network timeout for the JWKS fetch +``` + +`jwks_cache_lifespan_seconds` also bounds how long a key the provider has **revoked** continues to validate tokens, so lower it if your provider rotates or revokes aggressively; each reduction costs proportionally more JWKS fetches. Key rotations that introduce a new key id are picked up immediately regardless of this setting, because an unknown key id triggers a refetch. `jwks_request_timeout_seconds` bounds how long an unresponsive provider can block request serving. Both must be greater than zero. + #### Client-Side Configuration The client supports multiple token source modes. The SDK resolves tokens in the following priority order: diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index ba145487dc8..e2b8aeb79cf 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -49,12 +49,12 @@ def _get_jwks_client(self) -> PyJWKClient: A per-request client starts with a cold JWK-set cache, forcing a full HTTPS fetch of the JWKS document on every authenticated request. Reusing one client lets PyJWT cache the JWK set for - ``lifespan`` seconds, which also bounds two staleness windows: a - key the IdP has removed keeps validating, and a rotation that - reuses an existing ``kid`` keeps failing, for at most that long. - Rotations that introduce a new ``kid`` recover immediately - (``PyJWKClient.get_signing_key`` refreshes and retries once on a - cache miss). + ``jwks_cache_lifespan_seconds``, which also bounds two staleness + windows: a key the IdP has removed keeps validating, and a + rotation that reuses an existing ``kid`` keeps failing, for at + most that long. Rotations that introduce a new ``kid`` recover + immediately (``PyJWKClient.get_signing_key`` refreshes and + retries once on a cache miss). """ if self._jwks_client is None: ssl_ctx = ssl.create_default_context() @@ -72,8 +72,8 @@ def _get_jwks_client(self) -> PyJWKClient: # Explicit so upgrades cannot silently change the staleness # window documented above, and so a hung IdP bounds how long # a fetch can block the serving path. - lifespan=300, - timeout=10, + lifespan=self._auth_config.jwks_cache_lifespan_seconds, + timeout=self._auth_config.jwks_request_timeout_seconds, ) return self._jwks_client diff --git a/sdk/python/feast/permissions/auth_model.py b/sdk/python/feast/permissions/auth_model.py index 03d65c5973d..4648a068bce 100644 --- a/sdk/python/feast/permissions/auth_model.py +++ b/sdk/python/feast/permissions/auth_model.py @@ -2,7 +2,7 @@ from typing import Literal, Optional, Tuple -from pydantic import ConfigDict, model_validator +from pydantic import ConfigDict, Field, model_validator from feast.repo_config import FeastConfigBaseModel @@ -47,6 +47,15 @@ class OidcAuthConfig(AuthConfig): # against a v2.0 discovery URL). audience: Optional[str] = None issuer: Optional[str] = None + # How long the fetched JWK set is reused before the server refetches it. + # This also bounds how long a key the IdP has revoked keeps validating + # tokens, so lower it if your provider rotates or revokes aggressively; + # every reduction costs a corresponding increase in JWKS fetches. + jwks_cache_lifespan_seconds: int = Field(default=300, gt=0) + # Network timeout for the JWKS fetch. This fetch happens inline on the + # request path, so an unresponsive IdP blocks serving for at most this + # long. + jwks_request_timeout_seconds: float = Field(default=10, gt=0) class OidcClientAuthConfig(OidcAuthConfig): diff --git a/sdk/python/tests/unit/permissions/auth/test_token_parser.py b/sdk/python/tests/unit/permissions/auth/test_token_parser.py index e97d9476cdd..a5056393b60 100644 --- a/sdk/python/tests/unit/permissions/auth/test_token_parser.py +++ b/sdk/python/tests/unit/permissions/auth/test_token_parser.py @@ -8,6 +8,7 @@ import assertpy import jwt import pytest +from pydantic import ValidationError from starlette.authentication import ( AuthenticationError, ) @@ -549,6 +550,67 @@ def test_oidc_jwks_client_ssl_context_follows_config( assertpy.assert_that(ssl_ctx.check_hostname).is_false() +@pytest.mark.parametrize( + "overrides,expected_lifespan,expected_timeout", + [ + ({}, 300, 10), + ( + { + "jwks_cache_lifespan_seconds": 60, + "jwks_request_timeout_seconds": 2.5, + }, + 60, + 2.5, + ), + ], +) +@patch( + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" +) +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient") +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") +def test_oidc_jwks_client_cache_and_timeout_follow_config( + mock_discovery_data, + mock_jwks_client_cls, + mock_jwt, + mock_oauth2, + overrides, + expected_lifespan, + expected_timeout, + discovery_data, +): + """The JWK-set cache lifespan and the fetch timeout are operator-tunable: + the lifespan bounds how long a revoked key keeps validating, and the + timeout bounds how long an unresponsive IdP blocks the serving path.""" + mock_discovery_data.return_value = discovery_data + mock_jwt.return_value = {"preferred_username": "my-name"} + + token_parser = OidcTokenParser(auth_config=_oidc_config_with(**overrides)) + asyncio.run(token_parser.user_details_from_access_token(access_token="aaa-bbb-ccc")) + + kwargs = mock_jwks_client_cls.call_args.kwargs + assertpy.assert_that(kwargs["lifespan"]).is_equal_to(expected_lifespan) + assertpy.assert_that(kwargs["timeout"]).is_equal_to(expected_timeout) + + +@pytest.mark.parametrize( + "overrides", + [ + {"jwks_cache_lifespan_seconds": 0}, + {"jwks_cache_lifespan_seconds": -1}, + {"jwks_request_timeout_seconds": 0}, + {"jwks_request_timeout_seconds": -1}, + ], +) +def test_oidc_jwks_tunables_reject_non_positive_values(overrides): + """A non-positive lifespan would expire the cache immediately, silently + restoring a JWKS fetch per request; a non-positive timeout is equally + meaningless. Reject both at config load rather than at serving time.""" + with pytest.raises(ValidationError): + _oidc_config_with(**overrides) + + @patch( "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" ) From 1a5226e4760722016f60ea7f8c5ccbc2cd743323 Mon Sep 17 00:00:00 2001 From: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:22:52 -0500 Subject: [PATCH 4/4] chore: Update pixi.lock for the pyjwt floor CI runs pixi install --locked, which fails at setup when pyproject.toml and pixi.lock disagree, so adding the pyjwt>=2.13.0 bound took out all five pixi-based integration jobs before any test ran. Regenerate the lock; the only change is the corresponding requires_dist entry. Signed-off-by: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> --- pixi.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pixi.lock b/pixi.lock index b663a50e679..ec4c79ff7c3 100644 --- a/pixi.lock +++ b/pixi.lock @@ -2353,7 +2353,7 @@ packages: - prometheus-client>=0.20.0,<0.25.0 - psutil - bigtree>=0.19.2 - - pyjwt + - pyjwt>=2.13.0 - aerospike>=19.0.0,<20.0.0 ; extra == 'aerospike' - boto3>=1.38.27 ; extra == 'aws' - fsspec>=2024.1.0 ; extra == 'aws'