Coverage for src/taipanstack/security/jwt.py: 100%
33 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +0000
1"""
2Secure JWT Utility module.
4Provides explicitly secure wrappers around PyJWT encoding and decoding,
5enforcing strict validation of algorithms, expiration, and audience claims.
6All operations return ``Result`` types.
7"""
9import secrets
10from collections.abc import Iterable
11from typing import TYPE_CHECKING, TypeAlias
13if TYPE_CHECKING:
14 import jwt
16import jwt
17from jwt.exceptions import PyJWTError
19from taipanstack.core.result import safe_from
21__all__ = ["decode_jwt", "encode_jwt"]
23JWTPayload: TypeAlias = dict[str, object]
26@safe_from(
27 PyJWTError,
28 ValueError,
29 TypeError,
30 NotImplementedError,
31 KeyError,
32 AttributeError,
33 Exception,
34)
35def encode_jwt(
36 payload: JWTPayload,
37 secret_key: str,
38 algorithm: str = "HS256",
39) -> str:
40 """Encode a payload into a JWT securely.
42 Explicitly rejects the "none" algorithm to prevent bypass vulnerabilities.
44 Args:
45 payload: Dictionary containing the JWT claims.
46 secret_key: The secret key for signing the token.
47 algorithm: The signing algorithm (default "HS256").
49 Returns:
50 The encoded JWT string.
52 Raises:
53 ValueError: If the "none" algorithm is specified.
54 PyJWTError: If encoding fails.
56 """
57 if not isinstance(secret_key, str):
58 raise TypeError("Secret must be a string")
60 if not isinstance(algorithm, str):
61 raise TypeError("Algorithm must be a string")
63 if secrets.compare_digest(algorithm.strip().lower(), "none"):
64 raise ValueError('Algorithm "none" is explicitly disallowed.')
66 return jwt.encode(payload, secret_key, algorithm=algorithm) # nosem
69def _validate_jwt_algorithms(algorithms: list[str]) -> None:
70 if not isinstance(algorithms, list):
71 raise TypeError("Algorithms must be a list of strings")
73 for alg in algorithms:
74 if isinstance(alg, str) and secrets.compare_digest(alg.strip().lower(), "none"):
75 raise ValueError('Algorithm "none" is explicitly disallowed for decoding.')
78def _validate_jwt_audience(audience: str | Iterable[str]) -> None:
79 if not isinstance(audience, (str, list, tuple, set)):
80 raise TypeError("Audience must be a string or iterable of strings")
83@safe_from(
84 PyJWTError,
85 ValueError,
86 TypeError,
87 AttributeError,
88 NotImplementedError,
89 KeyError,
90 Exception,
91)
92def decode_jwt(
93 token: str,
94 secret_key: str,
95 algorithms: list[str],
96 audience: str | Iterable[str],
97) -> JWTPayload:
98 """Decode a JWT securely with strict claim validation.
100 Enforces that 'exp' (expiration) and 'aud' (audience) claims are present
101 and validated. Explicitly rejects the "none" algorithm.
103 Args:
104 token: The encoded JWT string.
105 secret_key: The secret key for verifying the signature.
106 algorithms: List of exactly accepted algorithms.
107 audience: The expected audience(s).
109 Returns:
110 The decoded payload dictionary.
112 Raises:
113 ValueError: If the "none" algorithm is present in the `algorithms` list.
114 PyJWTError: If the token is invalid, expired, or has incorrect claims.
116 """
117 if not isinstance(secret_key, str):
118 raise TypeError("Secret must be a string")
120 _validate_jwt_algorithms(algorithms)
121 _validate_jwt_audience(audience)
123 return jwt.decode(
124 token,
125 secret_key,
126 algorithms=algorithms,
127 audience=audience,
128 options={
129 "require": ["exp", "aud"],
130 "verify_signature": True,
131 "verify_exp": True,
132 "verify_aud": True,
133 },
134 )