py2http 0.1.62__tar.gz → 0.1.64__tar.gz

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.

Potentially problematic release.


This version of py2http might be problematic. Click here for more details.

Files changed (45) hide show
  1. {py2http-0.1.62 → py2http-0.1.64}/PKG-INFO +1 -1
  2. py2http-0.1.64/py2http/middleware.py +154 -0
  3. {py2http-0.1.62 → py2http-0.1.64}/py2http/service.py +153 -8
  4. py2http-0.1.64/py2http/tests/test_attr_dispatch.py +284 -0
  5. py2http-0.1.64/py2http/tests/test_jwt_middleware.py +179 -0
  6. {py2http-0.1.62 → py2http-0.1.64}/py2http.egg-info/PKG-INFO +1 -1
  7. {py2http-0.1.62 → py2http-0.1.64}/py2http.egg-info/SOURCES.txt +2 -0
  8. {py2http-0.1.62 → py2http-0.1.64}/setup.cfg +1 -1
  9. py2http-0.1.62/py2http/middleware.py +0 -71
  10. {py2http-0.1.62 → py2http-0.1.64}/LICENSE +0 -0
  11. {py2http-0.1.62 → py2http-0.1.64}/README.md +0 -0
  12. {py2http-0.1.62 → py2http-0.1.64}/py2http/__init__.py +0 -0
  13. {py2http-0.1.62 → py2http-0.1.64}/py2http/bottle_plugins.py +0 -0
  14. {py2http-0.1.62 → py2http-0.1.64}/py2http/config.py +0 -0
  15. {py2http-0.1.62 → py2http-0.1.64}/py2http/constants.py +0 -0
  16. {py2http-0.1.62 → py2http-0.1.64}/py2http/decorators.py +0 -0
  17. {py2http-0.1.62 → py2http-0.1.64}/py2http/default_configs.py +0 -0
  18. {py2http-0.1.62 → py2http-0.1.64}/py2http/diagnosis.py +0 -0
  19. {py2http-0.1.62 → py2http-0.1.64}/py2http/examples/__init__.py +0 -0
  20. {py2http-0.1.62 → py2http-0.1.64}/py2http/examples/custom_input_mapper.py +0 -0
  21. {py2http-0.1.62 → py2http-0.1.64}/py2http/examples/custom_input_mappers.py +0 -0
  22. {py2http-0.1.62 → py2http-0.1.64}/py2http/examples/example_service.py +0 -0
  23. {py2http-0.1.62 → py2http-0.1.64}/py2http/examples/example_service_bottle.py +0 -0
  24. {py2http-0.1.62 → py2http-0.1.64}/py2http/examples/example_service_flask.py +0 -0
  25. {py2http-0.1.62 → py2http-0.1.64}/py2http/examples/object_dispatch.py +0 -0
  26. {py2http-0.1.62 → py2http-0.1.64}/py2http/openapi_utils.py +0 -0
  27. {py2http-0.1.62 → py2http-0.1.64}/py2http/route_utils.py +0 -0
  28. {py2http-0.1.62 → py2http-0.1.64}/py2http/schema_tools.py +0 -0
  29. {py2http-0.1.62 → py2http-0.1.64}/py2http/tests/__init__.py +0 -0
  30. {py2http-0.1.62 → py2http-0.1.64}/py2http/tests/example_service_test.py +0 -0
  31. {py2http-0.1.62 → py2http-0.1.64}/py2http/tests/objects_for_testing.py +0 -0
  32. {py2http-0.1.62 → py2http-0.1.64}/py2http/tests/simple_run_process_test.py +0 -0
  33. {py2http-0.1.62 → py2http-0.1.64}/py2http/tests/test_binary_req.py +0 -0
  34. {py2http-0.1.62 → py2http-0.1.64}/py2http/tests/test_decorators.py +0 -0
  35. {py2http-0.1.62 → py2http-0.1.64}/py2http/tests/test_flask.py +0 -0
  36. {py2http-0.1.62 → py2http-0.1.64}/py2http/tests/test_not_set_defaults.py +0 -0
  37. {py2http-0.1.62 → py2http-0.1.64}/py2http/tests/test_p2h2p.py +0 -0
  38. {py2http-0.1.62 → py2http-0.1.64}/py2http/tests/utils_for_testing.py +0 -0
  39. {py2http-0.1.62 → py2http-0.1.64}/py2http/types.py +0 -0
  40. {py2http-0.1.62 → py2http-0.1.64}/py2http/util.py +0 -0
  41. {py2http-0.1.62 → py2http-0.1.64}/py2http.egg-info/dependency_links.txt +0 -0
  42. {py2http-0.1.62 → py2http-0.1.64}/py2http.egg-info/not-zip-safe +0 -0
  43. {py2http-0.1.62 → py2http-0.1.64}/py2http.egg-info/requires.txt +0 -0
  44. {py2http-0.1.62 → py2http-0.1.64}/py2http.egg-info/top_level.txt +0 -0
  45. {py2http-0.1.62 → py2http-0.1.64}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2http
3
- Version: 0.1.62
3
+ Version: 0.1.64
4
4
  Summary: Declarative HTTP service entry point.
5
5
  Home-page: https://github.com/i2mint/py2http
6
6
  License: MIT
