Accueil > OpenID Connect OAuth Serveur dédié > Développer > JWT : Un module de validation en Python

JWT : Un module de validation en Python

Un module utilitaire pour :

- décoder un JWT,
- vérifier sa signature (HMAC ou RSA),
- protèger contre les attaques par timing,
- lèver des exceptions explicites,
- valider les claims pour garantir que le token est encore valable et destiné au service.

C’est une implémentation qui évite les boîtes noires, donc parfaitement maîtrisée.

Ce module Python fournit une implémentation bas niveau. Il ne dépend pas de bibliothèques haut niveau comme PyJWT : on maîtrise entièrement le processus.

Comment cela fonctionne (étape par étape)

- 1) Découpage du JWT
Le token doit avoir la forme :

header.payload.signature

Le code vérifie :
- qu’il y a bien 3 segments,
- qu’ils sont valides en Base64URL,
- qu’ils contiennent du JSON correct.

En cas de problème → `JWTFormatError`.

- 2) Décodage Base64URL
Chaque segment est décodé en bytes, avec ajout automatique du padding `=` si nécessaire.

En cas d’erreur → `JWTFormatError`.

- 3) Analyse du header
Le header doit contenir un champ :

"alg" : "HS256" | "RS256" | ...

Si l’algorithme est absent ou non autorisé → `JWTAlgorithmError`.

- 4) Vérification de la signature
Selon l’algorithme :

- HMAC (HS256 / HS384 / HS512)
Recalcul du HMAC avec la clé secrète
Comparaison en temps constant (Time Constant Comparison) : `compare_digest`
→ protège contre les attaques par timing

Si la signature ne correspond pas → `JWTSignatureError`.

- RSA (RS256 / RS384 / RS512)
Chargement de la clé publique PEM
Vérification via `cryptography` + PKCS#1 v1.5

Si la signature est invalide → `JWTSignatureError`.

- 5) Retour du payload
Si tout est correct, la fonction renvoie le **payload décodé** (dict Python).

