xtr-security-oauth2-server-core 4.0.2__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.
Files changed (89) hide show
  1. xtr_security_oauth2_server_core/__init__.py +14 -0
  2. xtr_security_oauth2_server_core/_repeated_parameter.py +54 -0
  3. xtr_security_oauth2_server_core/abstract_handler.py +435 -0
  4. xtr_security_oauth2_server_core/authorization_server.py +316 -0
  5. xtr_security_oauth2_server_core/authorization_validators/__init__.py +25 -0
  6. xtr_security_oauth2_server_core/authorization_validators/authorization_validator_interface.py +38 -0
  7. xtr_security_oauth2_server_core/authorization_validators/bearer_token_validator.py +320 -0
  8. xtr_security_oauth2_server_core/authorization_validators/bearer_token_validator_interface.py +46 -0
  9. xtr_security_oauth2_server_core/bearer_challenge.py +58 -0
  10. xtr_security_oauth2_server_core/code_challenge_verifiers/__init__.py +19 -0
  11. xtr_security_oauth2_server_core/code_challenge_verifiers/code_challenge_verifier_interface.py +28 -0
  12. xtr_security_oauth2_server_core/code_challenge_verifiers/plain_verifier.py +45 -0
  13. xtr_security_oauth2_server_core/code_challenge_verifiers/s256_verifier.py +44 -0
  14. xtr_security_oauth2_server_core/crypt_trait.py +210 -0
  15. xtr_security_oauth2_server_core/entities/__init__.py +36 -0
  16. xtr_security_oauth2_server_core/entities/access_token_entity_interface.py +32 -0
  17. xtr_security_oauth2_server_core/entities/access_token_signer.py +63 -0
  18. xtr_security_oauth2_server_core/entities/auth_code_entity_interface.py +32 -0
  19. xtr_security_oauth2_server_core/entities/client_entity_interface.py +51 -0
  20. xtr_security_oauth2_server_core/entities/device_code_entity_interface.py +71 -0
  21. xtr_security_oauth2_server_core/entities/refresh_token_entity_interface.py +48 -0
  22. xtr_security_oauth2_server_core/entities/scope_entity_interface.py +34 -0
  23. xtr_security_oauth2_server_core/entities/token_interface.py +77 -0
  24. xtr_security_oauth2_server_core/entities/traits/__init__.py +35 -0
  25. xtr_security_oauth2_server_core/entities/traits/access_token_trait.py +158 -0
  26. xtr_security_oauth2_server_core/entities/traits/auth_code_trait.py +24 -0
  27. xtr_security_oauth2_server_core/entities/traits/client_trait.py +56 -0
  28. xtr_security_oauth2_server_core/entities/traits/device_code_trait.py +95 -0
  29. xtr_security_oauth2_server_core/entities/traits/entity_trait.py +65 -0
  30. xtr_security_oauth2_server_core/entities/traits/refresh_token_trait.py +43 -0
  31. xtr_security_oauth2_server_core/entities/traits/scope_trait.py +33 -0
  32. xtr_security_oauth2_server_core/entities/traits/token_entity_trait.py +85 -0
  33. xtr_security_oauth2_server_core/entities/user_entity_interface.py +22 -0
  34. xtr_security_oauth2_server_core/exception/__init__.py +30 -0
  35. xtr_security_oauth2_server_core/exception/invalid_argument_error.py +28 -0
  36. xtr_security_oauth2_server_core/exception/oauth2_error.py +24 -0
  37. xtr_security_oauth2_server_core/exception/oauth_server_error.py +336 -0
  38. xtr_security_oauth2_server_core/exception/unique_token_identifier_constraint_violation_error.py +29 -0
  39. xtr_security_oauth2_server_core/grant/__init__.py +41 -0
  40. xtr_security_oauth2_server_core/grant/abstract_authorize_grant.py +186 -0
  41. xtr_security_oauth2_server_core/grant/abstract_grant.py +612 -0
  42. xtr_security_oauth2_server_core/grant/auth_code_grant.py +356 -0
  43. xtr_security_oauth2_server_core/grant/client_credentials_grant.py +79 -0
  44. xtr_security_oauth2_server_core/grant/device_code_grant.py +332 -0
  45. xtr_security_oauth2_server_core/grant/grant_type_interface.py +191 -0
  46. xtr_security_oauth2_server_core/grant/implicit_grant.py +207 -0
  47. xtr_security_oauth2_server_core/grant/password_grant.py +142 -0
  48. xtr_security_oauth2_server_core/grant/refresh_token_grant.py +192 -0
  49. xtr_security_oauth2_server_core/handlers/__init__.py +31 -0
  50. xtr_security_oauth2_server_core/handlers/abstract_token_handler.py +154 -0
  51. xtr_security_oauth2_server_core/handlers/token_handler_interface.py +90 -0
  52. xtr_security_oauth2_server_core/handlers/token_introspection_handler.py +81 -0
  53. xtr_security_oauth2_server_core/handlers/token_revocation_handler.py +110 -0
  54. xtr_security_oauth2_server_core/handlers/validated_token.py +45 -0
  55. xtr_security_oauth2_server_core/oauth2_grants.py +63 -0
  56. xtr_security_oauth2_server_core/py.typed +0 -0
  57. xtr_security_oauth2_server_core/redirect_uri.py +45 -0
  58. xtr_security_oauth2_server_core/redirect_uri_validators/__init__.py +16 -0
  59. xtr_security_oauth2_server_core/redirect_uri_validators/redirect_uri_validator.py +102 -0
  60. xtr_security_oauth2_server_core/redirect_uri_validators/redirect_uri_validator_interface.py +21 -0
  61. xtr_security_oauth2_server_core/repositories/__init__.py +38 -0
  62. xtr_security_oauth2_server_core/repositories/access_token_repository_interface.py +77 -0
  63. xtr_security_oauth2_server_core/repositories/auth_code_repository_interface.py +51 -0
  64. xtr_security_oauth2_server_core/repositories/client_repository_interface.py +51 -0
  65. xtr_security_oauth2_server_core/repositories/device_code_repository_interface.py +61 -0
  66. xtr_security_oauth2_server_core/repositories/refresh_token_repository_interface.py +67 -0
  67. xtr_security_oauth2_server_core/repositories/repository_interface.py +23 -0
  68. xtr_security_oauth2_server_core/repositories/scope_repository_interface.py +70 -0
  69. xtr_security_oauth2_server_core/repositories/user_repository_interface.py +51 -0
  70. xtr_security_oauth2_server_core/request_access_token_event.py +47 -0
  71. xtr_security_oauth2_server_core/request_event.py +78 -0
  72. xtr_security_oauth2_server_core/request_refresh_token_event.py +46 -0
  73. xtr_security_oauth2_server_core/request_types/__init__.py +17 -0
  74. xtr_security_oauth2_server_core/request_types/authorization_request.py +186 -0
  75. xtr_security_oauth2_server_core/request_types/authorization_request_interface.py +108 -0
  76. xtr_security_oauth2_server_core/resource_server.py +69 -0
  77. xtr_security_oauth2_server_core/response_types/__init__.py +36 -0
  78. xtr_security_oauth2_server_core/response_types/abstract_response_type.py +81 -0
  79. xtr_security_oauth2_server_core/response_types/bearer_token_response.py +131 -0
  80. xtr_security_oauth2_server_core/response_types/device_code_response.py +124 -0
  81. xtr_security_oauth2_server_core/response_types/introspection_response.py +232 -0
  82. xtr_security_oauth2_server_core/response_types/introspection_response_type_interface.py +44 -0
  83. xtr_security_oauth2_server_core/response_types/redirect_response.py +56 -0
  84. xtr_security_oauth2_server_core/response_types/response_type_interface.py +66 -0
  85. xtr_security_oauth2_server_core/token_server.py +209 -0
  86. xtr_security_oauth2_server_core-4.0.2.dist-info/METADATA +386 -0
  87. xtr_security_oauth2_server_core-4.0.2.dist-info/RECORD +89 -0
  88. xtr_security_oauth2_server_core-4.0.2.dist-info/WHEEL +4 -0
  89. xtr_security_oauth2_server_core-4.0.2.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,14 @@
