py2http 0.1.67__tar.gz → 0.1.69__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.
Files changed (56) hide show
  1. {py2http-0.1.67 → py2http-0.1.69}/PKG-INFO +1 -1
  2. py2http-0.1.69/py2http/auth.py +174 -0
  3. py2http-0.1.69/py2http/bottle_plugins.py +153 -0
  4. py2http-0.1.69/py2http/middleware.py +124 -0
  5. py2http-0.1.69/py2http/obj_dispatch.py +603 -0
  6. py2http-0.1.69/py2http/service.py +623 -0
  7. py2http-0.1.69/py2http/sizeof.py +101 -0
  8. py2http-0.1.69/py2http/tests/test_auth_plugins.py +349 -0
  9. py2http-0.1.69/py2http/tests/test_error_statuses.py +65 -0
  10. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_instance_endpoints.py +3 -24
  11. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_obj_ownership.py +28 -25
  12. py2http-0.1.69/py2http/tests/test_positional_only.py +135 -0
  13. py2http-0.1.69/py2http/tests/utils_for_testing.py +69 -0
  14. {py2http-0.1.67 → py2http-0.1.69}/py2http/util.py +80 -1
  15. {py2http-0.1.67 → py2http-0.1.69}/py2http.egg-info/PKG-INFO +1 -1
  16. {py2http-0.1.67 → py2http-0.1.69}/py2http.egg-info/SOURCES.txt +6 -0
  17. {py2http-0.1.67 → py2http-0.1.69}/setup.cfg +1 -1
  18. py2http-0.1.67/py2http/bottle_plugins.py +0 -114
  19. py2http-0.1.67/py2http/middleware.py +0 -154
  20. py2http-0.1.67/py2http/service.py +0 -1266
  21. py2http-0.1.67/py2http/tests/utils_for_testing.py +0 -26
  22. {py2http-0.1.67 → py2http-0.1.69}/LICENSE +0 -0
  23. {py2http-0.1.67 → py2http-0.1.69}/README.md +0 -0
  24. {py2http-0.1.67 → py2http-0.1.69}/py2http/__init__.py +0 -0
  25. {py2http-0.1.67 → py2http-0.1.69}/py2http/config.py +0 -0
  26. {py2http-0.1.67 → py2http-0.1.69}/py2http/constants.py +0 -0
  27. {py2http-0.1.67 → py2http-0.1.69}/py2http/decorators.py +0 -0
  28. {py2http-0.1.67 → py2http-0.1.69}/py2http/default_configs.py +0 -0
  29. {py2http-0.1.67 → py2http-0.1.69}/py2http/diagnosis.py +0 -0
  30. {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/__init__.py +0 -0
  31. {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/custom_input_mapper.py +0 -0
  32. {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/custom_input_mappers.py +0 -0
  33. {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/example_service.py +0 -0
  34. {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/example_service_bottle.py +0 -0
  35. {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/example_service_flask.py +0 -0
  36. {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/object_dispatch.py +0 -0
  37. {py2http-0.1.67 → py2http-0.1.69}/py2http/openapi_utils.py +0 -0
  38. {py2http-0.1.67 → py2http-0.1.69}/py2http/route_utils.py +0 -0
  39. {py2http-0.1.67 → py2http-0.1.69}/py2http/schema_tools.py +0 -0
  40. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/__init__.py +0 -0
  41. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/example_service_test.py +0 -0
  42. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/objects_for_testing.py +0 -0
  43. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/simple_run_process_test.py +0 -0
  44. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_attr_dispatch.py +0 -0
  45. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_binary_req.py +0 -0
  46. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_decorators.py +0 -0
  47. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_flask.py +0 -0
  48. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_jwt_middleware.py +0 -0
  49. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_not_set_defaults.py +0 -0
  50. {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_p2h2p.py +0 -0
  51. {py2http-0.1.67 → py2http-0.1.69}/py2http/types.py +0 -0
  52. {py2http-0.1.67 → py2http-0.1.69}/py2http.egg-info/dependency_links.txt +0 -0
  53. {py2http-0.1.67 → py2http-0.1.69}/py2http.egg-info/not-zip-safe +0 -0
  54. {py2http-0.1.67 → py2http-0.1.69}/py2http.egg-info/requires.txt +0 -0
  55. {py2http-0.1.67 → py2http-0.1.69}/py2http.egg-info/top_level.txt +0 -0
  56. {py2http-0.1.67 → py2http-0.1.69}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2http
3
- Version: 0.1.67
3
+ Version: 0.1.69
4
4
  Summary: Declarative HTTP service entry point.
5
5
  Home-page: https://github.com/i2mint/py2http
6
6
  License: MIT
@@ -0,0 +1,174 @@
1
+ """Framework-independent credential checks, shared by the aiohttp middleware
2
+ (``py2http.middleware``) and the Bottle plugins (``py2http.bottle_plugins``), so that
3
+ both refuse the same tokens and keys for the same reasons.
4
+
5
+ >>> bearer_token('Bearer abc'), bearer_token('bearer abc'), bearer_token('Basic abc')
6
+ ('abc', 'abc', '')
7
+ >>> key_matches('k-123', 'k-123'), key_matches('k-12', 'k-123'), key_matches(None, 'k')
8
+ (True, False, False)
9
+ """
10
+
11
+ import hmac
12
+ import logging
13
+ from collections.abc import Iterable, Mapping
14
+ from typing import Optional
15
+
16
+ logger = logging.getLogger("py2http")
17
+
18
+ DFLT_JWT_ALGORITHMS = ("HS256",)
19
+ # ``jwt.decode`` arguments the verification setup owns, so ``decode_kwargs`` can't
20
+ _RESERVED_DECODE_KWARGS = frozenset({"jwt", "key", "algorithms", "options"})
21
+ _BEARER_PREFIX = "bearer "
22
+
23
+
24
+ class CredentialsRejected(Exception):
25
+ """A request's credentials were refused. The message is safe to show the client:
26
+ it never contains the token, nor anything about the server's key."""
27
+
28
+
29
+ def _algorithm_family(algorithm: str) -> str:
30
+ """The key family an algorithm verifies with: a shared secret for ``HS*``, a
31
+ public key otherwise.
32
+
33
+ >>> _algorithm_family('HS512'), _algorithm_family('RS256'), _algorithm_family('ES256')
34
+ ('hmac', 'public-key', 'public-key')
35
+ """
36
+ return "hmac" if algorithm.upper().startswith("HS") else "public-key"
37
+
38
+
39
+ def _names(name: str, value: Iterable[str]) -> list:
40
+ if isinstance(value, (str, bytes)):
41
+ raise TypeError(f"{name} must be a list of names, e.g. [{value!r}]")
42
+ return list(value)
43
+
44
+
45
+ def jwt_algorithms(
46
+ secret,
47
+ *,
48
+ verify: bool = True,
49
+ algorithms: Iterable[str] = DFLT_JWT_ALGORITHMS,
50
+ allow_mixed_algorithm_families: bool = False,
51
+ ) -> list:
52
+ """Validate a JWT verification setup, returning the algorithms as a list.
53
+
54
+ Raises at construction time, rather than accepting forgeries at request time, when:
55
+
56
+ - ``algorithms`` is empty, a bare string, or names an unknown or unsigned (``none``)
57
+ algorithm (PyJWT names are case-sensitive);
58
+ - ``verify`` is True and ``secret`` is empty (an empty HMAC secret lets anyone sign
59
+ tokens), or an HMAC algorithm is asked for with a non-``str``/``bytes`` secret;
60
+ - ``verify`` is True and HMAC and public-key algorithms are mixed with one
61
+ ``secret`` (the classic algorithm-confusion setup), unless
62
+ ``allow_mixed_algorithm_families`` is True.
63
+
64
+ >>> jwt_algorithms('s3cret')
65
+ ['HS256']
66
+ >>> jwt_algorithms('', algorithms=['HS256'])
67
+ Traceback (most recent call last):
68
+ ...
69
+ ValueError: secret must be a non-empty key when verify=True
70
+ """
71
+ import jwt
72
+
73
+ algorithms = _names("algorithms", algorithms)
74
+ if not algorithms:
75
+ raise ValueError("algorithms must name at least one JWT algorithm")
76
+ known = set(jwt.algorithms.get_default_algorithms()) - {"none"}
77
+ if unknown := [a for a in algorithms if a not in known]:
78
+ raise ValueError(
79
+ f"Unknown or unsigned JWT algorithm(s) {unknown}; names are "
80
+ f"case-sensitive, choose from {sorted(known)}"
81
+ )
82
+ if verify and not secret:
83
+ raise ValueError("secret must be a non-empty key when verify=True")
84
+ families = {_algorithm_family(a) for a in algorithms}
85
+ if verify and "hmac" in families and not isinstance(secret, (str, bytes)):
86
+ raise TypeError(f"an HMAC secret must be str or bytes, got {type(secret)}")
87
+ if verify and len(families) > 1 and not allow_mixed_algorithm_families:
88
+ raise ValueError(
89
+ f"algorithms={algorithms} mixes HMAC and public-key algorithms with one "
90
+ "secret, which allows algorithm-confusion forgeries; use one family, or "
91
+ "pass allow_mixed_algorithm_families=True if you really mean it"
92
+ )
93
+ return algorithms
94
+
95
+
96
+ def jwt_decode_kwargs(decode_kwargs: Optional[Mapping] = None) -> dict:
97
+ """Validate extra ``jwt.decode`` arguments (``audience``, ``issuer``,
98
+ ``leeway``...), refusing those the verification setup owns.
99
+
100
+ >>> jwt_decode_kwargs({'audience': 'my-api'})
101
+ {'audience': 'my-api'}
102
+ >>> jwt_decode_kwargs({'options': {'verify_signature': False}})
103
+ Traceback (most recent call last):
104
+ ...
105
+ ValueError: decode_kwargs may not set ['options']; use verify= and algorithms=
106
+ """
107
+ decode_kwargs = dict(decode_kwargs or {})
108
+ if reserved := sorted(_RESERVED_DECODE_KWARGS & set(decode_kwargs)):
109
+ raise ValueError(
110
+ f"decode_kwargs may not set {reserved}; use verify= and algorithms="
111
+ )
112
+ return decode_kwargs
113
+
114
+
115
+ def bearer_token(authorization_header) -> str:
116
+ """The token of an ``Authorization: Bearer <token>`` header, or ``''``."""
117
+ header = authorization_header or ""
118
+ if header[: len(_BEARER_PREFIX)].lower() != _BEARER_PREFIX:
119
+ return ""
120
+ return header[len(_BEARER_PREFIX) :].strip()
121
+
122
+
123
+ def decode_jwt(
124
+ token: str,
125
+ secret,
126
+ *,
127
+ algorithms: list,
128
+ verify: bool = True,
129
+ decode_kwargs: Optional[Mapping] = None,
130
+ ) -> dict:
131
+ """The claims of ``token``, or ``CredentialsRejected`` with a client-safe reason.
132
+
133
+ With ``verify=False`` neither the signature nor the claims (``exp``...) are checked.
134
+ ``decode_kwargs`` (``audience``, ``issuer``, ``leeway``...) go to ``jwt.decode``.
135
+ """
136
+ import jwt
137
+
138
+ try:
139
+ return jwt.decode(
140
+ token,
141
+ secret,
142
+ options={"verify_signature": verify},
143
+ algorithms=algorithms,
144
+ **(decode_kwargs or {}),
145
+ )
146
+ except jwt.InvalidTokenError as error: # its text describes the token
147
+ raise CredentialsRejected(f"Invalid authentication token: {error}") from error
148
+ except (jwt.PyJWTError, UnicodeError) as error:
149
+ # a key unfit for the token's alg (InvalidKeyError...) describes the server's
150
+ # key, so it is logged rather than sent; a non-UTF-8 header is just refused
151
+ if not isinstance(error, UnicodeError):
152
+ logger.warning("JWT rejected for a server-side reason: %r", error)
153
+ raise CredentialsRejected(
154
+ "Invalid authentication token: not acceptable"
155
+ ) from error
156
+
157
+
158
+ def required_key(key, *, name: str = "api_key"):
159
+ """``key``, refused when empty: an empty key would let a request with no
160
+ ``Authorization`` header through."""
161
+ if not key or not isinstance(key, (str, bytes)):
162
+ raise ValueError(f"{name} must be a non-empty str or bytes")
163
+ return key
164
+
165
+
166
+ def key_matches(given, expected) -> bool:
167
+ """Whether the ``given`` credential equals ``expected``, in constant time."""
168
+ if not given or not expected:
169
+ return False
170
+
171
+ def _bytes(x): # aiohttp keeps non-UTF-8 header bytes as surrogate escapes
172
+ return x.encode(errors="surrogateescape") if isinstance(x, str) else x
173
+
174
+ return hmac.compare_digest(_bytes(given), _bytes(expected))
@@ -0,0 +1,153 @@
1
+ """Plugins for adding middleware functionality to Bottle apps."""
2
+
3
+ import json
4
+ from functools import wraps
5
+ from typing import Iterable
6
+ from warnings import warn
7
+
8
+ from bottle import request, response
9
+
10
+ from py2http.auth import (
11
+ DFLT_JWT_ALGORITHMS,
12
+ CredentialsRejected,
13
+ bearer_token,
14
+ decode_jwt,
15
+ jwt_algorithms,
16
+ jwt_decode_kwargs,
17
+ key_matches,
18
+ required_key,
19
+ )
20
+ from py2http.constants import JSON_CONTENT_TYPE
21
+
22
+ OPTIONS = "OPTIONS"
23
+
24
+
25
+ def _authorization_header() -> str:
26
+ """The request's ``Authorization`` header; ``''`` if it is not valid UTF-8 (Bottle
27
+ raises when decoding it), which every check then refuses."""
28
+ try:
29
+ return request.headers.get("Authorization", "")
30
+ except UnicodeError:
31
+ return ""
32
+
33
+
34
+ def _unauthorized(message):
35
+ response.status = 401
36
+ response.content_type = JSON_CONTENT_TYPE
37
+ return json.dumps({"error": message})
38
+
39
+
40
+ class JWTPlugin:
41
+ """A plugin for validating JWTs and extracting their payloads.
42
+
43
+ After optionally validating a JWT found in the request header, will assign a dict with the JWT claims
44
+ to request.token.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ secret: str = "",
50
+ verify: bool = True,
51
+ mapper: dict = None,
52
+ ignore_methods: Iterable[str] = None,
53
+ algorithms: Iterable[str] = None,
54
+ *,
55
+ allow_mixed_algorithm_families: bool = False,
56
+ decode_kwargs: dict = None,
57
+ ):
58
+ """Creates a new JWTPlugin instance.
59
+
60
+ :param secret: The JWT public key (RS256) or shared secret (HS256) used to
61
+ validate tokens. Required (non-empty) when ``verify`` is True.
62
+ :param verify: (Optional) If True, will verify JWT signatures against the provided secret,
63
+ and reject unverified requests.
64
+ :param mapper: (Optional) A dict that specifies how to map JWT claim value names in the output.
65
+ :param ignore_methods: (Optional) A list of method names for the plugin to ignore.
66
+ :param algorithms: (Optional) The algorithms a token may be signed with
67
+ (default ``("HS256",)``); validated as ``py2http.auth.jwt_algorithms``
68
+ describes, as is ``allow_mixed_algorithm_families``.
69
+ :param decode_kwargs: (Optional) Extra ``jwt.decode`` arguments, such as
70
+ ``audience``, ``issuer`` or ``leeway``.
71
+ """
72
+ self._secret = secret
73
+ self._verify = verify
74
+ self._mapper = mapper if mapper else {}
75
+ self._ignore_methods = ignore_methods if ignore_methods else []
76
+ self._algorithms = jwt_algorithms(
77
+ secret,
78
+ verify=verify,
79
+ algorithms=algorithms or DFLT_JWT_ALGORITHMS,
80
+ allow_mixed_algorithm_families=allow_mixed_algorithm_families,
81
+ )
82
+ self._decode_kwargs = jwt_decode_kwargs(decode_kwargs)
83
+
84
+ def _claims(self):
85
+ """The (renamed) claims of the request's token; raises ``CredentialsRejected``."""
86
+ decoded = decode_jwt(
87
+ bearer_token(_authorization_header()),
88
+ self._secret,
89
+ algorithms=self._algorithms,
90
+ verify=self._verify,
91
+ decode_kwargs=self._decode_kwargs,
92
+ )
93
+ for k, v in self._mapper.items():
94
+ if k in decoded:
95
+ decoded[v] = decoded.pop(k)
96
+ return decoded
97
+
98
+ def __call__(self, handler):
99
+ if getattr(handler, "method_name", None) in self._ignore_methods:
100
+ return handler
101
+
102
+ @wraps(handler)
103
+ def wrapped_handler(*args, **kwargs):
104
+ if request.method == OPTIONS:
105
+ return handler(*args, **kwargs)
106
+ try:
107
+ request.token = self._claims()
108
+ except CredentialsRejected as rejection:
109
+ if self._verify:
110
+ return _unauthorized(str(rejection))
111
+ warn(f"Invalid JWT: {rejection.__cause__}")
112
+ return handler(*args, **kwargs)
113
+
114
+ return wrapped_handler
115
+
116
+
117
+ class ApiKeyAuthPlugin:
118
+ """A plugin letting through only requests whose ``Authorization`` header is
119
+ exactly ``api_key`` (compared in constant time; an empty key is refused)."""
120
+
121
+ def __init__(self, api_key: str):
122
+ self._api_key = required_key(api_key)
123
+
124
+ def __call__(self, handler):
125
+ @wraps(handler)
126
+ def wrapped_handler(*args, **kwargs):
127
+ if key_matches(_authorization_header(), self._api_key):
128
+ return handler(*args, **kwargs)
129
+ return _unauthorized("invalid API key")
130
+
131
+ return wrapped_handler
132
+
133
+
134
+ # from https://stackoverflow.com/questions/17262170/bottle-py-enabling-cors-for-jquery-ajax-requests
135
+ # TODO: accept lists of headers and methods as init args
136
+ class CorsPlugin:
137
+ def __init__(self, origins: str = "*"):
138
+ self._origins = origins
139
+
140
+ def __call__(self, handler):
141
+ @wraps(handler)
142
+ def wrapped_handler(*args, **kwargs):
143
+ response.headers["Access-Control-Allow-Origin"] = self._origins
144
+ response.headers["Access-Control-Allow-Methods"] = (
145
+ "GET, POST, PUT, DELETE, OPTIONS"
146
+ )
147
+ response.headers["Access-Control-Allow-Headers"] = (
148
+ "Origin, Accept, Content-Type, X-Requested-With, Authorization, X-api-key"
149
+ )
150
+ if request.method != OPTIONS:
151
+ return handler(*args, **kwargs)
152
+
153
+ return wrapped_handler
@@ -0,0 +1,124 @@
1
+ """aiohttp middleware that guards every route of an app: ``mk_jwt_middleware``
2
+ (a verified ``Authorization: Bearer`` JWT) and ``mk_superadmin_middleware`` (one
3
+ shared key). The checks themselves live in ``py2http.auth``, shared with the Bottle
4
+ plugins of ``py2http.bottle_plugins``."""
5
+
6
+ import json
7
+ from collections.abc import Iterable
8
+ from warnings import warn
9
+
10
+ from py2http.auth import (
11
+ DFLT_JWT_ALGORITHMS,
12
+ CredentialsRejected,
13
+ _algorithm_family, # noqa: F401 (was defined here)
14
+ _names,
15
+ bearer_token,
16
+ decode_jwt,
17
+ jwt_algorithms,
18
+ jwt_decode_kwargs,
19
+ key_matches,
20
+ required_key,
21
+ )
22
+ from py2http.constants import JSON_CONTENT_TYPE
23
+
24
+ DFLT_PUBLIC_ROUTES = ("ping", "openapi")
25
+
26
+
27
+ def _unauthorized(message):
28
+ from aiohttp import web
29
+
30
+ return web.HTTPUnauthorized(
31
+ text=json.dumps({"error": message}), content_type=JSON_CONTENT_TYPE
32
+ )
33
+
34
+
35
+ def mk_jwt_middleware(
36
+ secret,
37
+ verify=True,
38
+ *,
39
+ algorithms: Iterable[str] = DFLT_JWT_ALGORITHMS,
40
+ public_routes: Iterable[str] = DFLT_PUBLIC_ROUTES,
41
+ allow_mixed_algorithm_families: bool = False,
42
+ decode_kwargs: dict = None,
43
+ ):
44
+ """Make an aiohttp middleware that requires a valid ``Authorization: Bearer``
45
+ JWT on every request, and puts the decoded claims on ``request.token``.
46
+
47
+ Args:
48
+ secret: The key tokens are verified with (an HMAC secret for ``HS*``
49
+ algorithms, a public key for ``RS*``/``ES*``/...).
50
+ verify: If False, signatures and claims (``exp``, ...) are NOT checked:
51
+ whatever a token says ends up on ``request.token``, and an
52
+ undecodable token only triggers a warning. For development only.
53
+ (Before, PyJWT 2 ignored the legacy option this passed, so
54
+ signatures were still checked even with ``verify=False``.)
55
+ algorithms: The algorithms a token may be signed with (PyJWT names,
56
+ case-sensitive; ``"none"`` is refused). Defaults to ``("HS256",)``,
57
+ as ``py2http.bottle_plugins.JWTPlugin`` does.
58
+ public_routes: Names of aiohttp routes (``name=`` in ``add_route``) that
59
+ need no token. Matched on the route name, not the handler's
60
+ ``__name__``, so a handler that happens to be called ``ping`` is not
61
+ public by accident. Route names are matched in sub-applications
62
+ too, so a sub-app route named ``ping`` is public as well.
63
+ allow_mixed_algorithm_families: Accepting an HMAC algorithm and a
64
+ public-key algorithm with one ``secret`` is the classic
65
+ algorithm-confusion setup (a token "signed" with HS256 using the
66
+ public key as the HMAC secret), so it raises ``ValueError`` unless
67
+ this is True. The middleware used to accept ``["HS256", "RS256"]``;
68
+ pass that list with this flag to restore it.
69
+ decode_kwargs: Extra ``jwt.decode`` arguments, such as ``audience``,
70
+ ``issuer`` or ``leeway`` (without them a token carrying an ``aud``
71
+ claim is always refused).
72
+ """
73
+ from aiohttp import web
74
+
75
+ algorithms = jwt_algorithms(
76
+ secret,
77
+ verify=verify,
78
+ algorithms=algorithms,
79
+ allow_mixed_algorithm_families=allow_mixed_algorithm_families,
80
+ )
81
+ decode_kwargs = jwt_decode_kwargs(decode_kwargs)
82
+ public_routes = frozenset(
83
+ name for name in _names("public_routes", public_routes) if name is not None
84
+ )
85
+
86
+ @web.middleware
87
+ async def middleware(req, handler):
88
+ route = req.match_info.route
89
+ if getattr(route, "name", None) in public_routes:
90
+ return await handler(req)
91
+ token = bearer_token(req.headers.get("Authorization"))
92
+ try:
93
+ req.token = decode_jwt(
94
+ token,
95
+ secret,
96
+ algorithms=algorithms,
97
+ verify=verify,
98
+ decode_kwargs=decode_kwargs,
99
+ )
100
+ except CredentialsRejected as rejection:
101
+ if verify:
102
+ raise _unauthorized(str(rejection)) from None
103
+ warn(f"Invalid JWT: {rejection.__cause__}")
104
+ return await handler(req)
105
+
106
+ return middleware
107
+
108
+
109
+ def mk_superadmin_middleware(secret):
110
+ """Make an aiohttp middleware that only lets through requests whose
111
+ ``Authorization`` header is exactly ``secret`` (compared in constant time).
112
+ An empty ``secret`` is refused, since it would let header-less requests through.
113
+ """
114
+ from aiohttp import web
115
+
116
+ secret = required_key(secret, name="secret")
117
+
118
+ @web.middleware
119
+ async def middleware(req, handler):
120
+ if key_matches(req.headers.get("Authorization"), secret):
121
+ return await handler(req)
122
+ raise _unauthorized("invalid API key")
123
+
124
+ return middleware