55import subprocess
66import time
77from typing import Mapping as TypingMapping
8+ from datetime import datetime , timedelta , timezone
9+ import tempfile
10+ from pathlib import Path
11+ from kubernetes .client import V1Secret , V1ObjectMeta # type: ignore
12+ from kubernetes .client .exceptions import ApiException # type: ignore
813
914import requests # type: ignore[import-not-found,import-untyped]
1015
@@ -34,14 +39,17 @@ class HydraClientConfig(BaseModel):
3439
3540class HydraTaskConfig (BaseModel ):
3641 clients : dict [str , HydraClientConfig ] = Field (default_factory = dict )
42+ trusted_jwt_grant_issuers : list [dict ] = Field (default_factory = list )
3743
3844
3945class HydraSetup (SetupTask ):
4046 """Register/update Hydra OAuth2 clients based on typed config."""
4147
42- def __init__ (self , clients : TypingMapping [str , HydraClientConfig ], runner ) -> None :
48+ def __init__ (self , clients : TypingMapping [str , HydraClientConfig ], runner , trusted : list [ dict ], deps ) -> None :
4349 self .clients = dict (clients )
4450 self .runner = runner
51+ self .trusted = list (trusted )
52+ self .deps = deps
4553
4654 def _port_forward (self ) -> subprocess .Popen [bytes ]:
4755 return subprocess .Popen (
@@ -68,6 +76,30 @@ def run(self) -> None:
6876
6977 url_base = "http://127.0.0.1:8445/admin/clients"
7078 headers = {"content-type" : "application/json" }
79+
80+ # Ensure a persistent signing key secret exists for mock-oidc
81+ secret_ns = "oidc"
82+ secret_name = "mock-oidc-github-signing"
83+ core = self .deps .k8s ["core" ]
84+
85+ try :
86+ core .read_namespaced_secret (name = secret_name , namespace = secret_ns )
87+ log (f"Signing key secret exists: { secret_ns } /{ secret_name } " )
88+ except ApiException as e : # pragma: no cover
89+ if getattr (e , "status" , None ) != 404 :
90+ raise
91+ with tempfile .TemporaryDirectory () as td :
92+ key_path = Path (td ) / "signing_key.pem"
93+ self .runner .run (["openssl" , "genrsa" , "-out" , str (key_path ), "2048" ])
94+ pem = key_path .read_text ()
95+ sec = V1Secret (
96+ metadata = V1ObjectMeta (name = secret_name ),
97+ type = "Opaque" ,
98+ string_data = {"signing_key.pem" : pem },
99+ )
100+ core .create_namespaced_secret (namespace = secret_ns , body = sec )
101+ log (f"Created signing key secret: { secret_ns } /{ secret_name } " )
102+
71103 for key , spec in self .clients .items ():
72104 client_id = spec .client_id or key
73105 resp = requests .post (url_base , json = spec .model_dump (), headers = headers )
@@ -81,6 +113,92 @@ def run(self) -> None:
81113 )
82114 raise SystemExit (1 )
83115 log (f"Hydra client upserted: { client_id } " )
116+
117+ # Configure trusted JWT grant issuers (RFC 7523) for JWT-bearer assertions
118+ for entry in self .trusted :
119+ issuer = entry .get ("issuer" )
120+ jwks_uri = entry .get ("jwks_uri" ) or entry .get ("jwks_url" )
121+ allow_any_subject = bool (entry .get ("allow_any_subject" , True ))
122+ scope = entry .get ("scope" ) # optional
123+ subject = entry .get ("subject" ) # optional
124+ expires_at = entry .get ("expires_at" ) # optional RFC3339 string
125+ jwks_kid = entry .get ("jwks_kid" ) # optional: filter a specific key
126+ if not issuer or not jwks_uri :
127+ err ("trusted_jwt_grant_issuers entry missing 'issuer' or 'jwks_uri'" )
128+ raise SystemExit (1 )
129+ # Fetch JWKS and create one trust record per key (or the selected kid)
130+ try :
131+ sess = self .deps .requests_session
132+ jwks_resp = sess .get (jwks_uri , timeout = 10 )
133+ jwks = jwks_resp .json ()
134+ keys = jwks .get ("keys" , []) if isinstance (jwks , dict ) else []
135+ except Exception as e : # pragma: no cover
136+ err (f"Failed to fetch JWKS from { jwks_uri } : { e } " )
137+ raise SystemExit (1 )
138+
139+ if not keys :
140+ err (f"JWKS from { jwks_uri } contained no keys" )
141+ raise SystemExit (1 )
142+
143+ created = 0
144+ for jwk in keys :
145+ kid = jwk .get ("kid" )
146+ if jwks_kid and kid != jwks_kid :
147+ continue
148+
149+ payload = {
150+ "issuer" : issuer ,
151+ "jwk" : jwk ,
152+ "allow_any_subject" : allow_any_subject ,
153+ }
154+ if scope :
155+ payload ["scope" ] = scope
156+ if subject :
157+ payload ["subject" ] = subject
158+ if not expires_at :
159+ one_year = datetime .now (timezone .utc ) + timedelta (days = 365 )
160+ expires_at = one_year .strftime ("%Y-%m-%dT%H:%M:%SZ" )
161+ payload ["expires_at" ] = expires_at
162+
163+ resp = requests .post (
164+ "http://127.0.0.1:8445/admin/trust/grants/jwt-bearer/issuers" ,
165+ json = payload ,
166+ headers = headers ,
167+ )
168+ # Accept 201 Created or 409 Conflict (already exists for this kid)
169+ if resp .status_code not in (201 , 409 ):
170+ err (
171+ f"Failed to trust issuer '{ issuer } ' (kid={ kid } ): { resp .status_code } { resp .text } "
172+ )
173+ raise SystemExit (1 )
174+ created += 1
175+ if created == 0 and jwks_kid :
176+ err (f"No JWKS key with kid={ jwks_kid } found at { jwks_uri } " )
177+ raise SystemExit (1 )
178+ log (f"Trusted JWT grant issuer configured: { issuer } (keys={ created } )" )
179+
180+ # Verification: list trusted issuers from Hydra and log summaries
181+ try :
182+ resp = requests .get (
183+ "http://127.0.0.1:8445/admin/trust/grants/jwt-bearer/issuers" ,
184+ headers = headers ,
185+ timeout = 10 ,
186+ )
187+ if resp .status_code == 200 :
188+ items = resp .json ()
189+ count = len (items ) if isinstance (items , list ) else 0
190+ log (f"Hydra trusted JWT issuers: { count } " )
191+ if isinstance (items , list ):
192+ for it in items :
193+ iss = it .get ("issuer" )
194+ subj = it .get ("subject" )
195+ anysub = it .get ("allow_any_subject" )
196+ kid = (it .get ("public_key" ) or {}).get ("kid" ) if isinstance (it .get ("public_key" ), dict ) else None
197+ log (f" - issuer={ iss } subject={ subj } allow_any_subject={ anysub } kid={ kid } " )
198+ else :
199+ err (f"Failed to list trusted issuers: { resp .status_code } { resp .text } " )
200+ except Exception as e :
201+ err (f"List trusted issuers error: { e } " )
84202 finally :
85203 try :
86204 os .kill (pf .pid , signal .SIGTERM )
@@ -92,14 +210,14 @@ def run(self) -> None:
92210 name = "hydra" ,
93211 description = "Register/update Hydra OAuth2 clients from tasks.hydra" ,
94212 priority = 50 ,
95- dependencies = ("runner" ,),
213+ dependencies = ("runner" , "k8s" ),
96214)
97215def _factory (ctx : ConfigState , deps ) -> HydraSetup | None :
98216 raw_tasks = (ctx .raw or {}).get ("tasks" , {})
99217 raw_hydra = raw_tasks .get ("hydra" ) if isinstance (raw_tasks , dict ) else None
100218 if not raw_hydra :
101219 return None
102220 typed = HydraTaskConfig .model_validate (raw_hydra )
103- if not typed .clients :
221+ if not typed .clients and not typed . trusted_jwt_grant_issuers :
104222 return None
105- return HydraSetup (typed .clients , deps .runner )
223+ return HydraSetup (typed .clients , deps .runner , typed . trusted_jwt_grant_issuers , deps )
0 commit comments