generated from kgod/ai-review-template
提交
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
from .api_jwk import PyJWK, PyJWKSet
|
||||
from .api_jws import (
|
||||
PyJWS,
|
||||
get_algorithm_by_name,
|
||||
get_unverified_header,
|
||||
register_algorithm,
|
||||
unregister_algorithm,
|
||||
)
|
||||
from .api_jwt import PyJWT, decode, decode_complete, encode
|
||||
from .exceptions import (
|
||||
DecodeError,
|
||||
ExpiredSignatureError,
|
||||
ImmatureSignatureError,
|
||||
InvalidAlgorithmError,
|
||||
InvalidAudienceError,
|
||||
InvalidIssuedAtError,
|
||||
InvalidIssuerError,
|
||||
InvalidKeyError,
|
||||
InvalidSignatureError,
|
||||
InvalidTokenError,
|
||||
MissingRequiredClaimError,
|
||||
PyJWKClientConnectionError,
|
||||
PyJWKClientError,
|
||||
PyJWKError,
|
||||
PyJWKSetError,
|
||||
PyJWTError,
|
||||
)
|
||||
from .jwks_client import PyJWKClient
|
||||
from .warnings import InsecureKeyLengthWarning
|
||||
|
||||
__version__ = "2.12.1"
|
||||
|
||||
__title__ = "PyJWT"
|
||||
__description__ = "JSON Web Token implementation in Python"
|
||||
__url__ = "https://pyjwt.readthedocs.io"
|
||||
__uri__ = __url__
|
||||
__doc__ = f"{__description__} <{__uri__}>"
|
||||
|
||||
__author__ = "José Padilla"
|
||||
__email__ = "hello@jpadilla.com"
|
||||
|
||||
__license__ = "MIT"
|
||||
__copyright__ = "Copyright 2015-2026 José Padilla"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PyJWS",
|
||||
"PyJWT",
|
||||
"PyJWKClient",
|
||||
"PyJWK",
|
||||
"PyJWKSet",
|
||||
"decode",
|
||||
"decode_complete",
|
||||
"encode",
|
||||
"get_unverified_header",
|
||||
"register_algorithm",
|
||||
"unregister_algorithm",
|
||||
"get_algorithm_by_name",
|
||||
# Warnings
|
||||
"InsecureKeyLengthWarning",
|
||||
# Exceptions
|
||||
"DecodeError",
|
||||
"ExpiredSignatureError",
|
||||
"ImmatureSignatureError",
|
||||
"InvalidAlgorithmError",
|
||||
"InvalidAudienceError",
|
||||
"InvalidIssuedAtError",
|
||||
"InvalidIssuerError",
|
||||
"InvalidKeyError",
|
||||
"InvalidSignatureError",
|
||||
"InvalidTokenError",
|
||||
"MissingRequiredClaimError",
|
||||
"PyJWKClientConnectionError",
|
||||
"PyJWKClientError",
|
||||
"PyJWKError",
|
||||
"PyJWKSetError",
|
||||
"PyJWTError",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,998 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
ClassVar,
|
||||
Literal,
|
||||
NoReturn,
|
||||
Union,
|
||||
cast,
|
||||
get_args,
|
||||
overload,
|
||||
)
|
||||
|
||||
from .exceptions import InvalidKeyError
|
||||
from .types import HashlibHash, JWKDict
|
||||
from .utils import (
|
||||
base64url_decode,
|
||||
base64url_encode,
|
||||
der_to_raw_signature,
|
||||
force_bytes,
|
||||
from_base64url_uint,
|
||||
is_pem_format,
|
||||
is_ssh_key,
|
||||
raw_to_der_signature,
|
||||
to_base64url_uint,
|
||||
)
|
||||
|
||||
try:
|
||||
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||
ECDSA,
|
||||
SECP256K1,
|
||||
SECP256R1,
|
||||
SECP384R1,
|
||||
SECP521R1,
|
||||
EllipticCurve,
|
||||
EllipticCurvePrivateKey,
|
||||
EllipticCurvePrivateNumbers,
|
||||
EllipticCurvePublicKey,
|
||||
EllipticCurvePublicNumbers,
|
||||
)
|
||||
from cryptography.hazmat.primitives.asymmetric.ed448 import (
|
||||
Ed448PrivateKey,
|
||||
Ed448PublicKey,
|
||||
)
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import (
|
||||
RSAPrivateKey,
|
||||
RSAPrivateNumbers,
|
||||
RSAPublicKey,
|
||||
RSAPublicNumbers,
|
||||
rsa_crt_dmp1,
|
||||
rsa_crt_dmq1,
|
||||
rsa_crt_iqmp,
|
||||
rsa_recover_prime_factors,
|
||||
)
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
PublicFormat,
|
||||
load_pem_private_key,
|
||||
load_pem_public_key,
|
||||
load_ssh_public_key,
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
from typing import TypeAlias
|
||||
else:
|
||||
# Python 3.9 and lower
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
# Type aliases for convenience in algorithms method signatures
|
||||
AllowedRSAKeys: TypeAlias = Union[RSAPrivateKey, RSAPublicKey]
|
||||
AllowedECKeys: TypeAlias = Union[EllipticCurvePrivateKey, EllipticCurvePublicKey]
|
||||
AllowedOKPKeys: TypeAlias = Union[
|
||||
Ed25519PrivateKey, Ed25519PublicKey, Ed448PrivateKey, Ed448PublicKey
|
||||
]
|
||||
AllowedKeys: TypeAlias = Union[AllowedRSAKeys, AllowedECKeys, AllowedOKPKeys]
|
||||
#: Type alias for allowed ``cryptography`` private keys (requires ``cryptography`` to be installed)
|
||||
AllowedPrivateKeys: TypeAlias = Union[
|
||||
RSAPrivateKey, EllipticCurvePrivateKey, Ed25519PrivateKey, Ed448PrivateKey
|
||||
]
|
||||
#: Type alias for allowed ``cryptography`` public keys (requires ``cryptography`` to be installed)
|
||||
AllowedPublicKeys: TypeAlias = Union[
|
||||
RSAPublicKey, EllipticCurvePublicKey, Ed25519PublicKey, Ed448PublicKey
|
||||
]
|
||||
|
||||
if TYPE_CHECKING or bool(os.getenv("SPHINX_BUILD", "")):
|
||||
from cryptography.hazmat.primitives.asymmetric.types import (
|
||||
PrivateKeyTypes,
|
||||
PublicKeyTypes,
|
||||
)
|
||||
|
||||
has_crypto = True
|
||||
except ModuleNotFoundError:
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Never
|
||||
else:
|
||||
from typing_extensions import Never
|
||||
|
||||
AllowedRSAKeys = Never # type: ignore[misc]
|
||||
AllowedECKeys = Never # type: ignore[misc]
|
||||
AllowedOKPKeys = Never # type: ignore[misc]
|
||||
AllowedKeys = Never # type: ignore[misc]
|
||||
AllowedPrivateKeys = Never # type: ignore[misc]
|
||||
AllowedPublicKeys = Never # type: ignore[misc]
|
||||
has_crypto = False
|
||||
|
||||
|
||||
requires_cryptography = {
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"ES256",
|
||||
"ES256K",
|
||||
"ES384",
|
||||
"ES521",
|
||||
"ES512",
|
||||
"PS256",
|
||||
"PS384",
|
||||
"PS512",
|
||||
"EdDSA",
|
||||
}
|
||||
|
||||
|
||||
def get_default_algorithms() -> dict[str, Algorithm]:
|
||||
"""
|
||||
Returns the algorithms that are implemented by the library.
|
||||
"""
|
||||
default_algorithms: dict[str, Algorithm] = {
|
||||
"none": NoneAlgorithm(),
|
||||
"HS256": HMACAlgorithm(HMACAlgorithm.SHA256),
|
||||
"HS384": HMACAlgorithm(HMACAlgorithm.SHA384),
|
||||
"HS512": HMACAlgorithm(HMACAlgorithm.SHA512),
|
||||
}
|
||||
|
||||
if has_crypto:
|
||||
default_algorithms.update(
|
||||
{
|
||||
"RS256": RSAAlgorithm(RSAAlgorithm.SHA256),
|
||||
"RS384": RSAAlgorithm(RSAAlgorithm.SHA384),
|
||||
"RS512": RSAAlgorithm(RSAAlgorithm.SHA512),
|
||||
"ES256": ECAlgorithm(ECAlgorithm.SHA256, SECP256R1),
|
||||
"ES256K": ECAlgorithm(ECAlgorithm.SHA256, SECP256K1),
|
||||
"ES384": ECAlgorithm(ECAlgorithm.SHA384, SECP384R1),
|
||||
"ES521": ECAlgorithm(ECAlgorithm.SHA512, SECP521R1),
|
||||
"ES512": ECAlgorithm(
|
||||
ECAlgorithm.SHA512, SECP521R1
|
||||
), # Backward compat for #219 fix
|
||||
"PS256": RSAPSSAlgorithm(RSAPSSAlgorithm.SHA256),
|
||||
"PS384": RSAPSSAlgorithm(RSAPSSAlgorithm.SHA384),
|
||||
"PS512": RSAPSSAlgorithm(RSAPSSAlgorithm.SHA512),
|
||||
"EdDSA": OKPAlgorithm(),
|
||||
}
|
||||
)
|
||||
|
||||
return default_algorithms
|
||||
|
||||
|
||||
class Algorithm(ABC):
|
||||
"""
|
||||
The interface for an algorithm used to sign and verify tokens.
|
||||
"""
|
||||
|
||||
# pyjwt-964: Validate to ensure the key passed in was decoded to the correct cryptography key family
|
||||
_crypto_key_types: tuple[type[AllowedKeys], ...] | None = None
|
||||
|
||||
def compute_hash_digest(self, bytestr: bytes) -> bytes:
|
||||
"""
|
||||
Compute a hash digest using the specified algorithm's hash algorithm.
|
||||
|
||||
If there is no hash algorithm, raises a NotImplementedError.
|
||||
"""
|
||||
# lookup self.hash_alg if defined in a way that mypy can understand
|
||||
hash_alg = getattr(self, "hash_alg", None)
|
||||
if hash_alg is None:
|
||||
raise NotImplementedError
|
||||
|
||||
if (
|
||||
has_crypto
|
||||
and isinstance(hash_alg, type)
|
||||
and issubclass(hash_alg, hashes.HashAlgorithm)
|
||||
):
|
||||
digest = hashes.Hash(hash_alg(), backend=default_backend())
|
||||
digest.update(bytestr)
|
||||
return bytes(digest.finalize())
|
||||
else:
|
||||
return bytes(hash_alg(bytestr).digest())
|
||||
|
||||
def check_crypto_key_type(self, key: PublicKeyTypes | PrivateKeyTypes) -> None:
|
||||
"""Check that the key belongs to the right cryptographic family.
|
||||
|
||||
Note that this method only works when ``cryptography`` is installed.
|
||||
|
||||
:param key: Potentially a cryptography key
|
||||
:type key: :py:data:`PublicKeyTypes <cryptography.hazmat.primitives.asymmetric.types.PublicKeyTypes>` | :py:data:`PrivateKeyTypes <cryptography.hazmat.primitives.asymmetric.types.PrivateKeyTypes>`
|
||||
:raises ValueError: if ``cryptography`` is not installed, or this method is called by a non-cryptography algorithm
|
||||
:raises InvalidKeyError: if the key doesn't match the expected key classes
|
||||
"""
|
||||
if not has_crypto or self._crypto_key_types is None:
|
||||
raise ValueError(
|
||||
"This method requires the cryptography library, and should only be used by cryptography-based algorithms."
|
||||
)
|
||||
|
||||
if not isinstance(key, self._crypto_key_types):
|
||||
valid_classes = (cls.__name__ for cls in self._crypto_key_types)
|
||||
actual_class = key.__class__.__name__
|
||||
self_class = self.__class__.__name__
|
||||
raise InvalidKeyError(
|
||||
f"Expected one of {valid_classes}, got: {actual_class}. Invalid Key type for {self_class}"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def prepare_key(self, key: Any) -> Any:
|
||||
"""
|
||||
Performs necessary validation and conversions on the key and returns
|
||||
the key value in the proper format for sign() and verify().
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def sign(self, msg: bytes, key: Any) -> bytes:
|
||||
"""
|
||||
Returns a digital signature for the specified message
|
||||
using the specified key value.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def verify(self, msg: bytes, key: Any, sig: bytes) -> bool:
|
||||
"""
|
||||
Verifies that the specified digital signature is valid
|
||||
for the specified message and key values.
|
||||
"""
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def to_jwk(key_obj: Any, as_dict: Literal[True]) -> JWKDict: ... # pragma: no cover
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def to_jwk(
|
||||
key_obj: Any, as_dict: Literal[False] = False
|
||||
) -> str: ... # pragma: no cover
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def to_jwk(key_obj: Any, as_dict: bool = False) -> JWKDict | str:
|
||||
"""
|
||||
Serializes a given key into a JWK
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def from_jwk(jwk: str | JWKDict) -> Any:
|
||||
"""
|
||||
Deserializes a given key from JWK back into a key object
|
||||
"""
|
||||
|
||||
def check_key_length(self, key: Any) -> str | None:
|
||||
"""
|
||||
Return a warning message if the key is below the minimum
|
||||
recommended length for this algorithm, or None if adequate.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class NoneAlgorithm(Algorithm):
|
||||
"""
|
||||
Placeholder for use when no signing or verification
|
||||
operations are required.
|
||||
"""
|
||||
|
||||
def prepare_key(self, key: str | None) -> None:
|
||||
if key == "":
|
||||
key = None
|
||||
|
||||
if key is not None:
|
||||
raise InvalidKeyError('When alg = "none", key value must be None.')
|
||||
|
||||
return key
|
||||
|
||||
def sign(self, msg: bytes, key: None) -> bytes:
|
||||
return b""
|
||||
|
||||
def verify(self, msg: bytes, key: None, sig: bytes) -> bool:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def to_jwk(key_obj: Any, as_dict: bool = False) -> NoReturn:
|
||||
raise NotImplementedError()
|
||||
|
||||
@staticmethod
|
||||
def from_jwk(jwk: str | JWKDict) -> NoReturn:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class HMACAlgorithm(Algorithm):
|
||||
"""
|
||||
Performs signing and verification operations using HMAC
|
||||
and the specified hash function.
|
||||
"""
|
||||
|
||||
SHA256: ClassVar[HashlibHash] = hashlib.sha256
|
||||
SHA384: ClassVar[HashlibHash] = hashlib.sha384
|
||||
SHA512: ClassVar[HashlibHash] = hashlib.sha512
|
||||
|
||||
def __init__(self, hash_alg: HashlibHash) -> None:
|
||||
self.hash_alg = hash_alg
|
||||
|
||||
def prepare_key(self, key: str | bytes) -> bytes:
|
||||
key_bytes = force_bytes(key)
|
||||
|
||||
if is_pem_format(key_bytes) or is_ssh_key(key_bytes):
|
||||
raise InvalidKeyError(
|
||||
"The specified key is an asymmetric key or x509 certificate and"
|
||||
" should not be used as an HMAC secret."
|
||||
)
|
||||
|
||||
return key_bytes
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def to_jwk(key_obj: str | bytes, as_dict: Literal[True]) -> JWKDict: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def to_jwk(key_obj: str | bytes, as_dict: Literal[False] = False) -> str: ...
|
||||
|
||||
@staticmethod
|
||||
def to_jwk(key_obj: str | bytes, as_dict: bool = False) -> JWKDict | str:
|
||||
jwk = {
|
||||
"k": base64url_encode(force_bytes(key_obj)).decode(),
|
||||
"kty": "oct",
|
||||
}
|
||||
|
||||
if as_dict:
|
||||
return jwk
|
||||
else:
|
||||
return json.dumps(jwk)
|
||||
|
||||
@staticmethod
|
||||
def from_jwk(jwk: str | JWKDict) -> bytes:
|
||||
try:
|
||||
if isinstance(jwk, str):
|
||||
obj: JWKDict = json.loads(jwk)
|
||||
elif isinstance(jwk, dict):
|
||||
obj = jwk
|
||||
else:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise InvalidKeyError("Key is not valid JSON") from None
|
||||
|
||||
if obj.get("kty") != "oct":
|
||||
raise InvalidKeyError("Not an HMAC key")
|
||||
|
||||
return base64url_decode(obj["k"])
|
||||
|
||||
def check_key_length(self, key: bytes) -> str | None:
|
||||
min_length = self.hash_alg().digest_size
|
||||
if len(key) < min_length:
|
||||
return (
|
||||
f"The HMAC key is {len(key)} bytes long, which is below "
|
||||
f"the minimum recommended length of {min_length} bytes for "
|
||||
f"{self.hash_alg().name.upper()}. "
|
||||
f"See RFC 7518 Section 3.2."
|
||||
)
|
||||
return None
|
||||
|
||||
def sign(self, msg: bytes, key: bytes) -> bytes:
|
||||
return hmac.new(key, msg, self.hash_alg).digest()
|
||||
|
||||
def verify(self, msg: bytes, key: bytes, sig: bytes) -> bool:
|
||||
return hmac.compare_digest(sig, self.sign(msg, key))
|
||||
|
||||
|
||||
if has_crypto:
|
||||
|
||||
class RSAAlgorithm(Algorithm):
|
||||
"""
|
||||
Performs signing and verification operations using
|
||||
RSASSA-PKCS-v1_5 and the specified hash function.
|
||||
"""
|
||||
|
||||
SHA256: ClassVar[type[hashes.HashAlgorithm]] = hashes.SHA256
|
||||
SHA384: ClassVar[type[hashes.HashAlgorithm]] = hashes.SHA384
|
||||
SHA512: ClassVar[type[hashes.HashAlgorithm]] = hashes.SHA512
|
||||
|
||||
_crypto_key_types = cast(
|
||||
tuple[type[AllowedKeys], ...],
|
||||
get_args(Union[RSAPrivateKey, RSAPublicKey]),
|
||||
)
|
||||
_MIN_KEY_SIZE: ClassVar[int] = 2048
|
||||
|
||||
def __init__(self, hash_alg: type[hashes.HashAlgorithm]) -> None:
|
||||
self.hash_alg = hash_alg
|
||||
|
||||
def check_key_length(self, key: AllowedRSAKeys) -> str | None:
|
||||
if key.key_size < self._MIN_KEY_SIZE:
|
||||
return (
|
||||
f"The RSA key is {key.key_size} bits long, which is below "
|
||||
f"the minimum recommended size of {self._MIN_KEY_SIZE} bits. "
|
||||
f"See NIST SP 800-131A."
|
||||
)
|
||||
return None
|
||||
|
||||
def prepare_key(self, key: AllowedRSAKeys | str | bytes) -> AllowedRSAKeys:
|
||||
if isinstance(key, self._crypto_key_types):
|
||||
return cast(AllowedRSAKeys, key)
|
||||
|
||||
if not isinstance(key, (bytes, str)):
|
||||
raise TypeError("Expecting a PEM-formatted key.")
|
||||
|
||||
key_bytes = force_bytes(key)
|
||||
|
||||
try:
|
||||
if key_bytes.startswith(b"ssh-rsa"):
|
||||
public_key: PublicKeyTypes = load_ssh_public_key(key_bytes)
|
||||
self.check_crypto_key_type(public_key)
|
||||
return cast(RSAPublicKey, public_key)
|
||||
else:
|
||||
private_key: PrivateKeyTypes = load_pem_private_key(
|
||||
key_bytes, password=None
|
||||
)
|
||||
self.check_crypto_key_type(private_key)
|
||||
return cast(RSAPrivateKey, private_key)
|
||||
except ValueError:
|
||||
try:
|
||||
public_key = load_pem_public_key(key_bytes)
|
||||
self.check_crypto_key_type(public_key)
|
||||
return cast(RSAPublicKey, public_key)
|
||||
except (ValueError, UnsupportedAlgorithm):
|
||||
raise InvalidKeyError(
|
||||
"Could not parse the provided public key."
|
||||
) from None
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def to_jwk(key_obj: AllowedRSAKeys, as_dict: Literal[True]) -> JWKDict: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def to_jwk(key_obj: AllowedRSAKeys, as_dict: Literal[False] = False) -> str: ...
|
||||
|
||||
@staticmethod
|
||||
def to_jwk(key_obj: AllowedRSAKeys, as_dict: bool = False) -> JWKDict | str:
|
||||
obj: dict[str, Any] | None = None
|
||||
|
||||
if hasattr(key_obj, "private_numbers"):
|
||||
# Private key
|
||||
numbers = key_obj.private_numbers()
|
||||
|
||||
obj = {
|
||||
"kty": "RSA",
|
||||
"key_ops": ["sign"],
|
||||
"n": to_base64url_uint(numbers.public_numbers.n).decode(),
|
||||
"e": to_base64url_uint(numbers.public_numbers.e).decode(),
|
||||
"d": to_base64url_uint(numbers.d).decode(),
|
||||
"p": to_base64url_uint(numbers.p).decode(),
|
||||
"q": to_base64url_uint(numbers.q).decode(),
|
||||
"dp": to_base64url_uint(numbers.dmp1).decode(),
|
||||
"dq": to_base64url_uint(numbers.dmq1).decode(),
|
||||
"qi": to_base64url_uint(numbers.iqmp).decode(),
|
||||
}
|
||||
|
||||
elif hasattr(key_obj, "verify"):
|
||||
# Public key
|
||||
numbers = key_obj.public_numbers()
|
||||
|
||||
obj = {
|
||||
"kty": "RSA",
|
||||
"key_ops": ["verify"],
|
||||
"n": to_base64url_uint(numbers.n).decode(),
|
||||
"e": to_base64url_uint(numbers.e).decode(),
|
||||
}
|
||||
else:
|
||||
raise InvalidKeyError("Not a public or private key")
|
||||
|
||||
if as_dict:
|
||||
return obj
|
||||
else:
|
||||
return json.dumps(obj)
|
||||
|
||||
@staticmethod
|
||||
def from_jwk(jwk: str | JWKDict) -> AllowedRSAKeys:
|
||||
try:
|
||||
if isinstance(jwk, str):
|
||||
obj = json.loads(jwk)
|
||||
elif isinstance(jwk, dict):
|
||||
obj = jwk
|
||||
else:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise InvalidKeyError("Key is not valid JSON") from None
|
||||
|
||||
if obj.get("kty") != "RSA":
|
||||
raise InvalidKeyError("Not an RSA key") from None
|
||||
|
||||
if "d" in obj and "e" in obj and "n" in obj:
|
||||
# Private key
|
||||
if "oth" in obj:
|
||||
raise InvalidKeyError(
|
||||
"Unsupported RSA private key: > 2 primes not supported"
|
||||
)
|
||||
|
||||
other_props = ["p", "q", "dp", "dq", "qi"]
|
||||
props_found = [prop in obj for prop in other_props]
|
||||
any_props_found = any(props_found)
|
||||
|
||||
if any_props_found and not all(props_found):
|
||||
raise InvalidKeyError(
|
||||
"RSA key must include all parameters if any are present besides d"
|
||||
) from None
|
||||
|
||||
public_numbers = RSAPublicNumbers(
|
||||
from_base64url_uint(obj["e"]),
|
||||
from_base64url_uint(obj["n"]),
|
||||
)
|
||||
|
||||
if any_props_found:
|
||||
numbers = RSAPrivateNumbers(
|
||||
d=from_base64url_uint(obj["d"]),
|
||||
p=from_base64url_uint(obj["p"]),
|
||||
q=from_base64url_uint(obj["q"]),
|
||||
dmp1=from_base64url_uint(obj["dp"]),
|
||||
dmq1=from_base64url_uint(obj["dq"]),
|
||||
iqmp=from_base64url_uint(obj["qi"]),
|
||||
public_numbers=public_numbers,
|
||||
)
|
||||
else:
|
||||
d = from_base64url_uint(obj["d"])
|
||||
p, q = rsa_recover_prime_factors(
|
||||
public_numbers.n, d, public_numbers.e
|
||||
)
|
||||
|
||||
numbers = RSAPrivateNumbers(
|
||||
d=d,
|
||||
p=p,
|
||||
q=q,
|
||||
dmp1=rsa_crt_dmp1(d, p),
|
||||
dmq1=rsa_crt_dmq1(d, q),
|
||||
iqmp=rsa_crt_iqmp(p, q),
|
||||
public_numbers=public_numbers,
|
||||
)
|
||||
|
||||
return numbers.private_key()
|
||||
elif "n" in obj and "e" in obj:
|
||||
# Public key
|
||||
return RSAPublicNumbers(
|
||||
from_base64url_uint(obj["e"]),
|
||||
from_base64url_uint(obj["n"]),
|
||||
).public_key()
|
||||
else:
|
||||
raise InvalidKeyError("Not a public or private key")
|
||||
|
||||
def sign(self, msg: bytes, key: RSAPrivateKey) -> bytes:
|
||||
signature: bytes = key.sign(msg, padding.PKCS1v15(), self.hash_alg())
|
||||
return signature
|
||||
|
||||
def verify(self, msg: bytes, key: RSAPublicKey, sig: bytes) -> bool:
|
||||
try:
|
||||
key.verify(sig, msg, padding.PKCS1v15(), self.hash_alg())
|
||||
return True
|
||||
except InvalidSignature:
|
||||
return False
|
||||
|
||||
class ECAlgorithm(Algorithm):
|
||||
"""
|
||||
Performs signing and verification operations using
|
||||
ECDSA and the specified hash function
|
||||
"""
|
||||
|
||||
SHA256: ClassVar[type[hashes.HashAlgorithm]] = hashes.SHA256
|
||||
SHA384: ClassVar[type[hashes.HashAlgorithm]] = hashes.SHA384
|
||||
SHA512: ClassVar[type[hashes.HashAlgorithm]] = hashes.SHA512
|
||||
|
||||
_crypto_key_types = cast(
|
||||
tuple[type[AllowedKeys], ...],
|
||||
get_args(Union[EllipticCurvePrivateKey, EllipticCurvePublicKey]),
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hash_alg: type[hashes.HashAlgorithm],
|
||||
expected_curve: type[EllipticCurve] | None = None,
|
||||
) -> None:
|
||||
self.hash_alg = hash_alg
|
||||
self.expected_curve = expected_curve
|
||||
|
||||
def _validate_curve(self, key: AllowedECKeys) -> None:
|
||||
"""Validate that the key's curve matches the expected curve."""
|
||||
if self.expected_curve is None:
|
||||
return
|
||||
|
||||
if not isinstance(key.curve, self.expected_curve):
|
||||
raise InvalidKeyError(
|
||||
f"The key's curve '{key.curve.name}' does not match the expected "
|
||||
f"curve '{self.expected_curve.name}' for this algorithm"
|
||||
)
|
||||
|
||||
def prepare_key(self, key: AllowedECKeys | str | bytes) -> AllowedECKeys:
|
||||
if isinstance(key, self._crypto_key_types):
|
||||
ec_key = cast(AllowedECKeys, key)
|
||||
self._validate_curve(ec_key)
|
||||
return ec_key
|
||||
|
||||
if not isinstance(key, (bytes, str)):
|
||||
raise TypeError("Expecting a PEM-formatted key.")
|
||||
|
||||
key_bytes = force_bytes(key)
|
||||
|
||||
# Attempt to load key. We don't know if it's
|
||||
# a Signing Key or a Verifying Key, so we try
|
||||
# the Verifying Key first.
|
||||
try:
|
||||
if key_bytes.startswith(b"ecdsa-sha2-"):
|
||||
public_key: PublicKeyTypes = load_ssh_public_key(key_bytes)
|
||||
else:
|
||||
public_key = load_pem_public_key(key_bytes)
|
||||
|
||||
# Explicit check the key to prevent confusing errors from cryptography
|
||||
self.check_crypto_key_type(public_key)
|
||||
ec_public_key = cast(EllipticCurvePublicKey, public_key)
|
||||
self._validate_curve(ec_public_key)
|
||||
return ec_public_key
|
||||
except ValueError:
|
||||
private_key = load_pem_private_key(key_bytes, password=None)
|
||||
self.check_crypto_key_type(private_key)
|
||||
ec_private_key = cast(EllipticCurvePrivateKey, private_key)
|
||||
self._validate_curve(ec_private_key)
|
||||
return ec_private_key
|
||||
|
||||
def sign(self, msg: bytes, key: EllipticCurvePrivateKey) -> bytes:
|
||||
der_sig = key.sign(msg, ECDSA(self.hash_alg()))
|
||||
|
||||
return der_to_raw_signature(der_sig, key.curve)
|
||||
|
||||
def verify(self, msg: bytes, key: AllowedECKeys, sig: bytes) -> bool:
|
||||
try:
|
||||
der_sig = raw_to_der_signature(sig, key.curve)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
try:
|
||||
public_key = (
|
||||
key.public_key()
|
||||
if isinstance(key, EllipticCurvePrivateKey)
|
||||
else key
|
||||
)
|
||||
public_key.verify(der_sig, msg, ECDSA(self.hash_alg()))
|
||||
return True
|
||||
except InvalidSignature:
|
||||
return False
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def to_jwk(key_obj: AllowedECKeys, as_dict: Literal[True]) -> JWKDict: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def to_jwk(key_obj: AllowedECKeys, as_dict: Literal[False] = False) -> str: ...
|
||||
|
||||
@staticmethod
|
||||
def to_jwk(key_obj: AllowedECKeys, as_dict: bool = False) -> JWKDict | str:
|
||||
if isinstance(key_obj, EllipticCurvePrivateKey):
|
||||
public_numbers = key_obj.public_key().public_numbers()
|
||||
elif isinstance(key_obj, EllipticCurvePublicKey):
|
||||
public_numbers = key_obj.public_numbers()
|
||||
else:
|
||||
raise InvalidKeyError("Not a public or private key")
|
||||
|
||||
if isinstance(key_obj.curve, SECP256R1):
|
||||
crv = "P-256"
|
||||
elif isinstance(key_obj.curve, SECP384R1):
|
||||
crv = "P-384"
|
||||
elif isinstance(key_obj.curve, SECP521R1):
|
||||
crv = "P-521"
|
||||
elif isinstance(key_obj.curve, SECP256K1):
|
||||
crv = "secp256k1"
|
||||
else:
|
||||
raise InvalidKeyError(f"Invalid curve: {key_obj.curve}")
|
||||
|
||||
obj: dict[str, Any] = {
|
||||
"kty": "EC",
|
||||
"crv": crv,
|
||||
"x": to_base64url_uint(
|
||||
public_numbers.x,
|
||||
bit_length=key_obj.curve.key_size,
|
||||
).decode(),
|
||||
"y": to_base64url_uint(
|
||||
public_numbers.y,
|
||||
bit_length=key_obj.curve.key_size,
|
||||
).decode(),
|
||||
}
|
||||
|
||||
if isinstance(key_obj, EllipticCurvePrivateKey):
|
||||
obj["d"] = to_base64url_uint(
|
||||
key_obj.private_numbers().private_value,
|
||||
bit_length=key_obj.curve.key_size,
|
||||
).decode()
|
||||
|
||||
if as_dict:
|
||||
return obj
|
||||
else:
|
||||
return json.dumps(obj)
|
||||
|
||||
@staticmethod
|
||||
def from_jwk(jwk: str | JWKDict) -> AllowedECKeys:
|
||||
try:
|
||||
if isinstance(jwk, str):
|
||||
obj = json.loads(jwk)
|
||||
elif isinstance(jwk, dict):
|
||||
obj = jwk
|
||||
else:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise InvalidKeyError("Key is not valid JSON") from None
|
||||
|
||||
if obj.get("kty") != "EC":
|
||||
raise InvalidKeyError("Not an Elliptic curve key") from None
|
||||
|
||||
if "x" not in obj or "y" not in obj:
|
||||
raise InvalidKeyError("Not an Elliptic curve key") from None
|
||||
|
||||
x = base64url_decode(obj.get("x"))
|
||||
y = base64url_decode(obj.get("y"))
|
||||
|
||||
curve = obj.get("crv")
|
||||
curve_obj: EllipticCurve
|
||||
|
||||
if curve == "P-256":
|
||||
if len(x) == len(y) == 32:
|
||||
curve_obj = SECP256R1()
|
||||
else:
|
||||
raise InvalidKeyError(
|
||||
"Coords should be 32 bytes for curve P-256"
|
||||
) from None
|
||||
elif curve == "P-384":
|
||||
if len(x) == len(y) == 48:
|
||||
curve_obj = SECP384R1()
|
||||
else:
|
||||
raise InvalidKeyError(
|
||||
"Coords should be 48 bytes for curve P-384"
|
||||
) from None
|
||||
elif curve == "P-521":
|
||||
if len(x) == len(y) == 66:
|
||||
curve_obj = SECP521R1()
|
||||
else:
|
||||
raise InvalidKeyError(
|
||||
"Coords should be 66 bytes for curve P-521"
|
||||
) from None
|
||||
elif curve == "secp256k1":
|
||||
if len(x) == len(y) == 32:
|
||||
curve_obj = SECP256K1()
|
||||
else:
|
||||
raise InvalidKeyError(
|
||||
"Coords should be 32 bytes for curve secp256k1"
|
||||
)
|
||||
else:
|
||||
raise InvalidKeyError(f"Invalid curve: {curve}")
|
||||
|
||||
public_numbers = EllipticCurvePublicNumbers(
|
||||
x=int.from_bytes(x, byteorder="big"),
|
||||
y=int.from_bytes(y, byteorder="big"),
|
||||
curve=curve_obj,
|
||||
)
|
||||
|
||||
if "d" not in obj:
|
||||
return public_numbers.public_key()
|
||||
|
||||
d = base64url_decode(obj.get("d"))
|
||||
if len(d) != len(x):
|
||||
raise InvalidKeyError(
|
||||
"D should be {} bytes for curve {}", len(x), curve
|
||||
)
|
||||
|
||||
return EllipticCurvePrivateNumbers(
|
||||
int.from_bytes(d, byteorder="big"), public_numbers
|
||||
).private_key()
|
||||
|
||||
class RSAPSSAlgorithm(RSAAlgorithm):
|
||||
"""
|
||||
Performs a signature using RSASSA-PSS with MGF1
|
||||
"""
|
||||
|
||||
def sign(self, msg: bytes, key: RSAPrivateKey) -> bytes:
|
||||
signature: bytes = key.sign(
|
||||
msg,
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(self.hash_alg()),
|
||||
salt_length=self.hash_alg().digest_size,
|
||||
),
|
||||
self.hash_alg(),
|
||||
)
|
||||
return signature
|
||||
|
||||
def verify(self, msg: bytes, key: RSAPublicKey, sig: bytes) -> bool:
|
||||
try:
|
||||
key.verify(
|
||||
sig,
|
||||
msg,
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(self.hash_alg()),
|
||||
salt_length=self.hash_alg().digest_size,
|
||||
),
|
||||
self.hash_alg(),
|
||||
)
|
||||
return True
|
||||
except InvalidSignature:
|
||||
return False
|
||||
|
||||
class OKPAlgorithm(Algorithm):
|
||||
"""
|
||||
Performs signing and verification operations using EdDSA
|
||||
|
||||
This class requires ``cryptography>=2.6`` to be installed.
|
||||
"""
|
||||
|
||||
_crypto_key_types = cast(
|
||||
tuple[type[AllowedKeys], ...],
|
||||
get_args(
|
||||
Union[
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
Ed448PrivateKey,
|
||||
Ed448PublicKey,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
def prepare_key(self, key: AllowedOKPKeys | str | bytes) -> AllowedOKPKeys:
|
||||
if not isinstance(key, (str, bytes)):
|
||||
self.check_crypto_key_type(key)
|
||||
return key
|
||||
|
||||
key_str = key.decode("utf-8") if isinstance(key, bytes) else key
|
||||
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
||||
|
||||
loaded_key: PublicKeyTypes | PrivateKeyTypes
|
||||
if "-----BEGIN PUBLIC" in key_str:
|
||||
loaded_key = load_pem_public_key(key_bytes)
|
||||
elif "-----BEGIN PRIVATE" in key_str:
|
||||
loaded_key = load_pem_private_key(key_bytes, password=None)
|
||||
elif key_str[0:4] == "ssh-":
|
||||
loaded_key = load_ssh_public_key(key_bytes)
|
||||
else:
|
||||
raise InvalidKeyError("Not a public or private key")
|
||||
|
||||
# Explicit check the key to prevent confusing errors from cryptography
|
||||
self.check_crypto_key_type(loaded_key)
|
||||
return cast("AllowedOKPKeys", loaded_key)
|
||||
|
||||
def sign(
|
||||
self, msg: str | bytes, key: Ed25519PrivateKey | Ed448PrivateKey
|
||||
) -> bytes:
|
||||
"""
|
||||
Sign a message ``msg`` using the EdDSA private key ``key``
|
||||
:param str|bytes msg: Message to sign
|
||||
:param Ed25519PrivateKey}Ed448PrivateKey key: A :class:`.Ed25519PrivateKey`
|
||||
or :class:`.Ed448PrivateKey` isinstance
|
||||
:return bytes signature: The signature, as bytes
|
||||
"""
|
||||
msg_bytes = msg.encode("utf-8") if isinstance(msg, str) else msg
|
||||
signature: bytes = key.sign(msg_bytes)
|
||||
return signature
|
||||
|
||||
def verify(
|
||||
self, msg: str | bytes, key: AllowedOKPKeys, sig: str | bytes
|
||||
) -> bool:
|
||||
"""
|
||||
Verify a given ``msg`` against a signature ``sig`` using the EdDSA key ``key``
|
||||
|
||||
:param str|bytes sig: EdDSA signature to check ``msg`` against
|
||||
:param str|bytes msg: Message to sign
|
||||
:param Ed25519PrivateKey|Ed25519PublicKey|Ed448PrivateKey|Ed448PublicKey key:
|
||||
A private or public EdDSA key instance
|
||||
:return bool verified: True if signature is valid, False if not.
|
||||
"""
|
||||
try:
|
||||
msg_bytes = msg.encode("utf-8") if isinstance(msg, str) else msg
|
||||
sig_bytes = sig.encode("utf-8") if isinstance(sig, str) else sig
|
||||
|
||||
public_key = (
|
||||
key.public_key()
|
||||
if isinstance(key, (Ed25519PrivateKey, Ed448PrivateKey))
|
||||
else key
|
||||
)
|
||||
public_key.verify(sig_bytes, msg_bytes)
|
||||
return True # If no exception was raised, the signature is valid.
|
||||
except InvalidSignature:
|
||||
return False
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def to_jwk(key: AllowedOKPKeys, as_dict: Literal[True]) -> JWKDict: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def to_jwk(key: AllowedOKPKeys, as_dict: Literal[False] = False) -> str: ...
|
||||
|
||||
@staticmethod
|
||||
def to_jwk(key: AllowedOKPKeys, as_dict: bool = False) -> JWKDict | str:
|
||||
if isinstance(key, (Ed25519PublicKey, Ed448PublicKey)):
|
||||
x = key.public_bytes(
|
||||
encoding=Encoding.Raw,
|
||||
format=PublicFormat.Raw,
|
||||
)
|
||||
crv = "Ed25519" if isinstance(key, Ed25519PublicKey) else "Ed448"
|
||||
|
||||
obj = {
|
||||
"x": base64url_encode(force_bytes(x)).decode(),
|
||||
"kty": "OKP",
|
||||
"crv": crv,
|
||||
}
|
||||
|
||||
if as_dict:
|
||||
return obj
|
||||
else:
|
||||
return json.dumps(obj)
|
||||
|
||||
if isinstance(key, (Ed25519PrivateKey, Ed448PrivateKey)):
|
||||
d = key.private_bytes(
|
||||
encoding=Encoding.Raw,
|
||||
format=PrivateFormat.Raw,
|
||||
encryption_algorithm=NoEncryption(),
|
||||
)
|
||||
|
||||
x = key.public_key().public_bytes(
|
||||
encoding=Encoding.Raw,
|
||||
format=PublicFormat.Raw,
|
||||
)
|
||||
|
||||
crv = "Ed25519" if isinstance(key, Ed25519PrivateKey) else "Ed448"
|
||||
obj = {
|
||||
"x": base64url_encode(force_bytes(x)).decode(),
|
||||
"d": base64url_encode(force_bytes(d)).decode(),
|
||||
"kty": "OKP",
|
||||
"crv": crv,
|
||||
}
|
||||
|
||||
if as_dict:
|
||||
return obj
|
||||
else:
|
||||
return json.dumps(obj)
|
||||
|
||||
raise InvalidKeyError("Not a public or private key")
|
||||
|
||||
@staticmethod
|
||||
def from_jwk(jwk: str | JWKDict) -> AllowedOKPKeys:
|
||||
try:
|
||||
if isinstance(jwk, str):
|
||||
obj = json.loads(jwk)
|
||||
elif isinstance(jwk, dict):
|
||||
obj = jwk
|
||||
else:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise InvalidKeyError("Key is not valid JSON") from None
|
||||
|
||||
if obj.get("kty") != "OKP":
|
||||
raise InvalidKeyError("Not an Octet Key Pair")
|
||||
|
||||
curve = obj.get("crv")
|
||||
if curve != "Ed25519" and curve != "Ed448":
|
||||
raise InvalidKeyError(f"Invalid curve: {curve}")
|
||||
|
||||
if "x" not in obj:
|
||||
raise InvalidKeyError('OKP should have "x" parameter')
|
||||
x = base64url_decode(obj.get("x"))
|
||||
|
||||
try:
|
||||
if "d" not in obj:
|
||||
if curve == "Ed25519":
|
||||
return Ed25519PublicKey.from_public_bytes(x)
|
||||
return Ed448PublicKey.from_public_bytes(x)
|
||||
d = base64url_decode(obj.get("d"))
|
||||
if curve == "Ed25519":
|
||||
return Ed25519PrivateKey.from_private_bytes(d)
|
||||
return Ed448PrivateKey.from_private_bytes(d)
|
||||
except ValueError as err:
|
||||
raise InvalidKeyError("Invalid key parameter") from err
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from .algorithms import get_default_algorithms, has_crypto, requires_cryptography
|
||||
from .exceptions import (
|
||||
InvalidKeyError,
|
||||
MissingCryptographyError,
|
||||
PyJWKError,
|
||||
PyJWKSetError,
|
||||
PyJWTError,
|
||||
)
|
||||
from .types import JWKDict
|
||||
|
||||
|
||||
class PyJWK:
|
||||
def __init__(self, jwk_data: JWKDict, algorithm: str | None = None) -> None:
|
||||
"""A class that represents a `JSON Web Key <https://www.rfc-editor.org/rfc/rfc7517>`_.
|
||||
|
||||
:param jwk_data: The decoded JWK data.
|
||||
:type jwk_data: dict[str, typing.Any]
|
||||
:param algorithm: The key algorithm. If not specified, the key's ``alg`` will be used.
|
||||
:type algorithm: str or None
|
||||
:raises InvalidKeyError: If the key type (``kty``) is not found or unsupported, or if the curve (``crv``) is not found or unsupported.
|
||||
:raises MissingCryptographyError: If the algorithm requires ``cryptography`` to be installed and it is not available.
|
||||
:raises PyJWKError: If unable to find an algorithm for the key.
|
||||
"""
|
||||
self._jwk_data = jwk_data
|
||||
|
||||
kty = self._jwk_data.get("kty", None)
|
||||
if not kty:
|
||||
raise InvalidKeyError(f"kty is not found: {self._jwk_data}")
|
||||
|
||||
if not algorithm and isinstance(self._jwk_data, dict):
|
||||
algorithm = self._jwk_data.get("alg", None)
|
||||
|
||||
if not algorithm:
|
||||
# Determine alg with kty (and crv).
|
||||
crv = self._jwk_data.get("crv", None)
|
||||
if kty == "EC":
|
||||
if crv == "P-256" or not crv:
|
||||
algorithm = "ES256"
|
||||
elif crv == "P-384":
|
||||
algorithm = "ES384"
|
||||
elif crv == "P-521":
|
||||
algorithm = "ES512"
|
||||
elif crv == "secp256k1":
|
||||
algorithm = "ES256K"
|
||||
else:
|
||||
raise InvalidKeyError(f"Unsupported crv: {crv}")
|
||||
elif kty == "RSA":
|
||||
algorithm = "RS256"
|
||||
elif kty == "oct":
|
||||
algorithm = "HS256"
|
||||
elif kty == "OKP":
|
||||
if not crv:
|
||||
raise InvalidKeyError(f"crv is not found: {self._jwk_data}")
|
||||
if crv == "Ed25519":
|
||||
algorithm = "EdDSA"
|
||||
else:
|
||||
raise InvalidKeyError(f"Unsupported crv: {crv}")
|
||||
else:
|
||||
raise InvalidKeyError(f"Unsupported kty: {kty}")
|
||||
|
||||
if not has_crypto and algorithm in requires_cryptography:
|
||||
raise MissingCryptographyError(
|
||||
f"{algorithm} requires 'cryptography' to be installed."
|
||||
)
|
||||
|
||||
self.algorithm_name = algorithm
|
||||
|
||||
try:
|
||||
self.Algorithm = get_default_algorithms()[algorithm]
|
||||
except KeyError:
|
||||
raise PyJWKError(
|
||||
f"Unable to find an algorithm for key: {self._jwk_data}",
|
||||
) from None
|
||||
|
||||
self.key = self.Algorithm.from_jwk(self._jwk_data)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(obj: JWKDict, algorithm: str | None = None) -> PyJWK:
|
||||
"""Creates a :class:`PyJWK` object from a JSON-like dictionary.
|
||||
|
||||
:param obj: The JWK data, as a dictionary
|
||||
:type obj: dict[str, typing.Any]
|
||||
:param algorithm: The key algorithm. If not specified, the key's ``alg`` will be used.
|
||||
:type algorithm: str or None
|
||||
:rtype: PyJWK
|
||||
"""
|
||||
return PyJWK(obj, algorithm)
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: str, algorithm: None = None) -> PyJWK:
|
||||
"""Create a :class:`PyJWK` object from a JSON string.
|
||||
Implicitly calls :meth:`PyJWK.from_dict()`.
|
||||
|
||||
:param str data: The JWK data, as a JSON string.
|
||||
:param algorithm: The key algorithm. If not specific, the key's ``alg`` will be used.
|
||||
:type algorithm: str or None
|
||||
|
||||
:rtype: PyJWK
|
||||
"""
|
||||
obj = json.loads(data)
|
||||
return PyJWK.from_dict(obj, algorithm)
|
||||
|
||||
@property
|
||||
def key_type(self) -> str | None:
|
||||
"""The `kty` property from the JWK.
|
||||
|
||||
:rtype: str or None
|
||||
"""
|
||||
return self._jwk_data.get("kty", None)
|
||||
|
||||
@property
|
||||
def key_id(self) -> str | None:
|
||||
"""The `kid` property from the JWK.
|
||||
|
||||
:rtype: str or None
|
||||
"""
|
||||
return self._jwk_data.get("kid", None)
|
||||
|
||||
@property
|
||||
def public_key_use(self) -> str | None:
|
||||
"""The `use` property from the JWK.
|
||||
|
||||
:rtype: str or None
|
||||
"""
|
||||
return self._jwk_data.get("use", None)
|
||||
|
||||
|
||||
class PyJWKSet:
|
||||
def __init__(self, keys: list[JWKDict]) -> None:
|
||||
self.keys: list[PyJWK] = []
|
||||
|
||||
if not keys:
|
||||
raise PyJWKSetError("The JWK Set did not contain any keys")
|
||||
|
||||
if not isinstance(keys, list):
|
||||
raise PyJWKSetError("Invalid JWK Set value")
|
||||
|
||||
for key in keys:
|
||||
try:
|
||||
self.keys.append(PyJWK(key))
|
||||
except PyJWTError as error:
|
||||
if isinstance(error, MissingCryptographyError):
|
||||
raise error
|
||||
# skip unusable keys
|
||||
continue
|
||||
|
||||
if len(self.keys) == 0:
|
||||
raise PyJWKSetError(
|
||||
"The JWK Set did not contain any usable keys. Perhaps 'cryptography' is not installed?"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(obj: dict[str, Any]) -> PyJWKSet:
|
||||
keys = obj.get("keys", [])
|
||||
return PyJWKSet(keys)
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: str) -> PyJWKSet:
|
||||
obj = json.loads(data)
|
||||
return PyJWKSet.from_dict(obj)
|
||||
|
||||
def __getitem__(self, kid: str) -> PyJWK:
|
||||
for key in self.keys:
|
||||
if key.key_id == kid:
|
||||
return key
|
||||
raise KeyError(f"keyset has no key for kid: {kid}")
|
||||
|
||||
def __iter__(self) -> Iterator[PyJWK]:
|
||||
return iter(self.keys)
|
||||
|
||||
|
||||
class PyJWTSetWithTimestamp:
|
||||
def __init__(self, jwk_set: PyJWKSet):
|
||||
self.jwk_set = jwk_set
|
||||
self.timestamp = time.monotonic()
|
||||
|
||||
def get_jwk_set(self) -> PyJWKSet:
|
||||
return self.jwk_set
|
||||
|
||||
def get_timestamp(self) -> float:
|
||||
return self.timestamp
|
||||
@@ -0,0 +1,407 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
import json
|
||||
import warnings
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .algorithms import (
|
||||
Algorithm,
|
||||
get_default_algorithms,
|
||||
has_crypto,
|
||||
requires_cryptography,
|
||||
)
|
||||
from .api_jwk import PyJWK
|
||||
from .exceptions import (
|
||||
DecodeError,
|
||||
InvalidAlgorithmError,
|
||||
InvalidKeyError,
|
||||
InvalidSignatureError,
|
||||
InvalidTokenError,
|
||||
)
|
||||
from .utils import base64url_decode, base64url_encode
|
||||
from .warnings import InsecureKeyLengthWarning, RemovedInPyjwt3Warning
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .algorithms import AllowedPrivateKeys, AllowedPublicKeys
|
||||
from .types import SigOptions
|
||||
|
||||
_ALGORITHM_UNSET = object()
|
||||
|
||||
|
||||
class PyJWS:
|
||||
header_typ = "JWT"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
algorithms: Sequence[str] | None = None,
|
||||
options: SigOptions | None = None,
|
||||
) -> None:
|
||||
self._algorithms = get_default_algorithms()
|
||||
self._valid_algs = (
|
||||
set(algorithms) if algorithms is not None else set(self._algorithms)
|
||||
)
|
||||
|
||||
# Remove algorithms that aren't on the whitelist
|
||||
for key in list(self._algorithms.keys()):
|
||||
if key not in self._valid_algs:
|
||||
del self._algorithms[key]
|
||||
|
||||
self.options: SigOptions = self._get_default_options()
|
||||
if options is not None:
|
||||
self.options = {**self.options, **options}
|
||||
|
||||
@staticmethod
|
||||
def _get_default_options() -> SigOptions:
|
||||
return {"verify_signature": True, "enforce_minimum_key_length": False}
|
||||
|
||||
def register_algorithm(self, alg_id: str, alg_obj: Algorithm) -> None:
|
||||
"""
|
||||
Registers a new Algorithm for use when creating and verifying tokens.
|
||||
|
||||
:param str alg_id: the ID of the Algorithm
|
||||
:param alg_obj: the Algorithm object
|
||||
:type alg_obj: Algorithm
|
||||
"""
|
||||
if alg_id in self._algorithms:
|
||||
raise ValueError("Algorithm already has a handler.")
|
||||
|
||||
if not isinstance(alg_obj, Algorithm):
|
||||
raise TypeError("Object is not of type `Algorithm`")
|
||||
|
||||
self._algorithms[alg_id] = alg_obj
|
||||
self._valid_algs.add(alg_id)
|
||||
|
||||
def unregister_algorithm(self, alg_id: str) -> None:
|
||||
"""
|
||||
Unregisters an Algorithm for use when creating and verifying tokens
|
||||
:param str alg_id: the ID of the Algorithm
|
||||
:raises KeyError: if algorithm is not registered.
|
||||
"""
|
||||
if alg_id not in self._algorithms:
|
||||
raise KeyError(
|
||||
"The specified algorithm could not be removed"
|
||||
" because it is not registered."
|
||||
)
|
||||
|
||||
del self._algorithms[alg_id]
|
||||
self._valid_algs.remove(alg_id)
|
||||
|
||||
def get_algorithms(self) -> list[str]:
|
||||
"""
|
||||
Returns a list of supported values for the `alg` parameter.
|
||||
|
||||
:rtype: list[str]
|
||||
"""
|
||||
return list(self._valid_algs)
|
||||
|
||||
def get_algorithm_by_name(self, alg_name: str) -> Algorithm:
|
||||
"""
|
||||
For a given string name, return the matching Algorithm object.
|
||||
|
||||
Example usage:
|
||||
>>> jws_obj = PyJWS()
|
||||
>>> jws_obj.get_algorithm_by_name("RS256")
|
||||
|
||||
:param alg_name: The name of the algorithm to retrieve
|
||||
:type alg_name: str
|
||||
:rtype: Algorithm
|
||||
"""
|
||||
try:
|
||||
return self._algorithms[alg_name]
|
||||
except KeyError as e:
|
||||
if not has_crypto and alg_name in requires_cryptography:
|
||||
raise NotImplementedError(
|
||||
f"Algorithm '{alg_name}' could not be found. Do you have cryptography installed?"
|
||||
) from e
|
||||
raise NotImplementedError("Algorithm not supported") from e
|
||||
|
||||
def encode(
|
||||
self,
|
||||
payload: bytes,
|
||||
key: AllowedPrivateKeys | PyJWK | str | bytes,
|
||||
algorithm: str | None = _ALGORITHM_UNSET, # type: ignore[assignment]
|
||||
headers: dict[str, Any] | None = None,
|
||||
json_encoder: type[json.JSONEncoder] | None = None,
|
||||
is_payload_detached: bool = False,
|
||||
sort_headers: bool = True,
|
||||
) -> str:
|
||||
segments: list[bytes] = []
|
||||
|
||||
# declare a new var to narrow the type for type checkers
|
||||
if algorithm is _ALGORITHM_UNSET:
|
||||
if isinstance(key, PyJWK):
|
||||
algorithm_ = key.algorithm_name
|
||||
else:
|
||||
algorithm_ = "HS256"
|
||||
elif algorithm is None:
|
||||
if isinstance(key, PyJWK):
|
||||
algorithm_ = key.algorithm_name
|
||||
else:
|
||||
algorithm_ = "none"
|
||||
else:
|
||||
algorithm_ = algorithm
|
||||
|
||||
# Prefer headers values if present to function parameters.
|
||||
if headers:
|
||||
headers_alg = headers.get("alg")
|
||||
if headers_alg:
|
||||
algorithm_ = headers["alg"]
|
||||
|
||||
headers_b64 = headers.get("b64")
|
||||
if headers_b64 is False:
|
||||
is_payload_detached = True
|
||||
|
||||
# Header
|
||||
header: dict[str, Any] = {"typ": self.header_typ, "alg": algorithm_}
|
||||
|
||||
if headers:
|
||||
self._validate_headers(headers, encoding=True)
|
||||
header.update(headers)
|
||||
|
||||
if not header["typ"]:
|
||||
del header["typ"]
|
||||
|
||||
if is_payload_detached:
|
||||
header["b64"] = False
|
||||
elif "b64" in header:
|
||||
# True is the standard value for b64, so no need for it
|
||||
del header["b64"]
|
||||
|
||||
json_header = json.dumps(
|
||||
header, separators=(",", ":"), cls=json_encoder, sort_keys=sort_headers
|
||||
).encode()
|
||||
|
||||
segments.append(base64url_encode(json_header))
|
||||
|
||||
if is_payload_detached:
|
||||
msg_payload = payload
|
||||
else:
|
||||
msg_payload = base64url_encode(payload)
|
||||
segments.append(msg_payload)
|
||||
|
||||
# Segments
|
||||
signing_input = b".".join(segments)
|
||||
|
||||
alg_obj = self.get_algorithm_by_name(algorithm_)
|
||||
if isinstance(key, PyJWK):
|
||||
key = key.key
|
||||
key = alg_obj.prepare_key(key)
|
||||
|
||||
key_length_msg = alg_obj.check_key_length(key)
|
||||
if key_length_msg:
|
||||
if self.options.get("enforce_minimum_key_length", False):
|
||||
raise InvalidKeyError(key_length_msg)
|
||||
else:
|
||||
warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=2)
|
||||
|
||||
signature = alg_obj.sign(signing_input, key)
|
||||
|
||||
segments.append(base64url_encode(signature))
|
||||
|
||||
# Don't put the payload content inside the encoded token when detached
|
||||
if is_payload_detached:
|
||||
segments[1] = b""
|
||||
encoded_string = b".".join(segments)
|
||||
|
||||
return encoded_string.decode("utf-8")
|
||||
|
||||
def decode_complete(
|
||||
self,
|
||||
jwt: str | bytes,
|
||||
key: AllowedPublicKeys | PyJWK | str | bytes = "",
|
||||
algorithms: Sequence[str] | None = None,
|
||||
options: SigOptions | None = None,
|
||||
detached_payload: bytes | None = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if kwargs:
|
||||
warnings.warn(
|
||||
"passing additional kwargs to decode_complete() is deprecated "
|
||||
"and will be removed in pyjwt version 3. "
|
||||
f"Unsupported kwargs: {tuple(kwargs.keys())}",
|
||||
RemovedInPyjwt3Warning,
|
||||
stacklevel=2,
|
||||
)
|
||||
merged_options: SigOptions
|
||||
if options is None:
|
||||
merged_options = self.options
|
||||
else:
|
||||
merged_options = {**self.options, **options}
|
||||
|
||||
verify_signature = merged_options["verify_signature"]
|
||||
|
||||
if verify_signature and not algorithms and not isinstance(key, PyJWK):
|
||||
raise DecodeError(
|
||||
'It is required that you pass in a value for the "algorithms" argument when calling decode().'
|
||||
)
|
||||
|
||||
payload, signing_input, header, signature = self._load(jwt)
|
||||
|
||||
self._validate_headers(header)
|
||||
|
||||
if header.get("b64", True) is False:
|
||||
if detached_payload is None:
|
||||
raise DecodeError(
|
||||
'It is required that you pass in a value for the "detached_payload" argument to decode a message having the b64 header set to false.'
|
||||
)
|
||||
payload = detached_payload
|
||||
signing_input = b".".join([signing_input.rsplit(b".", 1)[0], payload])
|
||||
|
||||
if verify_signature:
|
||||
self._verify_signature(signing_input, header, signature, key, algorithms)
|
||||
|
||||
return {
|
||||
"payload": payload,
|
||||
"header": header,
|
||||
"signature": signature,
|
||||
}
|
||||
|
||||
def decode(
|
||||
self,
|
||||
jwt: str | bytes,
|
||||
key: AllowedPublicKeys | PyJWK | str | bytes = "",
|
||||
algorithms: Sequence[str] | None = None,
|
||||
options: SigOptions | None = None,
|
||||
detached_payload: bytes | None = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
if kwargs:
|
||||
warnings.warn(
|
||||
"passing additional kwargs to decode() is deprecated "
|
||||
"and will be removed in pyjwt version 3. "
|
||||
f"Unsupported kwargs: {tuple(kwargs.keys())}",
|
||||
RemovedInPyjwt3Warning,
|
||||
stacklevel=2,
|
||||
)
|
||||
decoded = self.decode_complete(
|
||||
jwt, key, algorithms, options, detached_payload=detached_payload
|
||||
)
|
||||
return decoded["payload"]
|
||||
|
||||
def get_unverified_header(self, jwt: str | bytes) -> dict[str, Any]:
|
||||
"""Returns back the JWT header parameters as a `dict`
|
||||
|
||||
Note: The signature is not verified so the header parameters
|
||||
should not be fully trusted until signature verification is complete
|
||||
"""
|
||||
headers = self._load(jwt)[2]
|
||||
self._validate_headers(headers)
|
||||
|
||||
return headers
|
||||
|
||||
def _load(self, jwt: str | bytes) -> tuple[bytes, bytes, dict[str, Any], bytes]:
|
||||
if isinstance(jwt, str):
|
||||
jwt = jwt.encode("utf-8")
|
||||
|
||||
if not isinstance(jwt, bytes):
|
||||
raise DecodeError(f"Invalid token type. Token must be a {bytes}")
|
||||
|
||||
try:
|
||||
signing_input, crypto_segment = jwt.rsplit(b".", 1)
|
||||
header_segment, payload_segment = signing_input.split(b".", 1)
|
||||
except ValueError as err:
|
||||
raise DecodeError("Not enough segments") from err
|
||||
|
||||
try:
|
||||
header_data = base64url_decode(header_segment)
|
||||
except (TypeError, binascii.Error) as err:
|
||||
raise DecodeError("Invalid header padding") from err
|
||||
|
||||
try:
|
||||
header: dict[str, Any] = json.loads(header_data)
|
||||
except ValueError as e:
|
||||
raise DecodeError(f"Invalid header string: {e}") from e
|
||||
|
||||
if not isinstance(header, dict):
|
||||
raise DecodeError("Invalid header string: must be a json object")
|
||||
|
||||
try:
|
||||
payload = base64url_decode(payload_segment)
|
||||
except (TypeError, binascii.Error) as err:
|
||||
raise DecodeError("Invalid payload padding") from err
|
||||
|
||||
try:
|
||||
signature = base64url_decode(crypto_segment)
|
||||
except (TypeError, binascii.Error) as err:
|
||||
raise DecodeError("Invalid crypto padding") from err
|
||||
|
||||
return (payload, signing_input, header, signature)
|
||||
|
||||
def _verify_signature(
|
||||
self,
|
||||
signing_input: bytes,
|
||||
header: dict[str, Any],
|
||||
signature: bytes,
|
||||
key: AllowedPublicKeys | PyJWK | str | bytes = "",
|
||||
algorithms: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
if algorithms is None and isinstance(key, PyJWK):
|
||||
algorithms = [key.algorithm_name]
|
||||
try:
|
||||
alg = header["alg"]
|
||||
except KeyError:
|
||||
raise InvalidAlgorithmError("Algorithm not specified") from None
|
||||
|
||||
if not alg or (algorithms is not None and alg not in algorithms):
|
||||
raise InvalidAlgorithmError("The specified alg value is not allowed")
|
||||
|
||||
if isinstance(key, PyJWK):
|
||||
alg_obj = key.Algorithm
|
||||
prepared_key = key.key
|
||||
else:
|
||||
try:
|
||||
alg_obj = self.get_algorithm_by_name(alg)
|
||||
except NotImplementedError as e:
|
||||
raise InvalidAlgorithmError("Algorithm not supported") from e
|
||||
prepared_key = alg_obj.prepare_key(key)
|
||||
|
||||
key_length_msg = alg_obj.check_key_length(prepared_key)
|
||||
if key_length_msg:
|
||||
if self.options.get("enforce_minimum_key_length", False):
|
||||
raise InvalidKeyError(key_length_msg)
|
||||
else:
|
||||
warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=4)
|
||||
|
||||
if not alg_obj.verify(signing_input, prepared_key, signature):
|
||||
raise InvalidSignatureError("Signature verification failed")
|
||||
|
||||
# Extensions that PyJWT actually understands and supports
|
||||
_supported_crit: set[str] = {"b64"}
|
||||
|
||||
def _validate_headers(
|
||||
self, headers: dict[str, Any], *, encoding: bool = False
|
||||
) -> None:
|
||||
if "kid" in headers:
|
||||
self._validate_kid(headers["kid"])
|
||||
if not encoding and "crit" in headers:
|
||||
self._validate_crit(headers)
|
||||
|
||||
def _validate_kid(self, kid: Any) -> None:
|
||||
if not isinstance(kid, str):
|
||||
raise InvalidTokenError("Key ID header parameter must be a string")
|
||||
|
||||
def _validate_crit(self, headers: dict[str, Any]) -> None:
|
||||
crit = headers["crit"]
|
||||
if not isinstance(crit, list) or len(crit) == 0:
|
||||
raise InvalidTokenError("Invalid 'crit' header: must be a non-empty list")
|
||||
for ext in crit:
|
||||
if not isinstance(ext, str):
|
||||
raise InvalidTokenError("Invalid 'crit' header: values must be strings")
|
||||
if ext not in self._supported_crit:
|
||||
raise InvalidTokenError(f"Unsupported critical extension: {ext}")
|
||||
if ext not in headers:
|
||||
raise InvalidTokenError(
|
||||
f"Critical extension '{ext}' is missing from headers"
|
||||
)
|
||||
|
||||
|
||||
_jws_global_obj = PyJWS()
|
||||
encode = _jws_global_obj.encode
|
||||
decode_complete = _jws_global_obj.decode_complete
|
||||
decode = _jws_global_obj.decode
|
||||
register_algorithm = _jws_global_obj.register_algorithm
|
||||
unregister_algorithm = _jws_global_obj.unregister_algorithm
|
||||
get_algorithm_by_name = _jws_global_obj.get_algorithm_by_name
|
||||
get_unverified_header = _jws_global_obj.get_unverified_header
|
||||
@@ -0,0 +1,590 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import warnings
|
||||
from calendar import timegm
|
||||
from collections.abc import Container, Iterable, Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Any, Union, cast
|
||||
|
||||
from .api_jws import PyJWS, _ALGORITHM_UNSET, _jws_global_obj
|
||||
from .exceptions import (
|
||||
DecodeError,
|
||||
ExpiredSignatureError,
|
||||
ImmatureSignatureError,
|
||||
InvalidAudienceError,
|
||||
InvalidIssuedAtError,
|
||||
InvalidIssuerError,
|
||||
InvalidJTIError,
|
||||
InvalidSubjectError,
|
||||
MissingRequiredClaimError,
|
||||
)
|
||||
from .warnings import RemovedInPyjwt3Warning
|
||||
|
||||
if TYPE_CHECKING or bool(os.getenv("SPHINX_BUILD", "")):
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
from typing import TypeAlias
|
||||
else:
|
||||
# Python 3.9 and lower
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
from .algorithms import AllowedPrivateKeys, AllowedPublicKeys
|
||||
from .api_jwk import PyJWK
|
||||
from .types import FullOptions, Options, SigOptions
|
||||
|
||||
AllowedPrivateKeyTypes: TypeAlias = Union[AllowedPrivateKeys, PyJWK, str, bytes]
|
||||
AllowedPublicKeyTypes: TypeAlias = Union[AllowedPublicKeys, PyJWK, str, bytes]
|
||||
|
||||
|
||||
class PyJWT:
|
||||
def __init__(self, options: Options | None = None) -> None:
|
||||
self.options: FullOptions
|
||||
self.options = self._get_default_options()
|
||||
if options is not None:
|
||||
self.options = self._merge_options(options)
|
||||
|
||||
self._jws = PyJWS(options=self._get_sig_options())
|
||||
|
||||
@staticmethod
|
||||
def _get_default_options() -> FullOptions:
|
||||
return {
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_nbf": True,
|
||||
"verify_iat": True,
|
||||
"verify_aud": True,
|
||||
"verify_iss": True,
|
||||
"verify_sub": True,
|
||||
"verify_jti": True,
|
||||
"require": [],
|
||||
"strict_aud": False,
|
||||
"enforce_minimum_key_length": False,
|
||||
}
|
||||
|
||||
def _get_sig_options(self) -> SigOptions:
|
||||
return {
|
||||
"verify_signature": self.options["verify_signature"],
|
||||
"enforce_minimum_key_length": self.options.get(
|
||||
"enforce_minimum_key_length", False
|
||||
),
|
||||
}
|
||||
|
||||
def _merge_options(self, options: Options | None = None) -> FullOptions:
|
||||
if options is None:
|
||||
return self.options
|
||||
|
||||
# (defensive) set defaults for verify_x to False if verify_signature is False
|
||||
if not options.get("verify_signature", True):
|
||||
options["verify_exp"] = options.get("verify_exp", False)
|
||||
options["verify_nbf"] = options.get("verify_nbf", False)
|
||||
options["verify_iat"] = options.get("verify_iat", False)
|
||||
options["verify_aud"] = options.get("verify_aud", False)
|
||||
options["verify_iss"] = options.get("verify_iss", False)
|
||||
options["verify_sub"] = options.get("verify_sub", False)
|
||||
options["verify_jti"] = options.get("verify_jti", False)
|
||||
return {**self.options, **options}
|
||||
|
||||
def encode(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
key: AllowedPrivateKeyTypes,
|
||||
algorithm: str | None = _ALGORITHM_UNSET, # type: ignore[assignment]
|
||||
headers: dict[str, Any] | None = None,
|
||||
json_encoder: type[json.JSONEncoder] | None = None,
|
||||
sort_headers: bool = True,
|
||||
) -> str:
|
||||
"""Encode the ``payload`` as JSON Web Token.
|
||||
|
||||
:param payload: JWT claims, e.g. ``dict(iss=..., aud=..., sub=...)``
|
||||
:type payload: dict[str, typing.Any]
|
||||
:param key: a key suitable for the chosen algorithm:
|
||||
|
||||
* for **asymmetric algorithms**: PEM-formatted private key, a multiline string
|
||||
* for **symmetric algorithms**: plain string, sufficiently long for security
|
||||
|
||||
:type key: str or bytes or PyJWK or :py:class:`jwt.algorithms.AllowedPrivateKeys`
|
||||
:param algorithm: algorithm to sign the token with, e.g. ``"ES256"``.
|
||||
If ``headers`` includes ``alg``, it will be preferred to this parameter.
|
||||
If ``key`` is a :class:`PyJWK` object, by default the key algorithm will be used.
|
||||
:type algorithm: str or None
|
||||
:param headers: additional JWT header fields, e.g. ``dict(kid="my-key-id")``.
|
||||
:type headers: dict[str, typing.Any] or None
|
||||
:param json_encoder: custom JSON encoder for ``payload`` and ``headers``
|
||||
:type json_encoder: json.JSONEncoder or None
|
||||
|
||||
:rtype: str
|
||||
:returns: a JSON Web Token
|
||||
|
||||
:raises TypeError: if ``payload`` is not a ``dict``
|
||||
"""
|
||||
# Check that we get a dict
|
||||
if not isinstance(payload, dict):
|
||||
raise TypeError(
|
||||
"Expecting a dict object, as JWT only supports "
|
||||
"JSON objects as payloads."
|
||||
)
|
||||
|
||||
# Payload
|
||||
payload = payload.copy()
|
||||
for time_claim in ["exp", "iat", "nbf"]:
|
||||
# Convert datetime to a intDate value in known time-format claims
|
||||
if isinstance(payload.get(time_claim), datetime):
|
||||
payload[time_claim] = timegm(payload[time_claim].utctimetuple())
|
||||
|
||||
# Issue #1039, iss being set to non-string
|
||||
if "iss" in payload and not isinstance(payload["iss"], str):
|
||||
raise TypeError("Issuer (iss) must be a string.")
|
||||
|
||||
json_payload = self._encode_payload(
|
||||
payload,
|
||||
headers=headers,
|
||||
json_encoder=json_encoder,
|
||||
)
|
||||
|
||||
return self._jws.encode(
|
||||
json_payload,
|
||||
key,
|
||||
algorithm,
|
||||
headers,
|
||||
json_encoder,
|
||||
sort_headers=sort_headers,
|
||||
)
|
||||
|
||||
def _encode_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
headers: dict[str, Any] | None = None,
|
||||
json_encoder: type[json.JSONEncoder] | None = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Encode a given payload to the bytes to be signed.
|
||||
|
||||
This method is intended to be overridden by subclasses that need to
|
||||
encode the payload in a different way, e.g. compress the payload.
|
||||
"""
|
||||
return json.dumps(
|
||||
payload,
|
||||
separators=(",", ":"),
|
||||
cls=json_encoder,
|
||||
).encode("utf-8")
|
||||
|
||||
def decode_complete(
|
||||
self,
|
||||
jwt: str | bytes,
|
||||
key: AllowedPublicKeyTypes = "",
|
||||
algorithms: Sequence[str] | None = None,
|
||||
options: Options | None = None,
|
||||
# deprecated arg, remove in pyjwt3
|
||||
verify: bool | None = None,
|
||||
# could be used as passthrough to api_jws, consider removal in pyjwt3
|
||||
detached_payload: bytes | None = None,
|
||||
# passthrough arguments to _validate_claims
|
||||
# consider putting in options
|
||||
audience: str | Iterable[str] | None = None,
|
||||
issuer: str | Container[str] | None = None,
|
||||
subject: str | None = None,
|
||||
leeway: float | timedelta = 0,
|
||||
# kwargs
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Identical to ``jwt.decode`` except for return value which is a dictionary containing the token header (JOSE Header),
|
||||
the token payload (JWT Payload), and token signature (JWT Signature) on the keys "header", "payload",
|
||||
and "signature" respectively.
|
||||
|
||||
:param jwt: the token to be decoded
|
||||
:type jwt: str or bytes
|
||||
:param key: the key suitable for the allowed algorithm
|
||||
:type key: str or bytes or PyJWK or :py:class:`jwt.algorithms.AllowedPublicKeys`
|
||||
|
||||
:param algorithms: allowed algorithms, e.g. ``["ES256"]``
|
||||
|
||||
.. warning::
|
||||
|
||||
Do **not** compute the ``algorithms`` parameter based on
|
||||
the ``alg`` from the token itself, or on any other data
|
||||
that an attacker may be able to influence, as that might
|
||||
expose you to various vulnerabilities (see `RFC 8725 §2.1
|
||||
<https://www.rfc-editor.org/rfc/rfc8725.html#section-2.1>`_). Instead,
|
||||
either hard-code a fixed value for ``algorithms``, or
|
||||
configure it in the same place you configure the
|
||||
``key``. Make sure not to mix symmetric and asymmetric
|
||||
algorithms that interpret the ``key`` in different ways
|
||||
(e.g. HS\\* and RS\\*).
|
||||
:type algorithms: typing.Sequence[str] or None
|
||||
|
||||
:param jwt.types.Options options: extended decoding and validation options
|
||||
Refer to :py:class:`jwt.types.Options` for more information.
|
||||
|
||||
:param audience: optional, the value for ``verify_aud`` check
|
||||
:type audience: str or typing.Iterable[str] or None
|
||||
:param issuer: optional, the value for ``verify_iss`` check
|
||||
:type issuer: str or typing.Container[str] or None
|
||||
:param leeway: a time margin in seconds for the expiration check
|
||||
:type leeway: float or datetime.timedelta
|
||||
:rtype: dict[str, typing.Any]
|
||||
:returns: Decoded JWT with the JOSE Header on the key ``header``, the JWS
|
||||
Payload on the key ``payload``, and the JWS Signature on the key ``signature``.
|
||||
"""
|
||||
if kwargs:
|
||||
warnings.warn(
|
||||
"passing additional kwargs to decode_complete() is deprecated "
|
||||
"and will be removed in pyjwt version 3. "
|
||||
f"Unsupported kwargs: {tuple(kwargs.keys())}",
|
||||
RemovedInPyjwt3Warning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if options is None:
|
||||
verify_signature = True
|
||||
else:
|
||||
verify_signature = options.get("verify_signature", True)
|
||||
|
||||
# If the user has set the legacy `verify` argument, and it doesn't match
|
||||
# what the relevant `options` entry for the argument is, inform the user
|
||||
# that they're likely making a mistake.
|
||||
if verify is not None and verify != verify_signature:
|
||||
warnings.warn(
|
||||
"The `verify` argument to `decode` does nothing in PyJWT 2.0 and newer. "
|
||||
"The equivalent is setting `verify_signature` to False in the `options` dictionary. "
|
||||
"This invocation has a mismatch between the kwarg and the option entry.",
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
merged_options = self._merge_options(options)
|
||||
|
||||
sig_options: SigOptions = {
|
||||
"verify_signature": verify_signature,
|
||||
}
|
||||
decoded = self._jws.decode_complete(
|
||||
jwt,
|
||||
key=key,
|
||||
algorithms=algorithms,
|
||||
options=sig_options,
|
||||
detached_payload=detached_payload,
|
||||
)
|
||||
|
||||
payload = self._decode_payload(decoded)
|
||||
|
||||
self._validate_claims(
|
||||
payload,
|
||||
merged_options,
|
||||
audience=audience,
|
||||
issuer=issuer,
|
||||
leeway=leeway,
|
||||
subject=subject,
|
||||
)
|
||||
|
||||
decoded["payload"] = payload
|
||||
return decoded
|
||||
|
||||
def _decode_payload(self, decoded: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Decode the payload from a JWS dictionary (payload, signature, header).
|
||||
|
||||
This method is intended to be overridden by subclasses that need to
|
||||
decode the payload in a different way, e.g. decompress compressed
|
||||
payloads.
|
||||
"""
|
||||
try:
|
||||
payload: dict[str, Any] = json.loads(decoded["payload"])
|
||||
except ValueError as e:
|
||||
raise DecodeError(f"Invalid payload string: {e}") from e
|
||||
if not isinstance(payload, dict):
|
||||
raise DecodeError("Invalid payload string: must be a json object")
|
||||
return payload
|
||||
|
||||
def decode(
|
||||
self,
|
||||
jwt: str | bytes,
|
||||
key: AllowedPublicKeys | PyJWK | str | bytes = "",
|
||||
algorithms: Sequence[str] | None = None,
|
||||
options: Options | None = None,
|
||||
# deprecated arg, remove in pyjwt3
|
||||
verify: bool | None = None,
|
||||
# could be used as passthrough to api_jws, consider removal in pyjwt3
|
||||
detached_payload: bytes | None = None,
|
||||
# passthrough arguments to _validate_claims
|
||||
# consider putting in options
|
||||
audience: str | Iterable[str] | None = None,
|
||||
subject: str | None = None,
|
||||
issuer: str | Container[str] | None = None,
|
||||
leeway: float | timedelta = 0,
|
||||
# kwargs
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Verify the ``jwt`` token signature and return the token claims.
|
||||
|
||||
:param jwt: the token to be decoded
|
||||
:type jwt: str or bytes
|
||||
:param key: the key suitable for the allowed algorithm
|
||||
:type key: str or bytes or PyJWK or :py:class:`jwt.algorithms.AllowedPublicKeys`
|
||||
|
||||
:param algorithms: allowed algorithms, e.g. ``["ES256"]``
|
||||
If ``key`` is a :class:`PyJWK` object, allowed algorithms will default to the key algorithm.
|
||||
|
||||
.. warning::
|
||||
|
||||
Do **not** compute the ``algorithms`` parameter based on
|
||||
the ``alg`` from the token itself, or on any other data
|
||||
that an attacker may be able to influence, as that might
|
||||
expose you to various vulnerabilities (see `RFC 8725 §2.1
|
||||
<https://www.rfc-editor.org/rfc/rfc8725.html#section-2.1>`_). Instead,
|
||||
either hard-code a fixed value for ``algorithms``, or
|
||||
configure it in the same place you configure the
|
||||
``key``. Make sure not to mix symmetric and asymmetric
|
||||
algorithms that interpret the ``key`` in different ways
|
||||
(e.g. HS\\* and RS\\*).
|
||||
:type algorithms: typing.Sequence[str] or None
|
||||
|
||||
:param jwt.types.Options options: extended decoding and validation options
|
||||
Refer to :py:class:`jwt.types.Options` for more information.
|
||||
|
||||
:param audience: optional, the value for ``verify_aud`` check
|
||||
:type audience: str or typing.Iterable[str] or None
|
||||
:param subject: optional, the value for ``verify_sub`` check
|
||||
:type subject: str or None
|
||||
:param issuer: optional, the value for ``verify_iss`` check
|
||||
:type issuer: str or typing.Container[str] or None
|
||||
:param leeway: a time margin in seconds for the expiration check
|
||||
:type leeway: float or datetime.timedelta
|
||||
:rtype: dict[str, typing.Any]
|
||||
:returns: the JWT claims
|
||||
"""
|
||||
if kwargs:
|
||||
warnings.warn(
|
||||
"passing additional kwargs to decode() is deprecated "
|
||||
"and will be removed in pyjwt version 3. "
|
||||
f"Unsupported kwargs: {tuple(kwargs.keys())}",
|
||||
RemovedInPyjwt3Warning,
|
||||
stacklevel=2,
|
||||
)
|
||||
decoded = self.decode_complete(
|
||||
jwt,
|
||||
key,
|
||||
algorithms,
|
||||
options,
|
||||
verify=verify,
|
||||
detached_payload=detached_payload,
|
||||
audience=audience,
|
||||
subject=subject,
|
||||
issuer=issuer,
|
||||
leeway=leeway,
|
||||
)
|
||||
return cast(dict[str, Any], decoded["payload"])
|
||||
|
||||
def _validate_claims(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
options: FullOptions,
|
||||
audience: Iterable[str] | str | None = None,
|
||||
issuer: Container[str] | str | None = None,
|
||||
subject: str | None = None,
|
||||
leeway: float | timedelta = 0,
|
||||
) -> None:
|
||||
if isinstance(leeway, timedelta):
|
||||
leeway = leeway.total_seconds()
|
||||
|
||||
if audience is not None and not isinstance(audience, (str, Iterable)):
|
||||
raise TypeError("audience must be a string, iterable or None")
|
||||
|
||||
self._validate_required_claims(payload, options["require"])
|
||||
|
||||
now = datetime.now(tz=timezone.utc).timestamp()
|
||||
|
||||
if "iat" in payload and options["verify_iat"]:
|
||||
self._validate_iat(payload, now, leeway)
|
||||
|
||||
if "nbf" in payload and options["verify_nbf"]:
|
||||
self._validate_nbf(payload, now, leeway)
|
||||
|
||||
if "exp" in payload and options["verify_exp"]:
|
||||
self._validate_exp(payload, now, leeway)
|
||||
|
||||
if options["verify_iss"]:
|
||||
self._validate_iss(payload, issuer)
|
||||
|
||||
if options["verify_aud"]:
|
||||
self._validate_aud(
|
||||
payload, audience, strict=options.get("strict_aud", False)
|
||||
)
|
||||
|
||||
if options["verify_sub"]:
|
||||
self._validate_sub(payload, subject)
|
||||
|
||||
if options["verify_jti"]:
|
||||
self._validate_jti(payload)
|
||||
|
||||
def _validate_required_claims(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
claims: Iterable[str],
|
||||
) -> None:
|
||||
for claim in claims:
|
||||
if payload.get(claim) is None:
|
||||
raise MissingRequiredClaimError(claim)
|
||||
|
||||
def _validate_sub(
|
||||
self, payload: dict[str, Any], subject: str | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Checks whether "sub" if in the payload is valid or not.
|
||||
This is an Optional claim
|
||||
|
||||
:param payload(dict): The payload which needs to be validated
|
||||
:param subject(str): The subject of the token
|
||||
"""
|
||||
|
||||
if "sub" not in payload:
|
||||
return
|
||||
|
||||
if not isinstance(payload["sub"], str):
|
||||
raise InvalidSubjectError("Subject must be a string")
|
||||
|
||||
if subject is not None:
|
||||
if payload.get("sub") != subject:
|
||||
raise InvalidSubjectError("Invalid subject")
|
||||
|
||||
def _validate_jti(self, payload: dict[str, Any]) -> None:
|
||||
"""
|
||||
Checks whether "jti" if in the payload is valid or not
|
||||
This is an Optional claim
|
||||
|
||||
:param payload(dict): The payload which needs to be validated
|
||||
"""
|
||||
|
||||
if "jti" not in payload:
|
||||
return
|
||||
|
||||
if not isinstance(payload.get("jti"), str):
|
||||
raise InvalidJTIError("JWT ID must be a string")
|
||||
|
||||
def _validate_iat(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
now: float,
|
||||
leeway: float,
|
||||
) -> None:
|
||||
try:
|
||||
iat = int(payload["iat"])
|
||||
except ValueError:
|
||||
raise InvalidIssuedAtError(
|
||||
"Issued At claim (iat) must be an integer."
|
||||
) from None
|
||||
if iat > (now + leeway):
|
||||
raise ImmatureSignatureError("The token is not yet valid (iat)")
|
||||
|
||||
def _validate_nbf(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
now: float,
|
||||
leeway: float,
|
||||
) -> None:
|
||||
try:
|
||||
nbf = int(payload["nbf"])
|
||||
except ValueError:
|
||||
raise DecodeError("Not Before claim (nbf) must be an integer.") from None
|
||||
|
||||
if nbf > (now + leeway):
|
||||
raise ImmatureSignatureError("The token is not yet valid (nbf)")
|
||||
|
||||
def _validate_exp(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
now: float,
|
||||
leeway: float,
|
||||
) -> None:
|
||||
try:
|
||||
exp = int(payload["exp"])
|
||||
except ValueError:
|
||||
raise DecodeError(
|
||||
"Expiration Time claim (exp) must be an integer."
|
||||
) from None
|
||||
|
||||
if exp <= (now - leeway):
|
||||
raise ExpiredSignatureError("Signature has expired")
|
||||
|
||||
def _validate_aud(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
audience: str | Iterable[str] | None,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> None:
|
||||
if audience is None:
|
||||
if "aud" not in payload or not payload["aud"]:
|
||||
return
|
||||
# Application did not specify an audience, but
|
||||
# the token has the 'aud' claim
|
||||
raise InvalidAudienceError("Invalid audience")
|
||||
|
||||
if "aud" not in payload or not payload["aud"]:
|
||||
# Application specified an audience, but it could not be
|
||||
# verified since the token does not contain a claim.
|
||||
raise MissingRequiredClaimError("aud")
|
||||
|
||||
audience_claims = payload["aud"]
|
||||
|
||||
# In strict mode, we forbid list matching: the supplied audience
|
||||
# must be a string, and it must exactly match the audience claim.
|
||||
if strict:
|
||||
# Only a single audience is allowed in strict mode.
|
||||
if not isinstance(audience, str):
|
||||
raise InvalidAudienceError("Invalid audience (strict)")
|
||||
|
||||
# Only a single audience claim is allowed in strict mode.
|
||||
if not isinstance(audience_claims, str):
|
||||
raise InvalidAudienceError("Invalid claim format in token (strict)")
|
||||
|
||||
if audience != audience_claims:
|
||||
raise InvalidAudienceError("Audience doesn't match (strict)")
|
||||
|
||||
return
|
||||
|
||||
if isinstance(audience_claims, str):
|
||||
audience_claims = [audience_claims]
|
||||
if not isinstance(audience_claims, list):
|
||||
raise InvalidAudienceError("Invalid claim format in token")
|
||||
if any(not isinstance(c, str) for c in audience_claims):
|
||||
raise InvalidAudienceError("Invalid claim format in token")
|
||||
|
||||
if isinstance(audience, str):
|
||||
audience = [audience]
|
||||
|
||||
if all(aud not in audience_claims for aud in audience):
|
||||
raise InvalidAudienceError("Audience doesn't match")
|
||||
|
||||
def _validate_iss(
|
||||
self, payload: dict[str, Any], issuer: Container[str] | str | None
|
||||
) -> None:
|
||||
if issuer is None:
|
||||
return
|
||||
|
||||
if "iss" not in payload:
|
||||
raise MissingRequiredClaimError("iss")
|
||||
|
||||
iss = payload["iss"]
|
||||
if not isinstance(iss, str):
|
||||
raise InvalidIssuerError("Payload Issuer (iss) must be a string")
|
||||
|
||||
if isinstance(issuer, str):
|
||||
if iss != issuer:
|
||||
raise InvalidIssuerError("Invalid issuer")
|
||||
else:
|
||||
try:
|
||||
if iss not in issuer:
|
||||
raise InvalidIssuerError("Invalid issuer")
|
||||
except TypeError:
|
||||
raise InvalidIssuerError(
|
||||
'Issuer param must be "str" or "Container[str]"'
|
||||
) from None
|
||||
|
||||
|
||||
_jwt_global_obj = PyJWT()
|
||||
_jwt_global_obj._jws = _jws_global_obj
|
||||
encode = _jwt_global_obj.encode
|
||||
decode_complete = _jwt_global_obj.decode_complete
|
||||
decode = _jwt_global_obj.decode
|
||||
@@ -0,0 +1,113 @@
|
||||
class PyJWTError(Exception):
|
||||
"""
|
||||
Base class for all exceptions
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidTokenError(PyJWTError):
|
||||
"""Base exception when ``decode()`` fails on a token"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DecodeError(InvalidTokenError):
|
||||
"""Raised when a token cannot be decoded because it failed validation"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidSignatureError(DecodeError):
|
||||
"""Raised when a token's signature doesn't match the one provided as part of
|
||||
the token."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ExpiredSignatureError(InvalidTokenError):
|
||||
"""Raised when a token's ``exp`` claim indicates that it has expired"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidAudienceError(InvalidTokenError):
|
||||
"""Raised when a token's ``aud`` claim does not match one of the expected
|
||||
audience values"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidIssuerError(InvalidTokenError):
|
||||
"""Raised when a token's ``iss`` claim does not match the expected issuer"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidIssuedAtError(InvalidTokenError):
|
||||
"""Raised when a token's ``iat`` claim is non-numeric"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ImmatureSignatureError(InvalidTokenError):
|
||||
"""Raised when a token's ``nbf`` or ``iat`` claims represent a time in the future"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidKeyError(PyJWTError):
|
||||
"""Raised when the specified key is not in the proper format"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidAlgorithmError(InvalidTokenError):
|
||||
"""Raised when the specified algorithm is not recognized by PyJWT"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MissingRequiredClaimError(InvalidTokenError):
|
||||
"""Raised when a claim that is required to be present is not contained
|
||||
in the claimset"""
|
||||
|
||||
def __init__(self, claim: str) -> None:
|
||||
self.claim = claim
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'Token is missing the "{self.claim}" claim'
|
||||
|
||||
|
||||
class PyJWKError(PyJWTError):
|
||||
pass
|
||||
|
||||
|
||||
class MissingCryptographyError(PyJWKError):
|
||||
"""Raised if the algorithm requires ``cryptography`` to be installed and it is not available."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PyJWKSetError(PyJWTError):
|
||||
pass
|
||||
|
||||
|
||||
class PyJWKClientError(PyJWTError):
|
||||
pass
|
||||
|
||||
|
||||
class PyJWKClientConnectionError(PyJWKClientError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidSubjectError(InvalidTokenError):
|
||||
"""Raised when a token's ``sub`` claim is not a string or doesn't match the expected ``subject``"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidJTIError(InvalidTokenError):
|
||||
"""Raised when a token's ``jti`` claim is not a string"""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,66 @@
|
||||
import json
|
||||
import platform
|
||||
import sys
|
||||
|
||||
from . import __version__ as pyjwt_version
|
||||
|
||||
try:
|
||||
import cryptography
|
||||
|
||||
cryptography_version = cryptography.__version__
|
||||
except ModuleNotFoundError:
|
||||
cryptography_version = ""
|
||||
|
||||
|
||||
def info() -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
Generate information for a bug report.
|
||||
Based on the requests package help utility module.
|
||||
"""
|
||||
try:
|
||||
platform_info = {
|
||||
"system": platform.system(),
|
||||
"release": platform.release(),
|
||||
}
|
||||
except OSError:
|
||||
platform_info = {"system": "Unknown", "release": "Unknown"}
|
||||
|
||||
implementation = platform.python_implementation()
|
||||
|
||||
if implementation == "CPython":
|
||||
implementation_version = platform.python_version()
|
||||
elif implementation == "PyPy":
|
||||
pypy_version_info = sys.pypy_version_info # type: ignore[attr-defined]
|
||||
implementation_version = (
|
||||
f"{pypy_version_info.major}."
|
||||
f"{pypy_version_info.minor}."
|
||||
f"{pypy_version_info.micro}"
|
||||
)
|
||||
if pypy_version_info.releaselevel != "final":
|
||||
implementation_version = "".join(
|
||||
[
|
||||
implementation_version,
|
||||
pypy_version_info.releaselevel,
|
||||
]
|
||||
)
|
||||
else:
|
||||
implementation_version = "Unknown"
|
||||
|
||||
return {
|
||||
"platform": platform_info,
|
||||
"implementation": {
|
||||
"name": implementation,
|
||||
"version": implementation_version,
|
||||
},
|
||||
"cryptography": {"version": cryptography_version},
|
||||
"pyjwt": {"version": pyjwt_version},
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Pretty-print the bug information as JSON."""
|
||||
print(json.dumps(info(), sort_keys=True, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from .api_jwk import PyJWKSet, PyJWTSetWithTimestamp
|
||||
|
||||
|
||||
class JWKSetCache:
|
||||
def __init__(self, lifespan: float) -> None:
|
||||
self.jwk_set_with_timestamp: Optional[PyJWTSetWithTimestamp] = None
|
||||
self.lifespan = lifespan
|
||||
|
||||
def put(self, jwk_set: PyJWKSet) -> None:
|
||||
if jwk_set is not None:
|
||||
self.jwk_set_with_timestamp = PyJWTSetWithTimestamp(jwk_set)
|
||||
else:
|
||||
# clear cache
|
||||
self.jwk_set_with_timestamp = None
|
||||
|
||||
def get(self) -> Optional[PyJWKSet]:
|
||||
if self.jwk_set_with_timestamp is None or self.is_expired():
|
||||
return None
|
||||
|
||||
return self.jwk_set_with_timestamp.get_jwk_set()
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
return (
|
||||
self.jwk_set_with_timestamp is not None
|
||||
and self.lifespan > -1
|
||||
and time.monotonic()
|
||||
> self.jwk_set_with_timestamp.get_timestamp() + self.lifespan
|
||||
)
|
||||
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
from functools import lru_cache
|
||||
from ssl import SSLContext
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
from .api_jwk import PyJWK, PyJWKSet
|
||||
from .api_jwt import decode_complete as decode_token
|
||||
from .exceptions import PyJWKClientConnectionError, PyJWKClientError
|
||||
from .jwk_set_cache import JWKSetCache
|
||||
|
||||
|
||||
class PyJWKClient:
|
||||
def __init__(
|
||||
self,
|
||||
uri: str,
|
||||
cache_keys: bool = False,
|
||||
max_cached_keys: int = 16,
|
||||
cache_jwk_set: bool = True,
|
||||
lifespan: float = 300,
|
||||
headers: dict[str, Any] | None = None,
|
||||
timeout: float = 30,
|
||||
ssl_context: SSLContext | None = None,
|
||||
):
|
||||
"""A client for retrieving signing keys from a JWKS endpoint.
|
||||
|
||||
``PyJWKClient`` uses a two-tier caching system to avoid unnecessary
|
||||
network requests:
|
||||
|
||||
**Tier 1 — JWK Set cache** (enabled by default):
|
||||
Caches the entire JSON Web Key Set response from the endpoint.
|
||||
Controlled by:
|
||||
|
||||
- ``cache_jwk_set``: Set to ``True`` (the default) to enable this
|
||||
cache. When enabled, the JWK Set is fetched from the network only
|
||||
when the cache is empty or expired.
|
||||
- ``lifespan``: Time in seconds before the cached JWK Set expires.
|
||||
Defaults to ``300`` (5 minutes). Must be greater than 0.
|
||||
|
||||
**Tier 2 — Signing key cache** (disabled by default):
|
||||
Caches individual signing keys (looked up by ``kid``) using an LRU
|
||||
cache with **no time-based expiration**. Keys are evicted only when
|
||||
the cache reaches its maximum size. Controlled by:
|
||||
|
||||
- ``cache_keys``: Set to ``True`` to enable this cache.
|
||||
Defaults to ``False``.
|
||||
- ``max_cached_keys``: Maximum number of signing keys to keep in
|
||||
the LRU cache. Defaults to ``16``.
|
||||
|
||||
:param uri: The URL of the JWKS endpoint.
|
||||
:type uri: str
|
||||
:param cache_keys: Enable the per-key LRU cache (Tier 2).
|
||||
:type cache_keys: bool
|
||||
:param max_cached_keys: Max entries in the signing key LRU cache.
|
||||
:type max_cached_keys: int
|
||||
:param cache_jwk_set: Enable the JWK Set response cache (Tier 1).
|
||||
:type cache_jwk_set: bool
|
||||
:param lifespan: TTL in seconds for the JWK Set cache.
|
||||
:type lifespan: float
|
||||
:param headers: Optional HTTP headers to include in requests.
|
||||
:type headers: dict or None
|
||||
:param timeout: HTTP request timeout in seconds.
|
||||
:type timeout: float
|
||||
:param ssl_context: Optional SSL context for the request.
|
||||
:type ssl_context: ssl.SSLContext or None
|
||||
"""
|
||||
if headers is None:
|
||||
headers = {}
|
||||
self.uri = uri
|
||||
self.jwk_set_cache: JWKSetCache | None = None
|
||||
self.headers = headers
|
||||
self.timeout = timeout
|
||||
self.ssl_context = ssl_context
|
||||
|
||||
if cache_jwk_set:
|
||||
# Init jwt set cache with default or given lifespan.
|
||||
# Default lifespan is 300 seconds (5 minutes).
|
||||
if lifespan <= 0:
|
||||
raise PyJWKClientError(
|
||||
f'Lifespan must be greater than 0, the input is "{lifespan}"'
|
||||
)
|
||||
self.jwk_set_cache = JWKSetCache(lifespan)
|
||||
else:
|
||||
self.jwk_set_cache = None
|
||||
|
||||
if cache_keys:
|
||||
# Cache signing keys
|
||||
get_signing_key = lru_cache(maxsize=max_cached_keys)(self.get_signing_key)
|
||||
# Ignore mypy (https://github.com/python/mypy/issues/2427)
|
||||
self.get_signing_key = get_signing_key # type: ignore[method-assign]
|
||||
|
||||
def fetch_data(self) -> Any:
|
||||
"""Fetch the JWK Set from the JWKS endpoint.
|
||||
|
||||
Makes an HTTP request to the configured ``uri`` and returns the
|
||||
parsed JSON response. If the JWK Set cache is enabled, the
|
||||
response is stored in the cache.
|
||||
|
||||
:returns: The parsed JWK Set as a dictionary.
|
||||
:raises PyJWKClientConnectionError: If the HTTP request fails.
|
||||
"""
|
||||
jwk_set: Any = None
|
||||
try:
|
||||
r = urllib.request.Request(url=self.uri, headers=self.headers)
|
||||
with urllib.request.urlopen(
|
||||
r, timeout=self.timeout, context=self.ssl_context
|
||||
) as response:
|
||||
jwk_set = json.load(response)
|
||||
except (URLError, TimeoutError) as e:
|
||||
if isinstance(e, HTTPError):
|
||||
e.close()
|
||||
raise PyJWKClientConnectionError(
|
||||
f'Fail to fetch data from the url, err: "{e}"'
|
||||
) from e
|
||||
else:
|
||||
return jwk_set
|
||||
finally:
|
||||
if self.jwk_set_cache is not None:
|
||||
self.jwk_set_cache.put(jwk_set)
|
||||
|
||||
def get_jwk_set(self, refresh: bool = False) -> PyJWKSet:
|
||||
"""Return the JWK Set, using the cache when available.
|
||||
|
||||
:param refresh: Force a fresh fetch from the endpoint, bypassing
|
||||
the cache.
|
||||
:type refresh: bool
|
||||
:returns: The JWK Set.
|
||||
:rtype: PyJWKSet
|
||||
:raises PyJWKClientError: If the endpoint does not return a JSON
|
||||
object.
|
||||
"""
|
||||
data = None
|
||||
if self.jwk_set_cache is not None and not refresh:
|
||||
data = self.jwk_set_cache.get()
|
||||
|
||||
if data is None:
|
||||
data = self.fetch_data()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise PyJWKClientError("The JWKS endpoint did not return a JSON object")
|
||||
|
||||
return PyJWKSet.from_dict(data)
|
||||
|
||||
def get_signing_keys(self, refresh: bool = False) -> list[PyJWK]:
|
||||
"""Return all signing keys from the JWK Set.
|
||||
|
||||
Filters the JWK Set to keys whose ``use`` is ``"sig"`` (or
|
||||
unspecified) and that have a ``kid``.
|
||||
|
||||
:param refresh: Force a fresh fetch from the endpoint, bypassing
|
||||
the cache.
|
||||
:type refresh: bool
|
||||
:returns: A list of signing keys.
|
||||
:rtype: list[PyJWK]
|
||||
:raises PyJWKClientError: If no signing keys are found.
|
||||
"""
|
||||
jwk_set = self.get_jwk_set(refresh)
|
||||
signing_keys = [
|
||||
jwk_set_key
|
||||
for jwk_set_key in jwk_set.keys
|
||||
if jwk_set_key.public_key_use in ["sig", None] and jwk_set_key.key_id
|
||||
]
|
||||
|
||||
if not signing_keys:
|
||||
raise PyJWKClientError("The JWKS endpoint did not contain any signing keys")
|
||||
|
||||
return signing_keys
|
||||
|
||||
def get_signing_key(self, kid: str) -> PyJWK:
|
||||
"""Return the signing key matching the given ``kid``.
|
||||
|
||||
If no match is found in the current JWK Set, the set is
|
||||
refreshed from the endpoint and the lookup is retried once.
|
||||
|
||||
:param kid: The key ID to look up.
|
||||
:type kid: str
|
||||
:returns: The matching signing key.
|
||||
:rtype: PyJWK
|
||||
:raises PyJWKClientError: If no matching key is found after
|
||||
refreshing.
|
||||
"""
|
||||
signing_keys = self.get_signing_keys()
|
||||
signing_key = self.match_kid(signing_keys, kid)
|
||||
|
||||
if not signing_key:
|
||||
# If no matching signing key from the jwk set, refresh the jwk set and try again.
|
||||
signing_keys = self.get_signing_keys(refresh=True)
|
||||
signing_key = self.match_kid(signing_keys, kid)
|
||||
|
||||
if not signing_key:
|
||||
raise PyJWKClientError(
|
||||
f'Unable to find a signing key that matches: "{kid}"'
|
||||
)
|
||||
|
||||
return signing_key
|
||||
|
||||
def get_signing_key_from_jwt(self, token: str | bytes) -> PyJWK:
|
||||
"""Return the signing key for a JWT by reading its ``kid`` header.
|
||||
|
||||
Extracts the ``kid`` from the token's unverified header and
|
||||
delegates to :meth:`get_signing_key`.
|
||||
|
||||
:param token: The encoded JWT.
|
||||
:type token: str or bytes
|
||||
:returns: The matching signing key.
|
||||
:rtype: PyJWK
|
||||
"""
|
||||
unverified = decode_token(token, options={"verify_signature": False})
|
||||
header = unverified["header"]
|
||||
return self.get_signing_key(header.get("kid"))
|
||||
|
||||
@staticmethod
|
||||
def match_kid(signing_keys: list[PyJWK], kid: str) -> PyJWK | None:
|
||||
"""Find a key in *signing_keys* that matches *kid*.
|
||||
|
||||
:param signing_keys: The list of keys to search.
|
||||
:type signing_keys: list[PyJWK]
|
||||
:param kid: The key ID to match.
|
||||
:type kid: str
|
||||
:returns: The matching key, or ``None`` if not found.
|
||||
:rtype: PyJWK or None
|
||||
"""
|
||||
signing_key = None
|
||||
|
||||
for key in signing_keys:
|
||||
if key.key_id == kid:
|
||||
signing_key = key
|
||||
break
|
||||
|
||||
return signing_key
|
||||
@@ -0,0 +1,69 @@
|
||||
from typing import Any, Callable, TypedDict
|
||||
|
||||
JWKDict = dict[str, Any]
|
||||
|
||||
HashlibHash = Callable[..., Any]
|
||||
|
||||
|
||||
class SigOptions(TypedDict, total=False):
|
||||
"""Options for PyJWS class (TypedDict). Note that this is a smaller set of options than
|
||||
for :py:func:`jwt.decode()`."""
|
||||
|
||||
verify_signature: bool
|
||||
"""verify the JWT cryptographic signature"""
|
||||
enforce_minimum_key_length: bool
|
||||
"""Default: ``False``. Raise :py:class:`jwt.exceptions.InvalidKeyError` instead of warning when keys are below minimum recommended length."""
|
||||
|
||||
|
||||
class Options(TypedDict, total=False):
|
||||
"""Options for :py:func:`jwt.decode()` and :py:func:`jwt.decode_complete()` (TypedDict).
|
||||
|
||||
.. warning::
|
||||
|
||||
Some claims, such as ``exp``, ``iat``, ``jti``, ``nbf``, and ``sub``,
|
||||
will only be verified if present. Please refer to the documentation below
|
||||
for which ones, and make sure to include them in the ``require`` param
|
||||
if you want to make sure that they are always present (and therefore always verified
|
||||
if ``verify_{claim} = True`` for that claim).
|
||||
"""
|
||||
|
||||
verify_signature: bool
|
||||
"""Default: ``True``. Verify the JWT cryptographic signature."""
|
||||
require: list[str]
|
||||
"""Default: ``[]``. List of claims that must be present.
|
||||
Example: ``require=["exp", "iat", "nbf"]``.
|
||||
**Only verifies that the claims exists**. Does not verify that the claims are valid."""
|
||||
strict_aud: bool
|
||||
"""Default: ``False``. (requires ``verify_aud=True``) Check that the ``aud`` claim is a single value (not a list), and matches ``audience`` exactly."""
|
||||
verify_aud: bool
|
||||
"""Default: ``verify_signature``. Check that ``aud`` (audience) claim matches ``audience``."""
|
||||
verify_exp: bool
|
||||
"""Default: ``verify_signature``. Check that ``exp`` (expiration) claim value is in the future (if present in payload). """
|
||||
verify_iat: bool
|
||||
"""Default: ``verify_signature``. Check that ``iat`` (issued at) claim value is an integer (if present in payload). """
|
||||
verify_iss: bool
|
||||
"""Default: ``verify_signature``. Check that ``iss`` (issuer) claim matches ``issuer``. """
|
||||
verify_jti: bool
|
||||
"""Default: ``verify_signature``. Check that ``jti`` (JWT ID) claim is a string (if present in payload). """
|
||||
verify_nbf: bool
|
||||
"""Default: ``verify_signature``. Check that ``nbf`` (not before) claim value is in the past (if present in payload). """
|
||||
verify_sub: bool
|
||||
"""Default: ``verify_signature``. Check that ``sub`` (subject) claim is a string and matches ``subject`` (if present in payload). """
|
||||
enforce_minimum_key_length: bool
|
||||
"""Default: ``False``. Raise :py:class:`jwt.exceptions.InvalidKeyError` instead of warning when keys are below minimum recommended length."""
|
||||
|
||||
|
||||
# The only difference between Options and FullOptions is that FullOptions
|
||||
# required _every_ value to be there; Options doesn't require any
|
||||
class FullOptions(TypedDict):
|
||||
verify_signature: bool
|
||||
require: list[str]
|
||||
strict_aud: bool
|
||||
verify_aud: bool
|
||||
verify_exp: bool
|
||||
verify_iat: bool
|
||||
verify_iss: bool
|
||||
verify_jti: bool
|
||||
verify_nbf: bool
|
||||
verify_sub: bool
|
||||
enforce_minimum_key_length: bool
|
||||
@@ -0,0 +1,142 @@
|
||||
import base64
|
||||
import binascii
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurve
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import (
|
||||
decode_dss_signature,
|
||||
encode_dss_signature,
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def force_bytes(value: Union[bytes, str]) -> bytes:
|
||||
if isinstance(value, str):
|
||||
return value.encode("utf-8")
|
||||
elif isinstance(value, bytes):
|
||||
return value
|
||||
else:
|
||||
raise TypeError("Expected a string value")
|
||||
|
||||
|
||||
def base64url_decode(input: Union[bytes, str]) -> bytes:
|
||||
input_bytes = force_bytes(input)
|
||||
|
||||
rem = len(input_bytes) % 4
|
||||
|
||||
if rem > 0:
|
||||
input_bytes += b"=" * (4 - rem)
|
||||
|
||||
return base64.urlsafe_b64decode(input_bytes)
|
||||
|
||||
|
||||
def base64url_encode(input: bytes) -> bytes:
|
||||
return base64.urlsafe_b64encode(input).replace(b"=", b"")
|
||||
|
||||
|
||||
def to_base64url_uint(val: int, *, bit_length: Optional[int] = None) -> bytes:
|
||||
if val < 0:
|
||||
raise ValueError("Must be a positive integer")
|
||||
|
||||
int_bytes = bytes_from_int(val, bit_length=bit_length)
|
||||
|
||||
if len(int_bytes) == 0:
|
||||
int_bytes = b"\x00"
|
||||
|
||||
return base64url_encode(int_bytes)
|
||||
|
||||
|
||||
def from_base64url_uint(val: Union[bytes, str]) -> int:
|
||||
data = base64url_decode(force_bytes(val))
|
||||
return int.from_bytes(data, byteorder="big")
|
||||
|
||||
|
||||
def number_to_bytes(num: int, num_bytes: int) -> bytes:
|
||||
padded_hex = "%0*x" % (2 * num_bytes, num)
|
||||
return binascii.a2b_hex(padded_hex.encode("ascii"))
|
||||
|
||||
|
||||
def bytes_to_number(string: bytes) -> int:
|
||||
return int(binascii.b2a_hex(string), 16)
|
||||
|
||||
|
||||
def bytes_from_int(val: int, *, bit_length: Optional[int] = None) -> bytes:
|
||||
if bit_length is None:
|
||||
bit_length = val.bit_length()
|
||||
byte_length = (bit_length + 7) // 8
|
||||
|
||||
return val.to_bytes(byte_length, "big", signed=False)
|
||||
|
||||
|
||||
def der_to_raw_signature(der_sig: bytes, curve: "EllipticCurve") -> bytes:
|
||||
num_bits = curve.key_size
|
||||
num_bytes = (num_bits + 7) // 8
|
||||
|
||||
r, s = decode_dss_signature(der_sig)
|
||||
|
||||
return number_to_bytes(r, num_bytes) + number_to_bytes(s, num_bytes)
|
||||
|
||||
|
||||
def raw_to_der_signature(raw_sig: bytes, curve: "EllipticCurve") -> bytes:
|
||||
num_bits = curve.key_size
|
||||
num_bytes = (num_bits + 7) // 8
|
||||
|
||||
if len(raw_sig) != 2 * num_bytes:
|
||||
raise ValueError("Invalid signature")
|
||||
|
||||
r = bytes_to_number(raw_sig[:num_bytes])
|
||||
s = bytes_to_number(raw_sig[num_bytes:])
|
||||
|
||||
return bytes(encode_dss_signature(r, s))
|
||||
|
||||
|
||||
# Based on https://github.com/hynek/pem/blob/7ad94db26b0bc21d10953f5dbad3acfdfacf57aa/src/pem/_core.py#L224-L252
|
||||
_PEMS = {
|
||||
b"CERTIFICATE",
|
||||
b"TRUSTED CERTIFICATE",
|
||||
b"PRIVATE KEY",
|
||||
b"PUBLIC KEY",
|
||||
b"ENCRYPTED PRIVATE KEY",
|
||||
b"OPENSSH PRIVATE KEY",
|
||||
b"DSA PRIVATE KEY",
|
||||
b"RSA PRIVATE KEY",
|
||||
b"RSA PUBLIC KEY",
|
||||
b"EC PRIVATE KEY",
|
||||
b"DH PARAMETERS",
|
||||
b"NEW CERTIFICATE REQUEST",
|
||||
b"CERTIFICATE REQUEST",
|
||||
b"SSH2 PUBLIC KEY",
|
||||
b"SSH2 ENCRYPTED PRIVATE KEY",
|
||||
b"X509 CRL",
|
||||
}
|
||||
|
||||
_PEM_RE = re.compile(
|
||||
b"----[- ]BEGIN ("
|
||||
+ b"|".join(_PEMS)
|
||||
+ b""")[- ]----\r?
|
||||
.+?\r?
|
||||
----[- ]END \\1[- ]----\r?\n?""",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def is_pem_format(key: bytes) -> bool:
|
||||
return bool(_PEM_RE.search(key))
|
||||
|
||||
|
||||
# Based on https://github.com/pyca/cryptography/blob/bcb70852d577b3f490f015378c75cba74986297b/src/cryptography/hazmat/primitives/serialization/ssh.py#L40-L46
|
||||
_SSH_KEY_FORMATS = (
|
||||
b"ssh-ed25519",
|
||||
b"ssh-rsa",
|
||||
b"ssh-dss",
|
||||
b"ecdsa-sha2-nistp256",
|
||||
b"ecdsa-sha2-nistp384",
|
||||
b"ecdsa-sha2-nistp521",
|
||||
)
|
||||
|
||||
|
||||
def is_ssh_key(key: bytes) -> bool:
|
||||
return key.startswith(_SSH_KEY_FORMATS)
|
||||
@@ -0,0 +1,11 @@
|
||||
class RemovedInPyjwt3Warning(DeprecationWarning):
|
||||
"""Warning for features that will be removed in PyJWT 3."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InsecureKeyLengthWarning(UserWarning):
|
||||
"""Warning emitted when a cryptographic key is shorter than the minimum
|
||||
recommended length. See :ref:`key-length-validation` for details."""
|
||||
|
||||
pass
|
||||
Reference in New Issue
Block a user