1
+ """The OAuth 2.0 authorization-server library: grants, servers, entities and response types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ __all__ = ["__version__"]
8
+
9
+ try:
10
+ __version__ = version("xtr-security-oauth2-server-core")
11
+ except PackageNotFoundError: # pragma: no cover
12
+ # Running from a source tree with no installed metadata to read; having no
13
+ # version is better than refusing to import.
14
+ __version__ = "0+unknown"
@@ -0,0 +1,54 @@
1
+ """Seeing that a request sent one protocol parameter twice, and refusing it.
2
+
3
+ RFC 6749 sections 3.1 and 3.2 have it that a request parameter "MUST NOT be
4
+ included more than once", and section 5.2 catalogues a request that "includes a
5
+ parameter more than once" under ``invalid_request``. Taking the last value
6
+ instead would let one hop append a parameter the one before it already sent and
7
+ have this server read the appended one.
8
+
9
+ Whether it can be seen at all depends on what the form or the query string was
10
+ parsed into: a plain mapping has already settled on a single value per name,
11
+ while the objects a served request hands over keep every value. Two places need
12
+ the same reading — the handlers, for every parameter they read, and the
13
+ authorization server, for the one parameter that decides which grant reads the
14
+ request at all — so it lives here once.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
20
+
21
+ from xtr_security_oauth2_server_core.exception.oauth_server_error import OAuthServerError
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Sequence
25
+
26
+ __all__ = ["MultiValued", "refuse_if_repeated"]
27
+
28
+
29
+ @runtime_checkable
30
+ class MultiValued(Protocol):
31
+ """A parameter source that remembers every value sent under a name.
32
+
33
+ A plain mapping cannot tell whether a name was sent twice; the objects a
34
+ served request hands over answer for every value under ``getlist``, which
35
+ is the only thing read here.
36
+ """
37
+
38
+ def getlist(self, key: str) -> Sequence[object]:
39
+ """Return every value sent under ``key``, in the order they were sent."""
40
+ ...
41
+
42
+
43
+ def refuse_if_repeated(parameter: str, source: object) -> None:
44
+ """Refuse when ``source`` carries ``parameter`` more than once.
45
+
46
+ A source that cannot say — a plain mapping, or a body that is no form at
47
+ all — is left alone rather than guessed about.
48
+
49
+ Raises:
50
+ OAuthServerError: ``invalid_request`` when the name was sent more than
51
+ once.
52
+ """
53
+ if isinstance(source, MultiValued) and len(source.getlist(parameter)) > 1:
54
+ raise OAuthServerError.invalid_request(parameter)
@@ -0,0 +1,435 @@
1
+ """What every handler of a client's request shares: reading it, and proving the client."""
2
+
3
+ # The only edge from this module into the ``grant`` package is a deliberate
4
+ # function-local import in ``_validate_client`` (deferred precisely to avoid a
5
+ # runtime cycle — naming the grant contract at module scope would be one).
6
+ # basedpyright follows that deferred edge and reports a module cycle, so the
7
+ # check is turned off here rather than undoing the deferral the import exists for.
8
+ # pyright: reportImportCycles=false
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import binascii
14
+ from collections.abc import Mapping
15
+ from typing import TYPE_CHECKING, Final, TypeVar, cast
16
+
17
+ from xtr_security_oauth2_server_core._repeated_parameter import refuse_if_repeated
18
+ from xtr_security_oauth2_server_core.crypt_trait import CryptTrait
19
+ from xtr_security_oauth2_server_core.exception.invalid_argument_error import InvalidArgumentError
20
+ from xtr_security_oauth2_server_core.exception.oauth2_error import OAuth2Error
21
+ from xtr_security_oauth2_server_core.exception.oauth_server_error import OAuthServerError
22
+ from xtr_security_oauth2_server_core.request_event import RequestEvent
23
+
24
+ if TYPE_CHECKING:
25
+ from xtr_clock import ClockInterface
26
+ from xtr_event_dispatcher_contracts import EventDispatcherInterface
27
+ from xtr_http_message_contracts import ServerRequestInterface
28
+
29
+ from xtr_security_oauth2_server_core.entities.client_entity_interface import (
30
+ ClientEntityInterface,
31
+ )
32
+ from xtr_security_oauth2_server_core.repositories.access_token_repository_interface import (
33
+ AccessTokenRepositoryInterface,
34
+ )
35
+ from xtr_security_oauth2_server_core.repositories.client_repository_interface import (
36
+ ClientRepositoryInterface,
37
+ )
38
+ from xtr_security_oauth2_server_core.repositories.refresh_token_repository_interface import (
39
+ RefreshTokenRepositoryInterface,
40
+ )
41
+
42
+ __all__ = ["AbstractHandler"]
43
+
44
+ #: How long the HTTP Basic scheme's name and separator are, so the rest of the
45
+ #: header is the credentials.
46
+ _BASIC_PREFIX_LENGTH: Final[int] = 6
47
+
48
+ _T = TypeVar("_T")
49
+
50
+
51
+ def _parse_param(
52
+ parameter: str,
53
+ source: Mapping[str, object],
54
+ default: str | None,
55
+ ) -> str | None:
56
+ """Read ``parameter`` out of ``source``, falling back to ``default``.
57
+
58
+ Surrounding space is dropped and an empty value counts as absent, because
59
+ a client that sends ``client_id=`` has named no client and should be told
60
+ so rather than be looked up under the empty name.
61
+
62
+ A name the request sent more than once is refused rather than read, the
63
+ way :func:`~xtr_security_oauth2_server_core._repeated_parameter.refuse_if_repeated`
64
+ describes.
65
+
66
+ Every protocol parameter travels as text — a token request is a form, a
67
+ query string is text — so a value that is anything else is a malformed
68
+ request rather than something to coerce: a file posted under the name of a
69
+ parameter, or a JSON number where an identifier belongs.
70
+
71
+ Raises:
72
+ OAuthServerError: ``invalid_request`` when the name was sent more than
73
+ once, or is carried by something that is not text.
74
+ """
75
+ refuse_if_repeated(parameter, source)
76
+ carried = source.get(parameter, "")
77
+ if not isinstance(carried, str):
78
+ raise OAuthServerError.invalid_request(parameter)
79
+ value = carried.strip()
80
+ if value == "" and default is not None:
81
+ value = default.strip()
82
+
83
+ return value if value != "" else None
84
+
85
+
86
+ def _parsed_body(request: ServerRequestInterface) -> Mapping[str, object]:
87
+ """Return the parameters in the body of ``request``, none when there are none.
88
+
89
+ A body is parsed into whatever its content type says it carries, so what
90
+ comes back need not be named parameters at all — a JSON array, or nothing
91
+ for a request with no body. Either reads as carrying no parameter, which is
92
+ what a protocol request that is not a form amounts to.
93
+ """
94
+ body = request.get_parsed_body()
95
+ if not isinstance(body, Mapping):
96
+ return {}
97
+
98
+ # The cast says what narrowing cannot: a mapping of anything is a mapping
99
+ # of objects, and every value is then asked whether it is text.
100
+ return cast("Mapping[str, object]", body)
101
+
102
+
103
+ class AbstractHandler(CryptTrait):
104
+ """What a grant and a token handler are both built out of.
105
+
106
+ Two kinds of request reach this server carrying client credentials: the
107
+ ones a flow answers, and the ones that are about a token the client
108
+ already holds. Everything the two do the same way lives here — reading a
109
+ parameter out of the request, reading the credentials out of the body or
110
+ the Basic header, proving which client they name, and opening a refresh
111
+ token this server sealed — so a flow and a handler cannot drift apart on
112
+ any of it.
113
+
114
+ Nothing here has a default standing in for wiring. The repositories and
115
+ the encryption key arrive through the setters whoever owns the handler
116
+ calls, and a handler asked to work before that says which setter was never
117
+ called: one that quietly fell back to a repository of its own choosing
118
+ would answer for storage nobody configured.
119
+
120
+ The event dispatcher and the clock are constructor arguments rather than
121
+ setters, because neither is optional and neither has a sensible default: a
122
+ handler that could not announce a failed authentication would leave an
123
+ application no way to watch for credential stuffing, and one reading the
124
+ system clock could not be tested for anything that expires.
125
+ """
126
+
127
+ def __init__(self, dispatcher: EventDispatcherInterface, clock: ClockInterface) -> None:
128
+ """Announce through ``dispatcher``, and read every instant from ``clock``."""
129
+ self._dispatcher: EventDispatcherInterface = dispatcher
130
+ self._clock: ClockInterface = clock
131
+ self._client_repository: ClientRepositoryInterface | None = None
132
+ self._access_token_repository: AccessTokenRepositoryInterface | None = None
133
+ self._refresh_token_repository: RefreshTokenRepositoryInterface | None = None
134
+
135
+ def set_client_repository(self, client_repository: ClientRepositoryInterface) -> None:
136
+ """Take the repository registered clients are read from."""
137
+ self._client_repository = client_repository
138
+
139
+ def set_access_token_repository(
140
+ self,
141
+ access_token_repository: AccessTokenRepositoryInterface,
142
+ ) -> None:
143
+ """Take the repository access tokens are minted and stored through."""
144
+ self._access_token_repository = access_token_repository
145
+
146
+ def set_refresh_token_repository(
147
+ self,
148
+ refresh_token_repository: RefreshTokenRepositoryInterface,
149
+ ) -> None:
150
+ """Take the repository refresh tokens are minted and stored through."""
151
+ self._refresh_token_repository = refresh_token_repository
152
+
153
+ async def validate_client(self, request: ServerRequestInterface) -> ClientEntityInterface:
154
+ """Return the client ``request`` proved it is.
155
+
156
+ A confidential client is made to present a secret before the repository
157
+ is asked anything, so a request that forgot one is told what is missing
158
+ rather than being counted as a failed authentication. A public client
159
+ presents none and the repository decides whether that is allowed.
160
+
161
+ The flow being used is named to the repository only by a handler that
162
+ is a flow: a request about a token the client already holds belongs to
163
+ no flow, so there is nothing to judge the client's registration
164
+ against and the repository is told so.
165
+
166
+ Raises:
167
+ OAuthServerError: When no client is named, a confidential client
168
+ sent no secret, or authentication failed.
169
+ """
170
+ client_id, client_secret = self.get_client_credentials(request)
171
+ client = await self.get_client_entity_or_fail(client_id, request)
172
+ if not client.is_confidential():
173
+ return client
174
+ if client_secret == "":
175
+ raise OAuthServerError.invalid_request("client_secret")
176
+ from xtr_security_oauth2_server_core.grant.grant_type_interface import ( # noqa: PLC0415 -- the grants are built on this class, so naming their contract at module scope would be a cycle
177
+ GrantTypeInterface,
178
+ )
179
+
180
+ grant_type = self.get_identifier() if isinstance(self, GrantTypeInterface) else None
181
+ repository = self._client_repository_or_fail()
182
+ if not await repository.validate_client(client_id, client_secret, grant_type):
183
+ await self._announce(RequestEvent.CLIENT_AUTHENTICATION_FAILED, request)
184
+
185
+ raise OAuthServerError.invalid_client(request)
186
+
187
+ return client
188
+
189
+ async def get_client_entity_or_fail(
190
+ self,
191
+ client_id: str,
192
+ request: ServerRequestInterface,
193
+ ) -> ClientEntityInterface:
194
+ """Return the client registered as ``client_id``.
195
+
196
+ Unlike the repository this wraps, it never answers nothing: a request
197
+ naming a client nobody registered is a failed authentication, and it is
198
+ announced as one so a deployment watching for credential stuffing sees
199
+ it.
200
+
201
+ Raises:
202
+ OAuthServerError: When no such client is registered.
203
+ """
204
+ repository = self._client_repository_or_fail()
205
+ client = await repository.get_client_entity(client_id)
206
+ if client is None:
207
+ await self._announce(RequestEvent.CLIENT_AUTHENTICATION_FAILED, request)
208
+
209
+ raise OAuthServerError.invalid_client(request)
210
+
211
+ return client
212
+
213
+ def get_client_credentials(self, request: ServerRequestInterface) -> tuple[str, str]:
214
+ """Return the client identifier and secret the request carries.
215
+
216
+ The body is read first and the header is only its default, so a client
217
+ sending both is taken at the word of its body — which is what RFC 6749
218
+ section 2.3.1 expects of a server that accepts either.
219
+
220
+ Raises:
221
+ OAuthServerError: When the request names no client at all.
222
+ """
223
+ basic_user, basic_password = self.get_basic_auth_credentials(request)
224
+ client_id = self.get_request_parameter("client_id", request, basic_user)
225
+ if client_id is None:
226
+ raise OAuthServerError.invalid_request("client_id")
227
+ client_secret = self.get_request_parameter("client_secret", request, basic_password)
228
+
229
+ return (client_id, client_secret if client_secret is not None else "")
230
+
231
+ def get_basic_auth_credentials(
232
+ self,
233
+ request: ServerRequestInterface,
234
+ ) -> tuple[str | None, str | None]:
235
+ """Return the client identifier and secret sent as HTTP Basic, or two ``None``.
236
+
237
+ Every way the header can fail to carry a pair — another scheme, a body
238
+ that is not base64 or not text, no colon, an empty name — answers the
239
+ same pair of ``None``, because a header that does not carry credentials
240
+ is not an attempt to authenticate and the request may still carry them
241
+ in its body.
242
+ """
243
+ header = request.get_header_line("Authorization")
244
+ if header[:_BASIC_PREFIX_LENGTH].lower() != "basic ":
245
+ return (None, None)
246
+ try:
247
+ decoded = base64.b64decode(header[_BASIC_PREFIX_LENGTH:], validate=True).decode(
248
+ "utf-8",
249
+ )
250
+ except (binascii.Error, UnicodeDecodeError, ValueError):
251
+ return (None, None)
252
+ username, separator, password = decoded.partition(":")
253
+ if separator == "" or username == "":
254
+ return (None, None)
255
+
256
+ return (username, password)
257
+
258
+ def get_request_parameter(
259
+ self,
260
+ parameter: str,
261
+ request: ServerRequestInterface,
262
+ default: str | None = None,
263
+ ) -> str | None:
264
+ """Read ``parameter`` out of the request body, or ``default``, or ``None``.
265
+
266
+ Raises:
267
+ OAuthServerError: ``invalid_request`` when the body carries the
268
+ name more than once, or as something other than text.
269
+ """
270
+ return _parse_param(parameter, _parsed_body(request), default)
271
+
272
+ def get_query_string_parameter(
273
+ self,
274
+ parameter: str,
275
+ request: ServerRequestInterface,
276
+ default: str | None = None,
277
+ ) -> str | None:
278
+ """Read ``parameter`` out of the query string, or ``default``, or ``None``.
279
+
280
+ Raises:
281
+ OAuthServerError: ``invalid_request`` when the query carries the
282
+ name more than once, or as something other than text.
283
+ """
284
+ return _parse_param(parameter, request.get_query_params(), default)
285
+
286
+ def get_cookie_parameter(
287
+ self,
288
+ parameter: str,
289
+ request: ServerRequestInterface,
290
+ default: str | None = None,
291
+ ) -> str | None:
292
+ """Read ``parameter`` out of the cookies, or ``default``, or ``None``.
293
+
294
+ No shipped flow reads a cookie: it is here because a deployment's own
295
+ handler or grant may carry something of its own that way, and reading
296
+ it should be the same one line as reading a form field.
297
+
298
+ Raises:
299
+ OAuthServerError: ``invalid_request`` when the cookie is carried
300
+ as something other than text.
301
+ """
302
+ return _parse_param(parameter, request.get_cookie_params(), default)
303
+
304
+ def get_server_parameter(
305
+ self,
306
+ parameter: str,
307
+ request: ServerRequestInterface,
308
+ default: str | None = None,
309
+ ) -> str | None:
310
+ """Read ``parameter`` out of the environment the request arrived in.
311
+
312
+ The environment describes the connection rather than the protocol, so
313
+ no shipped flow reads it either; a deployment that authenticates a
314
+ client by something the server in front established — a verified
315
+ certificate, say — reads it here.
316
+
317
+ Raises:
318
+ OAuthServerError: ``invalid_request`` when the environment carries
319
+ the name as something other than text.
320
+ """
321
+ return _parse_param(parameter, request.get_server_params(), default)
322
+
323
+ async def validate_encrypted_refresh_token(
324
+ self,
325
+ request: ServerRequestInterface,
326
+ encrypted_refresh_token: str,
327
+ client_id: str,
328
+ ) -> Mapping[str, object]:
329
+ """Open ``encrypted_refresh_token`` and return the payload it carries.
330
+
331
+ The payload is only handed back once every reason to refuse it is gone:
332
+ this server sealed it, it names ``client_id``, its window is still
333
+ open, it names the token it is, and that token has not been revoked.
334
+
335
+ Raises:
336
+ OAuthServerError: When this server did not seal the token, it names
337
+ another client, it has expired, it names no token, or it was
338
+ revoked.
339
+ """
340
+ try:
341
+ payload = self.decrypt(encrypted_refresh_token)
342
+ except OAuth2Error as error:
343
+ raise OAuthServerError.invalid_refresh_token(
344
+ "Cannot decrypt the refresh token",
345
+ ) from error
346
+ if self._payload_text(payload, "client_id") != client_id:
347
+ await self._announce(RequestEvent.REFRESH_TOKEN_CLIENT_FAILED, request)
348
+
349
+ raise OAuthServerError.invalid_refresh_token("Token is not linked to client")
350
+ expire_time = self._payload_whole_number(payload, "expire_time")
351
+ if expire_time is None or expire_time < int(self._clock.now().timestamp()):
352
+ raise OAuthServerError.invalid_refresh_token("Token has expired")
353
+ identifier = self._payload_text(payload, "refresh_token_id")
354
+ if identifier is None:
355
+ raise OAuthServerError.invalid_refresh_token("Token is malformed")
356
+ repository = self._refresh_token_repository_or_fail()
357
+ if await repository.is_refresh_token_revoked(identifier):
358
+ raise OAuthServerError.invalid_refresh_token("Token has been revoked")
359
+
360
+ return payload
361
+
362
+ @staticmethod
363
+ def _wired(value: _T | None, setter: str) -> _T:
364
+ """Return ``value``, or say which setter nobody called.
365
+
366
+ Raises:
367
+ InvalidArgumentError: When ``value`` is absent, which is a wiring
368
+ mistake: whoever owns a handler calls every setter before the
369
+ first request reaches it, so one missing a setter was never
370
+ wired at all.
371
+ """
372
+ if value is None:
373
+ raise InvalidArgumentError(
374
+ f"This handler was used before {setter}() was called; whoever owns it calls "
375
+ "that setter before the first request reaches it.",
376
+ )
377
+
378
+ return value
379
+
380
+ @staticmethod
381
+ def _payload_text(payload: Mapping[str, object], name: str) -> str | None:
382
+ """Read ``name`` out of a sealed payload as text, or ``None``.
383
+
384
+ A payload this server sealed is still read for what it is: one sealed
385
+ by an older version of this server, or by a deployment that has since
386
+ changed what it carries, opens cleanly and says something else.
387
+ """
388
+ value = payload.get(name)
389
+
390
+ return value if isinstance(value, str) else None
391
+
392
+ @staticmethod
393
+ def _payload_whole_number(payload: Mapping[str, object], name: str) -> int | None:
394
+ """Read ``name`` out of a sealed payload as a whole number, or ``None``.
395
+
396
+ A boolean is refused although it is one in Python: an expiry of
397
+ ``True`` is a payload saying something other than an instant.
398
+ """
399
+ value = payload.get(name)
400
+ if isinstance(value, bool) or not isinstance(value, int):
401
+ return None
402
+
403
+ return value
404
+
405
+ @staticmethod
406
+ def _payload_names(payload: Mapping[str, object], name: str) -> tuple[str, ...]:
407
+ """Read ``name`` out of a sealed payload as names, empty when there are none."""
408
+ value = payload.get(name)
409
+ if not isinstance(value, list):
410
+ return ()
411
+
412
+ # The cast says what narrowing cannot: a list of anything is a list of
413
+ # objects, and every item is then asked what it is.
414
+ return tuple(item for item in cast("list[object]", value) if isinstance(item, str))
415
+
416
+ def _client_repository_or_fail(self) -> ClientRepositoryInterface:
417
+ """Return the repository registered clients are read from."""
418
+ return self._wired(self._client_repository, "set_client_repository")
419
+
420
+ def _access_token_repository_or_fail(self) -> AccessTokenRepositoryInterface:
421
+ """Return the repository access tokens are minted and stored through."""
422
+ return self._wired(self._access_token_repository, "set_access_token_repository")
423
+
424
+ def _refresh_token_repository_or_fail(self) -> RefreshTokenRepositoryInterface:
425
+ """Return the repository refresh tokens are minted and stored through."""
426
+ return self._wired(self._refresh_token_repository, "set_refresh_token_repository")
427
+
428
+ async def _announce(self, name: str, request: ServerRequestInterface) -> None:
429
+ """Say that ``name`` happened while ``request`` was being answered.
430
+
431
+ The event carries the same name it is dispatched under, because a
432
+ listener registers under that name and a mismatch would reach nobody
433
+ and break nothing loudly.
434
+ """
435
+ _ = await self._dispatcher.dispatch(RequestEvent(name, request), name)