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.
- xtr_security_oauth2_server_core/__init__.py +14 -0
- xtr_security_oauth2_server_core/_repeated_parameter.py +54 -0
- xtr_security_oauth2_server_core/abstract_handler.py +435 -0
- xtr_security_oauth2_server_core/authorization_server.py +316 -0
- xtr_security_oauth2_server_core/authorization_validators/__init__.py +25 -0
- xtr_security_oauth2_server_core/authorization_validators/authorization_validator_interface.py +38 -0
- xtr_security_oauth2_server_core/authorization_validators/bearer_token_validator.py +320 -0
- xtr_security_oauth2_server_core/authorization_validators/bearer_token_validator_interface.py +46 -0
- xtr_security_oauth2_server_core/bearer_challenge.py +58 -0
- xtr_security_oauth2_server_core/code_challenge_verifiers/__init__.py +19 -0
- xtr_security_oauth2_server_core/code_challenge_verifiers/code_challenge_verifier_interface.py +28 -0
- xtr_security_oauth2_server_core/code_challenge_verifiers/plain_verifier.py +45 -0
- xtr_security_oauth2_server_core/code_challenge_verifiers/s256_verifier.py +44 -0
- xtr_security_oauth2_server_core/crypt_trait.py +210 -0
- xtr_security_oauth2_server_core/entities/__init__.py +36 -0
- xtr_security_oauth2_server_core/entities/access_token_entity_interface.py +32 -0
- xtr_security_oauth2_server_core/entities/access_token_signer.py +63 -0
- xtr_security_oauth2_server_core/entities/auth_code_entity_interface.py +32 -0
- xtr_security_oauth2_server_core/entities/client_entity_interface.py +51 -0
- xtr_security_oauth2_server_core/entities/device_code_entity_interface.py +71 -0
- xtr_security_oauth2_server_core/entities/refresh_token_entity_interface.py +48 -0
- xtr_security_oauth2_server_core/entities/scope_entity_interface.py +34 -0
- xtr_security_oauth2_server_core/entities/token_interface.py +77 -0
- xtr_security_oauth2_server_core/entities/traits/__init__.py +35 -0
- xtr_security_oauth2_server_core/entities/traits/access_token_trait.py +158 -0
- xtr_security_oauth2_server_core/entities/traits/auth_code_trait.py +24 -0
- xtr_security_oauth2_server_core/entities/traits/client_trait.py +56 -0
- xtr_security_oauth2_server_core/entities/traits/device_code_trait.py +95 -0
- xtr_security_oauth2_server_core/entities/traits/entity_trait.py +65 -0
- xtr_security_oauth2_server_core/entities/traits/refresh_token_trait.py +43 -0
- xtr_security_oauth2_server_core/entities/traits/scope_trait.py +33 -0
- xtr_security_oauth2_server_core/entities/traits/token_entity_trait.py +85 -0
- xtr_security_oauth2_server_core/entities/user_entity_interface.py +22 -0
- xtr_security_oauth2_server_core/exception/__init__.py +30 -0
- xtr_security_oauth2_server_core/exception/invalid_argument_error.py +28 -0
- xtr_security_oauth2_server_core/exception/oauth2_error.py +24 -0
- xtr_security_oauth2_server_core/exception/oauth_server_error.py +336 -0
- xtr_security_oauth2_server_core/exception/unique_token_identifier_constraint_violation_error.py +29 -0
- xtr_security_oauth2_server_core/grant/__init__.py +41 -0
- xtr_security_oauth2_server_core/grant/abstract_authorize_grant.py +186 -0
- xtr_security_oauth2_server_core/grant/abstract_grant.py +612 -0
- xtr_security_oauth2_server_core/grant/auth_code_grant.py +356 -0
- xtr_security_oauth2_server_core/grant/client_credentials_grant.py +79 -0
- xtr_security_oauth2_server_core/grant/device_code_grant.py +332 -0
- xtr_security_oauth2_server_core/grant/grant_type_interface.py +191 -0
- xtr_security_oauth2_server_core/grant/implicit_grant.py +207 -0
- xtr_security_oauth2_server_core/grant/password_grant.py +142 -0
- xtr_security_oauth2_server_core/grant/refresh_token_grant.py +192 -0
- xtr_security_oauth2_server_core/handlers/__init__.py +31 -0
- xtr_security_oauth2_server_core/handlers/abstract_token_handler.py +154 -0
- xtr_security_oauth2_server_core/handlers/token_handler_interface.py +90 -0
- xtr_security_oauth2_server_core/handlers/token_introspection_handler.py +81 -0
- xtr_security_oauth2_server_core/handlers/token_revocation_handler.py +110 -0
- xtr_security_oauth2_server_core/handlers/validated_token.py +45 -0
- xtr_security_oauth2_server_core/oauth2_grants.py +63 -0
- xtr_security_oauth2_server_core/py.typed +0 -0
- xtr_security_oauth2_server_core/redirect_uri.py +45 -0
- xtr_security_oauth2_server_core/redirect_uri_validators/__init__.py +16 -0
- xtr_security_oauth2_server_core/redirect_uri_validators/redirect_uri_validator.py +102 -0
- xtr_security_oauth2_server_core/redirect_uri_validators/redirect_uri_validator_interface.py +21 -0
- xtr_security_oauth2_server_core/repositories/__init__.py +38 -0
- xtr_security_oauth2_server_core/repositories/access_token_repository_interface.py +77 -0
- xtr_security_oauth2_server_core/repositories/auth_code_repository_interface.py +51 -0
- xtr_security_oauth2_server_core/repositories/client_repository_interface.py +51 -0
- xtr_security_oauth2_server_core/repositories/device_code_repository_interface.py +61 -0
- xtr_security_oauth2_server_core/repositories/refresh_token_repository_interface.py +67 -0
- xtr_security_oauth2_server_core/repositories/repository_interface.py +23 -0
- xtr_security_oauth2_server_core/repositories/scope_repository_interface.py +70 -0
- xtr_security_oauth2_server_core/repositories/user_repository_interface.py +51 -0
- xtr_security_oauth2_server_core/request_access_token_event.py +47 -0
- xtr_security_oauth2_server_core/request_event.py +78 -0
- xtr_security_oauth2_server_core/request_refresh_token_event.py +46 -0
- xtr_security_oauth2_server_core/request_types/__init__.py +17 -0
- xtr_security_oauth2_server_core/request_types/authorization_request.py +186 -0
- xtr_security_oauth2_server_core/request_types/authorization_request_interface.py +108 -0
- xtr_security_oauth2_server_core/resource_server.py +69 -0
- xtr_security_oauth2_server_core/response_types/__init__.py +36 -0
- xtr_security_oauth2_server_core/response_types/abstract_response_type.py +81 -0
- xtr_security_oauth2_server_core/response_types/bearer_token_response.py +131 -0
- xtr_security_oauth2_server_core/response_types/device_code_response.py +124 -0
- xtr_security_oauth2_server_core/response_types/introspection_response.py +232 -0
- xtr_security_oauth2_server_core/response_types/introspection_response_type_interface.py +44 -0
- xtr_security_oauth2_server_core/response_types/redirect_response.py +56 -0
- xtr_security_oauth2_server_core/response_types/response_type_interface.py +66 -0
- xtr_security_oauth2_server_core/token_server.py +209 -0
- xtr_security_oauth2_server_core-4.0.2.dist-info/METADATA +386 -0
- xtr_security_oauth2_server_core-4.0.2.dist-info/RECORD +89 -0
- xtr_security_oauth2_server_core-4.0.2.dist-info/WHEEL +4 -0
- 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)
|