1+ from typing import Any
2+ from urllib .parse import urlparse
3+
14import 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+
15160def 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