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

1""" 

2Secure JWT Utility module. 

3 

4Provides explicitly secure wrappers around PyJWT encoding and decoding, 

5enforcing strict validation of algorithms, expiration, and audience claims. 

6All operations return ``Result`` types. 

7""" 

8 

9import secrets 

10from collections.abc import Iterable 

11from typing import TYPE_CHECKING, TypeAlias 

12 

13if TYPE_CHECKING: 

14 import jwt 

15 

16import jwt 

17from jwt.exceptions import PyJWTError 

18 

19from taipanstack.core.result import safe_from 

20 

21__all__ = ["decode_jwt", "encode_jwt"] 

22 

23JWTPayload: TypeAlias = dict[str, object] 

24 

25 

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. 

41 

42 Explicitly rejects the "none" algorithm to prevent bypass vulnerabilities. 

43 

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"). 

48 

49 Returns: 

50 The encoded JWT string. 

51 

52 Raises: 

53 ValueError: If the "none" algorithm is specified. 

54 PyJWTError: If encoding fails. 

55 

56 """ 

57 if not isinstance(secret_key, str): 

58 raise TypeError("Secret must be a string") 

59 

60 if not isinstance(algorithm, str): 

61 raise TypeError("Algorithm must be a string") 

62 

63 if secrets.compare_digest(algorithm.strip().lower(), "none"): 

64 raise ValueError('Algorithm "none" is explicitly disallowed.') 

65 

66 return jwt.encode(payload, secret_key, algorithm=algorithm) # nosem 

67 

68 

69def _validate_jwt_algorithms(algorithms: list[str]) -> None: 

70 if not isinstance(algorithms, list): 

71 raise TypeError("Algorithms must be a list of strings") 

72 

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.') 

76 

77 

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") 

81 

82 

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. 

99 

100 Enforces that 'exp' (expiration) and 'aud' (audience) claims are present 

101 and validated. Explicitly rejects the "none" algorithm. 

102 

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). 

108 

109 Returns: 

110 The decoded payload dictionary. 

111 

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. 

115 

116 """ 

117 if not isinstance(secret_key, str): 

118 raise TypeError("Secret must be a string") 

119 

120 _validate_jwt_algorithms(algorithms) 

121 _validate_jwt_audience(audience) 

122 

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 )