Coverage for agentos/tools/jwt.py: 0%
108 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 01:44 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 01:44 +0800
1"""
2JWT — JSON Web Token encode/decode/verify (HS256/RS256/ES256).
4Supports:
5 - HS256 (HMAC-SHA256), RS256 (RSA), ES256 (ECDSA) algorithms
6 - Encode with claims (iss, sub, aud, exp, iat, nbf, jti, custom)
7 - Decode with signature verification
8 - Decode without verification (for inspection)
9 - Token expiry checking
10 - Claim validation
11"""
13from __future__ import annotations
15import base64
16import hashlib
17import hmac
18import json
19import time
21# ============================================================================
22# JWTError
23# ============================================================================
26class JWTError(Exception):
27 pass
30class ExpiredTokenError(JWTError):
31 pass
34class InvalidTokenError(JWTError):
35 pass
38# ============================================================================
39# Helpers
40# ============================================================================
43def _b64url_encode(data: bytes) -> str:
44 return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
47def _b64url_decode(s: str) -> bytes:
48 # Restore padding
49 padding = 4 - len(s) % 4
50 if padding != 4:
51 s += "=" * padding
52 return base64.urlsafe_b64decode(s)
55def _json_b64_decode(s: str) -> dict:
56 return json.loads(_b64url_decode(s))
59# ============================================================================
60# JWT
61# ============================================================================
63ALGORITHMS = frozenset(
64 {"HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}
65)
68class JWT:
69 """JSON Web Token encoder/decoder.
71 Usage:
72 jwt = JWT(secret="my-secret") # for HS256
74 # Encode
75 token = jwt.encode({"sub": "user123", "role": "admin"}, ttl=3600)
77 # Decode & verify
78 payload = jwt.decode(token)
80 # Decode without verification (inspect only)
81 payload = jwt.decode(token, verify=False)
82 """
84 def __init__(
85 self,
86 secret: str | None = None,
87 private_key: str | None = None,
88 public_key: str | None = None,
89 algorithm: str = "HS256",
90 ):
91 if algorithm not in ALGORITHMS:
92 raise ValueError(f"Unsupported algorithm: {algorithm}. Use one of {sorted(ALGORITHMS)}")
94 self._algorithm = algorithm
95 self._hash_func = {
96 "HS256": hashlib.sha256,
97 "HS384": hashlib.sha384,
98 "HS512": hashlib.sha512,
99 }
101 if algorithm.startswith("HS"):
102 if not secret:
103 raise ValueError(f"{algorithm} requires a secret")
104 self._secret = secret.encode("utf-8")
105 elif algorithm.startswith("RS") or algorithm.startswith("ES"):
106 if not private_key and not public_key:
107 raise ValueError(f"{algorithm} requires at least one key")
108 self._private_key = private_key
109 self._public_key = public_key
111 # ---------- Encode ----------
113 def encode(
114 self,
115 payload: dict,
116 ttl: int | None = None,
117 headers_extra: dict | None = None,
118 ) -> str:
119 """Encode a JWT token.
121 Args:
122 payload: Claims to include
123 ttl: Time-to-live in seconds (sets 'exp' claim)
124 headers_extra: Additional header parameters
125 """
126 header = {"alg": self._algorithm, "typ": "JWT"}
127 if headers_extra:
128 header.update(headers_extra)
130 claims = dict(payload)
131 now = int(time.time())
133 # Standard claims
134 if "iat" not in claims:
135 claims["iat"] = now
136 if ttl is not None and "exp" not in claims:
137 claims["exp"] = now + ttl
139 header_b64 = _b64url_encode(json.dumps(header).encode("utf-8"))
140 payload_b64 = _b64url_encode(json.dumps(claims).encode("utf-8"))
141 signing_input = f"{header_b64}.{payload_b64}"
143 signature = self._sign(signing_input)
144 return f"{signing_input}.{signature}"
146 # ---------- Decode ----------
148 def decode(
149 self,
150 token: str,
151 verify: bool = True,
152 audience: str | list[str] | None = None,
153 issuer: str | None = None,
154 ) -> dict:
155 """Decode and optionally verify a JWT token.
157 Args:
158 token: The JWT string
159 verify: Whether to verify the signature (default True)
160 audience: Expected audience (if present, validates 'aud' claim)
161 issuer: Expected issuer (if present, validates 'iss' claim)
162 """
163 parts = token.split(".")
164 if len(parts) != 3:
165 raise InvalidTokenError("JWT must have 3 parts (header.payload.signature)")
167 header_b64, payload_b64, signature_b64 = parts
169 # Decode header and payload (always safe)
170 header = _json_b64_decode(header_b64)
171 payload = _json_b64_decode(payload_b64)
173 # Verify algorithm
174 alg = header.get("alg")
175 if verify and alg != self._algorithm:
176 raise InvalidTokenError(f"Algorithm mismatch: expected {self._algorithm}, got {alg}")
178 # Verify signature
179 if verify:
180 signing_input = f"{header_b64}.{payload_b64}"
181 if not self._verify(signing_input, signature_b64):
182 raise InvalidTokenError("Invalid signature")
184 # Check expiry
185 exp = payload.get("exp")
186 if exp and int(exp) < time.time():
187 raise ExpiredTokenError(f"Token expired at {exp}")
189 # Check not-before
190 nbf = payload.get("nbf")
191 if nbf and int(nbf) > time.time():
192 raise InvalidTokenError(f"Token not valid before {nbf}")
194 # Check audience
195 if audience is not None:
196 aud = payload.get("aud")
197 if aud is None:
198 raise InvalidTokenError("Token missing 'aud' claim")
199 expected = [audience] if isinstance(audience, str) else audience
200 if isinstance(aud, str):
201 aud = [aud]
202 if not set(expected) & set(aud):
203 raise InvalidTokenError("Audience mismatch")
205 # Check issuer
206 if issuer is not None:
207 iss = payload.get("iss")
208 if iss != issuer:
209 raise InvalidTokenError(f"Issuer mismatch: expected {issuer}, got {iss}")
211 return payload
213 # ---------- Signature ----------
215 def _sign(self, data: str) -> str:
216 if self._algorithm.startswith("HS"):
217 d = hmac.new(
218 self._secret, data.encode("utf-8"), self._hash_func[self._algorithm]
219 ).digest()
220 return _b64url_encode(d)
221 else:
222 raise NotImplementedError(
223 f"Signing with {self._algorithm} requires cryptographic libraries (cryptography)"
224 )
226 def _verify(self, data: str, signature_b64: str) -> bool:
227 if self._algorithm.startswith("HS"):
228 expected = self._sign(data)
229 return hmac.compare_digest(expected, signature_b64)
230 else:
231 raise NotImplementedError(
232 f"Verification with {self._algorithm} requires cryptographic libraries (cryptography)"
233 )
235 # ---------- Static helpers ----------
237 @staticmethod
238 def decode_unverified(token: str) -> dict:
239 """Decode JWT without verifying signature (inspect only)."""
240 parts = token.split(".")
241 if len(parts) != 3:
242 raise InvalidTokenError("JWT must have 3 parts")
243 return _json_b64_decode(parts[1])
245 @staticmethod
246 def get_header(token: str) -> dict:
247 """Extract JWT header without verification."""
248 parts = token.split(".")
249 if len(parts) != 3:
250 raise InvalidTokenError("JWT must have 3 parts")
251 return _json_b64_decode(parts[0])