xtr-security-http 3.0.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.
Files changed (93) hide show
  1. xtr_security_http/__init__.py +143 -0
  2. xtr_security_http/_runner.py +134 -0
  3. xtr_security_http/_state.py +57 -0
  4. xtr_security_http/access_map.py +52 -0
  5. xtr_security_http/access_map_interface.py +24 -0
  6. xtr_security_http/access_token/__init__.py +19 -0
  7. xtr_security_http/access_token/access_token_extractor_interface.py +31 -0
  8. xtr_security_http/access_token/access_token_handler_interface.py +34 -0
  9. xtr_security_http/access_token/chain_access_token_extractor.py +58 -0
  10. xtr_security_http/access_token/form_encoded_body_extractor.py +57 -0
  11. xtr_security_http/access_token/header_access_token_extractor.py +80 -0
  12. xtr_security_http/access_token/oidc/__init__.py +41 -0
  13. xtr_security_http/access_token/oidc/exception/__init__.py +7 -0
  14. xtr_security_http/access_token/oidc/exception/oidc_key_set_error.py +18 -0
  15. xtr_security_http/access_token/oidc/oidc_token_handler.py +246 -0
  16. xtr_security_http/access_token/query_access_token_extractor.py +51 -0
  17. xtr_security_http/authentication/__init__.py +24 -0
  18. xtr_security_http/authentication/_sensitive.py +60 -0
  19. xtr_security_http/authentication/authentication_failure_handler_interface.py +31 -0
  20. xtr_security_http/authentication/authentication_success_handler_interface.py +31 -0
  21. xtr_security_http/authentication/authenticator_manager.py +232 -0
  22. xtr_security_http/authentication/authenticator_manager_interface.py +40 -0
  23. xtr_security_http/authentication/expose_security_level.py +27 -0
  24. xtr_security_http/authenticator/__init__.py +9 -0
  25. xtr_security_http/authenticator/abstract_authenticator.py +66 -0
  26. xtr_security_http/authenticator/access_token_authenticator.py +199 -0
  27. xtr_security_http/authenticator/authenticator_interface.py +68 -0
  28. xtr_security_http/authenticator/oidc/__init__.py +7 -0
  29. xtr_security_http/authenticator/oidc/oidc_jwks.py +87 -0
  30. xtr_security_http/authenticator/passport/__init__.py +8 -0
  31. xtr_security_http/authenticator/passport/badge/__init__.py +16 -0
  32. xtr_security_http/authenticator/passport/badge/badge_interface.py +24 -0
  33. xtr_security_http/authenticator/passport/badge/password_upgrade_badge.py +59 -0
  34. xtr_security_http/authenticator/passport/badge/pre_authenticated_user_badge.py +27 -0
  35. xtr_security_http/authenticator/passport/badge/user_badge.py +163 -0
  36. xtr_security_http/authenticator/passport/credentials/__init__.py +9 -0
  37. xtr_security_http/authenticator/passport/credentials/credentials_interface.py +21 -0
  38. xtr_security_http/authenticator/passport/credentials/custom_credentials.py +64 -0
  39. xtr_security_http/authenticator/passport/credentials/password_credentials.py +51 -0
  40. xtr_security_http/authenticator/passport/passport.py +104 -0
  41. xtr_security_http/authenticator/passport/self_validating_passport.py +42 -0
  42. xtr_security_http/authenticator/token/__init__.py +7 -0
  43. xtr_security_http/authenticator/token/post_authentication_token.py +43 -0
  44. xtr_security_http/authorization/__init__.py +15 -0
  45. xtr_security_http/authorization/access_denied_handler_interface.py +27 -0
  46. xtr_security_http/authorization/insufficient_scope_access_denied_handler.py +66 -0
  47. xtr_security_http/authorization/oauth2_scope_voter.py +122 -0
  48. xtr_security_http/decorator/__init__.py +16 -0
  49. xtr_security_http/decorator/current_user.py +83 -0
  50. xtr_security_http/decorator/is_granted.py +199 -0
  51. xtr_security_http/decorator/is_granted_context.py +14 -0
  52. xtr_security_http/entry_point/__init__.py +7 -0
  53. xtr_security_http/entry_point/authentication_entry_point_interface.py +33 -0
  54. xtr_security_http/event/__init__.py +20 -0
  55. xtr_security_http/event/authentication_token_created_event.py +48 -0
  56. xtr_security_http/event/check_passport_event.py +48 -0
  57. xtr_security_http/event/login_failure_event.py +79 -0
  58. xtr_security_http/event/login_success_event.py +79 -0
  59. xtr_security_http/event_listener/__init__.py +21 -0
  60. xtr_security_http/event_listener/check_credentials_listener.py +162 -0
  61. xtr_security_http/event_listener/password_migrating_listener.py +79 -0
  62. xtr_security_http/event_listener/user_checker_listener.py +60 -0
  63. xtr_security_http/event_listener/user_provider_listener.py +53 -0
  64. xtr_security_http/exception/__init__.py +18 -0
  65. xtr_security_http/exception/firewall_not_booted_error.py +18 -0
  66. xtr_security_http/exception/invalid_access_token_error.py +20 -0
  67. xtr_security_http/exception/unknown_firewall_error.py +34 -0
  68. xtr_security_http/firewall/__init__.py +15 -0
  69. xtr_security_http/firewall/access_listener.py +66 -0
  70. xtr_security_http/firewall/exception_listener.py +151 -0
  71. xtr_security_http/firewall/firewall.py +142 -0
  72. xtr_security_http/firewall_context_interface.py +64 -0
  73. xtr_security_http/firewall_map.py +93 -0
  74. xtr_security_http/firewall_map_interface.py +48 -0
  75. xtr_security_http/firewall_scheme.py +114 -0
  76. xtr_security_http/firewall_scheme_registry.py +87 -0
  77. xtr_security_http/oidc/__init__.py +7 -0
  78. xtr_security_http/oidc/oidc_discovery.py +238 -0
  79. xtr_security_http/py.typed +0 -0
  80. xtr_security_http/request_matcher/__init__.py +28 -0
  81. xtr_security_http/request_matcher/_pattern.py +27 -0
  82. xtr_security_http/request_matcher/callable_request_matcher.py +37 -0
  83. xtr_security_http/request_matcher/chain_request_matcher.py +37 -0
  84. xtr_security_http/request_matcher/host_request_matcher.py +43 -0
  85. xtr_security_http/request_matcher/ip_request_matcher.py +37 -0
  86. xtr_security_http/request_matcher/method_request_matcher.py +32 -0
  87. xtr_security_http/request_matcher/path_request_matcher.py +41 -0
  88. xtr_security_http/request_matcher/request_matcher_interface.py +25 -0
  89. xtr_security_http/security_events.py +41 -0
  90. xtr_security_http-3.0.0.dist-info/METADATA +427 -0
  91. xtr_security_http-3.0.0.dist-info/RECORD +93 -0
  92. xtr_security_http-3.0.0.dist-info/WHEEL +4 -0
  93. xtr_security_http-3.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,232 @@
