262660-second cooldown throttles both successful and failed refreshes.
2727"""
2828
29+ import base64
30+ import hashlib
2931import json
3032import logging
3133import math
@@ -74,12 +76,13 @@ def _get_state_fernet():
7476 return _state_fernet
7577
7678
77- def _encode_state (nonce : str , redirect_uri : str ) -> str :
79+ def _encode_state (nonce : str , redirect_uri : str , code_verifier : str ) -> str :
7880 """Return a Fernet-encrypted state token containing nonce + metadata."""
7981 fernet = _get_state_fernet ()
8082 payload = json .dumps ({
8183 "nonce" : nonce ,
8284 "redirect_uri" : redirect_uri ,
85+ "code_verifier" : code_verifier ,
8386 "created" : time .time (),
8487 })
8588 return fernet .encrypt (payload .encode ()).decode ()
@@ -97,11 +100,14 @@ def _decode_state(state: str) -> Optional[Dict[str, Any]]:
97100 return None
98101 nonce = data .get ("nonce" )
99102 redirect_uri = data .get ("redirect_uri" )
103+ code_verifier = data .get ("code_verifier" )
100104 created = data .get ("created" )
101105 if not isinstance (nonce , str ) or not nonce :
102106 return None
103107 if not isinstance (redirect_uri , str ):
104108 return None
109+ if not isinstance (code_verifier , str ) or not code_verifier :
110+ return None
105111 if not _is_numericdate (created ):
106112 return None
107113 now = time .time ()
@@ -164,8 +170,19 @@ def __init__(
164170 self ._jwks_cache : Dict [str , Dict [str , Any ]] = {}
165171 self ._jwks_cache_lock = threading .Lock ()
166172 self ._allowed_algs : Optional [List [str ]] = None
173+ self ._token_auth_methods : List [str ] = ["client_secret_basic" ]
167174 self ._discover ()
168175
176+ def _use_basic_auth (self ) -> bool :
177+ """True when the token endpoint should use client_secret_basic."""
178+ if "client_secret_basic" in self ._token_auth_methods :
179+ return True
180+ if "client_secret_post" in self ._token_auth_methods :
181+ return False
182+ # Provider advertises neither shared-secret method — use the OIDC
183+ # default rather than silently leaking the secret in the body.
184+ return True
185+
169186 # -- discovery -----------------------------------------------------------
170187
171188 def _discover (self ) -> None :
@@ -176,6 +193,15 @@ def _discover(self) -> None:
176193 well_known_url = self .issuer + "/.well-known/openid-configuration"
177194 if not well_known_url .startswith (("http://" , "https://" )):
178195 well_known_url = f"https://{ well_known_url } "
196+ # The issuer (and therefore the discovery document) must be HTTPS:
197+ # the authorization redirect carries state/nonce, and an http://
198+ # issuer lets an active network attacker substitute authorization
199+ # codes or rewrite the discovery document entirely.
200+ if not well_known_url .startswith ("https://" ):
201+ raise OidcError (
202+ f"OIDC issuer must use HTTPS, got { self .issuer !r} . "
203+ "Configure the IdP with TLS or set OIDC_ENABLED=false."
204+ )
179205
180206 try :
181207 resp = httpx .get (well_known_url , timeout = 15.0 )
@@ -204,10 +230,12 @@ def _discover(self) -> None:
204230 f"discovery doc returned { doc_issuer !r} "
205231 )
206232
207- # Client credentials and bearer tokens must never be sent over
208- # cleartext transport. Authorization is browser-facing and is not
209- # included because it does not carry those secrets.
210- for name in ("token_endpoint" , "jwks_uri" , "userinfo_endpoint" ):
233+ # No OIDC endpoint may use cleartext transport. The back-channel
234+ # endpoints carry client credentials and bearer tokens; the browser-
235+ # facing authorization endpoint carries state/nonce and returns the
236+ # authorization code, so an http:// endpoint enables code
237+ # substitution by an active network observer.
238+ for name in ("authorization_endpoint" , "token_endpoint" , "jwks_uri" , "userinfo_endpoint" ):
211239 url = self ._config .get (name )
212240 if url and not isinstance (url , str ):
213241 raise OidcError (f"OIDC { name } must be a URL string" )
@@ -224,6 +252,13 @@ def _discover(self) -> None:
224252 safe = [a for a in supported if a in ("RS256" , "RS384" , "RS512" , "ES256" , "ES384" , "ES512" , "PS256" , "PS384" , "PS512" )]
225253 self ._allowed_algs = safe or ["RS256" ]
226254
255+ # Token-endpoint auth methods. Per OIDC Discovery §3, an omitted
256+ # token_endpoint_auth_methods_supported means client_secret_basic.
257+ methods = self ._config .get ("token_endpoint_auth_methods_supported" )
258+ if not isinstance (methods , list ) or not methods :
259+ methods = ["client_secret_basic" ]
260+ self ._token_auth_methods = methods
261+
227262 logger .info (
228263 "OIDC provider discovered: issuer=%r auth=%r token=%r algs=%s" ,
229264 self .issuer ,
@@ -264,9 +299,19 @@ def get_authorization_url(self, redirect_uri: str) -> Tuple[str, str, str]:
264299 """
265300 nonce = secrets .token_hex (32 )
266301
302+ # PKCE (RFC 7636, S256). The verifier travels inside the encrypted
303+ # state token, so the callback can recover it without server-side
304+ # storage — same carrier as the nonce.
305+ code_verifier = secrets .token_urlsafe (64 )
306+ code_challenge = (
307+ base64 .urlsafe_b64encode (hashlib .sha256 (code_verifier .encode ("ascii" )).digest ())
308+ .rstrip (b"=" )
309+ .decode ("ascii" )
310+ )
311+
267312 # Encode the nonce + metadata into the state parameter (Fernet-
268313 # encrypted, stateless — works across multiple workers/processes).
269- state = _encode_state (nonce , redirect_uri )
314+ state = _encode_state (nonce , redirect_uri , code_verifier )
270315
271316 from urllib .parse import urlencode
272317 params = {
@@ -276,6 +321,8 @@ def get_authorization_url(self, redirect_uri: str) -> Tuple[str, str, str]:
276321 "scope" : self .scopes ,
277322 "state" : state ,
278323 "nonce" : nonce ,
324+ "code_challenge" : code_challenge ,
325+ "code_challenge_method" : "S256" ,
279326 }
280327 # Request forced re-authentication when OIDC_MAX_AGE is configured.
281328 # The claim is later verified in _verify_id_token against auth_time.
@@ -300,6 +347,7 @@ def exchange_code(
300347 raise OidcError ("OIDC state not found — may be expired, reused, or from a different worker" )
301348 nonce = stored .get ("nonce" , "" )
302349 stored_redirect_uri = stored .get ("redirect_uri" , "" )
350+ code_verifier = stored .get ("code_verifier" , "" )
303351
304352 # Bind the token exchange to the redirect_uri that was used in the
305353 # authorization request (carried in the signed state token). Reject
@@ -312,7 +360,9 @@ def exchange_code(
312360 )
313361
314362 # 2. Exchange code for tokens (using the stored redirect_uri)
315- token_data = self ._token_request (code , stored_redirect_uri or redirect_uri )
363+ token_data = self ._token_request (
364+ code , stored_redirect_uri or redirect_uri , code_verifier
365+ )
316366
317367 # 3. Verify id_token
318368 id_token = token_data .get ("id_token" )
@@ -349,9 +399,13 @@ def exchange_code(
349399 )
350400 userinfo = {}
351401 else :
352- # Require a non-empty sub that matches the verified
353- # id_token subject before trusting any UserInfo claims.
354- ui_sub = (userinfo .get ("sub" ) or "" ).strip ()
402+ # Require a non-empty sub that exactly matches the
403+ # verified id_token subject before trusting any UserInfo
404+ # claims. No normalization: subs are opaque identifiers
405+ # and trimming could equate two distinct subjects.
406+ ui_sub = userinfo .get ("sub" )
407+ if not isinstance (ui_sub , str ):
408+ ui_sub = ""
355409 if not ui_sub :
356410 logger .warning (
357411 "UserInfo response missing sub claim — "
@@ -388,18 +442,34 @@ def exchange_code(
388442 claims ["_userinfo_available" ] = userinfo_available
389443 return claims
390444
391- def _token_request (self , code : str , redirect_uri : str ) -> Dict [str , Any ]:
445+ def _token_request (
446+ self , code : str , redirect_uri : str , code_verifier : str
447+ ) -> Dict [str , Any ]:
392448 """POST the token endpoint to exchange code for tokens."""
393449 token_endpoint = self ._config ["token_endpoint" ]
394450 payload = {
395451 "grant_type" : "authorization_code" ,
396452 "code" : code ,
397453 "redirect_uri" : redirect_uri ,
398- "client_id" : self .client_id ,
399- "client_secret" : self .client_secret ,
454+ "code_verifier" : code_verifier ,
400455 }
456+ # client_secret_basic is the OIDC default and the method the
457+ # conformance suite expects; use it whenever the provider supports
458+ # it (or doesn't advertise methods at all, which per Discovery §3
459+ # means client_secret_basic). Fall back to client_secret_post only
460+ # when the provider explicitly excludes basic.
461+ auth = None
462+ if self ._use_basic_auth ():
463+ from urllib .parse import quote
464+ auth = (
465+ quote (self .client_id , safe = "" ),
466+ quote (self .client_secret , safe = "" ),
467+ )
468+ else :
469+ payload ["client_id" ] = self .client_id
470+ payload ["client_secret" ] = self .client_secret
401471 try :
402- resp = httpx .post (token_endpoint , data = payload , timeout = 15.0 )
472+ resp = httpx .post (token_endpoint , data = payload , auth = auth , timeout = 15.0 )
403473 resp .raise_for_status ()
404474 data = resp .json ()
405475 except httpx .HTTPStatusError as exc :
@@ -583,15 +653,17 @@ def _verify_id_token(self, id_token: str, nonce: str) -> Dict[str, Any]:
583653 f"{ self .max_age } s (now={ now :.0f} , age={ now - auth_time :.0f} s)"
584654 )
585655
586- # Verify iat (issued-at) is not in the far future.
656+ # Verify iat (issued-at): required by OIDC Core §2, must be numeric
657+ # and not in the far future.
587658 iat = claims .get ("iat" )
588- if iat is not None :
589- if not _is_numericdate (iat ):
590- raise OidcError (f"id_token iat claim is non-numeric: { iat !r} " )
591- if iat > time .time () + 60 :
592- raise OidcError (
593- f"id_token iat { iat } is more than 60 s in the future"
594- )
659+ if iat is None :
660+ raise OidcError ("id_token missing iat claim" )
661+ if not _is_numericdate (iat ):
662+ raise OidcError (f"id_token iat claim is non-numeric: { iat !r} " )
663+ if iat > time .time () + 60 :
664+ raise OidcError (
665+ f"id_token iat { iat } is more than 60 s in the future"
666+ )
595667
596668 return claims
597669
0 commit comments