Coverage for src/lexigram/auth/authn/_binding.py: 42%

26 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 12:26 +0800

1"""JWT token binding configuration and helpers. 

2 

3Provides :class:`TokenBindingConfig` and the :func:`compute_binding_hash` / 

4:func:`verify_binding` helpers consumed by :class:`~lexigram.auth.authn.jwt.JWTTokenManager`. 

5 

6Token binding is **opt-in** and backward-compatible: tokens issued without a 

7``bind`` claim continue to pass verification so that callers can enable 

8binding incrementally without invalidating existing sessions. 

9""" 

10 

11from __future__ import annotations 

12 

13from dataclasses import dataclass 

14import hashlib 

15 

16 

17@dataclass 

18class TokenBindingConfig: 

19 """Configuration for opt-in JWT client binding. 

20 

21 When configured, a SHA-256 hash of the active binding factors is embedded 

22 in the ``bind`` claim of every issued token. On verification the hash is 

23 re-computed from the current request context and compared against the 

24 stored value. A mismatch rejects the token, preventing reuse of a stolen 

25 token from a different IP or device. 

26 

27 Attributes: 

28 bind_to_ip: Include the client IP address in the binding hash. 

29 bind_to_fingerprint: Include an arbitrary client fingerprint string 

30 (e.g., the value of ``X-Client-Fingerprint``) in the hash. 

31 fingerprint_header: HTTP header name carrying the fingerprint value. 

32 Informational only — callers must extract the header value and 

33 pass it via the ``binding_context`` dict. 

34 """ 

35 

36 bind_to_ip: bool = False 

37 bind_to_fingerprint: bool = False 

38 fingerprint_header: str = "X-Client-Fingerprint" 

39 

40 

41def compute_binding_hash( 

42 config: TokenBindingConfig, 

43 ctx: dict[str, str], 

44) -> str | None: 

45 """Compute a binding hash from the provided context. 

46 

47 Args: 

48 config: Active binding configuration. 

49 ctx: Request context mapping. Recognised keys are ``ip`` and 

50 ``fingerprint``. 

51 

52 Returns: 

53 Hex-encoded SHA-256 digest of the active binding factors joined by 

54 ``|``, or ``None`` if no binding factors are active in *config* or 

55 *ctx* supplies no values for them. 

56 """ 

57 parts: list[str] = [] 

58 if config.bind_to_ip and (ip := ctx.get("ip")): 

59 parts.append(f"ip:{ip}") 

60 if config.bind_to_fingerprint and (fp := ctx.get("fingerprint")): 

61 parts.append(f"fp:{fp}") 

62 if not parts: 

63 return None 

64 return hashlib.sha256("|".join(parts).encode()).hexdigest() 

65 

66 

67def verify_binding( 

68 config: TokenBindingConfig, 

69 claims: dict[str, object], 

70 ctx: dict[str, str], 

71) -> bool: 

72 """Verify that the token binding hash matches the current context. 

73 

74 Tokens that carry no ``bind`` claim pass unconditionally so that 

75 pre-binding tokens remain valid after binding is enabled. 

76 

77 Args: 

78 config: Active binding configuration. 

79 claims: Decoded JWT payload. 

80 ctx: Current request context (same keys as :func:`compute_binding_hash`). 

81 

82 Returns: 

83 ``True`` when binding is satisfied or no binding was stored. 

84 ``False`` when the stored hash does not match the re-computed value. 

85 """ 

86 stored = claims.get("bind") 

87 if stored is None: 

88 # Issued before binding was configured — backward-compatible pass. 

89 return True 

90 expected = compute_binding_hash(config, ctx) 

91 if expected is None: 

92 # Config has no active binding factors — skip. 

93 return True 

94 return stored == expected 

95 

96 

97__all__ = ["TokenBindingConfig", "compute_binding_hash", "verify_binding"]