py2http 0.1.68__tar.gz → 0.1.70__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 (57) hide show
  1. {py2http-0.1.68 → py2http-0.1.70}/PKG-INFO +1 -1
  2. py2http-0.1.70/py2http/auth.py +174 -0
  3. py2http-0.1.70/py2http/bottle_plugins.py +154 -0
  4. {py2http-0.1.68 → py2http-0.1.70}/py2http/constants.py +8 -0
  5. {py2http-0.1.68 → py2http-0.1.70}/py2http/default_configs.py +44 -3
  6. py2http-0.1.70/py2http/middleware.py +126 -0
  7. py2http-0.1.70/py2http/obj_dispatch.py +711 -0
  8. py2http-0.1.70/py2http/service.py +623 -0
  9. py2http-0.1.70/py2http/sizeof.py +101 -0
  10. py2http-0.1.70/py2http/tests/test_auth_plugins.py +349 -0
  11. py2http-0.1.70/py2http/tests/test_error_statuses.py +65 -0
  12. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_instance_endpoints.py +3 -24
  13. py2http-0.1.70/py2http/tests/test_obj_measuring.py +263 -0
  14. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_obj_ownership.py +28 -25
  15. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_positional_only.py +2 -17
  16. py2http-0.1.70/py2http/tests/utils_for_testing.py +80 -0
  17. {py2http-0.1.68 → py2http-0.1.70}/py2http.egg-info/PKG-INFO +1 -1
  18. {py2http-0.1.68 → py2http-0.1.70}/py2http.egg-info/SOURCES.txt +6 -0
  19. {py2http-0.1.68 → py2http-0.1.70}/setup.cfg +1 -1
  20. py2http-0.1.68/py2http/bottle_plugins.py +0 -114
  21. py2http-0.1.68/py2http/middleware.py +0 -154
  22. py2http-0.1.68/py2http/service.py +0 -1266
  23. py2http-0.1.68/py2http/tests/utils_for_testing.py +0 -26
  24. {py2http-0.1.68 → py2http-0.1.70}/LICENSE +0 -0
  25. {py2http-0.1.68 → py2http-0.1.70}/README.md +0 -0
  26. {py2http-0.1.68 → py2http-0.1.70}/py2http/__init__.py +0 -0
  27. {py2http-0.1.68 → py2http-0.1.70}/py2http/config.py +0 -0
  28. {py2http-0.1.68 → py2http-0.1.70}/py2http/decorators.py +0 -0
  29. {py2http-0.1.68 → py2http-0.1.70}/py2http/diagnosis.py +0 -0
  30. {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/__init__.py +0 -0
  31. {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/custom_input_mapper.py +0 -0
  32. {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/custom_input_mappers.py +0 -0
  33. {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/example_service.py +0 -0
  34. {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/example_service_bottle.py +0 -0
  35. {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/example_service_flask.py +0 -0
  36. {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/object_dispatch.py +0 -0
  37. {py2http-0.1.68 → py2http-0.1.70}/py2http/openapi_utils.py +0 -0
  38. {py2http-0.1.68 → py2http-0.1.70}/py2http/route_utils.py +0 -0
  39. {py2http-0.1.68 → py2http-0.1.70}/py2http/schema_tools.py +0 -0
  40. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/__init__.py +0 -0
  41. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/example_service_test.py +0 -0
  42. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/objects_for_testing.py +0 -0
  43. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/simple_run_process_test.py +0 -0
  44. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_attr_dispatch.py +0 -0
  45. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_binary_req.py +0 -0
  46. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_decorators.py +0 -0
  47. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_flask.py +0 -0
  48. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_jwt_middleware.py +0 -0
  49. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_not_set_defaults.py +0 -0
  50. {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_p2h2p.py +0 -0
  51. {py2http-0.1.68 → py2http-0.1.70}/py2http/types.py +0 -0
  52. {py2http-0.1.68 → py2http-0.1.70}/py2http/util.py +0 -0
  53. {py2http-0.1.68 → py2http-0.1.70}/py2http.egg-info/dependency_links.txt +0 -0
  54. {py2http-0.1.68 → py2http-0.1.70}/py2http.egg-info/not-zip-safe +0 -0
  55. {py2http-0.1.68 → py2http-0.1.70}/py2http.egg-info/requires.txt +0 -0
  56. {py2http-0.1.68 → py2http-0.1.70}/py2http.egg-info/top_level.txt +0 -0
  57. {py2http-0.1.68 → py2http-0.1.70}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2http
3
- Version: 0.1.68
3
+ Version: 0.1.70
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,154 @@
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 API_KEY_CHALLENGE, BEARER_CHALLENGE, 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, challenge=BEARER_CHALLENGE):
35
+ response.status = 401
36
+ response.set_header("WWW-Authenticate", challenge)
37
+ response.content_type = JSON_CONTENT_TYPE
38
+ return json.dumps({"error": message})
39
+
40
+
41
+ class JWTPlugin:
42
+ """A plugin for validating JWTs and extracting their payloads.
43
+
44
+ After optionally validating a JWT found in the request header, will assign a dict with the JWT claims
45
+ to request.token.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ secret: str = "",
51
+ verify: bool = True,
52
+ mapper: dict = None,
53
+ ignore_methods: Iterable[str] = None,
54
+ algorithms: Iterable[str] = None,
55
+ *,
56
+ allow_mixed_algorithm_families: bool = False,
57
+ decode_kwargs: dict = None,
58
+ ):
59
+ """Creates a new JWTPlugin instance.
60
+
61
+ :param secret: The JWT public key (RS256) or shared secret (HS256) used to
62
+ validate tokens. Required (non-empty) when ``verify`` is True.
63
+ :param verify: (Optional) If True, will verify JWT signatures against the provided secret,
64
+ and reject unverified requests.
65
+ :param mapper: (Optional) A dict that specifies how to map JWT claim value names in the output.
66
+ :param ignore_methods: (Optional) A list of method names for the plugin to ignore.
67
+ :param algorithms: (Optional) The algorithms a token may be signed with
68
+ (default ``("HS256",)``); validated as ``py2http.auth.jwt_algorithms``
69
+ describes, as is ``allow_mixed_algorithm_families``.
70
+ :param decode_kwargs: (Optional) Extra ``jwt.decode`` arguments, such as
71
+ ``audience``, ``issuer`` or ``leeway``.
72
+ """
73
+ self._secret = secret
74
+ self._verify = verify
75
+ self._mapper = mapper if mapper else {}
76
+ self._ignore_methods = ignore_methods if ignore_methods else []
77
+ self._algorithms = jwt_algorithms(
78
+ secret,
79
+ verify=verify,
80
+ algorithms=algorithms or DFLT_JWT_ALGORITHMS,
81
+ allow_mixed_algorithm_families=allow_mixed_algorithm_families,
82
+ )
83
+ self._decode_kwargs = jwt_decode_kwargs(decode_kwargs)
84
+
85
+ def _claims(self):
86
+ """The (renamed) claims of the request's token; raises ``CredentialsRejected``."""
87
+ decoded = decode_jwt(
88
+ bearer_token(_authorization_header()),
89
+ self._secret,
90
+ algorithms=self._algorithms,
91
+ verify=self._verify,
92
+ decode_kwargs=self._decode_kwargs,
93
+ )
94
+ for k, v in self._mapper.items():
95
+ if k in decoded:
96
+ decoded[v] = decoded.pop(k)
97
+ return decoded
98
+
99
+ def __call__(self, handler):
100
+ if getattr(handler, "method_name", None) in self._ignore_methods:
101
+ return handler
102
+
103
+ @wraps(handler)
104
+ def wrapped_handler(*args, **kwargs):
105
+ if request.method == OPTIONS:
106
+ return handler(*args, **kwargs)
107
+ try:
108
+ request.token = self._claims()
109
+ except CredentialsRejected as rejection:
110
+ if self._verify:
111
+ return _unauthorized(str(rejection))
112
+ warn(f"Invalid JWT: {rejection.__cause__}")
113
+ return handler(*args, **kwargs)
114
+
115
+ return wrapped_handler
116
+
117
+
118
+ class ApiKeyAuthPlugin:
119
+ """A plugin letting through only requests whose ``Authorization`` header is
120
+ exactly ``api_key`` (compared in constant time; an empty key is refused)."""
121
+
122
+ def __init__(self, api_key: str):
123
+ self._api_key = required_key(api_key)
124
+
125
+ def __call__(self, handler):
126
+ @wraps(handler)
127
+ def wrapped_handler(*args, **kwargs):
128
+ if key_matches(_authorization_header(), self._api_key):
129
+ return handler(*args, **kwargs)
130
+ return _unauthorized("invalid API key", API_KEY_CHALLENGE)
131
+
132
+ return wrapped_handler
133
+
134
+
135
+ # from https://stackoverflow.com/questions/17262170/bottle-py-enabling-cors-for-jquery-ajax-requests
136
+ # TODO: accept lists of headers and methods as init args
137
+ class CorsPlugin:
138
+ def __init__(self, origins: str = "*"):
139
+ self._origins = origins
140
+
141
+ def __call__(self, handler):
142
+ @wraps(handler)
143
+ def wrapped_handler(*args, **kwargs):
144
+ response.headers["Access-Control-Allow-Origin"] = self._origins
145
+ response.headers["Access-Control-Allow-Methods"] = (
146
+ "GET, POST, PUT, DELETE, OPTIONS"
147
+ )
148
+ response.headers["Access-Control-Allow-Headers"] = (
149
+ "Origin, Accept, Content-Type, X-Requested-With, Authorization, X-api-key"
150
+ )
151
+ if request.method != OPTIONS:
152
+ return handler(*args, **kwargs)
153
+
154
+ return wrapped_handler
@@ -5,3 +5,11 @@ BINARY_CONTENT_TYPE = "application/octet-stream"
5
5
  FORM_CONTENT_TYPE = "multipart/form-data"
6
6
  RAW_CONTENT_TYPE = "text/plain"
7
7
  HTML_CONTENT_TYPE = "text/html"
8
+
9
+ # ``WWW-Authenticate`` challenges sent with every 401 (RFC 7235 requires one): a JWT is
10
+ # sent as ``Authorization: Bearer <token>``; an API key as the bare header value.
11
+ BEARER_CHALLENGE = "Bearer"
12
+ API_KEY_CHALLENGE = "ApiKey"
13
+ #: Environment variable naming the challenge for a 401 a dispatched function raised
14
+ #: (``AuthorizationError``); the deployment's auth decides it. Default: Bearer.
15
+ WWW_AUTHENTICATE_ENV_VAR = "PY2HTTP_WWW_AUTHENTICATE"
@@ -15,6 +15,7 @@ up an HTTP service in Python using the py2http library."""
15
15
  from aiohttp import web
16
16
  from bottle import response
17
17
  import json
18
+ import logging
18
19
  import os
19
20
 
20
21
  from i2.errors import (
@@ -28,7 +29,13 @@ from i2.errors import (
28
29
 
29
30
  from py2http.decorators import handle_json_req, send_json_resp, JsonRespEncoder
30
31
  from py2http.config import AIOHTTP, BOTTLE, FLASK
31
- from py2http.constants import JSON_CONTENT_TYPE
32
+ from py2http.constants import (
33
+ BEARER_CHALLENGE,
34
+ JSON_CONTENT_TYPE,
35
+ WWW_AUTHENTICATE_ENV_VAR,
36
+ )
37
+
38
+ logger = logging.getLogger(__name__)
32
39
 
33
40
  DFLT_CONTENT_TYPE = JSON_CONTENT_TYPE
34
41
 
@@ -52,14 +59,41 @@ def bottle_output_mapper(output, **inputs):
52
59
  return json.dumps(output, cls=JsonRespEncoder)
53
60
 
54
61
 
55
- def _raise_http_client_error(error, message, reason=None):
62
+ def _raise_http_client_error(error, message, reason=None, headers=None):
56
63
  raise error(
57
64
  text=json.dumps({"error": message}),
58
65
  content_type=JSON_CONTENT_TYPE,
59
66
  reason=reason,
67
+ headers=headers,
60
68
  )
61
69
 
62
70
 
71
+ def www_authenticate_challenge() -> str:
72
+ """The ``WWW-Authenticate`` value of a 401 a dispatched function raised: the
73
+ ``PY2HTTP_WWW_AUTHENTICATE`` environment variable, else ``Bearer`` (also when the
74
+ variable holds a line break, which would make every 401 fail to send).
75
+
76
+ >>> os.environ.pop(WWW_AUTHENTICATE_ENV_VAR, None) and None
77
+ >>> www_authenticate_challenge()
78
+ 'Bearer'
79
+ """
80
+ challenge = os.getenv(WWW_AUTHENTICATE_ENV_VAR)
81
+ if not challenge:
82
+ return BEARER_CHALLENGE
83
+ if any(c in challenge for c in "\r\n\0"):
84
+ logger.warning(
85
+ "%s holds a control character; sending %r instead",
86
+ WWW_AUTHENTICATE_ENV_VAR,
87
+ BEARER_CHALLENGE,
88
+ )
89
+ return BEARER_CHALLENGE
90
+ return challenge
91
+
92
+
93
+ def _status_headers(status: int) -> dict:
94
+ return {"WWW-Authenticate": www_authenticate_challenge()} if status == 401 else {}
95
+
96
+
63
97
  # HTTP status of each client error, most specific class first. ``ForbiddenError`` and
64
98
  # ``OverwritesNotAllowed`` mean the client may not do this (403); any other
65
99
  # ``AuthorizationError`` means the request did not say who it is (401).
@@ -96,13 +130,20 @@ def aiohttp_error_handler(error: Exception):
96
130
  if status == 500:
97
131
  _raise_http_client_error(web.HTTPInternalServerError, "Internal server error")
98
132
  reason = type(error).__name__ if status == 400 else None
99
- _raise_http_client_error(_AIOHTTP_ERRORS[status], str(error), reason=reason)
133
+ _raise_http_client_error(
134
+ _AIOHTTP_ERRORS[status],
135
+ str(error),
136
+ reason=reason,
137
+ headers=_status_headers(status) or None,
138
+ )
100
139
 
101
140
 
102
141
  def bottle_error_handler(error: Exception):
103
142
  message = str(error)
104
143
  status = error_status(error)
105
144
  response.status = f"400 {type(error).__name__}" if status == 400 else status
145
+ for header, value in _status_headers(status).items():
146
+ response.set_header(header, value)
106
147
  if status == 500 and os.getenv("OPAQUE_ERRORS", None):
107
148
  message = "Internal server error"
108
149
  return {"error": message}
@@ -0,0 +1,126 @@
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 API_KEY_CHALLENGE, BEARER_CHALLENGE, JSON_CONTENT_TYPE
23
+
24
+ DFLT_PUBLIC_ROUTES = ("ping", "openapi")
25
+
26
+
27
+ def _unauthorized(message, challenge=BEARER_CHALLENGE):
28
+ from aiohttp import web
29
+
30
+ return web.HTTPUnauthorized(
31
+ text=json.dumps({"error": message}),
32
+ content_type=JSON_CONTENT_TYPE,
33
+ headers={"WWW-Authenticate": challenge},
34
+ )
35
+
36
+
37
+ def mk_jwt_middleware(
38
+ secret,
39
+ verify=True,
40
+ *,
41
+ algorithms: Iterable[str] = DFLT_JWT_ALGORITHMS,
42
+ public_routes: Iterable[str] = DFLT_PUBLIC_ROUTES,
43
+ allow_mixed_algorithm_families: bool = False,
44
+ decode_kwargs: dict = None,
45
+ ):
46
+ """Make an aiohttp middleware that requires a valid ``Authorization: Bearer``
47
+ JWT on every request, and puts the decoded claims on ``request.token``.
48
+
49
+ Args:
50
+ secret: The key tokens are verified with (an HMAC secret for ``HS*``
51
+ algorithms, a public key for ``RS*``/``ES*``/...).
52
+ verify: If False, signatures and claims (``exp``, ...) are NOT checked:
53
+ whatever a token says ends up on ``request.token``, and an
54
+ undecodable token only triggers a warning. For development only.
55
+ (Before, PyJWT 2 ignored the legacy option this passed, so
56
+ signatures were still checked even with ``verify=False``.)
57
+ algorithms: The algorithms a token may be signed with (PyJWT names,
58
+ case-sensitive; ``"none"`` is refused). Defaults to ``("HS256",)``,
59
+ as ``py2http.bottle_plugins.JWTPlugin`` does.
60
+ public_routes: Names of aiohttp routes (``name=`` in ``add_route``) that
61
+ need no token. Matched on the route name, not the handler's
62
+ ``__name__``, so a handler that happens to be called ``ping`` is not
63
+ public by accident. Route names are matched in sub-applications
64
+ too, so a sub-app route named ``ping`` is public as well.
65
+ allow_mixed_algorithm_families: Accepting an HMAC algorithm and a
66
+ public-key algorithm with one ``secret`` is the classic
67
+ algorithm-confusion setup (a token "signed" with HS256 using the
68
+ public key as the HMAC secret), so it raises ``ValueError`` unless
69
+ this is True. The middleware used to accept ``["HS256", "RS256"]``;
70
+ pass that list with this flag to restore it.
71
+ decode_kwargs: Extra ``jwt.decode`` arguments, such as ``audience``,
72
+ ``issuer`` or ``leeway`` (without them a token carrying an ``aud``
73
+ claim is always refused).
74
+ """
75
+ from aiohttp import web
76
+
77
+ algorithms = jwt_algorithms(
78
+ secret,
79
+ verify=verify,
80
+ algorithms=algorithms,
81
+ allow_mixed_algorithm_families=allow_mixed_algorithm_families,
82
+ )
83
+ decode_kwargs = jwt_decode_kwargs(decode_kwargs)
84
+ public_routes = frozenset(
85
+ name for name in _names("public_routes", public_routes) if name is not None
86
+ )
87
+
88
+ @web.middleware
89
+ async def middleware(req, handler):
90
+ route = req.match_info.route
91
+ if getattr(route, "name", None) in public_routes:
92
+ return await handler(req)
93
+ token = bearer_token(req.headers.get("Authorization"))
94
+ try:
95
+ req.token = decode_jwt(
96
+ token,
97
+ secret,
98
+ algorithms=algorithms,
99
+ verify=verify,
100
+ decode_kwargs=decode_kwargs,
101
+ )
102
+ except CredentialsRejected as rejection:
103
+ if verify:
104
+ raise _unauthorized(str(rejection)) from None
105
+ warn(f"Invalid JWT: {rejection.__cause__}")
106
+ return await handler(req)
107
+
108
+ return middleware
109
+
110
+
111
+ def mk_superadmin_middleware(secret):
112
+ """Make an aiohttp middleware that only lets through requests whose
113
+ ``Authorization`` header is exactly ``secret`` (compared in constant time).
114
+ An empty ``secret`` is refused, since it would let header-less requests through.
115
+ """
116
+ from aiohttp import web
117
+
118
+ secret = required_key(secret, name="secret")
119
+
120
+ @web.middleware
121
+ async def middleware(req, handler):
122
+ if key_matches(req.headers.get("Authorization"), secret):
123
+ return await handler(req)
124
+ raise _unauthorized("invalid API key", API_KEY_CHALLENGE)
125
+
126
+ return middleware