first commit
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import time
|
||||
import json
|
||||
import datetime
|
||||
import calendar
|
||||
from json import JSONEncoder
|
||||
from typing import TypedDict, Any, Callable
|
||||
from ..util import to_bytes
|
||||
from ..errors import (
|
||||
MissingClaimError,
|
||||
InvalidClaimError,
|
||||
ExpiredTokenError,
|
||||
)
|
||||
|
||||
Claims = dict[str, Any]
|
||||
|
||||
|
||||
def convert_claims(claims: Claims, encoder_cls: type[JSONEncoder] | None = None) -> bytes:
|
||||
"""Turn claims into bytes payload."""
|
||||
for k in ["exp", "iat", "nbf"]:
|
||||
claim = claims.get(k)
|
||||
if isinstance(claim, datetime.datetime):
|
||||
claims[k] = calendar.timegm(claim.utctimetuple())
|
||||
|
||||
content = json.dumps(claims, ensure_ascii=False, separators=(",", ":"), cls=encoder_cls)
|
||||
return to_bytes(content)
|
||||
|
||||
|
||||
#: http://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests
|
||||
class ClaimsOption(TypedDict, total=False):
|
||||
essential: bool
|
||||
allow_blank: bool | None
|
||||
value: str | int | bool
|
||||
values: list[str | int | bool] | list[str] | list[int] | list[bool]
|
||||
|
||||
|
||||
class BaseClaimsRegistry:
|
||||
"""Requesting "claims" for JWT with the given conditions."""
|
||||
|
||||
def __init__(self, **kwargs: ClaimsOption):
|
||||
self.options = kwargs
|
||||
|
||||
@property
|
||||
def essential_keys(self) -> set[str]:
|
||||
"""Returns the essential claim names."""
|
||||
return {key for key in self.options if self.options[key].get("essential")}
|
||||
|
||||
def check_value(self, claim_name: str, value: Any) -> None:
|
||||
"""
|
||||
Validates a given claim value based on predefined options.
|
||||
|
||||
:param claim_name: The name of the claim to validate.
|
||||
:param value: The value of the claim to be validated.
|
||||
:raises InvalidClaimError: If the value does not meet the claim's validation requirements.
|
||||
"""
|
||||
option = self.options.get(claim_name)
|
||||
if not option:
|
||||
return
|
||||
|
||||
allow_blank = option.get("allow_blank")
|
||||
if not allow_blank and value in (None, "", [], {}):
|
||||
raise InvalidClaimError(claim_name)
|
||||
|
||||
option_values = option.get("values")
|
||||
|
||||
if option_values is None:
|
||||
option_value = option.get("value")
|
||||
if option_value is not None:
|
||||
option_values = [option_value]
|
||||
|
||||
if not option_values:
|
||||
return
|
||||
|
||||
if isinstance(value, list):
|
||||
if not any(v in value for v in option_values):
|
||||
raise InvalidClaimError(claim_name)
|
||||
else:
|
||||
if value not in option_values:
|
||||
raise InvalidClaimError(claim_name)
|
||||
|
||||
def validate(self, claims: dict[str, Any]) -> None:
|
||||
"""
|
||||
Validates the provided claims against specified requirements and checks.
|
||||
|
||||
:param claims: A dictionary containing claims to validate.
|
||||
:raises InvalidClaimError: Raised if any claim fails validation.
|
||||
:raises MissingClaimError: Raised if one or more essential keys are missing.
|
||||
"""
|
||||
missed_keys = {key for key in self.essential_keys if claims.get(key) is None}
|
||||
if missed_keys:
|
||||
raise MissingClaimError(",".join(sorted(missed_keys)))
|
||||
|
||||
for key in claims:
|
||||
value = claims[key]
|
||||
func = getattr(self, "validate_" + key, None)
|
||||
if func:
|
||||
func(value)
|
||||
elif key in self.options:
|
||||
self.check_value(key, value)
|
||||
|
||||
|
||||
class JWTClaimsRegistry(BaseClaimsRegistry):
|
||||
"""A claims registry for validating JWT claims.
|
||||
|
||||
:param now: timestamp of "now" time
|
||||
:param leeway: leeway time in seconds
|
||||
:param kwargs: claims options
|
||||
"""
|
||||
|
||||
def __init__(self, now: int | Callable[[], int] | None = None, leeway: int = 0, **kwargs: ClaimsOption) -> None:
|
||||
if now is None:
|
||||
now = _generate_now
|
||||
self._now = now
|
||||
self.leeway = leeway
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@property
|
||||
def now(self) -> int:
|
||||
"""Returns the current timestamp."""
|
||||
if callable(self._now):
|
||||
return self._now()
|
||||
return self._now
|
||||
|
||||
def validate_iss(self, value: str) -> None:
|
||||
"""The "iss" (issuer) claim identifies the principal that issued the
|
||||
JWT. The processing of this claim is generally application specific.
|
||||
The "iss" value is a case-sensitive string containing a StringOrURI
|
||||
value. Use of this claim is OPTIONAL.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise InvalidClaimError("str", "Claim 'str' must be a StringOrURI value")
|
||||
self.check_value("iss", value)
|
||||
|
||||
def validate_sub(self, value: str) -> None:
|
||||
"""The "sub" (subject) claim identifies the principal that is the
|
||||
subject of the JWT. The claims in a JWT are normally statements
|
||||
about the subject. The subject value MUST either be scoped to be
|
||||
locally unique in the context of the issuer or be globally unique.
|
||||
The processing of this claim is generally application specific. The
|
||||
"sub" value is a case-sensitive string containing a StringOrURI
|
||||
value. Use of this claim is OPTIONAL.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise InvalidClaimError("sub", "Claim 'sub' must be a StringOrURI value")
|
||||
self.check_value("sub", value)
|
||||
|
||||
def validate_aud(self, value: str | list[str]) -> None:
|
||||
"""The "aud" (audience) claim identifies the recipients that the JWT is
|
||||
intended for. Each principal intended to process the JWT MUST
|
||||
identify itself with a value in the audience claim. If the principal
|
||||
processing the claim does not identify itself with a value in the
|
||||
"aud" claim when this claim is present, then the JWT MUST be
|
||||
rejected. In the general case, the "aud" value is an array of case-
|
||||
sensitive strings, each containing a StringOrURI value. In the
|
||||
special case when the JWT has one audience, the "aud" value MAY be a
|
||||
single case-sensitive string containing a StringOrURI value. The
|
||||
interpretation of audience values is generally application specific.
|
||||
Use of this claim is OPTIONAL.
|
||||
"""
|
||||
if isinstance(value, str) or _validate_list_of_strings(value):
|
||||
self.check_value("aud", value)
|
||||
else:
|
||||
raise InvalidClaimError(
|
||||
"aud", "Claim 'aud' must be an array of StringOrURI value or a single StringOrURI value"
|
||||
)
|
||||
|
||||
def validate_exp(self, value: int) -> None:
|
||||
"""The "exp" (expiration time) claim identifies the expiration time on
|
||||
or after which the JWT MUST NOT be accepted for processing. The
|
||||
processing of the "exp" claim requires that the current date/time
|
||||
MUST be before the expiration date/time listed in the "exp" claim.
|
||||
Implementers MAY provide for some small leeway, usually no more than
|
||||
a few minutes, to account for clock skew. Its value MUST be a number
|
||||
containing a NumericDate value. Use of this claim is OPTIONAL.
|
||||
"""
|
||||
if not _validate_numeric_time(value):
|
||||
raise InvalidClaimError("exp", "Claim 'exp' must be a NumericDate value")
|
||||
if value < (self.now - self.leeway):
|
||||
raise ExpiredTokenError("exp")
|
||||
self.check_value("exp", value)
|
||||
|
||||
def validate_nbf(self, value: int) -> None:
|
||||
"""The "nbf" (not before) claim identifies the time before which the JWT
|
||||
MUST NOT be accepted for processing. The processing of the "nbf"
|
||||
claim requires that the current date/time MUST be after or equal to
|
||||
the not-before date/time listed in the "nbf" claim. Implementers MAY
|
||||
provide for some small leeway, usually no more than a few minutes, to
|
||||
account for clock skew. Its value MUST be a number containing a
|
||||
NumericDate value. Use of this claim is OPTIONAL.
|
||||
"""
|
||||
if not _validate_numeric_time(value):
|
||||
raise InvalidClaimError("nbf", "Claim 'nbf' must be a NumericDate value")
|
||||
if value > (self.now + self.leeway):
|
||||
raise InvalidClaimError("nbf", "The token is not yet valid")
|
||||
self.check_value("nbf", value)
|
||||
|
||||
def validate_iat(self, value: int) -> None:
|
||||
"""The "iat" (issued at) claim identifies the time at which the JWT was
|
||||
issued. This claim can be used to determine the age of the JWT. Its
|
||||
value MUST be a number containing a NumericDate value. Use of this
|
||||
claim is OPTIONAL.
|
||||
"""
|
||||
if not _validate_numeric_time(value):
|
||||
raise InvalidClaimError("iat", "Claim 'iat' must be a NumericDate value")
|
||||
if value > (self.now + self.leeway):
|
||||
raise InvalidClaimError("iat", "The token was issued in the future")
|
||||
self.check_value("iat", value)
|
||||
|
||||
|
||||
def _validate_numeric_time(s: int) -> bool:
|
||||
return isinstance(s, (int, float))
|
||||
|
||||
|
||||
def _validate_list_of_strings(s: list[str]) -> bool:
|
||||
return isinstance(s, list) and all(isinstance(v, str) for v in s)
|
||||
|
||||
|
||||
def _generate_now() -> int:
|
||||
return int(time.time())
|
||||
@@ -0,0 +1,38 @@
|
||||
import re
|
||||
from typing import Any
|
||||
from ..errors import InsecureClaimError
|
||||
|
||||
|
||||
SENSITIVE_NAMES = ("password", "token", "secret", "secret_key", "api_key")
|
||||
SENSITIVE_VALUES = re.compile(
|
||||
r"|".join(
|
||||
[
|
||||
# http://www.richardsramblings.com/regex/credit-card-numbers/
|
||||
r"\b(?:3[47]\d|(?:4\d|5[1-5]|65)\d{2}|6011)\d{12}\b",
|
||||
# various private keys
|
||||
r"-----BEGIN[A-Z ]+PRIVATE KEY-----.+-----END[A-Z ]+PRIVATE KEY-----",
|
||||
# social security numbers (US)
|
||||
r"^\b(?!(000|666|9))\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b",
|
||||
]
|
||||
),
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def check_sensitive_data(claims: dict[str, Any]) -> None:
|
||||
"""
|
||||
Checks for sensitive data within a dictionary of claims and raises an
|
||||
error if any sensitive names or values are detected.
|
||||
|
||||
:param claims: JWT claims to check for sensitive data
|
||||
:raises InsecureClaimError: if any sensitive names or values are detected
|
||||
"""
|
||||
for k in claims:
|
||||
# check claims key name
|
||||
if k in SENSITIVE_NAMES:
|
||||
raise InsecureClaimError(k)
|
||||
|
||||
# check claims values
|
||||
v = claims[k]
|
||||
if isinstance(v, str) and SENSITIVE_VALUES.search(v):
|
||||
raise InsecureClaimError(k)
|
||||
Reference in New Issue
Block a user