1
+ """Drives a firewall's authenticators over one request, in the fixed order."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast, final
6
+
7
+ from typing_extensions import override
8
+ from xtr_security_core.event.authentication_success_event import AuthenticationSuccessEvent
9
+ from xtr_security_core.exception import AuthenticationError, BadCredentialsError
10
+
11
+ from xtr_security_http.authentication._sensitive import mask
12
+ from xtr_security_http.authentication.expose_security_level import ExposeSecurityLevel
13
+ from xtr_security_http.event.authentication_token_created_event import (
14
+ AuthenticationTokenCreatedEvent,
15
+ )
16
+ from xtr_security_http.event.check_passport_event import CheckPassportEvent
17
+ from xtr_security_http.event.login_failure_event import LoginFailureEvent
18
+ from xtr_security_http.event.login_success_event import LoginSuccessEvent
19
+
20
+ from .authenticator_manager_interface import AuthenticatorManagerInterface
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Sequence
24
+
25
+ from starlette.requests import Request
26
+ from starlette.responses import Response
27
+ from xtr_event_dispatcher_contracts import EventDispatcherInterface
28
+ from xtr_logging_contracts import LoggerInterface
29
+ from xtr_security_core.authentication.token.storage.token_storage_interface import (
30
+ TokenStorageInterface,
31
+ )
32
+ from xtr_security_core.authentication.token.token_interface import TokenInterface
33
+
34
+ from xtr_security_http.authenticator.authenticator_interface import AuthenticatorInterface
35
+ from xtr_security_http.authenticator.passport.badge.badge_interface import BadgeInterface
36
+ from xtr_security_http.authenticator.passport.passport import Passport
37
+
38
+ __all__ = ["AuthenticatorManager"]
39
+
40
+ #: Returned when a lazy authenticator did not apply — no credential was presented.
41
+ _ABSTAINED: object = object()
42
+
43
+
44
+ @final
45
+ class AuthenticatorManager(AuthenticatorManagerInterface):
46
+ """Runs the authenticators of one firewall and dispatches the events around each step.
47
+
48
+ The order is fixed and is the whole contract of this class:
49
+
50
+ 1. the first authenticator whose :meth:`supports` is not ``False`` reads
51
+ the request into a passport;
52
+ 2. a :class:`~xtr_security_http.event.check_passport_event.CheckPassportEvent`
53
+ lets listeners resolve the badges — set the user loader, verify a
54
+ password;
55
+ 3. every badge must report itself resolved, and every required badge must
56
+ be present, or a
57
+ :class:`~xtr_security_core.exception.BadCredentialsError` is raised;
58
+ 4. the user is loaded and a token is created, then an
59
+ :class:`~xtr_security_http.event.authentication_token_created_event.AuthenticationTokenCreatedEvent`
60
+ lets a listener replace it;
61
+ 5. an
62
+ :class:`~xtr_security_core.event.authentication_success_event.AuthenticationSuccessEvent`
63
+ runs the post-authentication account check;
64
+ 6. the token is stored, the authenticator's success handler runs, and a
65
+ :class:`~xtr_security_http.event.login_success_event.LoginSuccessEvent`
66
+ lets a listener migrate a password or replace the response.
67
+
68
+ A failure anywhere is masked to the configured level, announced as a
69
+ :class:`~xtr_security_http.event.login_failure_event.LoginFailureEvent`,
70
+ offered to the authenticator's failure handler, and — if nobody answered —
71
+ re-raised for the firewall's entry point to turn into a challenge.
72
+ """
73
+
74
+ __slots__ = (
75
+ "_authenticators",
76
+ "_event_dispatcher",
77
+ "_expose_security_errors",
78
+ "_firewall_name",
79
+ "_logger",
80
+ "_required_badges",
81
+ "_token_storage",
82
+ )
83
+
84
+ def __init__( # noqa: PLR0913, PLR0917 -- a wiring constructor; every dependency is required
85
+ self,
86
+ authenticators: Sequence[AuthenticatorInterface],
87
+ token_storage: TokenStorageInterface,
88
+ event_dispatcher: EventDispatcherInterface,
89
+ firewall_name: str,
90
+ logger: LoggerInterface | None = None,
91
+ expose_security_errors: ExposeSecurityLevel = ExposeSecurityLevel.NONE,
92
+ required_badges: Sequence[type[BadgeInterface]] = (),
93
+ ) -> None:
94
+ """Record the authenticators, the storage, the dispatcher and the policy."""
95
+ self._authenticators = tuple(authenticators)
96
+ self._token_storage = token_storage
97
+ self._event_dispatcher = event_dispatcher
98
+ self._firewall_name = firewall_name
99
+ self._logger = logger
100
+ self._expose_security_errors = expose_security_errors
101
+ self._required_badges = tuple(required_badges)
102
+
103
+ @override
104
+ def supports(self, request: Request) -> bool | None:
105
+ """Tell whether an authenticator handles ``request``: ``True``, ``None`` or ``False``."""
106
+ lazy = False
107
+ for authenticator in self._authenticators:
108
+ supported = authenticator.supports(request)
109
+ if supported is True:
110
+ return True
111
+ if supported is None:
112
+ lazy = True
113
+ return None if lazy else False
114
+
115
+ @override
116
+ async def authenticate_request(self, request: Request) -> Response | None:
117
+ """Authenticate ``request`` through the first supporting authenticator.
118
+
119
+ A lazy authenticator — one whose :meth:`supports` returned ``None`` —
120
+ that raises a plain
121
+ :class:`~xtr_security_core.exception.BadCredentialsError` is read as "no
122
+ credential presented": it did not apply, so the next authenticator is
123
+ tried and, failing that, the request stays anonymous. Any other failure
124
+ from it, and every failure from an eager authenticator, is a real
125
+ authentication failure the firewall turns into a challenge.
126
+ """
127
+ for authenticator in self._authenticators:
128
+ supported = authenticator.supports(request)
129
+ if supported is False:
130
+ continue
131
+ outcome = await self._authenticate(authenticator, request, lazy=supported is None)
132
+ if outcome is not _ABSTAINED:
133
+ return cast("Response | None", outcome)
134
+ return None
135
+
136
+ async def _authenticate(
137
+ self,
138
+ authenticator: AuthenticatorInterface,
139
+ request: Request,
140
+ *,
141
+ lazy: bool,
142
+ ) -> Response | object | None:
143
+ """Carry one authenticator through the sequence, or abstain when it does not apply."""
144
+ passport: Passport | None = None
145
+ try:
146
+ passport = await authenticator.authenticate(request)
147
+ _ = await self._event_dispatcher.dispatch(CheckPassportEvent(authenticator, passport))
148
+ self._ensure_resolved(passport)
149
+ _ = await passport.get_user()
150
+ token = await authenticator.create_token(passport, self._firewall_name)
151
+ token = await self._token_created(token, passport)
152
+ _ = await self._event_dispatcher.dispatch(AuthenticationSuccessEvent(token))
153
+ except BadCredentialsError as error:
154
+ if lazy and passport is None:
155
+ return _ABSTAINED
156
+ return await self._on_failure(authenticator, request, passport, error)
157
+ except AuthenticationError as error:
158
+ return await self._on_failure(authenticator, request, passport, error)
159
+ return await self._on_success(authenticator, request, passport, token)
160
+
161
+ def _ensure_resolved(self, passport: Passport) -> None:
162
+ """Raise when a badge is unresolved or a required badge is absent."""
163
+ passport.check_if_completely_resolved()
164
+ for badge in self._required_badges:
165
+ if not passport.has_badge(badge):
166
+ raise BadCredentialsError(
167
+ f"The passport is missing the required badge {badge.__name__}.",
168
+ )
169
+
170
+ async def _token_created(self, token: TokenInterface, passport: Passport) -> TokenInterface:
171
+ """Announce the created token and return whichever token a listener settled on."""
172
+ event = AuthenticationTokenCreatedEvent(token, passport)
173
+ _ = await self._event_dispatcher.dispatch(event)
174
+ return event.get_authenticated_token()
175
+
176
+ async def _on_success(
177
+ self,
178
+ authenticator: AuthenticatorInterface,
179
+ request: Request,
180
+ passport: Passport,
181
+ token: TokenInterface,
182
+ ) -> Response | None:
183
+ """Store the token, run the success handler, then announce the login."""
184
+ self._token_storage.set_token(token)
185
+ if self._logger is not None:
186
+ self._logger.info(
187
+ "Authenticated the request.",
188
+ {"firewall": self._firewall_name, "user": token.get_user_identifier()},
189
+ )
190
+ response = await authenticator.on_authentication_success(
191
+ request,
192
+ token,
193
+ self._firewall_name,
194
+ )
195
+ event = LoginSuccessEvent(
196
+ authenticator,
197
+ passport,
198
+ token,
199
+ request,
200
+ response,
201
+ self._firewall_name,
202
+ )
203
+ _ = await self._event_dispatcher.dispatch(event)
204
+ return event.get_response()
205
+
206
+ async def _on_failure(
207
+ self,
208
+ authenticator: AuthenticatorInterface,
209
+ request: Request,
210
+ passport: Passport | None,
211
+ error: AuthenticationError,
212
+ ) -> Response | None:
213
+ """Mask the error, announce the failure, offer it to the handler, then re-raise.
214
+
215
+ Raises:
216
+ AuthenticationError: The masked error, when no listener or handler
217
+ answered it — the firewall turns it into a challenge.
218
+ """
219
+ masked = mask(error, self._expose_security_errors)
220
+ if self._logger is not None:
221
+ self._logger.info(
222
+ "Authentication failed.",
223
+ {"firewall": self._firewall_name, "error": type(error).__name__},
224
+ )
225
+ event = LoginFailureEvent(masked, authenticator, request, passport, self._firewall_name)
226
+ _ = await self._event_dispatcher.dispatch(event)
227
+ if event.get_response() is not None:
228
+ return event.get_response()
229
+ response = await authenticator.on_authentication_failure(request, masked)
230
+ if response is not None:
231
+ return response
232
+ raise masked
@@ -0,0 +1,40 @@
1
+ """What drives a firewall's authenticators over one request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
6
+
7
+ if TYPE_CHECKING:
8
+ from starlette.requests import Request
9
+ from starlette.responses import Response
10
+
11
+ __all__ = ["AuthenticatorManagerInterface"]
12
+
13
+
14
+ @runtime_checkable
15
+ class AuthenticatorManagerInterface(Protocol):
16
+ """Runs a firewall's authenticators over a request, settling on a token or a failure.
17
+
18
+ Asks each authenticator whether it supports the request, authenticates
19
+ through the first that does, checks the passport, creates and stores the
20
+ token, and dispatches the events around each step. It answers with a
21
+ response only when a success or failure handler produced one; otherwise the
22
+ request goes on with its token set on the storage.
23
+ """
24
+
25
+ def supports(self, request: Request) -> bool | None:
26
+ """Tell whether any authenticator handles ``request``.
27
+
28
+ ``None`` means no authenticator is sure yet — a lazy one that would try
29
+ only if a credential were present.
30
+ """
31
+ ...
32
+
33
+ async def authenticate_request(self, request: Request) -> Response | None:
34
+ """Authenticate ``request``, returning a handler's response if one answered.
35
+
36
+ Raises:
37
+ AuthenticationError: When authentication fails and no failure
38
+ handler answered it — the firewall turns it into a challenge.
39
+ """
40
+ ...
@@ -0,0 +1,27 @@
1
+ """How much of an authentication failure a client is allowed to learn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ __all__ = ["ExposeSecurityLevel"]
8
+
9
+
10
+ class ExposeSecurityLevel(Enum):
11
+ """How much of the real reason an authentication failure may reveal.
12
+
13
+ Telling a client precisely why authentication failed helps an attacker:
14
+ that a user exists, or that an account is merely disabled rather than
15
+ unknown, is a way to probe. This level decides how much leaks out; anything
16
+ hidden is reported to the client as plain bad credentials.
17
+
18
+ - ``NONE`` — reveal nothing: unknown users and account-status failures
19
+ alike become bad credentials. The safe default.
20
+ - ``ACCOUNT_STATUS`` — reveal that an account is disabled, locked or
21
+ expired, but still hide whether a user exists.
22
+ - ``ALL`` — reveal every failure as it happened. For development.
23
+ """
24
+
25
+ NONE = "none"
26
+ ACCOUNT_STATUS = "account_status"
27
+ ALL = "all"
@@ -0,0 +1,9 @@
1
+ """The authenticators a firewall runs, and the passports they produce."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .abstract_authenticator import AbstractAuthenticator
6
+ from .access_token_authenticator import AccessTokenAuthenticator
7
+ from .authenticator_interface import AuthenticatorInterface
8
+
9
+ __all__ = ["AbstractAuthenticator", "AccessTokenAuthenticator", "AuthenticatorInterface"]
@@ -0,0 +1,66 @@
1
+ """A starting point for an authenticator: the token, and no-op hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from typing_extensions import override
8
+
9
+ from xtr_security_http.authenticator.token.post_authentication_token import PostAuthenticationToken
10
+
11
+ from .authenticator_interface import AuthenticatorInterface
12
+
13
+ if TYPE_CHECKING:
14
+ from starlette.requests import Request
15
+ from starlette.responses import Response
16
+ from xtr_security_core.authentication.token.token_interface import TokenInterface
17
+ from xtr_security_core.exception import AuthenticationError
18
+
19
+ from .passport.passport import Passport
20
+
21
+ __all__ = ["AbstractAuthenticator"]
22
+
23
+
24
+ class AbstractAuthenticator( # pyright: ignore[reportImplicitAbstractClass] -- subclasses implement supports/authenticate
25
+ AuthenticatorInterface,
26
+ ):
27
+ """The parts of an authenticator every kind shares.
28
+
29
+ Turns a resolved passport into a
30
+ :class:`~xtr_security_http.authenticator.token.post_authentication_token.PostAuthenticationToken`
31
+ carrying the user and its roles, and answers both ``on_authentication_*``
32
+ hooks with ``None`` — letting the request go on. A concrete authenticator
33
+ overrides :meth:`supports` and :meth:`authenticate`, and either hook when
34
+ it wants to answer the request itself.
35
+ """
36
+
37
+ @override
38
+ async def create_token(self, passport: Passport, firewall_name: str) -> TokenInterface:
39
+ """Build a post-authentication token for the passport's user and roles.
40
+
41
+ The user is read from the passport's user badge, which the passport
42
+ check already loaded, so this call does no I/O.
43
+ """
44
+ user = passport.get_user_badge().get_loaded_user()
45
+ return PostAuthenticationToken(user, firewall_name, user.get_roles())
46
+
47
+ @override
48
+ async def on_authentication_success(
49
+ self,
50
+ request: Request,
51
+ token: TokenInterface,
52
+ firewall_name: str,
53
+ ) -> Response | None:
54
+ """Answer nothing: the request goes on to its endpoint."""
55
+ del request, token, firewall_name
56
+ return None
57
+
58
+ @override
59
+ async def on_authentication_failure(
60
+ self,
61
+ request: Request,
62
+ error: AuthenticationError,
63
+ ) -> Response | None:
64
+ """Answer nothing: the failure is turned into a challenge elsewhere."""
65
+ del request, error
66
+ return None
@@ -0,0 +1,199 @@
1
+ """The authenticator that proves a caller by a bearer access token."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, ClassVar, cast, final
6
+
7
+ from starlette.responses import JSONResponse
8
+ from typing_extensions import override
9
+ from xtr_security_core.exception import AuthenticationError, BadCredentialsError
10
+
11
+ from xtr_security_http.authenticator.passport.self_validating_passport import SelfValidatingPassport
12
+ from xtr_security_http.authorization.oauth2_scope_voter import OAuth2ScopeVoter
13
+ from xtr_security_http.entry_point.authentication_entry_point_interface import (
14
+ AuthenticationEntryPointInterface,
15
+ )
16
+ from xtr_security_http.exception.invalid_access_token_error import InvalidAccessTokenError
17
+
18
+ from .abstract_authenticator import AbstractAuthenticator
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Iterable
22
+
23
+ from starlette.requests import Request
24
+ from starlette.responses import Response
25
+ from xtr_security_core.authentication.token.token_interface import TokenInterface
26
+ from xtr_security_core.user.user_interface import UserInterface
27
+ from xtr_security_core.user.user_provider_interface import UserProviderInterface
28
+
29
+ from xtr_security_http.access_token.access_token_extractor_interface import (
30
+ AccessTokenExtractorInterface,
31
+ )
32
+ from xtr_security_http.access_token.access_token_handler_interface import (
33
+ AccessTokenHandlerInterface,
34
+ )
35
+ from xtr_security_http.authentication.authentication_failure_handler_interface import (
36
+ AuthenticationFailureHandlerInterface,
37
+ )
38
+ from xtr_security_http.authentication.authentication_success_handler_interface import (
39
+ AuthenticationSuccessHandlerInterface,
40
+ )
41
+ from xtr_security_http.authenticator.passport.passport import Passport
42
+
43
+ __all__ = ["AccessTokenAuthenticator"]
44
+
45
+
46
+ @final
47
+ class AccessTokenAuthenticator(AbstractAuthenticator, AuthenticationEntryPointInterface):
48
+ """Authenticates a request by a bearer access token, and challenges when it is absent or bad.
49
+
50
+ An extractor reads the token out of the request; a handler validates it and
51
+ returns a user badge. The passport is self-validating — the token already
52
+ proved the user — so no credentials are checked. The badge's ``scope``
53
+ attribute (a list, or a space-separated string) becomes the token's
54
+ ``oauth2_scope`` attribute, the one
55
+ :class:`~xtr_security_http.authorization.oauth2_scope_voter.OAuth2ScopeVoter`
56
+ reads.
57
+
58
+ As the firewall's entry point it also answers an unauthenticated or failed
59
+ request with the RFC 6750 ``WWW-Authenticate: Bearer`` challenge — a bare
60
+ one when no token was sent, ``error="invalid_token"`` when one was rejected.
61
+
62
+ Attributes:
63
+ realm: The protection realm named in the challenge, when set.
64
+ """
65
+
66
+ #: The token attribute the copied scopes are stored under.
67
+ SCOPE_ATTRIBUTE: ClassVar[str] = OAuth2ScopeVoter.SCOPE_ATTRIBUTE
68
+
69
+ #: The badge attribute the handler stores the granted scopes under.
70
+ BADGE_SCOPE_ATTRIBUTE: ClassVar[str] = "scope"
71
+
72
+ __slots__: ClassVar[tuple[str, ...]] = (
73
+ "_extractor",
74
+ "_failure_handler",
75
+ "_handler",
76
+ "_realm",
77
+ "_success_handler",
78
+ "_user_provider",
79
+ )
80
+
81
+ def __init__( # noqa: PLR0913, PLR0917 -- a wiring constructor; the handlers are optional peers
82
+ self,
83
+ handler: AccessTokenHandlerInterface,
84
+ extractor: AccessTokenExtractorInterface,
85
+ user_provider: UserProviderInterface | None = None,
86
+ success_handler: AuthenticationSuccessHandlerInterface | None = None,
87
+ failure_handler: AuthenticationFailureHandlerInterface | None = None,
88
+ realm: str | None = None,
89
+ ) -> None:
90
+ """Record the handler, extractor, optional provider, handlers and realm."""
91
+ self._handler = handler
92
+ self._extractor = extractor
93
+ self._user_provider = user_provider
94
+ self._success_handler = success_handler
95
+ self._failure_handler = failure_handler
96
+ self._realm = realm
97
+
98
+ @override
99
+ def supports(self, request: Request) -> bool | None:
100
+ """Handle the request only when it carries a token this extractor reads.
101
+
102
+ Returns ``None`` — not ``False`` — when no token is present, so the
103
+ firewall leaves the request anonymous rather than failing it; a
104
+ protected route then challenges through the entry point.
105
+ """
106
+ del request
107
+ return None
108
+
109
+ @override
110
+ async def authenticate(self, request: Request) -> Passport:
111
+ """Read and validate the token, and build a self-validating passport.
112
+
113
+ Raises:
114
+ AuthenticationError: When no token is present, or the handler
115
+ rejects the one that is.
116
+ """
117
+ token = await self._extractor.extract_access_token(request)
118
+ if token is None:
119
+ raise BadCredentialsError("No access token was found in the request.")
120
+ badge = await self._handler.get_user_badge_from(token)
121
+ if self._user_provider is not None and badge.get_user_loader() is None:
122
+ provider = self._user_provider
123
+
124
+ async def load(identifier: str) -> UserInterface:
125
+ return await provider.load_user_by_identifier(identifier)
126
+
127
+ badge.set_user_loader(load)
128
+ passport = SelfValidatingPassport(badge)
129
+ scope = badge.get_attributes().get(self.BADGE_SCOPE_ATTRIBUTE)
130
+ if scope is not None:
131
+ passport.set_attribute(self.SCOPE_ATTRIBUTE, scope)
132
+ return passport
133
+
134
+ @override
135
+ async def create_token(self, passport: Passport, firewall_name: str) -> TokenInterface:
136
+ """Build the token and copy the passport's scope onto it as ``oauth2_scope``."""
137
+ token = await super().create_token(passport, firewall_name)
138
+ scope = passport.get_attribute(self.SCOPE_ATTRIBUTE)
139
+ if scope is not None:
140
+ token.set_attribute(self.SCOPE_ATTRIBUTE, self._normalize_scope(scope))
141
+ return token
142
+
143
+ @override
144
+ async def on_authentication_success(
145
+ self,
146
+ request: Request,
147
+ token: TokenInterface,
148
+ firewall_name: str,
149
+ ) -> Response | None:
150
+ """Delegate to the success handler, or let the request go on."""
151
+ if self._success_handler is not None:
152
+ return await self._success_handler.on_authentication_success(
153
+ request,
154
+ token,
155
+ firewall_name,
156
+ )
157
+ return None
158
+
159
+ @override
160
+ async def on_authentication_failure(
161
+ self,
162
+ request: Request,
163
+ error: AuthenticationError,
164
+ ) -> Response | None:
165
+ """Delegate to the failure handler, or let the entry point challenge."""
166
+ if self._failure_handler is not None:
167
+ return await self._failure_handler.on_authentication_failure(request, error)
168
+ return None
169
+
170
+ @override
171
+ async def start(self, request: Request, error: AuthenticationError | None = None) -> Response:
172
+ """Answer an unauthenticated or failed request with an RFC 6750 challenge.
173
+
174
+ A rejected token — an
175
+ :class:`InvalidAccessTokenError` — draws the
176
+ ``error="invalid_token"`` challenge and a matching body; a request that
177
+ carried no token, or one only lacking authentication, draws a bare
178
+ ``Bearer`` challenge.
179
+ """
180
+ del request
181
+ parts = ["Bearer"]
182
+ if self._realm is not None:
183
+ parts.append(f'realm="{self._realm}"')
184
+ body: dict[str, str] = {}
185
+ if isinstance(error, InvalidAccessTokenError):
186
+ parts.append('error="invalid_token"')
187
+ parts.append(f'error_description="{error.get_message_key()}"')
188
+ body = {"error": "invalid_token"}
189
+ challenge = parts[0] + (" " + ", ".join(parts[1:]) if len(parts) > 1 else "")
190
+ return JSONResponse(body, status_code=401, headers={"WWW-Authenticate": challenge})
191
+
192
+ def _normalize_scope(self, scope: object) -> list[str]:
193
+ """Turn a scope list or space-separated string into a list of scopes."""
194
+ if isinstance(scope, str):
195
+ return scope.split()
196
+ if isinstance(scope, (list, tuple, set, frozenset)):
197
+ items: Iterable[object] = cast("Iterable[object]", scope)
198
+ return [str(one) for one in items]
199
+ return [str(scope)]
@@ -0,0 +1,68 @@
1
+ """What an authenticator — one way of proving who is calling — answers to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
6
+
7
+ if TYPE_CHECKING:
8
+ from starlette.requests import Request
9
+ from starlette.responses import Response
10
+ from xtr_security_core.authentication.token.token_interface import TokenInterface
11
+ from xtr_security_core.exception import AuthenticationError
12
+
13
+ from .passport.passport import Passport
14
+
15
+ __all__ = ["AuthenticatorInterface"]
16
+
17
+
18
+ @runtime_checkable
19
+ class AuthenticatorInterface(Protocol):
20
+ """One way of authenticating a request, from a bearer token to a form post.
21
+
22
+ A firewall runs its authenticators in order. Each is asked whether it
23
+ :meth:`supports` the request; the first that does authenticates it,
24
+ producing a passport the manager checks and turns into a token. The two
25
+ ``on_authentication_*`` hooks let an authenticator answer the request
26
+ itself — a redirect on success, a challenge on failure — rather than
27
+ letting the request go on to its endpoint.
28
+ """
29
+
30
+ def supports(self, request: Request) -> bool | None:
31
+ """Tell whether this authenticator handles ``request``.
32
+
33
+ ``True`` authenticates it and stops the others; ``False`` skips it;
34
+ ``None`` authenticates it but lets a later authenticator try too if
35
+ this one produces no token — a lazy authenticator with no credential
36
+ present in the request.
37
+ """
38
+ ...
39
+
40
+ async def authenticate(self, request: Request) -> Passport:
41
+ """Read ``request`` into a passport, before any badge is resolved.
42
+
43
+ Raises:
44
+ AuthenticationError: When the request cannot even be read into a
45
+ passport — a malformed token, a missing field.
46
+ """
47
+ ...
48
+
49
+ async def create_token(self, passport: Passport, firewall_name: str) -> TokenInterface:
50
+ """Turn a resolved ``passport`` into the token authentication settles on."""
51
+ ...
52
+
53
+ async def on_authentication_success(
54
+ self,
55
+ request: Request,
56
+ token: TokenInterface,
57
+ firewall_name: str,
58
+ ) -> Response | None:
59
+ """React to a successful authentication, optionally answering the request."""
60
+ ...
61
+
62
+ async def on_authentication_failure(
63
+ self,
64
+ request: Request,
65
+ error: AuthenticationError,
66
+ ) -> Response | None:
67
+ """React to a failed authentication, optionally answering the request."""
68
+ ...
@@ -0,0 +1,7 @@
1
+ """The OIDC key material a firewall's access-token authenticator verifies with."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .oidc_jwks import OidcKeySetProviderInterface, StaticOidcKeySetProvider
6
+
7
+ __all__ = ["OidcKeySetProviderInterface", "StaticOidcKeySetProvider"]