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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 12:26 +0800
1"""JWT token binding configuration and helpers.
3Provides :class:`TokenBindingConfig` and the :func:`compute_binding_hash` /
4:func:`verify_binding` helpers consumed by :class:`~lexigram.auth.authn.jwt.JWTTokenManager`.
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"""
11from __future__ import annotations
13from dataclasses import dataclass
14import hashlib
17@dataclass
18class TokenBindingConfig:
19 """Configuration for opt-in JWT client binding.
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.
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 """
36 bind_to_ip: bool = False
37 bind_to_fingerprint: bool = False
38 fingerprint_header: str = "X-Client-Fingerprint"
41def compute_binding_hash(
42 config: TokenBindingConfig,
43 ctx: dict[str, str],
44) -> str | None:
45 """Compute a binding hash from the provided context.
47 Args:
48 config: Active binding configuration.
49 ctx: Request context mapping. Recognised keys are ``ip`` and
50 ``fingerprint``.
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()
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.
74 Tokens that carry no ``bind`` claim pass unconditionally so that
75 pre-binding tokens remain valid after binding is enabled.
77 Args:
78 config: Active binding configuration.
79 claims: Decoded JWT payload.
80 ctx: Current request context (same keys as :func:`compute_binding_hash`).
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
97__all__ = ["TokenBindingConfig", "compute_binding_hash", "verify_binding"]