Voici le code :

  1. # jwt_validation.py
  2. # coding: utf8
  3.  
  4. """    
  5. Décodage et validation d'un Jeton d'Identité JSON Web Token (JWT).
  6.  
  7. Auteur :
  8.    B.Degoy bertrand@degoy.com    
  9. copyright (c) 2026 B.Degoy
  10. licence : MIT
  11. """
  12.  
  13. import json
  14. import base64
  15. import hmac
  16. import hashlib
  17. from typing import Any, Dict
  18.  
  19.  
  20. class JWTDecodeError(Exception):
  21.     """Base class for all JWT decoding errors."""
  22.     pass
  23.  
  24.  
  25. class JWTFormatError(JWTDecodeError):
  26.     """Raised when the JWT structure is invalid."""
  27.     pass
  28.  
  29.  
  30. class JWTAlgorithmError(JWTDecodeError):
  31.     """Raised when the algorithm is missing or not allowed."""
  32.     pass
  33.  
  34.  
  35. class JWTSignatureError(JWTDecodeError):
  36.     """Raised when the signature is invalid."""
  37.     pass
  38.  
  39.  
  40. class JWTUtils:
  41.     """
  42.    A static utility class providing low-level JWT decoding and signature
  43.    verification, equivalent to the provided PHP implementation but using
  44.    Python exceptions instead of returning False.
  45.  
  46.    This class does NOT validate claims (exp, nbf, iss, etc.).
  47.    """
  48.  
  49.     # ----------------------------------------------------------------------
  50.     # Base64URL decoding
  51.     # ----------------------------------------------------------------------
  52.     @staticmethod
  53.     def urlsafe_b64decode(data: str) -> bytes:
  54.         """
  55.        Decode a Base64URL string into raw bytes.
  56.        Adds required padding if missing.
  57.        """
  58.         padding = "=" * (-len(data) % 4)
  59.         try:
  60.             return base64.urlsafe_b64decode(data + padding)
  61.         except Exception as e:
  62.             raise JWTFormatError(f"Invalid Base64URL segment: {data}") from e
  63.  
  64.     # ----------------------------------------------------------------------
  65.     # HMAC signing
  66.     # ----------------------------------------------------------------------
  67.     @staticmethod
  68.     def sign(message: str, key: bytes, algo: str) -> bytes:
  69.         """
  70.        Compute an HMAC signature for the given message using HS256/384/512.
  71.        """
  72.         if algo == "HS256":
  73.             return hmac.new(key, message.encode(), hashlib.sha256).digest()
  74.         if algo == "HS384":
  75.             return hmac.new(key, message.encode(), hashlib.sha384).digest()
  76.         if algo == "HS512":
  77.             return hmac.new(key, message.encode(), hashlib.sha512).digest()
  78.  
  79.         raise JWTAlgorithmError(f"Unsupported HMAC algorithm: {algo}")
  80.  
  81.     # ----------------------------------------------------------------------
  82.     # Constant-time comparison
  83.     # ----------------------------------------------------------------------
  84.     @staticmethod
  85.     def hash_equals(a: bytes, b: bytes) -> bool:
  86.         """Constant-time comparison to avoid timing attacks."""
  87.         return hmac.compare_digest(a, b)
  88.  
  89.     # ----------------------------------------------------------------------
  90.     # RSA signature verification
  91.     # ----------------------------------------------------------------------
  92.     @staticmethod
  93.     def verify_rsa(signature: bytes, message: str, key_pem: str, algo: str) -> bool:
  94.         """
  95.        Verify an RSA signature using a PEM-encoded public key.
  96.        Supported algorithms: RS256, RS384, RS512.
  97.        """
  98.         from cryptography.hazmat.primitives import serialization, hashes
  99.         from cryptography.hazmat.primitives.asymmetric import padding
  100.  
  101.         public_key = serialization.load_pem_public_key(key_pem.encode())
  102.  
  103.         hash_algo = {
  104.             "RS256": hashes.SHA256(),
  105.             "RS384": hashes.SHA384(),
  106.             "RS512": hashes.SHA512(),
  107.         }.get(algo)
  108.  
  109.         if hash_algo is None:
  110.             raise JWTAlgorithmError(f"Unsupported RSA algorithm: {algo}")
  111.  
  112.         try:
  113.             public_key.verify(
  114.                 signature,
  115.                 message.encode(),
  116.                 padding.PKCS1v15(),
  117.                 hash_algo
  118.             )
  119.             return True
  120.         except Exception:
  121.             return False
  122.  
  123.     # ----------------------------------------------------------------------
  124.     # Signature verification dispatcher
  125.     # ----------------------------------------------------------------------
  126.     @staticmethod
  127.     def verify_signature(signature: bytes, input_data: str, key: Any, algo: str) -> None:
  128.         """
  129.        Verify the signature for the given algorithm.
  130.        Raises JWTSignatureError on failure.
  131.        """
  132.         if algo in ("HS256", "HS384", "HS512"):
  133.             key_bytes = key if isinstance(key, bytes) else key.encode()
  134.             expected = JWTUtils.sign(input_data, key_bytes, algo)
  135.  
  136.             if not JWTUtils.hash_equals(expected, signature):
  137.                 raise JWTSignatureError("Invalid HMAC signature")
  138.             return
  139.  
  140.         if algo in ("RS256", "RS384", "RS512"):
  141.             if not JWTUtils.verify_rsa(signature, input_data, key, algo):
  142.                 raise JWTSignatureError("Invalid RSA signature")
  143.             return
  144.  
  145.         raise JWTAlgorithmError(f"Unsupported or invalid signing algorithm: {algo}")
  146.  
  147.     # ----------------------------------------------------------------------
  148.     # Main decode function
  149.     # ----------------------------------------------------------------------
  150.     @staticmethod
  151.     def decode(jwt: str, key: Any = None, allowed_algorithms: Any = True) -> Dict:
  152.         """
  153.        Decode a JWT token, validate its structure, optionally verify its
  154.        signature, and return the payload as a dictionary.
  155.  
  156.        Raises:
  157.            JWTFormatError: Invalid structure or Base64URL segments.
  158.            JWTAlgorithmError: Missing or disallowed algorithm.
  159.            JWTSignatureError: Signature verification failed.
  160.        """
  161.         # Basic structure check
  162.         if "." not in jwt:
  163.             raise JWTFormatError("JWT must contain at least one dot")
  164.  
  165.         parts = jwt.split(".")
  166.         if len(parts) != 3:
  167.             raise JWTFormatError("JWT must contain exactly 3 segments")
  168.  
  169.         head_b64, payload_b64, sig_b64 = parts
  170.  
  171.         # Decode JSON header and payload
  172.         try:
  173.             header = json.loads(JWTUtils.urlsafe_b64decode(head_b64))
  174.         except Exception as e:
  175.             raise JWTFormatError("Invalid JWT header") from e
  176.  
  177.         try:
  178.             payload = json.loads(JWTUtils.urlsafe_b64decode(payload_b64))
  179.         except Exception as e:
  180.             raise JWTFormatError("Invalid JWT payload") from e
  181.  
  182.         signature = JWTUtils.urlsafe_b64decode(sig_b64)
  183.  
  184.         # Algorithm checks
  185.         if allowed_algorithms:
  186.             if "alg" not in header:
  187.                 raise JWTAlgorithmError("Missing 'alg' in JWT header")
  188.  
  189.             algo = header["alg"]
  190.  
  191.             if isinstance(allowed_algorithms, list) and algo not in allowed_algorithms:
  192.                 raise JWTAlgorithmError(f"Algorithm '{algo}' not allowed")
  193.  
  194.             signing_input = f"{head_b64}.{payload_b64}"
  195.  
  196.             JWTUtils.verify_signature(signature, signing_input, key, algo)
  197.  
  198.         return payload

