reshut 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
reshut/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ # SPDX-FileCopyrightText: © 2026 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ __version__ = '0.0.3'
5
+ __commit__ = '7ac5fb9'
6
+ __all__ = [
7
+ '__version__', '__commit__',
8
+ 'authorization',
9
+ 'middleware',
10
+ 'utils'
11
+ ]
reshut/__main__.py ADDED
@@ -0,0 +1,113 @@
1
+ # SPDX-FileCopyrightText: © 2026 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from typing import Optional
8
+ from pathlib import Path
9
+
10
+ from .utils import Algorithm, keygen, tokenize, validate
11
+
12
+
13
+ def __write_key_files(basename:str, prikey:str, pubkey:Optional[str]) -> None:
14
+ base_path = Path(basename)
15
+ if pubkey is None:
16
+ # shared secret
17
+ out_path = base_path.with_suffix('.b64')
18
+ out_path.write_text(prikey, encoding='utf-8')
19
+ print(f'Wrote secret key to {out_path}')
20
+ else:
21
+ # keypair
22
+ prikey_path = base_path.with_name(f'{base_path.name}_prikey.pem')
23
+ pubkey_path = base_path.with_name(f'{base_path.name}_pubkey.pem')
24
+ prikey_path.write_text(prikey, encoding='utf-8')
25
+ print(f'Wrote private key to {prikey_path}')
26
+ pubkey_path.write_text(pubkey, encoding='utf-8')
27
+ print(f'Wrote public key to {pubkey_path}')
28
+
29
+ def __cmd_keygen(args: argparse.Namespace) -> None:
30
+ try:
31
+ alg = Algorithm(str(args.type).upper())
32
+ except ValueError as exc:
33
+ sys.stderr.write(f'Unsupported algorithm: {args.type}\n')
34
+ raise SystemExit(2) from exc
35
+
36
+ prikey, pubkey = keygen(alg) # type: ignore[arg-type]
37
+ __write_key_files(args.output, prikey, pubkey)
38
+
39
+
40
+ def __read_key_file(path: Path) -> str:
41
+ try:
42
+ return path.read_text(encoding='utf-8').strip()
43
+ except OSError as exc:
44
+ sys.stderr.write(f'Unable to read key file {path}: {exc}\n')
45
+ raise SystemExit(3) from exc
46
+
47
+
48
+ def __cmd_tokenize(args: argparse.Namespace) -> None:
49
+ try:
50
+ alg = Algorithm(str(args.type).upper())
51
+ except ValueError as exc:
52
+ sys.stderr.write(f'Unsupported algorithm: {args.type}\n')
53
+ raise SystemExit(4) from exc
54
+ try:
55
+ claims = json.loads(args.claims)
56
+ if not isinstance(claims, dict):
57
+ raise TypeError
58
+ except Exception as exc:
59
+ sys.stderr.write('Claims must be a JSON object.\n')
60
+ raise SystemExit(5) from exc
61
+ private_key = __read_key_file(Path(args.key))
62
+ token = tokenize(alg, private_key, claims)
63
+ print(token)
64
+
65
+
66
+ def __cmd_validate(args: argparse.Namespace) -> None:
67
+ try:
68
+ alg = Algorithm(str(args.type).upper())
69
+ except ValueError as exc:
70
+ sys.stderr.write(f'Unsupported algorithm: {args.type}\n')
71
+ raise SystemExit(6) from exc
72
+ public_key = __read_key_file(Path(args.key))
73
+ try:
74
+ claims = validate(alg, public_key, args.token)
75
+ except Exception as exc:
76
+ sys.stderr.write(f'Validation failed: {exc}\n')
77
+ raise SystemExit(7) from exc
78
+
79
+ print(json.dumps(claims, indent=2, sort_keys=True))
80
+
81
+
82
+ def main(argv:list[str]) -> None:
83
+ parser = argparse.ArgumentParser(
84
+ prog='python -m reshut',
85
+ description='Utility for generating keys, creating and validating JWTs.'
86
+ )
87
+ subparsers = parser.add_subparsers(dest='command', required=True)
88
+
89
+ # reshut-keygen
90
+ reshut_keygen = subparsers.add_parser('keygen', help='Generate a secret or key‑pair.')
91
+ reshut_keygen.add_argument('--type', required=True, help='Algorithm name (e.g. HS256, RS256).')
92
+ reshut_keygen.add_argument('--output', required=True, help='Base filename (prefix) for generated key files.')
93
+ reshut_keygen.set_defaults(func=__cmd_keygen)
94
+
95
+ # reshut-tokenize
96
+ reshut_tokenize = subparsers.add_parser('tokenize', help='Create a JWT from claims.')
97
+ reshut_tokenize.add_argument('--type', required=True, help='Algorithm name.')
98
+ reshut_tokenize.add_argument('--claims', required=True, help='JSON string representing the claim set.')
99
+ reshut_tokenize.add_argument('--key', required=True, help='Path to the private key / secret file.')
100
+ reshut_tokenize.set_defaults(func=__cmd_tokenize)
101
+
102
+ # reshut-validate
103
+ reshut_validate = subparsers.add_parser('validate', help='Validate a JWT.')
104
+ reshut_validate.add_argument('--type', required=True, help='Algorithm name.')
105
+ reshut_validate.add_argument('--token', required=True, help='JWT string to validate.')
106
+ reshut_validate.add_argument('--key', required=True, help='Path to the public key / secret file.')
107
+ reshut_validate.set_defaults(func=__cmd_validate)
108
+
109
+ args = parser.parse_args(argv)
110
+ args.func(args) # type: ignore[attr-defined]
111
+
112
+ if __name__ == '__main__':
113
+ main(sys.argv[1:])
@@ -0,0 +1,85 @@
1
+ # SPDX-FileCopyrightText: © 2026 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import inspect
5
+ from typing import Any, Callable, Optional, TypeAlias, cast
6
+
7
+ ClaimEvaluator:TypeAlias = Callable[[Any], bool]
8
+
9
+
10
+ def allow_anonymous(func:Callable[..., Any]) -> Callable[..., Any]:
11
+ """
12
+ Indicates that a handler does not require Authorization.
13
+
14
+ :param func: The handler function.
15
+ :return: The handler function (not wrapped.)
16
+ """
17
+ org = inspect.unwrap(func)
18
+ setattr(org, '__reshut_noauth', True)
19
+ return func
20
+
21
+ def allow_claim(func:Callable[..., Any], claim_name:str, claim_check:Optional[Any|ClaimEvaluator] = None, is_required:bool = False) -> Callable[..., Any]:
22
+ """
23
+ Adds an ALLOW claim rule to a handler.
24
+
25
+ When at least one ALLOW claim rule is defined on a handler, then access is denied if at least one of the allowed claims is not presented.
26
+
27
+ :param func: The handler function.
28
+ :param claim_name: Claim name.
29
+ :param claim_check: Optional literal value that the claim must present, or a ``ClaimEvaluator`` that checks the claim is a match.
30
+ :param is_required: Optional boolean indicating that the claim is required, forming a "REQUIRED claim rule".
31
+ :return: The handler function (not wrapped.)
32
+ """
33
+ bag_name = '__reshut_require' if is_required else '__reshut_allow'
34
+ org = inspect.unwrap(func)
35
+ if not hasattr(org, bag_name):
36
+ setattr(org, bag_name, dict[str,Any]({
37
+ claim_name: claim_check
38
+ }))
39
+ else:
40
+ allow_list = cast(dict[str,Any],getattr(org, bag_name))
41
+ allow_list[claim_name] = claim_check
42
+ return func
43
+
44
+ def deny_claim(func:Callable[..., Any], claim_name:str, claim_check:Optional[Any|ClaimEvaluator] = None) -> Callable[..., Any]:
45
+ """
46
+ Adds a DENY claim rule to a handler.
47
+
48
+ When any presented claim matches a DENY claim rule, then access is denied.
49
+
50
+ :param func: The handler function.
51
+ :param claim_name: Claim name.
52
+ :param claim_check: Optional literal value that the claim must NOT present, or a ``ClaimEvaluator`` that checks the claim is a match.
53
+ :return: The handler function (not wrapped.)
54
+ """
55
+ org = inspect.unwrap(func)
56
+ if not hasattr(org, '__reshut_deny'):
57
+ setattr(org, '__reshut_deny', {
58
+ claim_name: claim_check
59
+ })
60
+ else:
61
+ deny_list = cast(dict[str,Any],getattr(org, '__reshut_deny'))
62
+ deny_list[claim_name] = claim_check
63
+ return func
64
+
65
+ def require_claim(func:Callable[..., Any], claim_name:str, claim_check:Optional[Any|ClaimEvaluator] = None) -> Callable[..., Any]:
66
+ """
67
+ Adds a REQUIRED claim rule to a handler.
68
+
69
+ When all required claims are presented, then access is granted.
70
+
71
+ :param func: The handler function.
72
+ :param claim_name: Claim name.
73
+ :param claim_check: Optional literal value that the claim must present, or a ``ClaimEvaluator`` that checks the claim is a match.
74
+ :return: The handler function (not wrapped.)
75
+ """
76
+ return allow_claim(func, claim_name, claim_check, True)
77
+
78
+
79
+ __all__ = [
80
+ 'ClaimEvaluator',
81
+ 'allow_anonymous',
82
+ 'allow_claim',
83
+ 'deny_claim',
84
+ 'require_claim'
85
+ ]
@@ -0,0 +1,42 @@
1
+ # SPDX-FileCopyrightText: © 2026 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import falcon
5
+ from falcon._typing import AsgiMiddlewareWithProcessResource
6
+ import inspect
7
+ from typing import Any, Mapping, Optional, cast
8
+ from .AuthorizationEvaluator import AuthorizationEvaluator
9
+ from .TokenEvaluator import TokenEvaluator
10
+
11
+
12
+ class AsgiAuthorizationMiddleware(AsgiMiddlewareWithProcessResource):
13
+ """
14
+ ASGI-compatible Authorization Middleware
15
+
16
+ ---
17
+ Intercepts resource requests to apply Authorization logic.
18
+ """
19
+
20
+ __authorization_evaluator:AuthorizationEvaluator
21
+
22
+ def __init__(self, apikey_token_evaluator:Optional[TokenEvaluator], basic_token_evaluator:Optional[TokenEvaluator], bearer_token_evaluator:Optional[TokenEvaluator]) -> None:
23
+ self.__authorization_evaluator = AuthorizationEvaluator(apikey_token_evaluator=apikey_token_evaluator, basic_token_evaluator=basic_token_evaluator, bearer_token_evaluator=bearer_token_evaluator)
24
+
25
+ async def process_resource(self, req:falcon.Request, resp:falcon.Response, resource:object, params: Mapping[str, Any]) -> None:
26
+ """
27
+ Intercept for ``process_resource`` that evaluates authorization data in the request against authorization requirements of the resource handler.
28
+ """
29
+ handler_name = f'on_{req.method.lower()}'
30
+ handler = getattr(resource, handler_name, None)
31
+ if handler is None:
32
+ return
33
+ handler = inspect.unwrap(cast(Any, handler))
34
+ if getattr(handler, '__reshut_noauth', False):
35
+ # access granted
36
+ return
37
+ self.__authorization_evaluator.evaluate(req, handler)
38
+
39
+
40
+ __all__ = [
41
+ 'AsgiAuthorizationMiddleware'
42
+ ]
@@ -0,0 +1,80 @@
1
+ # SPDX-FileCopyrightText: © 2026 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import falcon
5
+ from typing import Any, Optional, cast
6
+ from ..authorization import ClaimEvaluator
7
+ from .TokenEvaluator import TokenEvaluator
8
+
9
+
10
+ class AuthorizationEvaluator:
11
+ """
12
+ Evaluates the authorization data in a request against the authorization requirements of a resource handler.
13
+ """
14
+
15
+ __apikey_token_evaluator:Optional[TokenEvaluator]
16
+ __basic_token_evaluator:Optional[TokenEvaluator]
17
+ __bearer_token_evaluator:Optional[TokenEvaluator]
18
+
19
+ def __init__(
20
+ self,
21
+ apikey_token_evaluator:Optional[TokenEvaluator],
22
+ basic_token_evaluator:Optional[TokenEvaluator],
23
+ bearer_token_evaluator:Optional[TokenEvaluator]
24
+ ) -> None:
25
+ self.__apikey_token_evaluator = apikey_token_evaluator
26
+ self.__basic_token_evaluator = basic_token_evaluator
27
+ self.__bearer_token_evaluator = bearer_token_evaluator
28
+
29
+ def evaluate(self, req:falcon.Request, handler:Any) -> None:
30
+ """
31
+ Evaluate authorization data in ``req`` against authorization requirements of ``handler``.
32
+
33
+ :raises falcon.HTTPBadRequest: When the request has missing or invalid Authorization data.
34
+ :raises falcon.HTTPUnauthorized: When claim rule checks fail.
35
+ """
36
+ scheme:str|None = None
37
+ token:str|None = None
38
+ deny_claims = {} if not hasattr(handler, '__reshut_deny') else cast(dict[str,str|ClaimEvaluator], getattr(handler, '__reshut_deny'))
39
+ allow_claims = {} if not hasattr(handler, '__reshut_allow') else cast(dict[str,str|ClaimEvaluator], getattr(handler, '__reshut_allow'))
40
+ require_claims = {} if not hasattr(handler, '__reshut_require') else cast(dict[str,str|ClaimEvaluator], getattr(handler, '__reshut_require'))
41
+ # check for Authorization header
42
+ authorization = req.get_header('Authorization', False, None)
43
+ if authorization is not None:
44
+ scheme, token = authorization.split(' ', 1)
45
+ scheme = scheme.lower()
46
+ else:
47
+ # check for X-API-Key header
48
+ token = req.get_header('X-API-Key', False, None)
49
+ if token is not None and len(token) > 0:
50
+ scheme = 'apikey'
51
+ if scheme is None or token is None:
52
+ raise falcon.HTTPBadRequest(
53
+ title='Authorization Required',
54
+ description=f'Missing Authorization',
55
+ )
56
+ match scheme:
57
+ case 'apikey':
58
+ if self.__apikey_token_evaluator is not None and self.__apikey_token_evaluator.evaluate(token, deny_claims, allow_claims, require_claims):
59
+ return
60
+ case 'basic':
61
+ if self.__basic_token_evaluator is not None and self.__basic_token_evaluator.evaluate(token, deny_claims, allow_claims, require_claims):
62
+ return
63
+ case 'bearer':
64
+ if self.__bearer_token_evaluator is not None and self.__bearer_token_evaluator.evaluate(token, deny_claims, allow_claims, require_claims):
65
+ return
66
+ case _:
67
+ raise falcon.HTTPBadRequest(
68
+ title='Authorization Unsupported',
69
+ description=f'Scheme "{scheme}" not supported.',
70
+ )
71
+ # reject.
72
+ raise falcon.HTTPUnauthorized(
73
+ title='Authorization Failed',
74
+ description='Unsuccessful',
75
+ )
76
+
77
+
78
+ __all__ = [
79
+ 'AuthorizationEvaluator'
80
+ ]
@@ -0,0 +1,76 @@
1
+ # SPDX-FileCopyrightText: © 2026 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import falcon
5
+ from typing import cast
6
+ from ..authorization import ClaimEvaluator
7
+ from ..utils import Algorithm, validate
8
+
9
+
10
+ class TokenEvaluator:
11
+ """
12
+ Evaluates a token given an Algorithm and Key.
13
+ """
14
+
15
+ __algorithm:Algorithm
16
+ __key:str
17
+
18
+ def __init__(self, algorithm:Algorithm, key:str) -> None:
19
+ self.__algorithm = algorithm
20
+ self.__key = key
21
+
22
+ def evaluate(
23
+ self,
24
+ token:str,
25
+ deny_claims:dict[str,str|ClaimEvaluator],
26
+ allow_claims:dict[str,str|ClaimEvaluator],
27
+ require_claims:dict[str,str|ClaimEvaluator]
28
+ ) -> bool:
29
+ """
30
+ Evaluate a token against the supplied claim rules.
31
+
32
+ :param token: The token to be evaluated.
33
+ :param deny_claims: Claim DENY rules.
34
+ :param allow_claims: Claim ALLOW rules.
35
+ :param require_claims: Claim REQUIRE rules.
36
+ :raises falcon.HTTPUnauthorized: When claim rule checks fail.
37
+ :return: A boolean indicating success or failure, on failure the calling code should raise an appopriate exception.
38
+ """
39
+ claims = validate(self.__algorithm, self.__key, token)
40
+ # check for denied claims (if any match, access denied)
41
+ for k,claim_check in deny_claims.items():
42
+ claim_value = claims.get(k, None)
43
+ if claim_value is not None and (claim_check == None or claim_value == claim_check or (callable(claim_check) and cast(ClaimEvaluator, claim_check)(claim_value))):
44
+ # access denied
45
+ raise falcon.HTTPUnauthorized(
46
+ title='Authorization Denied',
47
+ description='DENY'
48
+ )
49
+ # check for required claims (if any not present, access denied]
50
+ for k,claim_check in require_claims.items():
51
+ claim_value = claims.get(k, None)
52
+ if claim_value is None or (claim_value != claim_check and (not callable(claim_check) or not cast(ClaimEvaluator, claim_check)(claim_value))):
53
+ # access denied
54
+ raise falcon.HTTPUnauthorized(
55
+ title='Authorization Missing',
56
+ description='REQUIRE'
57
+ )
58
+ # check for allowed claims (if none match, access denied)
59
+ if len(allow_claims) > 0:
60
+ for k,claim_check in allow_claims.items():
61
+ claim_value = claims.get(k, None)
62
+ if claim_value is not None and (claim_value == claim_check or (callable(claim_check) and cast(ClaimEvaluator, claim_check)(claim_value))):
63
+ # access granted
64
+ return True
65
+ # access denied
66
+ raise falcon.HTTPUnauthorized(
67
+ title='Authorization Disallowed',
68
+ description='ALLOW'
69
+ )
70
+ # access granted
71
+ return True
72
+
73
+
74
+ __all__ = [
75
+ 'TokenEvaluator'
76
+ ]
@@ -0,0 +1,42 @@
1
+ # SPDX-FileCopyrightText: © 2026 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import falcon
5
+ from falcon._typing import WsgiMiddlewareWithProcessResource
6
+ import inspect
7
+ from typing import Any, Mapping, Optional, cast
8
+ from .AuthorizationEvaluator import AuthorizationEvaluator
9
+ from .TokenEvaluator import TokenEvaluator
10
+
11
+
12
+ class WsgiAuthorizationMiddleware(WsgiMiddlewareWithProcessResource):
13
+ """
14
+ WSGI-compatible Authorization Middleware
15
+
16
+ ---
17
+ Intercepts resource requests to apply Authorization logic.
18
+ """
19
+
20
+ __authorization_evaluator:AuthorizationEvaluator
21
+
22
+ def __init__(self, apikey_token_evaluator:Optional[TokenEvaluator], basic_token_evaluator:Optional[TokenEvaluator], bearer_token_evaluator:Optional[TokenEvaluator]) -> None:
23
+ self.__authorization_evaluator = AuthorizationEvaluator(apikey_token_evaluator=apikey_token_evaluator, basic_token_evaluator=basic_token_evaluator, bearer_token_evaluator=bearer_token_evaluator)
24
+
25
+ def process_resource(self, req:falcon.Request, resp:falcon.Response, resource:object, params: Mapping[str, Any]) -> None:
26
+ """
27
+ Intercept for ``process_resource`` that evaluates authorization data in the request against authorization requirements of the resource handler.
28
+ """
29
+ handler_name = f'on_{req.method.lower()}'
30
+ handler = getattr(resource, handler_name, None)
31
+ if handler is None:
32
+ return
33
+ handler = inspect.unwrap(cast(Any, handler))
34
+ if getattr(handler, '__reshut_noauth', False):
35
+ # access granted
36
+ return
37
+ self.__authorization_evaluator.evaluate(req, handler)
38
+
39
+
40
+ __all__ = [
41
+ 'WsgiAuthorizationMiddleware'
42
+ ]
@@ -0,0 +1,15 @@
1
+ # SPDX-FileCopyrightText: © 2026 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from .AsgiAuthorizationMiddleware import AsgiAuthorizationMiddleware
5
+ from .AuthorizationEvaluator import AuthorizationEvaluator
6
+ from .TokenEvaluator import TokenEvaluator
7
+ from .WsgiAuthorizationMiddleware import WsgiAuthorizationMiddleware
8
+
9
+
10
+ __all__ = [
11
+ 'AsgiAuthorizationMiddleware',
12
+ 'AuthorizationEvaluator',
13
+ 'TokenEvaluator',
14
+ 'WsgiAuthorizationMiddleware'
15
+ ]
reshut/py.typed ADDED
File without changes
reshut/utils.py ADDED
@@ -0,0 +1,172 @@
1
+ # SPDX-FileCopyrightText: © 2026 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import base64
5
+ from cryptography.hazmat.primitives import serialization
6
+ from cryptography.hazmat.primitives.asymmetric import ec, ed25519, ed448, rsa
7
+ from enum import StrEnum, unique
8
+ import secrets
9
+ import jwt
10
+ from typing import Any, Optional
11
+
12
+
13
+ @unique
14
+ class Algorithm(StrEnum):
15
+ """
16
+ Enumeration of Algorithms that can be used for tokenizing claims.
17
+ """
18
+ HS256 = 'HS256'
19
+ """HMAC (256-bit, symmetric, fastest)"""
20
+ HS384 = 'HS384'
21
+ """HMAC (384-bit, symmetric, fastest)"""
22
+ HS512 = 'HS512'
23
+ """HMAC (512-bit, symmetric, fastest)"""
24
+ RS256 = 'RS256'
25
+ """RSA (256-bit, asymmetric, slow)"""
26
+ RS384 = 'RS384'
27
+ """RSA (384-bit, asymmetric, slow)"""
28
+ RS512 = 'RS512'
29
+ """RSA (512-bit, asymmetric, slow)"""
30
+ ES256 = 'ES256'
31
+ """Elliptic-curve (256-bit, asymmetric, fast)"""
32
+ ES384 = 'ES384'
33
+ """Elliptic-curve (384-bit, asymmetric, fast)"""
34
+ ES512 = 'ES512'
35
+ """Elliptic-curve (512-bit, asymmetric, fast)"""
36
+ ED25519 = 'ED25519'
37
+ """Edwards-curve (256-bit, asymmetric, faster)"""
38
+ ED448 = 'ED448'
39
+ """Edwards-curve (448-bit, asymmetric, faster)"""
40
+ def __str__(self) -> str:
41
+ return self.value
42
+
43
+ def keygen(algorithm:Algorithm, key_size:Optional[int] = None) -> tuple[str,str|None]:
44
+ """
45
+ Generates a key (or keypair) for the specified algorithm.
46
+
47
+ :param algorithm: The algorithm to use.
48
+ :param key_size: If provided, overrides the size of the generated key(s), in bits, if supported by the algorithm. Typically you would not do this.
49
+ :raises NotImplementedError: Raised when an unsupported algorithm is specified.
50
+ :return: A tuple containing the key(s) that were generated. When returning a keypair the first value is the private key, the second value is the public key.
51
+ """
52
+ match algorithm:
53
+ case Algorithm.HS256 | Algorithm.HS384 | Algorithm.HS512:
54
+ if key_size is None:
55
+ match algorithm:
56
+ case Algorithm.HS256:
57
+ key_size = 256
58
+ case Algorithm.HS384:
59
+ key_size = 384
60
+ case Algorithm.HS512:
61
+ key_size = 512
62
+ return (base64.b64encode(secrets.token_bytes(key_size)).decode('ascii'), None)
63
+ case Algorithm.RS256 | Algorithm.RS384 | Algorithm.RS512:
64
+ if key_size is None:
65
+ match algorithm:
66
+ case Algorithm.RS256:
67
+ key_size = 2048
68
+ case Algorithm.RS384:
69
+ key_size = 3072
70
+ case Algorithm.RS512:
71
+ key_size = 4096
72
+ rsa_obj = rsa.generate_private_key(
73
+ public_exponent=65537,
74
+ key_size=key_size
75
+ )
76
+ prikey_pem = rsa_obj.private_bytes(
77
+ encoding=serialization.Encoding.PEM,
78
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
79
+ encryption_algorithm=serialization.NoEncryption()
80
+ ).decode('utf-8')
81
+ pubkey_pem = rsa_obj.public_key().public_bytes(
82
+ encoding=serialization.Encoding.PEM,
83
+ format=serialization.PublicFormat.SubjectPublicKeyInfo
84
+ ).decode('utf-8')
85
+ return (prikey_pem, pubkey_pem)
86
+ case Algorithm.ES256 | Algorithm.ES384 | Algorithm.ES512:
87
+ curve:ec.EllipticCurve
88
+ match algorithm:
89
+ case Algorithm.ES256:
90
+ curve = ec.SECP256R1()
91
+ case Algorithm.ES384:
92
+ curve = ec.SECP384R1()
93
+ case Algorithm.ES512:
94
+ curve = ec.SECP521R1()
95
+ ec_obj = ec.generate_private_key(curve)
96
+ prikey_pem = ec_obj.private_bytes(
97
+ encoding=serialization.Encoding.PEM,
98
+ format=serialization.PrivateFormat.PKCS8,
99
+ encryption_algorithm=serialization.NoEncryption()
100
+ ).decode('utf-8')
101
+ pubkey_pem = ec_obj.public_key().public_bytes(
102
+ encoding=serialization.Encoding.PEM,
103
+ format=serialization.PublicFormat.SubjectPublicKeyInfo
104
+ ).decode('utf-8')
105
+ return (prikey_pem, pubkey_pem)
106
+ case Algorithm.ED25519 | Algorithm.ED448:
107
+ eddsa_obj = (ed25519.Ed25519PrivateKey if algorithm == Algorithm.ED25519 else ed448.Ed448PrivateKey).generate()
108
+ prikey_pem = eddsa_obj.private_bytes(
109
+ encoding=serialization.Encoding.PEM,
110
+ format=serialization.PrivateFormat.PKCS8,
111
+ encryption_algorithm=serialization.NoEncryption(),
112
+ ).decode('utf-8')
113
+ pubkey_pem = eddsa_obj.public_key().public_bytes(
114
+ encoding=serialization.Encoding.PEM,
115
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
116
+ ).decode('utf-8')
117
+ return (prikey_pem, pubkey_pem)
118
+ case _:
119
+ raise NotImplementedError(f'Unsupported Algorithm "{algorithm}"')
120
+
121
+ def tokenize(algorithm:Algorithm, private_key:str, claims:dict[str,Any], *, audience:Optional[str|list[str]] = None, issuer:Optional[str] = None) -> str:
122
+ """
123
+ Tokenize the provided claims, optionally injecting ``aud`` and ``iss`` claims into the claims before tokenizing.
124
+
125
+ :param algorithm: The algorithm to use when signing the token.
126
+ :param private_key: The private key (or secret) used for signing.
127
+ :param claims: The claims to be tokenized.
128
+ :param audience: An optional value to be used as the ``aud`` claim.
129
+ :param issuer: An optional value to be used as the ``iss`` claim.
130
+ :return: The claims tokenized as a "compact‑serialization" JWT.
131
+ :raises Exception: If there is an error creating a token.
132
+ """
133
+ try:
134
+ if audience is not None:
135
+ claims |= { 'aud': audience }
136
+ if issuer is not None:
137
+ claims |= { 'iss': issuer }
138
+ match algorithm:
139
+ case Algorithm.ED25519 | Algorithm.ED448:
140
+ return jwt.encode(claims, private_key, 'EdDSA')
141
+ case Algorithm.HS256 | Algorithm.HS384 | Algorithm.HS512:
142
+ return jwt.encode(claims, base64.b64decode(private_key), algorithm.value)
143
+ case _:
144
+ return jwt.encode(claims, private_key, algorithm.value)
145
+ except Exception as ex:
146
+ raise Exception()
147
+
148
+
149
+ def validate(algorithm:Algorithm, public_key:str, token:str, *, audience:Optional[str|list[str]] = None, issuer:Optional[str] = None) -> dict[str, Any]:
150
+ """
151
+ Verify a token and return the contained claims.
152
+
153
+ If ``audience`` or ``issuer`` are provided `validate(...)` also checks the ``aud`` and ``iss`` claims.
154
+
155
+ :param algorithm: The algorithm that was used to sign the token.
156
+ :param public_key: The public key (or secret) used for verification.
157
+ :param token: The compact‑serialization JWT string to be validated.
158
+ :param audience: Expected ``aud`` claim. Omit to skip validating ``aud`` claim.
159
+ :param issuer: Expected ``iss`` claim. Omit to skip validating ``iss`` claim.
160
+ :return: The decoded claims (as a ``dict``).
161
+ :raises Exception: If the token is invalid, expired, or claims do not match.
162
+ """
163
+ try:
164
+ match algorithm:
165
+ case Algorithm.ED25519 | Algorithm.ED448:
166
+ return jwt.decode(token, public_key, algorithms=['EdDSA'], audience=audience, issuer=issuer, options={"verify_aud": audience is not None})
167
+ case Algorithm.HS256 | Algorithm.HS384 | Algorithm.HS512:
168
+ return jwt.decode(token, base64.b64decode(public_key), algorithms=[algorithm.value], audience=audience, issuer=issuer, options={"verify_aud": audience is not None})
169
+ case _:
170
+ return jwt.decode(token, public_key, algorithms=[algorithm.value], audience=audience, issuer=issuer)
171
+ except Exception as ex:
172
+ raise Exception()
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: reshut
3
+ Version: 0.0.3
4
+ Summary: ..an authorization library for Falcon.
5
+ Author-email: Shaun Wilson <mrshaunwilson@msn.com>
6
+ License-Expression: MIT
7
+ Keywords: falcon,auth,bearer,apikey,hmac,rsa,ed25519,ed448
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: cryptography>=48
14
+ Requires-Dist: falcon<5,>=4.2
15
+ Requires-Dist: pyjwt<3.0.0,>=2.12.1
16
+ Provides-Extra: dev
17
+ Requires-Dist: coverage; extra == "dev"
18
+ Requires-Dist: lxml-stubs==0.5.1; extra == "dev"
19
+ Requires-Dist: mypy; extra == "dev"
20
+ Requires-Dist: punit<2.0.0,>=1.3.7; extra == "dev"
21
+ Requires-Dist: twine; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+
25
+ [![reshut on PyPI](https://img.shields.io/pypi/v/reshut.svg)](https://pypi.org/project/reshut/) [![reshut on readthedocs](https://readthedocs.org/projects/reshut/badge/?version=latest)](https://reshut.readthedocs.io)
26
+
27
+ **reshut** (רשות) is a decorative auth library for [Falcon](https://falconframework.org/).
28
+
29
+ This README is only a high-level introduction to **reshut**. For more detailed documentation, please view the official docs at [https://reshut.readthedocs.io](https://reshut.readthedocs.io).
30
+
31
+ ## Installation
32
+
33
+ You can install `reshut` from [PyPI](https://pypi.org/project/reshut/) through usual means, such as `pip`:
34
+
35
+ ```bash
36
+ pip install reshut
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ To use `reshut` two things must be done; first you must add an authorization middleware, and second you must apply one or more authorization decorators to a request handler method. Consider the following example:
42
+
43
+ ```python
44
+
45
+ import falcon
46
+ from reshut import middleware, utils
47
+ from .api.v3.FakeApi import FakeApi
48
+
49
+ # you create a falcon app
50
+ app = asgi.App()
51
+ # you register the middleware, which applies Authorization checks to ALL requests
52
+ symmetric_key = utils.keygen(Algorithm.HS256)
53
+ asymmetric_key = utils.keygen(Algorithm.ED448)
54
+ app.add_middleware(middleware.AsgiAuthorizationMiddleware(
55
+ apikey_evaluater=TokenEvaluator(Algorithm.HS256, key),
56
+ bearer_evaluater=TokenEvaluator(Algorithm.ED448, key)
57
+ ))
58
+ # you add some routes to your app
59
+ app.add_route('/api/v3/fakes', api.v3.FakeApi())
60
+ app.add_route('/api/v3/fakes/{id:int}', api.v3.FakeApi())
61
+ ```
62
+
63
+ Elsewhere in your project, you defined `FakeApi` and decorated at least one handler to customize Authorization:
64
+
65
+ ```python
66
+
67
+ import falcon
68
+ from reshut.authorization import allow_anonymous, allow_claim, deny_claim, require_claim
69
+
70
+ class FakeApi:
71
+
72
+ def initialize(self) -> None:
73
+ pass
74
+
75
+ # access granted to any caller:
76
+ @allow_anonymous
77
+ async def on_delete(self, id:str) -> None:
78
+ pass
79
+
80
+ # access denied to callers with `fake_restricted==yes`:
81
+ @deny_claim('fake_restricted', 'yes')
82
+ # access granted to callers having either `fake_reader` OR `fake_writer`:
83
+ @allow_claim('fake_reader')
84
+ @allow_claim('fake_writer')
85
+ async def on_get(self, id:str) -> None:
86
+ pass
87
+
88
+ # access granted to callers having `roles` claim, where one
89
+ # of the roles is "ADMIN", performed via `ClaimEvaluator` callback:
90
+ @allow_claim('roles', lambda roles: 'ADMIN' in roles)
91
+ # access granted to callers having `readonly_user` with a `False` value:
92
+ @allow_claim('readonly_user', False)
93
+ async def on_put(self, id:str) -> None:
94
+ pass
95
+
96
+ # access granted to callers having BOTH `department_id` of 123, 234, or 345
97
+ # and-also having `can_create==True`:
98
+ @require_claim('department_id', lambda x: x in [123,234,345])
99
+ @require_claim('can_create', True)
100
+ async def on_post(self, id:str) -> None:
101
+ pass
102
+ ```
103
+
104
+ In the above example:
105
+
106
+ `@allow_anonymous` will grant access to all callers, authenticated or not.
107
+
108
+ `@allow_claim` specifies which claims will grant access, at least one of them must be satisfied by the request.
109
+
110
+ `@deny_claim` specifies which claims will deny access.
111
+
112
+ `@require_claim` specifies which claims are required for "access granted", ALL specified claims must be satisfied by the request or access is denied.
113
+
114
+ All authorization decorators optionally allow matching a specific literal value or a Claim Evaluator function. Claim Evaluator functions are useful for checking complex claim types like dates, dicts, lists, etc while literal values are useful for checking for well-known/individual values.
115
+
116
+ ## Generating Keys, Tokenizing Claims, and Validating Tokens
117
+
118
+ If you need to generate keys there is a CLI tool `reshut-keygen` you can use:
119
+
120
+ ```bash
121
+ # these generate PRIVATE SECRETS only to be used
122
+ # by you or your organization. they are NOT to be
123
+ # shared with a third party.
124
+
125
+ # generate an hs256 secret, output ios written
126
+ # to a "my_secret.b64" file
127
+ reshut-keygen --type RS256 --output my_secret
128
+
129
+ # generate an rs256 keypair, outputs go to
130
+ # separate PEM files named "my_rs256_public.pem"
131
+ # and "my_rs256_private.pem"
132
+ reshut-keygen --type RS256 --output my_rs256
133
+
134
+ # the --output arg may specify a path, the last
135
+ # part of the path is always taken as a filename base.
136
+
137
+ # if you omit --output a default is derived from
138
+ # the --type arg, ie "hs256.b64", "rs256_public.pem",
139
+ # and "rs256_private.pem" for the examples above.
140
+ ```
141
+
142
+ There is also a `reshut-tokenize` tool you can use to tokenize claims:
143
+
144
+ ```bash
145
+ # this generates a SHARED TOKEN meant to be provided
146
+ # to a third party such as developers, testers,
147
+ # or business partners/integrators for authorization.
148
+
149
+ # tokenize claims
150
+ reshut-tokenize --type RS256 --key my_secret.b64 --claims '{"foo":"bar"}' --output shared_token.b64
151
+ ```
152
+
153
+ Lastly, there is a `reshut-validate` tool you can use to validate tokens:
154
+
155
+ ```bash
156
+ reshut-tokenize --type RS256 --key my_secret.b64 --claims '{"foo":"bar"}' --output shared_token.b64
157
+ ```
158
+
159
+ These tools are written using the `reshut.utils` namespace, you can use the utils namespace within your own code to dynamically allocate keys and tokens as you see fit for your solution (instead of dropping to a shell for the same result).
160
+
161
+ For example, here is a snippet demonstrating the generation of an ed448 keypair and-also an ed448 token valid for that keypair:
162
+
163
+ ```python
164
+
165
+ from reshut.utils import Algorithm, keygen, tokenize, validate
166
+
167
+ # on the server/etc, generate keys
168
+ ed448_prikey, ed448_pubkey = keygen(Algorithm.ED448)
169
+ print(ed448_prikey)
170
+ print(ed448_pubkey)
171
+
172
+ # on the server/etc, issue "secure" claims (claims the recipient can see/verify, but cannot modify)
173
+ token = tokenize(Algorithm.ED448, ed448_prikey, {
174
+ sub: 'Subject',
175
+ iss: 'Issuer',
176
+ exp: datetime.now()+timedelta(days=90)
177
+ })
178
+ print(token)
179
+
180
+ claims = validate(Algorithm.ED448, ed448_pubkey, token)
181
+ print(claims)
182
+
183
+ # individual claims can then be verified. these examples are only really useful
184
+ # if you are automating key issuance, token issuance, are a third-party that
185
+ # needs to generate a complex/symmetric token on-demand, or are implementing
186
+ # a custom token validator.
187
+ ```
188
+
189
+ ## Contact
190
+
191
+ You can reach me on [Discord](https://discordapp.com/users/307684202080501761) or [open an Issue on Github](https://github.com/wilson0x4d/reshut/issues/new/choose).
@@ -0,0 +1,15 @@
1
+ reshut/__init__.py,sha256=H_24D1xLKZtGBOBP2QTTO7ag-qW8tsqGsUEZyMTUoko,222
2
+ reshut/__main__.py,sha256=Avsq2EutIxoiZpTEJbj27ZeooLPN9ZxxPoBdKm2JjUc,4362
3
+ reshut/authorization.py,sha256=O5RyKnXruYiXGGLtIjbV4GQFeb4pDeqO3mhtC9DzUu0,3163
4
+ reshut/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ reshut/utils.py,sha256=P__BWcljtefqyAo_RdwgDK33ydAtFIgB2J348CpkxQA,8019
6
+ reshut/middleware/AsgiAuthorizationMiddleware.py,sha256=4EevcVqY4CfzeaJYMYh98NjmrmRcQwfa59j5qvrRQ70,1690
7
+ reshut/middleware/AuthorizationEvaluator.py,sha256=_mVR03-Ii42FWy8t4-xOvLs4uH12fDGRbKPirlrBhNI,3514
8
+ reshut/middleware/TokenEvaluator.py,sha256=obQWU5wbYkOVGmLTOa9x8gj2ac5n-Rpoc19LrFotx0o,2953
9
+ reshut/middleware/WsgiAuthorizationMiddleware.py,sha256=e8elEomycMSXmyqGY4FUe_lSoM7zft5c3Zcaw1LZnPY,1684
10
+ reshut/middleware/__init__.py,sha256=RR2tHP09wPAmTnOUeLm4TbnDTQmXpCkZPlZIWKFe0uM,456
11
+ reshut-0.0.3.dist-info/licenses/LICENSE,sha256=WDHBjw5FnacUEgi2Zf5fbjqqMMUM6Sw4DOHaIrFoewY,1058
12
+ reshut-0.0.3.dist-info/METADATA,sha256=zzYQCOFYrYjfPj3Xr2dBOfrxC5J1AVlSzTIiLzNpQ34,7294
13
+ reshut-0.0.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
+ reshut-0.0.3.dist-info/top_level.txt,sha256=RxpGNEH0IRWjL8NF0oLi3S7bEcubozcQoBW5hP3rlMA,7
15
+ reshut-0.0.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ © 2026 Shaun Wilson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ reshut