canvas 0.24.0__py3-none-any.whl → 0.26.0__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.
Potentially problematic release.
This version of canvas might be problematic. Click here for more details.
- {canvas-0.24.0.dist-info → canvas-0.26.0.dist-info}/METADATA +1 -1
- {canvas-0.24.0.dist-info → canvas-0.26.0.dist-info}/RECORD +29 -18
- canvas_generated/messages/effects_pb2.py +2 -2
- canvas_generated/messages/effects_pb2.pyi +2 -0
- canvas_generated/messages/events_pb2.py +2 -2
- canvas_generated/messages/events_pb2.pyi +4 -0
- canvas_sdk/effects/simple_api.py +83 -0
- canvas_sdk/handlers/base.py +4 -0
- canvas_sdk/handlers/simple_api/__init__.py +22 -0
- canvas_sdk/handlers/simple_api/api.py +328 -0
- canvas_sdk/handlers/simple_api/exceptions.py +39 -0
- canvas_sdk/handlers/simple_api/security.py +184 -0
- canvas_sdk/tests/handlers/test_simple_api.py +828 -0
- canvas_sdk/v1/data/__init__.py +4 -2
- canvas_sdk/v1/data/appointment.py +22 -0
- canvas_sdk/v1/data/staff.py +25 -1
- plugin_runner/plugin_runner.py +27 -0
- plugin_runner/sandbox.py +1 -0
- plugin_runner/tests/fixtures/plugins/test_simple_api/CANVAS_MANIFEST.json +47 -0
- plugin_runner/tests/fixtures/plugins/test_simple_api/README.md +11 -0
- plugin_runner/tests/fixtures/plugins/test_simple_api/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_simple_api/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_simple_api/protocols/my_protocol.py +43 -0
- plugin_runner/tests/test_plugin_runner.py +71 -1
- protobufs/canvas_generated/messages/effects.proto +2 -0
- protobufs/canvas_generated/messages/events.proto +4 -0
- {canvas-0.24.0.dist-info → canvas-0.26.0.dist-info}/WHEEL +0 -0
- {canvas-0.24.0.dist-info → canvas-0.26.0.dist-info}/entry_points.txt +0 -0
- /plugin_runner/tests/data/plugins/.gitkeep → /canvas_sdk/tests/handlers/__init__.py +0 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import traceback
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from base64 import b64decode
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
from urllib.parse import parse_qs
|
|
10
|
+
|
|
11
|
+
from requests.structures import CaseInsensitiveDict
|
|
12
|
+
|
|
13
|
+
from canvas_sdk.effects import Effect, EffectType
|
|
14
|
+
from canvas_sdk.effects.simple_api import JSONResponse, Response
|
|
15
|
+
from canvas_sdk.events import Event, EventType
|
|
16
|
+
from canvas_sdk.handlers.base import BaseHandler
|
|
17
|
+
from logger import log
|
|
18
|
+
from plugin_runner.exceptions import PluginError
|
|
19
|
+
|
|
20
|
+
from .exceptions import AuthenticationError, InvalidCredentialsError
|
|
21
|
+
from .security import Credentials
|
|
22
|
+
|
|
23
|
+
# TODO: Routing by path regex?
|
|
24
|
+
# TODO: Support multipart/form-data by adding helpers to the request class
|
|
25
|
+
# TODO: Log requests in a format similar to other API frameworks (probably need effect metadata)
|
|
26
|
+
# TODO: Support Effect metadata that is separate from payload
|
|
27
|
+
# TODO: Encode event payloads with MessagePack instead of JSON
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
JSON = dict[str, "JSON"] | list["JSON"] | int | float | str | bool | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Request:
|
|
34
|
+
"""Request class for incoming requests to the API."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, event: Event) -> None:
|
|
37
|
+
self.method = event.context["method"]
|
|
38
|
+
self.path = event.context["path"]
|
|
39
|
+
self.query_string = event.context["query_string"]
|
|
40
|
+
self._body = event.context["body"]
|
|
41
|
+
self.headers: CaseInsensitiveDict = CaseInsensitiveDict(event.context["headers"])
|
|
42
|
+
|
|
43
|
+
self.query_params = parse_qs(self.query_string)
|
|
44
|
+
self.content_type = self.headers.get("Content-Type")
|
|
45
|
+
|
|
46
|
+
@cached_property
|
|
47
|
+
def body(self) -> bytes:
|
|
48
|
+
"""Decode and return the response body."""
|
|
49
|
+
return b64decode(self._body)
|
|
50
|
+
|
|
51
|
+
def json(self) -> JSON:
|
|
52
|
+
"""Return the response JSON."""
|
|
53
|
+
return json.loads(self.body)
|
|
54
|
+
|
|
55
|
+
def text(self) -> str:
|
|
56
|
+
"""Return the response body as plain text."""
|
|
57
|
+
return self.body.decode()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
SimpleAPIType = TypeVar("SimpleAPIType", bound="SimpleAPIBase")
|
|
61
|
+
|
|
62
|
+
RouteHandler = Callable[[SimpleAPIType], list[Response | Effect]]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get(path: str) -> Callable[[RouteHandler], RouteHandler]:
|
|
66
|
+
"""Decorator for adding API GET routes."""
|
|
67
|
+
return _handler_decorator("GET", path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def post(path: str) -> Callable[[RouteHandler], RouteHandler]:
|
|
71
|
+
"""Decorator for adding API POST routes."""
|
|
72
|
+
return _handler_decorator("POST", path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def put(path: str) -> Callable[[RouteHandler], RouteHandler]:
|
|
76
|
+
"""Decorator for adding API PUT routes."""
|
|
77
|
+
return _handler_decorator("PUT", path)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def delete(path: str) -> Callable[[RouteHandler], RouteHandler]:
|
|
81
|
+
"""Decorator for adding API DELETE routes."""
|
|
82
|
+
return _handler_decorator("DELETE", path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def patch(path: str) -> Callable[[RouteHandler], RouteHandler]:
|
|
86
|
+
"""Decorator for adding API PATCH routes."""
|
|
87
|
+
return _handler_decorator("PATCH", path)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _handler_decorator(method: str, path: str) -> Callable[[RouteHandler], RouteHandler]:
|
|
91
|
+
if not path.startswith("/"):
|
|
92
|
+
raise PluginError(f"Route path '{path}' must start with a forward slash")
|
|
93
|
+
|
|
94
|
+
def decorator(handler: RouteHandler) -> RouteHandler:
|
|
95
|
+
"""Mark the handler with the HTTP method and path."""
|
|
96
|
+
handler.route = (method, path) # type: ignore[attr-defined]
|
|
97
|
+
|
|
98
|
+
return handler
|
|
99
|
+
|
|
100
|
+
return decorator
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SimpleAPIBase(BaseHandler, ABC):
|
|
104
|
+
"""Abstract base class for HTTP APIs."""
|
|
105
|
+
|
|
106
|
+
RESPONDS_TO = [
|
|
107
|
+
EventType.Name(EventType.SIMPLE_API_AUTHENTICATE),
|
|
108
|
+
EventType.Name(EventType.SIMPLE_API_REQUEST),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
112
|
+
super().__init__(*args, **kwargs)
|
|
113
|
+
|
|
114
|
+
# Build the registry of routes so that requests can be routed to the correct handler. This
|
|
115
|
+
# is done by iterating over the methods on the class instance and looking for methods that
|
|
116
|
+
# have been marked by the handler decorators (get, post, etc.).
|
|
117
|
+
self._routes: dict[tuple[str, str], Callable] = {}
|
|
118
|
+
for attr in self.__class__.__dict__.values():
|
|
119
|
+
if callable(attr) and (route := getattr(attr, "route", None)):
|
|
120
|
+
method, relative_path = route
|
|
121
|
+
path = f"{self._path_prefix()}{relative_path}"
|
|
122
|
+
self._routes[(method, path)] = attr
|
|
123
|
+
|
|
124
|
+
def _path_prefix(self) -> str:
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
@cached_property
|
|
128
|
+
def request(self) -> Request:
|
|
129
|
+
"""Return the request object from the event."""
|
|
130
|
+
return Request(self.event)
|
|
131
|
+
|
|
132
|
+
def compute(self) -> list[Effect]:
|
|
133
|
+
"""Handle the authenticate or request event."""
|
|
134
|
+
try:
|
|
135
|
+
if self.event.type == EventType.SIMPLE_API_AUTHENTICATE:
|
|
136
|
+
return self._authenticate()
|
|
137
|
+
elif self.event.type == EventType.SIMPLE_API_REQUEST:
|
|
138
|
+
return self._handle_request()
|
|
139
|
+
else:
|
|
140
|
+
raise AssertionError(f"Cannot handle event type {EventType.Name(self.event.type)}")
|
|
141
|
+
except Exception as exception:
|
|
142
|
+
for error_line_with_newlines in traceback.format_exception(exception):
|
|
143
|
+
for error_line in error_line_with_newlines.split("\n"):
|
|
144
|
+
log.error(error_line)
|
|
145
|
+
|
|
146
|
+
return [Response(status_code=HTTPStatus.INTERNAL_SERVER_ERROR).apply()]
|
|
147
|
+
|
|
148
|
+
def _authenticate(self) -> list[Effect]:
|
|
149
|
+
"""Authenticate the request."""
|
|
150
|
+
try:
|
|
151
|
+
# Create the credentials object
|
|
152
|
+
credentials_cls = self.authenticate.__annotations__.get("credentials")
|
|
153
|
+
if not credentials_cls or not issubclass(credentials_cls, Credentials):
|
|
154
|
+
raise PluginError(
|
|
155
|
+
"Cannot determine authentication scheme; please specify the type of "
|
|
156
|
+
"credentials your endpoint requires"
|
|
157
|
+
)
|
|
158
|
+
credentials = credentials_cls(self.request)
|
|
159
|
+
|
|
160
|
+
# Pass the credentials object into the developer-defined authenticate method. If
|
|
161
|
+
# authentication succeeds, return a 200 back to home-app, otherwise return a response
|
|
162
|
+
# with the error
|
|
163
|
+
if self.authenticate(credentials):
|
|
164
|
+
return [Response(status_code=HTTPStatus.OK).apply()]
|
|
165
|
+
else:
|
|
166
|
+
raise InvalidCredentialsError
|
|
167
|
+
except AuthenticationError as error:
|
|
168
|
+
return [
|
|
169
|
+
JSONResponse(
|
|
170
|
+
content={"error": str(error)}, status_code=HTTPStatus.UNAUTHORIZED
|
|
171
|
+
).apply()
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
def _handle_request(self) -> list[Effect]:
|
|
175
|
+
"""Route the incoming request to the handler method based on the HTTP method and path."""
|
|
176
|
+
# Get the handler method
|
|
177
|
+
handler = self._routes[(self.request.method, self.request.path)]
|
|
178
|
+
|
|
179
|
+
# Handle the request
|
|
180
|
+
effects = handler(self)
|
|
181
|
+
|
|
182
|
+
# Iterate over the returned effects and:
|
|
183
|
+
# 1. Change any response objects to response effects
|
|
184
|
+
# 2. Detect if the handler returned multiple responses, and if it did, log an error and
|
|
185
|
+
# return only a response effect with a 500 Internal Server Error.
|
|
186
|
+
# 3. Detect if the handler returned an error response object, and if it did, return only
|
|
187
|
+
# the response effect.
|
|
188
|
+
response_found = False
|
|
189
|
+
for index, effect in enumerate(effects):
|
|
190
|
+
# Change the response object to a response effect
|
|
191
|
+
if isinstance(effect, Response):
|
|
192
|
+
effects[index] = effect.apply()
|
|
193
|
+
|
|
194
|
+
if effects[index].type == EffectType.SIMPLE_API_RESPONSE:
|
|
195
|
+
# If a response has already been found, return an error response immediately
|
|
196
|
+
if response_found:
|
|
197
|
+
log.error(f"Multiple responses provided by {SimpleAPI.__name__} handler")
|
|
198
|
+
return [Response(status_code=HTTPStatus.INTERNAL_SERVER_ERROR).apply()]
|
|
199
|
+
else:
|
|
200
|
+
response_found = True
|
|
201
|
+
|
|
202
|
+
# Get the status code of the response. If the initial response was an object and
|
|
203
|
+
# not an effect, get it from the response object to avoid having to deserialize
|
|
204
|
+
# the payload.
|
|
205
|
+
if isinstance(effect, Response):
|
|
206
|
+
status_code = effect.status_code
|
|
207
|
+
else:
|
|
208
|
+
status_code = json.loads(effect.payload)["status_code"]
|
|
209
|
+
|
|
210
|
+
# If the handler returned an error response, return only that response effect
|
|
211
|
+
# and omit any other included effects
|
|
212
|
+
if 400 <= status_code <= 599:
|
|
213
|
+
return [effects[index]]
|
|
214
|
+
|
|
215
|
+
return effects
|
|
216
|
+
|
|
217
|
+
def accept_event(self) -> bool:
|
|
218
|
+
"""Ignore the event if the handler does not implement the route."""
|
|
219
|
+
return (self.request.method, self.request.path) in self._routes
|
|
220
|
+
|
|
221
|
+
def authenticate(self, credentials: Credentials) -> bool:
|
|
222
|
+
"""Method the user should override to authenticate requests."""
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class SimpleAPI(SimpleAPIBase, ABC):
|
|
227
|
+
"""Base class for HTTP APIs."""
|
|
228
|
+
|
|
229
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
230
|
+
"""
|
|
231
|
+
Detect errors the user makes when defining their handler.
|
|
232
|
+
|
|
233
|
+
Prevent developers from defining multiple handlers for the same route, and from defining
|
|
234
|
+
methods that clash with base class methods.
|
|
235
|
+
"""
|
|
236
|
+
if (prefix := getattr(cls, "PREFIX", None)) and not prefix.startswith("/"):
|
|
237
|
+
raise PluginError(f"Route prefix '{prefix}' must start with a forward slash")
|
|
238
|
+
|
|
239
|
+
super().__init_subclass__(**kwargs)
|
|
240
|
+
|
|
241
|
+
routes = set()
|
|
242
|
+
route_handler_method_names = set()
|
|
243
|
+
for name, value in cls.__dict__.items():
|
|
244
|
+
if callable(value) and hasattr(value, "route"):
|
|
245
|
+
if value.route in routes:
|
|
246
|
+
method, path = value.route
|
|
247
|
+
raise PluginError(
|
|
248
|
+
f"The route {method} {path} must only be handled by one route handler"
|
|
249
|
+
)
|
|
250
|
+
routes.add(value.route)
|
|
251
|
+
route_handler_method_names.add(name)
|
|
252
|
+
|
|
253
|
+
for superclass in cls.__mro__[1:]:
|
|
254
|
+
if names := route_handler_method_names.intersection(superclass.__dict__):
|
|
255
|
+
raise PluginError(
|
|
256
|
+
f"{SimpleAPI.__name__} subclass route handler methods are overriding base "
|
|
257
|
+
f"class attributes: {', '.join(f'{cls.__name__}.{name}' for name in names)}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _path_prefix(self) -> str:
|
|
261
|
+
return getattr(self, "PREFIX", None) or ""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class SimpleAPIRoute(SimpleAPIBase, ABC):
|
|
265
|
+
"""Base class for HTTP API routes."""
|
|
266
|
+
|
|
267
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
268
|
+
"""Automatically mark the get, post, put, delete, and patch methods as handler methods."""
|
|
269
|
+
if hasattr(cls, "PREFIX"):
|
|
270
|
+
raise PluginError(
|
|
271
|
+
f"Setting a PREFIX value on a {SimpleAPIRoute.__name__} is not allowed"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
super().__init_subclass__(**kwargs)
|
|
275
|
+
|
|
276
|
+
for attr_name, attr_value in cls.__dict__.items():
|
|
277
|
+
decorator: Callable | None
|
|
278
|
+
match attr_name:
|
|
279
|
+
case "get":
|
|
280
|
+
decorator = get
|
|
281
|
+
case "post":
|
|
282
|
+
decorator = post
|
|
283
|
+
case "put":
|
|
284
|
+
decorator = put
|
|
285
|
+
case "delete":
|
|
286
|
+
decorator = delete
|
|
287
|
+
case "patch":
|
|
288
|
+
decorator = patch
|
|
289
|
+
case _:
|
|
290
|
+
decorator = None
|
|
291
|
+
|
|
292
|
+
if not callable(attr_value):
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
if hasattr(attr_value, "route"):
|
|
296
|
+
raise PluginError(
|
|
297
|
+
f"Using the api decorator on subclasses of {SimpleAPIRoute.__name__} is not "
|
|
298
|
+
"allowed"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if decorator is None:
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
path = getattr(cls, "PATH", None)
|
|
305
|
+
if not path:
|
|
306
|
+
raise PluginError(f"PATH must be specified on a {SimpleAPIRoute.__name__}")
|
|
307
|
+
|
|
308
|
+
decorator(path)(attr_value)
|
|
309
|
+
|
|
310
|
+
def get(self) -> list[Response | Effect]:
|
|
311
|
+
"""Stub method for GET handler."""
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
def post(self) -> list[Response | Effect]:
|
|
315
|
+
"""Stub method for POST handler."""
|
|
316
|
+
return []
|
|
317
|
+
|
|
318
|
+
def put(self) -> list[Response | Effect]:
|
|
319
|
+
"""Stub method for PUT handler."""
|
|
320
|
+
return []
|
|
321
|
+
|
|
322
|
+
def delete(self) -> list[Response | Effect]:
|
|
323
|
+
"""Stub method for DELETE handler."""
|
|
324
|
+
return []
|
|
325
|
+
|
|
326
|
+
def patch(self) -> list[Response | Effect]:
|
|
327
|
+
"""Stub method for PATCH handler."""
|
|
328
|
+
return []
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
class SimpleAPIException(Exception):
|
|
2
|
+
"""Base class for all SimpleAPI exceptions."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, message: str) -> None:
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthenticationError(SimpleAPIException):
|
|
9
|
+
"""Base class for all authentication exceptions."""
|
|
10
|
+
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NoAuthorizationHeaderError(AuthenticationError):
|
|
15
|
+
"""Exception class for requests without an Authorization header."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
super().__init__("Request has no Authorization header")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthenticationSchemeError(AuthenticationError):
|
|
22
|
+
"""Exception class for requests that do not have a valid authentication scheme."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
super().__init__("Authorization header has no recognized authentication scheme")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidCredentialsFormatError(AuthenticationError):
|
|
29
|
+
"""Exception class for requests that have incorrectly-formatted credentials."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
super().__init__("Provided credentials are incorrectly formatted")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InvalidCredentialsError(AuthenticationError):
|
|
36
|
+
"""Exception class for requests that have invalid credentials."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
super().__init__("Provided credentials are invalid")
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from base64 import b64decode
|
|
2
|
+
from secrets import compare_digest
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
4
|
+
|
|
5
|
+
from logger import log
|
|
6
|
+
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
AuthenticationSchemeError,
|
|
9
|
+
InvalidCredentialsError,
|
|
10
|
+
InvalidCredentialsFormatError,
|
|
11
|
+
NoAuthorizationHeaderError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .api import Request
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Credentials:
|
|
19
|
+
"""
|
|
20
|
+
Credentials base class.
|
|
21
|
+
|
|
22
|
+
Performs no parsing of the request Authorization header. This class can be used as a base class
|
|
23
|
+
for defining custom credentials classes, or can be specified by a developer as the credentials
|
|
24
|
+
class if they wish to just access the request directly in their authentication method.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, request: "Request") -> None:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BasicCredentials(Credentials):
|
|
32
|
+
"""
|
|
33
|
+
Basic authentication credentials class.
|
|
34
|
+
|
|
35
|
+
Parses and decodes the username and password from the request Authorization header and saves
|
|
36
|
+
them as attributes.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, request: "Request") -> None:
|
|
40
|
+
super().__init__(request)
|
|
41
|
+
|
|
42
|
+
authorization = request.headers.get("Authorization")
|
|
43
|
+
if not authorization:
|
|
44
|
+
raise NoAuthorizationHeaderError
|
|
45
|
+
|
|
46
|
+
scheme, delimiter, value = authorization.partition(" ")
|
|
47
|
+
if delimiter != " ":
|
|
48
|
+
raise AuthenticationSchemeError
|
|
49
|
+
if scheme.lower() != "basic":
|
|
50
|
+
raise AuthenticationSchemeError
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
value = b64decode(value.encode()).decode()
|
|
54
|
+
except Exception as exception:
|
|
55
|
+
raise InvalidCredentialsFormatError from exception
|
|
56
|
+
|
|
57
|
+
username, delimiter, password = value.partition(":")
|
|
58
|
+
if delimiter != ":":
|
|
59
|
+
raise InvalidCredentialsFormatError
|
|
60
|
+
if not username or not password:
|
|
61
|
+
raise InvalidCredentialsFormatError
|
|
62
|
+
|
|
63
|
+
self.username = username
|
|
64
|
+
self.password = password
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BearerCredentials(Credentials):
|
|
68
|
+
"""
|
|
69
|
+
Bearer authentication credentials class.
|
|
70
|
+
|
|
71
|
+
Parses the token from the request Authorization header and saves it as an attribute.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, request: "Request") -> None:
|
|
75
|
+
super().__init__(request)
|
|
76
|
+
|
|
77
|
+
authorization = request.headers.get("Authorization")
|
|
78
|
+
if not authorization:
|
|
79
|
+
raise NoAuthorizationHeaderError
|
|
80
|
+
|
|
81
|
+
scheme, delimiter, token = authorization.partition(" ")
|
|
82
|
+
if delimiter != " ":
|
|
83
|
+
raise AuthenticationSchemeError
|
|
84
|
+
if scheme.lower() != "bearer":
|
|
85
|
+
raise AuthenticationSchemeError
|
|
86
|
+
if not token:
|
|
87
|
+
raise InvalidCredentialsFormatError
|
|
88
|
+
|
|
89
|
+
self.token = token
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class APIKeyCredentials(Credentials):
|
|
93
|
+
"""
|
|
94
|
+
API Key credentials class.
|
|
95
|
+
|
|
96
|
+
Obtains the API key from the request Authorization header and saves it as an attribute.
|
|
97
|
+
|
|
98
|
+
The default header name is "Authorization", but can be changed by overriding the HEADER_NAME
|
|
99
|
+
class variable in subclasses.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
HEADER_NAME = "Authorization"
|
|
103
|
+
|
|
104
|
+
def __init__(self, request: "Request") -> None:
|
|
105
|
+
super().__init__(request)
|
|
106
|
+
|
|
107
|
+
if self.HEADER_NAME not in request.headers:
|
|
108
|
+
raise NoAuthorizationHeaderError
|
|
109
|
+
|
|
110
|
+
key = request.headers.get(self.HEADER_NAME)
|
|
111
|
+
if not key:
|
|
112
|
+
raise InvalidCredentialsFormatError
|
|
113
|
+
|
|
114
|
+
self.key = key
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AuthSchemeMixin(Protocol):
|
|
118
|
+
"""Protocol for authentication scheme mixins."""
|
|
119
|
+
|
|
120
|
+
secrets: dict[str, str]
|
|
121
|
+
|
|
122
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
123
|
+
super().__init__(*args, **kwargs)
|
|
124
|
+
|
|
125
|
+
def authenticate(self, credentials: Credentials) -> bool:
|
|
126
|
+
"""Authenticate the request."""
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class BasicAuthMixin(AuthSchemeMixin):
|
|
131
|
+
"""
|
|
132
|
+
Basic authentication scheme mixin.
|
|
133
|
+
|
|
134
|
+
Provides an implementation of the authenticate method for Basic authentication.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
USERNAME_SECRET_NAME = "simpleapi-basic-username"
|
|
138
|
+
PASSWORD_SECRET_NAME = "simpleapi-basic-password"
|
|
139
|
+
|
|
140
|
+
def authenticate(self, credentials: BasicCredentials) -> bool: # type: ignore[override]
|
|
141
|
+
"""Authenticate the request."""
|
|
142
|
+
try:
|
|
143
|
+
username = self.secrets[self.USERNAME_SECRET_NAME]
|
|
144
|
+
password = self.secrets[self.PASSWORD_SECRET_NAME]
|
|
145
|
+
except KeyError as error:
|
|
146
|
+
log.error(
|
|
147
|
+
"SimpleAPI secrets for Basic authentication are not set; please set values for "
|
|
148
|
+
f"{self.USERNAME_SECRET_NAME} and {self.PASSWORD_SECRET_NAME}"
|
|
149
|
+
)
|
|
150
|
+
raise InvalidCredentialsError from error
|
|
151
|
+
|
|
152
|
+
if not (
|
|
153
|
+
compare_digest(credentials.username.encode(), username.encode())
|
|
154
|
+
and compare_digest(credentials.password.encode(), password.encode())
|
|
155
|
+
):
|
|
156
|
+
raise InvalidCredentialsError
|
|
157
|
+
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class APIKeyAuthMixin(AuthSchemeMixin):
|
|
162
|
+
"""
|
|
163
|
+
API Key authentication scheme mixin.
|
|
164
|
+
|
|
165
|
+
Provides an implementation of the authenticate method for API Key authentication.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
API_KEY_SECRET_NAME = "simpleapi-api-key"
|
|
169
|
+
|
|
170
|
+
def authenticate(self, credentials: APIKeyCredentials) -> bool: # type: ignore[override]
|
|
171
|
+
"""Authenticate the request."""
|
|
172
|
+
try:
|
|
173
|
+
api_key = self.secrets[self.API_KEY_SECRET_NAME]
|
|
174
|
+
except KeyError as error:
|
|
175
|
+
log.error(
|
|
176
|
+
f"SimpleAPI secret for API Key authentication is not set; please set a value for "
|
|
177
|
+
f"{self.API_KEY_SECRET_NAME}"
|
|
178
|
+
)
|
|
179
|
+
raise InvalidCredentialsError from error
|
|
180
|
+
|
|
181
|
+
if not compare_digest(credentials.key.encode(), api_key.encode()):
|
|
182
|
+
raise InvalidCredentialsError
|
|
183
|
+
|
|
184
|
+
return True
|