@@ -0,0 +1,154 @@
1
+ """
2
+ py2http is a Python module that allows you to dispatch Python functions as HTTP
3
+ services.
4
+ By running a basic HTTP service from a list of functions, you can access OpenAPI
5
+ specifications, Swagger documentation, and utilize the HTTP service's routes.
6
+ Additionally, Py2http provides features for method transformation, input mapping,
7
+ output mapping, error handling, and client generation.
8
+
9
+ The code snippet provided below is a part of Py2http module and includes middleware
10
+ functions for handling JWT authentication and superadmin authorization.
11
+ These middleware functions are designed to be used within the Py2http framework to
12
+ ensure secure and authenticated access to HTTP routes.
13
+
14
+ Include the following docstring to describe this module:
15
+ "Middleware functions for handling JWT authentication and superadmin authorization
16
+ within the Py2http framework. These functions ensure secure access to HTTP routes
17
+ by verifying credentials and permissions. Use these middleware functions to enforce
18
+ authentication and control access to web resources.
19
+ """
20
+
21
+ import json
22
+ from collections.abc import Iterable
23
+ from warnings import warn
24
+
25
+ from py2http.constants import JSON_CONTENT_TYPE
26
+
27
+ DFLT_JWT_ALGORITHMS = ("HS256",)
28
+ DFLT_PUBLIC_ROUTES = ("ping", "openapi")
29
+
30
+
31
+ def _algorithm_family(algorithm: str) -> str:
32
+ """The key family an algorithm verifies with: a shared secret for ``HS*``, a
33
+ public key otherwise.
34
+
35
+ >>> _algorithm_family('HS512'), _algorithm_family('RS256'), _algorithm_family('ES256')
36
+ ('hmac', 'public-key', 'public-key')
37
+ """
38
+ return "hmac" if algorithm.upper().startswith("HS") else "public-key"
39
+
40
+
41
+ def mk_jwt_middleware(
42
+ secret,
43
+ verify=True,
44
+ *,
45
+ algorithms: Iterable[str] = DFLT_JWT_ALGORITHMS,
46
+ public_routes: Iterable[str] = DFLT_PUBLIC_ROUTES,
47
+ allow_mixed_algorithm_families: bool = False,
48
+ ):
49
+ """Make an aiohttp middleware that requires a valid ``Authorization: Bearer``
50
+ JWT on every request, and puts the decoded claims on ``request.token``.
51
+
52
+ Args:
53
+ secret: The key tokens are verified with (an HMAC secret for ``HS*``
54
+ algorithms, a public key for ``RS*``/``ES*``/...).
55
+ verify: If False, signatures and claims (``exp``, ...) are NOT checked:
56
+ whatever a token says ends up on ``request.token``, and an
57
+ undecodable token only triggers a warning. For development only.
58
+ (Before, PyJWT 2 ignored the legacy option this passed, so
59
+ signatures were still checked even with ``verify=False``.)
60
+ algorithms: The algorithms a token may be signed with (PyJWT names,
61
+ case-sensitive; ``"none"`` is refused). Defaults to ``("HS256",)``,
62
+ as ``py2http.bottle_plugins.JWTPlugin`` does.
63
+ public_routes: Names of aiohttp routes (``name=`` in ``add_route``) that
64
+ need no token. Matched on the route name, not the handler's
65
+ ``__name__``, so a handler that happens to be called ``ping`` is not
66
+ public by accident. Route names are matched in sub-applications
67
+ too, so a sub-app route named ``ping`` is public as well.
68
+ allow_mixed_algorithm_families: Accepting an HMAC algorithm and a
69
+ public-key algorithm with one ``secret`` is the classic
70
+ algorithm-confusion setup (a token "signed" with HS256 using the
71
+ public key as the HMAC secret), so it raises ``ValueError`` unless
72
+ this is True. The middleware used to accept ``["HS256", "RS256"]``;
73
+ pass that list with this flag to restore it.
74
+ """
75
+ import jwt
76
+ from aiohttp import web
77
+
78
+ for name, value in [("algorithms", algorithms), ("public_routes", public_routes)]:
79
+ if isinstance(value, (str, bytes)):
80
+ raise TypeError(f"{name} must be a list of names, e.g. [{value!r}]")
81
+ algorithms = list(algorithms)
82
+ if not algorithms:
83
+ raise ValueError("algorithms must name at least one JWT algorithm")
84
+ known = set(jwt.algorithms.get_default_algorithms()) - {"none"}
85
+ if unknown := [a for a in algorithms if a not in known]:
86
+ raise ValueError(
87
+ f"Unknown or unsigned JWT algorithm(s) {unknown}; names are "
88
+ f"case-sensitive, choose from {sorted(known)}"
89
+ )
90
+ if verify and not secret:
91
+ raise ValueError("secret must be a non-empty key when verify=True")
92
+ families = {_algorithm_family(a) for a in algorithms}
93
+ if verify and "hmac" in families and not isinstance(secret, (str, bytes)):
94
+ raise TypeError(f"an HMAC secret must be str or bytes, got {type(secret)}")
95
+ if len(families) > 1 and not allow_mixed_algorithm_families:
96
+ raise ValueError(
97
+ f"algorithms={algorithms} mixes HMAC and public-key algorithms with one "
98
+ "secret, which allows algorithm-confusion forgeries; use one family, or "
99
+ "pass allow_mixed_algorithm_families=True if you really mean it"
100
+ )
101
+ public_routes = frozenset(name for name in public_routes if name is not None)
102
+
103
+ def _unauthorized(message):
104
+ return web.HTTPUnauthorized(
105
+ text=json.dumps({"error": message}),
106
+ content_type=JSON_CONTENT_TYPE,
107
+ )
108
+
109
+ @web.middleware
110
+ async def middleware(req, handler):
111
+ route = req.match_info.route
112
+ if getattr(route, "name", None) in public_routes:
113
+ return await handler(req)
114
+ auth_header = req.headers.get("Authorization", "")
115
+ token = auth_header[7:] if auth_header[:7].lower() == "bearer " else ""
116
+ try:
117
+ decoded = jwt.decode(
118
+ token,
119
+ secret,
120
+ options={"verify_signature": verify},
121
+ algorithms=algorithms,
122
+ )
123
+ except jwt.PyJWTError as error: # incl. a key unfit for the token's alg
124
+ if verify:
125
+ # InvalidTokenError texts describe the token ("Signature has
126
+ # expired"); other PyJWTErrors can describe the server's key
127
+ reason = (
128
+ str(error)
129
+ if isinstance(error, jwt.InvalidTokenError)
130
+ else "not acceptable"
131
+ )
132
+ raise _unauthorized(f"Invalid authentication token: {reason}")
133
+ warn(f"Invalid JWT: {error}")
134
+ return await handler(req)
135
+ req.token = decoded
136
+ return await handler(req)
137
+
138
+ return middleware
139
+
140
+
141
+ def mk_superadmin_middleware(secret):
142
+ from aiohttp import web
143
+
144
+ @web.middleware
145
+ async def middleware(req, handler):
146
+ auth_header = req.headers.get("Authorization", "")
147
+ if auth_header == secret:
148
+ return await handler(req)
149
+ return web.HTTPUnauthorized(
150
+ text=json.dumps({"error": "invalid API key"}),
151
+ content_type=JSON_CONTENT_TYPE,
152
+ )
153
+
154
+ return middleware
@@ -16,6 +16,9 @@ Python functions, enabling developers to easily expose their functions as remote
16
16
  with minimal setup."""
17
17
 
18
18
  import inspect
19
+ from collections import OrderedDict
20
+ from collections.abc import MutableMapping
21
+ from types import ModuleType
19
22
  from uuid import uuid4
20
23
  from aiohttp import web
21
24
  import asyncio
@@ -53,7 +56,146 @@ from py2http.schema_tools import (
53
56
  from py2http.util import TypeAsserter
54
57
  from py2http.constants import JSON_CONTENT_TYPE
55
58
 
56
- obj_store = {}
59
+ # Maximum number of objects a class endpoint keeps alive; the least recently used
60
+ # one is evicted beyond that. Pass ``obj_store=...`` in a handler to change it.
61
+ # Note: this bounds the number of objects, not their size, and object ids are the
62
+ # only access control (any client holding an id can use the object).
63
+ DFLT_OBJ_STORE_MAX_SIZE = 1000
64
+
65
+
66
+ class LRUObjStore(MutableMapping):
67
+ """A mapping that keeps at most ``max_size`` items, evicting the least recently
68
+ used one when a new key would exceed it.
69
+
70
+ Used as the default store of objects created through a class endpoint, so that
71
+ clients cannot grow server memory without bound by creating objects.
72
+
73
+ >>> s = LRUObjStore(max_size=2)
74
+ >>> s['a'], s['b'] = 1, 2
75
+ >>> _ = s['a'] # 'a' is now the most recently used
76
+ >>> s['c'] = 3
77
+ >>> sorted(s)
78
+ ['a', 'c']
79
+ """
80
+
81
+ def __init__(self, *, max_size: int = DFLT_OBJ_STORE_MAX_SIZE):
82
+ if max_size < 1:
83
+ raise ValueError(f"max_size must be at least 1, got {max_size}")
84
+ self.max_size = max_size
85
+ self._data = OrderedDict()
86
+
87
+ def __getitem__(self, k):
88
+ v = self._data[k]
89
+ self._data.move_to_end(k)
90
+ return v
91
+
92
+ def __setitem__(self, k, v):
93
+ self._data[k] = v
94
+ self._data.move_to_end(k)
95
+ while len(self._data) > self.max_size:
96
+ self._data.popitem(last=False)
97
+
98
+ def __delitem__(self, k):
99
+ del self._data[k]
100
+
101
+ def __iter__(self):
102
+ return iter(self._data)
103
+
104
+ def __len__(self):
105
+ return len(self._data)
106
+
107
+
108
+ def public_attr(obj, attr_name: str) -> bool:
109
+ """The attribute policy ``attr_names='*'`` stands for: only public methods.
110
+
111
+ A name is dispatchable if it does not start with ``_`` and
112
+
113
+ - for a module: it is a function that the module defines (or lists in its
114
+ ``__all__``), never something it merely imports, nor plain data;
115
+ - otherwise: it is a plain Python function, ``staticmethod``,
116
+ ``classmethod`` or ``property`` defined in the object's class (or a base
117
+ class), and the instance does not shadow it.
118
+
119
+ This keeps private helpers, dunders (``__dict__``, ``__init__``, ...),
120
+ per-instance state (``__dict__`` entries, ``__slots__``, dataclass and
121
+ namedtuple fields) and callables merely assigned as class attributes
122
+ (``opener = open``) out of reach of HTTP clients.
123
+
124
+ Note that every public method is exposed, inherited ones included (e.g. a
125
+ pydantic model's ``model_dump``). For anything holding secrets, or a mapping
126
+ whose mutators should stay private (``MutableMapping`` mixins such as
127
+ ``clear`` and ``pop`` are plain functions), list the names explicitly instead.
128
+
129
+ >>> class A:
130
+ ... data = 0
131
+ ... opener = open
132
+ ... def f(self): ...
133
+ ... def _g(self): ...
134
+ ... def __init__(self): self.secret = 1
135
+ >>> [public_attr(A(), name)
136
+ ... for name in ('f', '_g', '__init__', '__dict__', 'secret', 'data', 'opener')]
137
+ [True, False, False, False, False, False, False]
138
+ """
139
+ if not isinstance(attr_name, str) or attr_name.startswith("_"):
140
+ return False
141
+ if isinstance(obj, ModuleType):
142
+ return _is_public_module_function(obj, attr_name)
143
+ try: # (bypassing any class-level ``__dict__`` attribute)
144
+ instance_dict = object.__getattribute__(obj, "__dict__")
145
+ except AttributeError:
146
+ instance_dict = {}
147
+ if attr_name in instance_dict:
148
+ return False # instance state, or an instance attribute shadowing a method
149
+ # Look in the class's MRO dicts only (not the metaclass: ``mro``, ...), and at
150
+ # the raw class attribute, not what the instance would return for it
151
+ for klass in type(obj).__mro__:
152
+ if attr_name in vars(klass):
153
+ return _is_method_like(vars(klass)[attr_name])
154
+ return False
155
+
156
+
157
+ def _is_method_like(class_attr) -> bool:
158
+ if isinstance(class_attr, (staticmethod, classmethod)):
159
+ class_attr = class_attr.__func__
160
+ return isinstance(class_attr, (FunctionType, property))
161
+
162
+
163
+ def _is_public_module_function(module: ModuleType, attr_name: str) -> bool:
164
+ value = vars(module).get(attr_name)
165
+ if not inspect.isfunction(value):
166
+ return False
167
+ exported = getattr(module, "__all__", None)
168
+ if exported is not None:
169
+ return attr_name in exported
170
+ return value.__module__ == module.__name__
171
+
172
+
173
+ def unsafe_any_attr(obj, attr_name: str) -> bool:
174
+ """Dispatch ANY attribute of the object, including private ones and dunders.
175
+
176
+ This was the behaviour of ``attr_names='*'`` before ``public_attr``: it lets a
177
+ client read ``__dict__``, call ``__init__`` with arguments of its choosing, and so
178
+ on. Use ``attr_names=unsafe_any_attr`` only when every client is trusted.
179
+ """
180
+ return isinstance(attr_name, str) and hasattr(obj, attr_name)
181
+
182
+
183
+ def _attr_name_policy(attr_names) -> Callable[[Any, str], bool]:
184
+ """Normalize a handler's ``attr_names`` into a ``(obj, attr_name) -> bool``
185
+ predicate: ``'*'`` means ``public_attr``, a callable is used as is, and an
186
+ iterable of names is an explicit allow-list (dunders included, if listed).
187
+ """
188
+ if attr_names == "*":
189
+ return public_attr
190
+ if callable(attr_names):
191
+ return attr_names
192
+ if isinstance(attr_names, str):
193
+ allowed = {attr_names}
194
+ else:
195
+ allowed = set(attr_names)
196
+ return lambda obj, attr_name: (
197
+ isinstance(attr_name, str) and attr_name in allowed and hasattr(obj, attr_name)
198
+ )
57
199
 
58
200
 
59
201
  def method_not_found(method_name):
@@ -617,8 +759,12 @@ def _get_func_to_dispatch_handler(handler):
617
759
  return endpoint
618
760
  # was return endpoint, but then the output didn't get the __name__ = name
619
761
  # func = endpoint
762
+ is_dispatchable = _attr_name_policy(attr_names)
620
763
  if inspect.isclass(endpoint):
621
764
  cls = endpoint
765
+ obj_store = handler.get("obj_store")
766
+ if obj_store is None:
767
+ obj_store = LRUObjStore()
622
768
 
623
769
  def func(_obj_id=None, _attr_name=None, **kwargs):
624
770
  if _obj_id is None:
@@ -628,25 +774,24 @@ def _get_func_to_dispatch_handler(handler):
628
774
  new_id = str(uuid4())
629
775
  obj_store[new_id] = new_obj
630
776
  return new_id
631
- obj = obj_store.get(_obj_id)
632
- if not obj:
777
+ obj = obj_store.get(_obj_id) if isinstance(_obj_id, str) else None
778
+ if obj is None:
633
779
  raise InputError(f"No object found with id {_obj_id}")
634
780
  if _attr_name is None:
635
781
  raise InputError("_attr_name must be provided when _obj_id is not None")
636
- return _get_attr_value(obj, _attr_name, attr_names, **kwargs)
782
+ return _get_attr_value(obj, _attr_name, is_dispatchable, **kwargs)
637
783
 
638
784
  else:
639
785
 
640
786
  def func(_attr_name=None, **kwargs):
641
- return _get_attr_value(endpoint, _attr_name, attr_names, **kwargs)
787
+ return _get_attr_value(endpoint, _attr_name, is_dispatchable, **kwargs)
642
788
 
643
789
  func.__name__ = name
644
790
  return func
645
791
 
646
792
 
647
- def _get_attr_value(obj, attr_name, valid_attr_names, **kwargs):
648
- valid_attr_names = [attr_name] if valid_attr_names == "*" else valid_attr_names
649
- if attr_name not in valid_attr_names or not hasattr(obj, attr_name):
793
+ def _get_attr_value(obj, attr_name, is_dispatchable, /, **kwargs):
794
+ if not is_dispatchable(obj, attr_name):
650
795
  raise InputError(
651
796
  f"No attribute found with name {attr_name} or it is not dispatched"
652
797
  )
@@ -0,0 +1,284 @@
1
+ """Object/attribute dispatch (``attr_names``) only exposes what was meant to be exposed."""
2
+
3
+ import pytest
4
+ from i2.errors import InputError
5
+
6
+ from py2http.service import (
7
+ LRUObjStore,
8
+ _get_func_to_dispatch_handler,
9
+ public_attr,
10
+ unsafe_any_attr,
11
+ )
12
+
13
+
14
+ class Counter:
15
+ def __init__(self, start: int = 0):
16
+ self.count = start
17
+ self._secret = "s3cr3t"
18
+
19
+ def incr(self, by: int = 1):
20
+ self.count += by
21
+ return self.count
22
+
23
+ def _reset(self):
24
+ self.count = 0
25
+
26
+
27
+ def _dispatcher(endpoint, attr_names, **handler_extras):
28
+ return _get_func_to_dispatch_handler(
29
+ dict(endpoint=endpoint, name="ep", attr_names=attr_names, **handler_extras)
30
+ )
31
+
32
+
33
+ @pytest.mark.parametrize(
34
+ "attr_name", ["_reset", "_secret", "__dict__", "__init__", "__class__", "count"]
35
+ )
36
+ def test_star_refuses_private_dunder_and_instance_attrs(attr_name):
37
+ obj = Counter(5)
38
+ func = _dispatcher(obj, "*")
39
+ with pytest.raises(InputError):
40
+ func(_attr_name=attr_name)
41
+ assert obj.count == 5
42
+
43
+
44
+ def test_star_allows_public_methods():
45
+ func = _dispatcher(Counter(5), "*")
46
+ assert func(_attr_name="incr", by=2) == 7
47
+
48
+
49
+ def test_star_on_class_endpoint():
50
+ func = _dispatcher(Counter, "*")
51
+ obj_id = func(start=3)
52
+ assert func(_obj_id=obj_id, _attr_name="incr") == 4
53
+ for attr_name in ("__init__", "__dict__", "_secret", "mro"):
54
+ with pytest.raises(InputError):
55
+ func(_obj_id=obj_id, _attr_name=attr_name)
56
+
57
+
58
+ def test_explicit_list_unaffected():
59
+ """An explicit allow-list may name dunders on purpose (see examples/object_dispatch)."""
60
+
61
+ class Store(dict):
62
+ def __getitem__(self, key):
63
+ return super().__getitem__(key)
64
+
65
+ d = Store(a=1)
66
+ func = _dispatcher(d, ["__getitem__", "__len__"])
67
+ assert func(_attr_name="__getitem__", key="a") == 1
68
+ assert func(_attr_name="__len__") == 1
69
+ with pytest.raises(InputError):
70
+ func(_attr_name="clear")
71
+ with pytest.raises(InputError):
72
+ func(_attr_name="__dict__")
73
+ assert d == {"a": 1}
74
+
75
+
76
+ def test_unsafe_any_attr_opt_in_restores_old_behaviour():
77
+ obj = Counter(5)
78
+ func = _dispatcher(obj, unsafe_any_attr)
79
+ func(_attr_name="_reset")
80
+ assert obj.count == 0
81
+
82
+
83
+ def test_custom_predicate():
84
+ func = _dispatcher(Counter(1), lambda obj, name: name == "incr")
85
+ assert func(_attr_name="incr") == 2
86
+
87
+
88
+ def test_public_attr_on_module():
89
+ import json
90
+
91
+ assert public_attr(json, "dumps")
92
+ assert not public_attr(json, "_default_encoder")
93
+ assert not public_attr(json, "__loader__")
94
+
95
+
96
+ def test_obj_store_is_bounded():
97
+ store = LRUObjStore(max_size=2)
98
+ func = _dispatcher(Counter, "*", obj_store=store)
99
+ ids = [func(start=i) for i in range(3)]
100
+ assert len(store) == 2
101
+ with pytest.raises(InputError, match="No object found"):
102
+ func(_obj_id=ids[0], _attr_name="incr")
103
+ assert func(_obj_id=ids[2], _attr_name="incr") == 3
104
+
105
+
106
+ def test_obj_store_default_is_bounded_lru():
107
+ func = _dispatcher(Counter, "*")
108
+ obj_id = func()
109
+ assert func(_obj_id=obj_id, _attr_name="incr") == 1
110
+
111
+
112
+ def test_falsy_object_is_still_found():
113
+ func = _dispatcher(list, ["append", "__len__"])
114
+ obj_id = func() # an empty list is falsy
115
+ assert func(_obj_id=obj_id, _attr_name="__len__") == 0
116
+
117
+
118
+ def test_lru_obj_store_rejects_bad_size():
119
+ with pytest.raises(ValueError):
120
+ LRUObjStore(max_size=0)
121
+
122
+
123
+ # --- shapes found by the adversarial review: instance state must stay private ---
124
+
125
+ from dataclasses import dataclass
126
+ from types import ModuleType
127
+ from typing import NamedTuple
128
+
129
+
130
+ class Slotted:
131
+ __slots__ = ("token",)
132
+
133
+ def __init__(self):
134
+ self.token = "TOK"
135
+
136
+ def ping(self):
137
+ return "pong"
138
+
139
+
140
+ @dataclass
141
+ class DC:
142
+ token: str = "default"
143
+
144
+ def ping(self):
145
+ return "pong"
146
+
147
+
148
+ class NT(NamedTuple):
149
+ token: str
150
+
151
+ def ping(self):
152
+ return "pong"
153
+
154
+
155
+ class Odd:
156
+ count = 0
157
+ opener = open
158
+ Nested = dict
159
+
160
+ def __init__(self):
161
+ self.count = 42
162
+ self.incr = lambda: "shadowed"
163
+
164
+ def incr(self):
165
+ return "real"
166
+
167
+ @staticmethod
168
+ def s():
169
+ return "static"
170
+
171
+ @classmethod
172
+ def c(cls):
173
+ return "class"
174
+
175
+ @property
176
+ def p(self):
177
+ return "prop"
178
+
179
+
180
+ @pytest.mark.parametrize(
181
+ "obj", [Slotted(), DC(token="TOK"), NT(token="TOK")], ids=["slots", "dc", "nt"]
182
+ )
183
+ def test_star_refuses_fields(obj):
184
+ func = _dispatcher(obj, "*")
185
+ with pytest.raises(InputError):
186
+ func(_attr_name="token")
187
+ assert func(_attr_name="ping") == "pong"
188
+
189
+
190
+ @pytest.mark.parametrize("attr_name", ["count", "opener", "Nested", "incr"])
191
+ def test_star_refuses_data_shadowed_and_assigned_callables(attr_name):
192
+ func = _dispatcher(Odd(), "*")
193
+ with pytest.raises(InputError):
194
+ func(_attr_name=attr_name)
195
+
196
+
197
+ def test_star_allows_static_class_methods_and_properties():
198
+ func = _dispatcher(Odd(), "*")
199
+ assert [func(_attr_name=n) for n in ("s", "c", "p")] == ["static", "class", "prop"]
200
+
201
+
202
+ def test_star_refuses_builtin_mapping_mutators():
203
+ class Store(dict):
204
+ def keys_list(self):
205
+ return list(self)
206
+
207
+ d = Store(a=1)
208
+ func = _dispatcher(d, "*")
209
+ with pytest.raises(InputError):
210
+ func(_attr_name="clear")
211
+ assert func(_attr_name="keys_list") == ["a"] and d == {"a": 1}
212
+
213
+
214
+ def _mk_module(name, *, all_=None):
215
+ mod = ModuleType(name)
216
+ exec(
217
+ "from os import listdir\nfrom json import dumps\nimport os\nAPI_KEY = 'k-123'\n"
218
+ "def hello():\n return 'hi'\n",
219
+ vars(mod),
220
+ )
221
+ if all_ is not None:
222
+ mod.__all__ = all_
223
+ return mod
224
+
225
+
226
+ @pytest.mark.parametrize("attr_name", ["listdir", "dumps", "os", "API_KEY"])
227
+ def test_star_on_module_refuses_imports_and_data(attr_name):
228
+ func = _dispatcher(_mk_module("some_service"), "*")
229
+ with pytest.raises(InputError):
230
+ func(_attr_name=attr_name)
231
+ assert func(_attr_name="hello") == "hi"
232
+
233
+
234
+ def test_star_on_module_honours_all():
235
+ mod = _mk_module("some_service", all_=["dumps", "listdir"])
236
+ assert public_attr(mod, "dumps") # a re-export listed in __all__
237
+ assert not public_attr(mod, "listdir") # listed, but not a Python function
238
+ assert not public_attr(mod, "hello") # not listed
239
+
240
+
241
+ @pytest.mark.parametrize("bad", [["x"], {"a": 1}, 3, None])
242
+ def test_non_str_names_and_ids_are_input_errors(bad):
243
+ func = _dispatcher({"x": 1}, ["x", "__len__"])
244
+ if bad is not None:
245
+ with pytest.raises(InputError):
246
+ func(_attr_name=bad)
247
+ cls_func = _dispatcher(Counter, "*")
248
+ with pytest.raises(InputError):
249
+ cls_func(_obj_id=bad if bad is not None else [], _attr_name="incr")
250
+
251
+
252
+ def test_method_params_named_like_dispatch_internals():
253
+ class A:
254
+ def f(self, obj, attr_name, is_dispatchable):
255
+ return [obj, attr_name, is_dispatchable]
256
+
257
+ func = _dispatcher(A(), "*")
258
+ assert func(_attr_name="f", obj=1, attr_name=2, is_dispatchable=3) == [1, 2, 3]
259
+
260
+
261
+ def test_star_exposes_mutable_mapping_mixins_as_documented():
262
+ """Inherited public Python methods ARE dispatched: use an explicit list for stores."""
263
+ from collections.abc import MutableMapping
264
+
265
+ class MM(MutableMapping):
266
+ def __init__(self):
267
+ self._d = {"a": 1}
268
+
269
+ def __getitem__(self, k):
270
+ return self._d[k]
271
+
272
+ def __setitem__(self, k, v):
273
+ self._d[k] = v
274
+
275
+ def __delitem__(self, k):
276
+ del self._d[k]
277
+
278
+ def __iter__(self):
279
+ return iter(self._d)
280
+
281
+ def __len__(self):
282
+ return len(self._d)
283
+
284
+ assert public_attr(MM(), "clear")
@@ -0,0 +1,179 @@
1
+ """``mk_jwt_middleware`` verifies signatures, pins algorithms, and only lets named
2
+ public routes through without a token."""
3
+
4
+ import asyncio
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import time
10
+
11
+ import jwt
12
+ import pytest
13
+ from aiohttp import web
14
+ from aiohttp.test_utils import TestClient, TestServer
15
+
16
+ from py2http.middleware import mk_jwt_middleware
17
+
18
+ SECRET = "s3cret-for-tests-only-0123456789abcdef"
19
+
20
+
21
+ async def _whoami(request):
22
+ return web.json_response(getattr(request, "token", None))
23
+
24
+
25
+ async def ping(request): # a handler whose __name__ is "ping", on a private route
26
+ return web.json_response("private")
27
+
28
+
29
+ def _app(middleware):
30
+ app = web.Application(middlewares=[middleware])
31
+ app.router.add_get("/ping", _whoami, name="ping")
32
+ app.router.add_get("/me", _whoami, name="me")
33
+ app.router.add_get("/secret", ping)
34
+ return app
35
+
36
+
37
+ def _get(middleware, path, token=None):
38
+ """GET ``path`` on a fresh app (an aiohttp app is bound to one event loop)."""
39
+ app = _app(middleware)
40
+
41
+ async def go():
42
+ headers = {"Authorization": f"Bearer {token}"} if token is not None else {}
43
+ async with TestClient(TestServer(app)) as client:
44
+ resp = await client.get(path, headers=headers)
45
+ return resp.status, await resp.text()
46
+
47
+ return asyncio.run(go())
48
+
49
+
50
+ def _b64(data: bytes) -> str:
51
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
52
+
53
+
54
+ def _hs256_with_key(claims, key: bytes) -> str:
55
+ """Sign by hand, so tests can forge what PyJWT itself refuses to produce."""
56
+ signing_input = f"{_b64(json.dumps({'alg': 'HS256', 'typ': 'JWT'}).encode())}.{_b64(json.dumps(claims).encode())}"
57
+ sig = hmac.new(key, signing_input.encode(), hashlib.sha256).digest()
58
+ return f"{signing_input}.{_b64(sig)}"
59
+
60
+
61
+ def test_valid_token_is_decoded_onto_request():
62
+ token = jwt.encode({"sub": "alice"}, SECRET, algorithm="HS256")
63
+ status, body = _get(mk_jwt_middleware(SECRET), "/me", token)
64
+ assert status == 200 and json.loads(body) == {"sub": "alice"}
65
+
66
+
67
+ @pytest.mark.parametrize(
68
+ "token",
69
+ [
70
+ None,
71
+ "",
72
+ "garbage",
73
+ jwt.encode(
74
+ {"sub": "alice"}, "wrong-secret-0123456789abcdef0123", algorithm="HS256"
75
+ ),
76
+ _b64(b'{"alg":"none","typ":"JWT"}') + "." + _b64(b'{"sub":"alice"}') + ".",
77
+ jwt.encode(
78
+ {"sub": "alice", "exp": int(time.time()) - 60}, SECRET, algorithm="HS256"
79
+ ),
80
+ jwt.encode({"sub": "alice"}, SECRET, algorithm="HS512"), # not an allowed alg
81
+ ],
82
+ )
83
+ def test_bad_tokens_get_401_and_no_echo(token):
84
+ status, body = _get(mk_jwt_middleware(SECRET), "/me", token)
85
+ assert status == 401
86
+ if token:
87
+ assert token not in body
88
+
89
+
90
+ def test_public_routes_are_matched_by_route_name_not_handler_name():
91
+ mw = mk_jwt_middleware(SECRET)
92
+ assert _get(mw, "/ping")[0] == 200
93
+ assert _get(mw, "/secret")[0] == 401 # handler is called "ping", route is not
94
+ mw = mk_jwt_middleware(SECRET, public_routes=())
95
+ assert _get(mw, "/ping")[0] == 401
96
+
97
+
98
+ def test_mixed_algorithm_families_need_explicit_opt_in():
99
+ with pytest.raises(ValueError, match="algorithm-confusion"):
100
+ mk_jwt_middleware(SECRET, algorithms=["HS256", "RS256"])
101
+ with pytest.raises(ValueError):
102
+ mk_jwt_middleware(SECRET, algorithms=[])
103
+ mk_jwt_middleware(
104
+ SECRET, algorithms=["HS256", "RS256"], allow_mixed_algorithm_families=True
105
+ )
106
+
107
+
108
+ def test_algorithm_confusion_forgery_is_rejected_with_public_key_secret():
109
+ from cryptography.hazmat.primitives import serialization
110
+ from cryptography.hazmat.primitives.asymmetric import rsa
111
+
112
+ private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
113
+ public_pem = private.public_key().public_bytes(
114
+ serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
115
+ )
116
+ mw = mk_jwt_middleware(public_pem, algorithms=["RS256"])
117
+ genuine = jwt.encode({"sub": "alice"}, private, algorithm="RS256")
118
+ assert _get(mw, "/me", genuine)[0] == 200
119
+ forged = _hs256_with_key({"sub": "mallory"}, public_pem)
120
+ assert _get(mw, "/me", forged)[0] == 401
121
+
122
+
123
+ def test_verify_false_skips_signature_check():
124
+ token = jwt.encode(
125
+ {"sub": "alice"}, "another-secret-0123456789abcdef01", algorithm="HS256"
126
+ )
127
+ with pytest.warns(UserWarning):
128
+ status, _ = _get(mk_jwt_middleware(SECRET, verify=False), "/me", "garbage")
129
+ assert status == 200
130
+ status, body = _get(mk_jwt_middleware(SECRET, verify=False), "/me", token)
131
+ assert status == 200 and json.loads(body) == {"sub": "alice"}
132
+
133
+
134
+ @pytest.mark.parametrize(
135
+ "kwargs, error",
136
+ [
137
+ ({"algorithms": "HS256"}, TypeError),
138
+ ({"algorithms": ["hs256"]}, ValueError),
139
+ ({"algorithms": ["none"]}, ValueError),
140
+ ({"algorithms": ["RS256", "none"]}, ValueError),
141
+ ({"public_routes": "ping"}, TypeError),
142
+ ],
143
+ )
144
+ def test_bad_algorithm_config_fails_at_construction(kwargs, error):
145
+ with pytest.raises(error):
146
+ mk_jwt_middleware(SECRET, **kwargs)
147
+
148
+
149
+ @pytest.mark.parametrize("secret", ["", b"", None, 12345])
150
+ def test_empty_or_unusable_secret_fails_at_construction(secret):
151
+ with pytest.raises((ValueError, TypeError)):
152
+ mk_jwt_middleware(secret)
153
+
154
+
155
+ def test_key_unfit_for_token_algorithm_is_401_not_500():
156
+ from cryptography.hazmat.primitives import serialization
157
+ from cryptography.hazmat.primitives.asymmetric import rsa
158
+
159
+ public_pem = (
160
+ rsa.generate_private_key(public_exponent=65537, key_size=2048)
161
+ .public_key()
162
+ .public_bytes(
163
+ serialization.Encoding.PEM,
164
+ serialization.PublicFormat.SubjectPublicKeyInfo,
165
+ )
166
+ )
167
+ mw = mk_jwt_middleware(
168
+ public_pem,
169
+ algorithms=["HS256", "RS256"],
170
+ allow_mixed_algorithm_families=True,
171
+ )
172
+ forged = _hs256_with_key({"sub": "mallory"}, public_pem)
173
+ status, body = _get(mw, "/me", forged)
174
+ assert status == 401 and "asymmetric" not in body.lower()
175
+
176
+
177
+ def test_none_in_public_routes_does_not_open_unnamed_routes():
178
+ mw = mk_jwt_middleware(SECRET, public_routes=["ping", None])
179
+ assert _get(mw, "/secret")[0] == 401
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2http
3
- Version: 0.1.62
3
+ Version: 0.1.64
4
4
  Summary: Declarative HTTP service entry point.
5
5
  Home-page: https://github.com/i2mint/py2http
6
6
  License: MIT
@@ -33,9 +33,11 @@ py2http/tests/__init__.py
33
33
  py2http/tests/example_service_test.py
34
34
  py2http/tests/objects_for_testing.py
35
35
  py2http/tests/simple_run_process_test.py
36
+ py2http/tests/test_attr_dispatch.py
36
37
  py2http/tests/test_binary_req.py
37
38
  py2http/tests/test_decorators.py
38
39
  py2http/tests/test_flask.py
40
+ py2http/tests/test_jwt_middleware.py
39
41
  py2http/tests/test_not_set_defaults.py
40
42
  py2http/tests/test_p2h2p.py
41
43
  py2http/tests/utils_for_testing.py
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = py2http
3
- version = 0.1.62
3
+ version = 0.1.64
4
4
  url = https://github.com/i2mint/py2http
5
5
  root_url = https://github.com/i2mint
6
6
  platforms = any
@@ -1,71 +0,0 @@
1
- """
2
- py2http is a Python module that allows you to dispatch Python functions as HTTP
3
- services.
4
- By running a basic HTTP service from a list of functions, you can access OpenAPI
5
- specifications, Swagger documentation, and utilize the HTTP service's routes.
6
- Additionally, Py2http provides features for method transformation, input mapping,
7
- output mapping, error handling, and client generation.
8
-
9
- The code snippet provided below is a part of Py2http module and includes middleware
10
- functions for handling JWT authentication and superadmin authorization.
11
- These middleware functions are designed to be used within the Py2http framework to
12
- ensure secure and authenticated access to HTTP routes.
13
-
14
- Include the following docstring to describe this module:
15
- "Middleware functions for handling JWT authentication and superadmin authorization
16
- within the Py2http framework. These functions ensure secure access to HTTP routes
17
- by verifying credentials and permissions. Use these middleware functions to enforce
18
- authentication and control access to web resources.
19
- """
20
-
21
- import json
22
- from warnings import warn
23
- from py2http.constants import JSON_CONTENT_TYPE
24
-
25
-
26
- def mk_jwt_middleware(secret, verify=True):
27
- from aiohttp import web
28
- import jwt
29
-
30
- @web.middleware
31
- async def middleware(req, handler):
32
- if handler.__name__ == "ping" or handler.__name__ == "openapi":
33
- return await handler(req)
34
- auth_header = req.headers.get("Authorization", "")
35
- token = auth_header[7:]
36
- try:
37
- decoded = jwt.decode(
38
- token, secret, options={"verify": verify}, algorithms=["HS256", "RS256"]
39
- )
40
- req.token = decoded
41
- return await handler(req)
42
- except jwt.DecodeError as error:
43
- if verify:
44
- return web.HTTPUnauthorized(
45
- text=json.dumps(
46
- {
47
- "error": f'Invalid authentication token "{token}", {str(error)}'
48
- }
49
- ),
50
- content_type=JSON_CONTENT_TYPE,
51
- )
52
- warn(f"Invalid JWT: {token}")
53
- return await handler(req)
54
-
55
- return middleware
56
-
57
-
58
- def mk_superadmin_middleware(secret):
59
- from aiohttp import web
60
-
61
- @web.middleware
62
- async def middleware(req, handler):
63
- auth_header = req.headers.get("Authorization", "")
64
- if auth_header == secret:
65
- return await handler(req)
66
- return web.HTTPUnauthorized(
67
- text=json.dumps({"error": "invalid API key"}),
68
- content_type=JSON_CONTENT_TYPE,
69
- )
70
-
71
- return middleware
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes