Skip to content

Commit 93e060d

Browse files
Add JWT issuer verification and caching
Implement fetching and caching of JWT verification public keys from issuer URLs and add JWT verification helpers. New utilities include bearer stripping, issuer allowlist checks, PEM/JWK(S) payload parsing, a TTLCache-backed issuer key cache, and get_public_key_from_issuer/verify_jwt_from_issuer functions. get_opensearch_service_token now accepts verify_token (default true) and will verify the returned service JWT against the auth server's issuer/pinned key. Add comprehensive unit tests for issuer verification (PEM, JWKS, raw PEM, prefix allowlist, and token verification behavior) and a small import cleanup in an existing test.
1 parent 2dd167f commit 93e060d

3 files changed

Lines changed: 446 additions & 6 deletions

File tree

src/config/utils.py

Lines changed: 175 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
1+
from typing import Any
2+
from urllib.parse import urlparse
3+
14
import httpx
5+
import jwt
6+
from cachetools import TTLCache
7+
from cryptography.hazmat.primitives.serialization import load_pem_public_key
8+
9+
from utils.logging_config import get_logger
10+
11+
logger = get_logger(__name__)
212

313
_DEFAULT_K8S_SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token"
14+
_ISSUER_PUBLIC_KEY_CACHE: TTLCache[str, Any] = TTLCache(maxsize=128, ttl=300)
415

516

617
# Read the K8S service account token
@@ -12,14 +23,153 @@ def _read_k8s_sa_token(k8s_sa_token_path: str) -> str | None:
1223
return None
1324

1425

26+
def _strip_bearer_prefix(token: str) -> str:
27+
scheme, _, value = token.partition(" ")
28+
return value if scheme.lower() == "bearer" and value else token
29+
30+
31+
def _issuer_allowed(
32+
issuer: str,
33+
allowed_issuers: set[str] | None,
34+
allowed_issuer_prefixes: tuple[str, ...],
35+
) -> bool:
36+
if allowed_issuers and issuer in allowed_issuers:
37+
return True
38+
for prefix in allowed_issuer_prefixes:
39+
base = prefix.rstrip("/")
40+
if issuer == base or issuer.startswith(base + "/"):
41+
return True
42+
return False
43+
44+
45+
def _load_public_key_from_payload(payload: Any, key_id: str | None = None):
46+
if isinstance(payload, str):
47+
return load_pem_public_key(payload.encode("utf-8"))
48+
49+
if not isinstance(payload, dict):
50+
raise ValueError("Public key response must be PEM text or JSON")
51+
52+
public_key_pem = payload.get("public_key") or payload.get("pem") or payload.get("key")
53+
if public_key_pem:
54+
if isinstance(public_key_pem, bytes):
55+
return load_pem_public_key(public_key_pem)
56+
return load_pem_public_key(str(public_key_pem).encode("utf-8"))
57+
58+
jwks = payload.get("keys")
59+
if isinstance(jwks, list) and jwks:
60+
jwk = next(
61+
(candidate for candidate in jwks if key_id and candidate.get("kid") == key_id),
62+
jwks[0],
63+
)
64+
return jwt.PyJWK.from_dict(jwk).key
65+
66+
if payload.get("kty"):
67+
return jwt.PyJWK.from_dict(payload).key
68+
69+
raise ValueError("Public key response does not contain a supported key format")
70+
71+
72+
def get_public_key_from_issuer(
73+
issuer: str,
74+
key_id: str | None = None,
75+
*,
76+
verify_tls: bool = True,
77+
timeout: float = 10.0,
78+
):
79+
"""Fetch and cache a JWT verification public key from a trusted issuer URL."""
80+
parsed = urlparse(issuer)
81+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
82+
raise ValueError("Issuer must be an absolute HTTP(S) URL")
83+
84+
cache_key = f"{issuer}#{key_id or ''}"
85+
cached = _ISSUER_PUBLIC_KEY_CACHE.get(cache_key)
86+
if cached is not None:
87+
return cached
88+
89+
with httpx.Client(verify=verify_tls, timeout=timeout) as client:
90+
response = client.get(issuer)
91+
response.raise_for_status()
92+
93+
content_type = response.headers.get("content-type", "")
94+
if "json" in content_type:
95+
key_payload = response.json()
96+
else:
97+
try:
98+
key_payload = response.json()
99+
except ValueError:
100+
key_payload = response.text
101+
102+
public_key = _load_public_key_from_payload(key_payload, key_id)
103+
_ISSUER_PUBLIC_KEY_CACHE[cache_key] = public_key
104+
return public_key
105+
106+
107+
def verify_jwt_from_issuer(
108+
token: str,
109+
*,
110+
allowed_issuers: set[str] | None = None,
111+
allowed_issuer_prefixes: tuple[str, ...] = (),
112+
algorithms: tuple[str, ...] = ("ES256",),
113+
audience: str | list[str] | None = None,
114+
verify_tls: bool = True,
115+
timeout: float = 10.0,
116+
) -> dict[str, Any] | None:
117+
"""Verify a JWT by fetching the issuer public key after allowlist checks."""
118+
raw_token = _strip_bearer_prefix(token)
119+
try:
120+
header = jwt.get_unverified_header(raw_token)
121+
algorithm = header.get("alg")
122+
if algorithm not in algorithms:
123+
return None
124+
125+
unverified_claims = jwt.decode(
126+
raw_token,
127+
options={"verify_signature": False, "verify_exp": False},
128+
)
129+
issuer = unverified_claims.get("iss")
130+
if not isinstance(issuer, str) or not _issuer_allowed(
131+
issuer,
132+
allowed_issuers,
133+
allowed_issuer_prefixes,
134+
):
135+
return None
136+
137+
public_key = get_public_key_from_issuer(
138+
issuer,
139+
header.get("kid"),
140+
verify_tls=verify_tls,
141+
timeout=timeout,
142+
)
143+
144+
options: dict[str, Any] = {"require": ["iss", "sub", "exp", "iat"]}
145+
decode_kwargs: dict[str, Any] = {
146+
"algorithms": list(algorithms),
147+
"issuer": issuer,
148+
"options": options,
149+
}
150+
if audience is None:
151+
options["verify_aud"] = False
152+
else:
153+
decode_kwargs["audience"] = audience
154+
155+
return jwt.decode(raw_token, public_key, **decode_kwargs)
156+
except (ValueError, httpx.HTTPError, jwt.InvalidTokenError):
157+
return None
158+
159+
15160
def get_opensearch_service_token(
16161
auth_server_url: str | None,
17162
tenant_id: str,
18163
k8s_sa_token_path: str = _DEFAULT_K8S_SA_TOKEN_PATH,
164+
*,
165+
verify_token: bool = True,
19166
) -> str | None:
20167
"""
21168
Fetch an OpenSearch service token from the internal auth server using the current K8S service account token.
22169
170+
When ``verify_token`` is True (default), the returned JWT is verified
171+
against the auth server's public key (issuer pinned to ``auth_server_url``).
172+
23173
Args:
24174
tenant_id (str): The tenant ID for which the token is requested.
25175
@@ -31,7 +181,6 @@ def get_opensearch_service_token(
31181

32182
token_endpoint = f"{auth_server_url.rstrip('/')}/internal/token/opensearch"
33183
try:
34-
# Read the K8S service account token
35184
k8s_token = _read_k8s_sa_token(k8s_sa_token_path)
36185
if not k8s_token:
37186
return None
@@ -47,6 +196,29 @@ def get_opensearch_service_token(
47196
resp = client.post(token_endpoint, headers=headers, json=json_body)
48197
resp.raise_for_status()
49198
data = resp.json()
50-
return data.get("token")
51-
except Exception:
199+
token = data.get("token")
200+
except Exception as exc:
201+
logger.warning(
202+
"Failed to fetch OpenSearch service token",
203+
error=str(exc),
204+
auth_server_url=auth_server_url,
205+
)
52206
return None
207+
208+
if not token:
209+
return None
210+
211+
if verify_token:
212+
claims = verify_jwt_from_issuer(
213+
token,
214+
allowed_issuer_prefixes=(auth_server_url.rstrip("/"),),
215+
verify_tls=False,
216+
)
217+
if claims is None:
218+
logger.warning(
219+
"OpenSearch service token failed JWT verification; rejecting",
220+
auth_server_url=auth_server_url,
221+
)
222+
return None
223+
224+
return token

0 commit comments

Comments
 (0)