Télécharger

Validation des déclarations (claims)

L’utilitaire ci-dessus décode le JWT et vérifie la signature — mais **il ne valide pas les claims**, c’est‑à‑dire les champs qui définissent *quand* et *dans quelles conditions* le token est valable.

Quels claims doivent être validés ?

Les principaux champs standardisés dans un JWT sont :

Claim Signification Validation
`exp` Expiration time Le token doit être encore valide
`nbf` Not Before Le token ne doit pas être utilisé trop tôt
`iat` Issued At Optionnel : vérifier que la date n’est pas aberrante
`iss` Issuer Vérifier que le token vient du bon émetteur
`aud` Audience Vérifier que le token est destiné à ton service
`sub` Subject Optionnel : vérifier l’identité attendue

Fonction validate_claims()

Voici comment ajouter cette validation :
On ajoute une fonction **validate_claims(payload, options)** qui :
- lit les champs du payload,
- compare avec l’heure actuelle,
- lève des exceptions explicites si quelque chose ne va pas.

  1. from datetime import datetime, timezone
  2.  
  3.  
  4. class JWTClaimsError(Exception):
  5.     """Raised when JWT claims validation fails."""
  6.     pass
  7.  
  8.  
  9. class JWTUtils:
  10.     # ... (tout le reste de ta classe ici)
  11.  
  12.     @staticmethod
  13.     def validate_claims(
  14.         payload: dict,
  15.         issuer: str = None,
  16.         audience: str = None,
  17.         leeway: int = 0
  18.     ) -> None:
  19.         """
  20.        Validate standard JWT claims (exp, nbf, iss, aud).
  21.  
  22.        Parameters:
  23.            payload (dict): The decoded JWT payload.
  24.            issuer (str|None): Expected 'iss' value.
  25.            audience (str|None): Expected 'aud' value.
  26.            leeway (int): Allowed clock skew in seconds.
  27.  
  28.        Raises:
  29.            JWTClaimsError: If any claim is invalid.
  30.        """
  31.  
  32.         now = datetime.now(timezone.utc).timestamp()
  33.  
  34.         # --- exp: expiration time ---
  35.         if "exp" in payload:
  36.             if now > payload["exp"] + leeway:
  37.                 raise JWTClaimsError("Token has expired")
  38.  
  39.         # --- nbf: not before ---
  40.         if "nbf" in payload:
  41.             if now < payload["nbf"] - leeway:
  42.                 raise JWTClaimsError("Token is not yet valid (nbf)")
  43.  
  44.         # --- iat: issued at ---
  45.         if "iat" in payload:
  46.             if payload["iat"] > now + leeway:
  47.                 raise JWTClaimsError("Token issued in the future (iat)")
  48.  
  49.         # --- iss: issuer ---
  50.         if issuer is not None:
  51.             if payload.get("iss") != issuer:
  52.                 raise JWTClaimsError(f"Invalid issuer: {payload.get('iss')}")
  53.  
  54.         # --- aud: audience ---
  55.         if audience is not None:
  56.             aud = payload.get("aud")
  57.             if isinstance(aud, list):
  58.                 if audience not in aud:
  59.                     raise JWTClaimsError(f"Audience '{audience}' not allowed")
  60.             else:
  61.                 if aud != audience:
  62.                     raise JWTClaimsError(f"Invalid audience: {aud}")
  63. </python>
  64.  
  65. {{intégration dans `decode()`}}
  66.  
  67. On ajoute un paramètre optionnel :
  68.  
  69. <code class="python">
  70. @staticmethod
  71. def decode(jwt, key=None, allowed_algorithms=True, validate=False, issuer=None, audience=None):
  72.     payload = ...  # comme avant
  73.  
  74.     if validate:
  75.         JWTUtils.validate_claims(payload, issuer=issuer, audience=audience)
  76.  
  77.     return payload
  78. </python>
  79.  
  80. Et on l’appelle comme ceci :
  81.  
  82. <code class="python">
  83. payload = JWTUtils.decode(
  84.     token,
  85.     key=public_key,
  86.     allowed_algorithms=["RS256"],
  87.     validate=True,
  88.     issuer="https://auth.example.com",
  89.     audience="my-api"
  90. )

Télécharger