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,80 @@
1
+ """An extractor that reads a bearer token from the Authorization header."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from fastapi.security import APIKeyHeader, HTTPBearer
8
+ from typing_extensions import override
9
+
10
+ from .access_token_extractor_interface import AccessTokenExtractorInterface
11
+
12
+ if TYPE_CHECKING:
13
+ from fastapi.security.base import SecurityBase
14
+ from starlette.requests import Request
15
+
16
+ __all__ = ["HeaderAccessTokenExtractor"]
17
+
18
+
19
+ @final
20
+ class HeaderAccessTokenExtractor(AccessTokenExtractorInterface):
21
+ """Reads a token from a request header, ``Authorization: Bearer <token>`` by default.
22
+
23
+ Built on FastAPI's own :class:`~fastapi.security.HTTPBearer` with
24
+ ``auto_error=False``, so a missing or malformed header reads as ``None``
25
+ and the firewall's entry point — not FastAPI — answers the challenge. A
26
+ different header name or token type folds the extraction onto an
27
+ :class:`~fastapi.security.APIKeyHeader` instead, for a scheme that is not
28
+ the standard bearer one.
29
+
30
+ Attributes:
31
+ header_name: The header the token is read from.
32
+ token_type: The scheme word before the token (``"Bearer"`` by default).
33
+ """
34
+
35
+ __slots__ = ("_api_key", "_bearer", "_header_name", "_token_type")
36
+
37
+ def __init__(
38
+ self,
39
+ header_name: str = "Authorization",
40
+ token_type: str = "Bearer", # noqa: S107 -- a scheme word, not a secret
41
+ ) -> None:
42
+ """Build the extractor for ``header_name`` carrying a ``token_type`` token."""
43
+ self._header_name = header_name
44
+ self._token_type = token_type
45
+ standard = header_name.lower() == "authorization" and token_type.lower() == "bearer"
46
+ self._bearer = (
47
+ HTTPBearer(auto_error=False, bearerFormat="JWT", scheme_name="Bearer")
48
+ if standard
49
+ else None
50
+ )
51
+ self._api_key = (
52
+ None
53
+ if standard
54
+ else APIKeyHeader(name=header_name, auto_error=False, scheme_name=header_name)
55
+ )
56
+
57
+ @override
58
+ async def extract_access_token(self, request: Request) -> str | None:
59
+ """Return the token in the header, or ``None`` when it is absent or malformed."""
60
+ if self._bearer is not None:
61
+ credentials = await self._bearer(request)
62
+ return credentials.credentials if credentials is not None else None
63
+ assert self._api_key is not None # noqa: S101 -- one of the two is always set
64
+ value = await self._api_key(request)
65
+ if value is None:
66
+ return None
67
+ prefix = f"{self._token_type} "
68
+ if self._token_type and value.startswith(prefix):
69
+ return value[len(prefix) :]
70
+ return value if not self._token_type else None
71
+
72
+ @override
73
+ def scheme(self) -> SecurityBase:
74
+ """Return the bearer or API-key scheme this extractor reads and documents by."""
75
+ return self._bearer if self._bearer is not None else self._require_api_key()
76
+
77
+ def _require_api_key(self) -> APIKeyHeader:
78
+ """Return the API-key scheme, asserting it was built."""
79
+ assert self._api_key is not None # noqa: S101 -- built whenever the bearer is not
80
+ return self._api_key
@@ -0,0 +1,41 @@
1
+ """Verifying bearer tokens from third-party OIDC issuers (needs the ``oidc`` extra).
2
+
3
+ Importing this subpackage requires joserfc and httpx, brought by the ``oidc``
4
+ extra of xtr-security-http. Importing :mod:`xtr_security_http` itself pulls in
5
+ neither; only reaching in here does, so an application that never verifies OIDC
6
+ tokens carries no JOSE or HTTP-client dependency.
7
+
8
+ The key material lives with the authenticator and discovery it serves —
9
+ :mod:`xtr_security_http.authenticator.oidc` and :mod:`xtr_security_http.oidc` —
10
+ and is re-exported here so the whole OIDC surface is reachable from one place.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ try:
16
+ import httpx as _httpx
17
+ import joserfc as _joserfc
18
+ except ImportError as _error: # pragma: no cover -- exercised in a subprocess without the extra
19
+ raise ImportError(
20
+ "The OIDC access-token handler needs joserfc and httpx; install the 'oidc' extra: "
21
+ 'uv add "xtr-security-http[oidc]".',
22
+ ) from _error
23
+ else:
24
+ del _httpx, _joserfc
25
+
26
+ from xtr_security_http.authenticator.oidc.oidc_jwks import (
27
+ OidcKeySetProviderInterface,
28
+ StaticOidcKeySetProvider,
29
+ )
30
+ from xtr_security_http.oidc.oidc_discovery import DiscoveryOidcKeySetProvider
31
+
32
+ from .exception.oidc_key_set_error import OidcKeySetError
33
+ from .oidc_token_handler import OidcTokenHandler
34
+
35
+ __all__ = [
36
+ "DiscoveryOidcKeySetProvider",
37
+ "OidcKeySetError",
38
+ "OidcKeySetProviderInterface",
39
+ "OidcTokenHandler",
40
+ "StaticOidcKeySetProvider",
41
+ ]
@@ -0,0 +1,7 @@
1
+ """OIDC access-token errors: the keys to verify a token could not be obtained."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .oidc_key_set_error import OidcKeySetError
6
+
7
+ __all__ = ["OidcKeySetError"]
@@ -0,0 +1,18 @@
1
+ """The keys that verify an OIDC token could not be obtained."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from xtr_security_core.exception import SecurityError
6
+
7
+ __all__ = ["OidcKeySetError"]
8
+
9
+
10
+ class OidcKeySetError(SecurityError):
11
+ """The verifying key set could not be fetched, discovered or read.
12
+
13
+ Raised when a discovery document or a JWKS endpoint cannot be reached, an
14
+ answer is not the JSON a key set is read from, or a discovered endpoint is
15
+ not the ``https`` a key set is trusted to come over. The token handler turns
16
+ it into an :class:`~xtr_security_http.exception.InvalidAccessTokenError`,
17
+ since a token that cannot be verified cannot be trusted.
18
+ """
@@ -0,0 +1,246 @@
1
+ """Verifies a bearer token from a third-party OIDC issuer into a user badge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast, final
6
+
7
+ from joserfc import jwt
8
+ from joserfc.errors import InvalidKeyIdError, JoseError
9
+ from joserfc.jwt import JWTClaimsRegistry
10
+ from xtr_security_core.exception import InvalidArgumentError
11
+ from xtr_security_core.user.attributes_based_user_provider_interface import (
12
+ AttributesBasedUserProviderInterface,
13
+ )
14
+ from xtr_security_core.user.oidc_user import OidcUser
15
+
16
+ from xtr_security_http.authenticator.passport.badge.user_badge import UserBadge
17
+ from xtr_security_http.exception.invalid_access_token_error import InvalidAccessTokenError
18
+
19
+ from .exception.oidc_key_set_error import OidcKeySetError
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
23
+
24
+ from joserfc.jwt import ClaimsOption, Token
25
+ from xtr_security_core.user.user_interface import UserInterface
26
+ from xtr_security_core.user.user_provider_interface import UserProviderInterface
27
+
28
+ from xtr_security_http.authenticator.oidc.oidc_jwks import OidcKeySetProviderInterface
29
+
30
+ UserLoader = Callable[[str], UserInterface | Awaitable[UserInterface]]
31
+
32
+ __all__ = ["OidcTokenHandler"]
33
+
34
+ _AT_JWT_TYPES = frozenset({"at+jwt", "application/at+jwt"})
35
+
36
+
37
+ @final
38
+ class OidcTokenHandler:
39
+ """Turns a third-party OIDC access token into the badge naming its user.
40
+
41
+ Verifies a token issued by an OIDC provider — not one this application
42
+ signs: its signature against the provider's keys, its ``iss`` among the
43
+ trusted issuers, its ``aud`` against the expected audience, and its
44
+ ``exp`` / ``nbf`` / ``iat`` against the clock with a leeway. An ``exp`` is
45
+ required — a token that never expires is refused. The algorithms
46
+ are an explicit allow-list refusing ``none`` and any symmetric ``HS*`` (a
47
+ third-party token is signed with the issuer's private key, verified with its
48
+ public one). When ``enforce_at_jwt_type`` is set the header's ``typ`` must be
49
+ ``at+jwt`` (RFC 9068).
50
+
51
+ The identifier is read from ``claim`` (``sub`` by default). With no provider
52
+ the claims are the user — an
53
+ :class:`~xtr_security_core.user.oidc_user.OidcUser`; with one, its
54
+ identifier loads the user, the claims handed alongside to a provider that
55
+ reads them. The badge carries the granted ``scope`` (from the ``scope``
56
+ string or the ``scp`` list), the ``client_id``, the ``jti`` and every claim.
57
+ Every failure becomes an
58
+ :class:`~xtr_security_http.exception.InvalidAccessTokenError`.
59
+ """
60
+
61
+ __slots__ = (
62
+ "_algorithms",
63
+ "_audience",
64
+ "_claim",
65
+ "_clock",
66
+ "_enforce_at_jwt_type",
67
+ "_issuers",
68
+ "_key_set_provider",
69
+ "_leeway",
70
+ "_user_provider",
71
+ )
72
+
73
+ def __init__( # noqa: PLR0913 -- a wiring constructor; the verification knobs default
74
+ self,
75
+ key_set_provider: OidcKeySetProviderInterface,
76
+ *,
77
+ issuers: Sequence[str],
78
+ audience: str,
79
+ algorithms: Sequence[str] = ("RS256",),
80
+ claim: str = "sub",
81
+ leeway: int = 0,
82
+ enforce_at_jwt_type: bool = False,
83
+ clock: Callable[[], int],
84
+ user_provider: UserProviderInterface | None = None,
85
+ ) -> None:
86
+ """Record the provider, the trusted issuers, the audience and the knobs.
87
+
88
+ Raises:
89
+ InvalidArgumentError: When no issuer is given, or the algorithm
90
+ allow-list is empty or holds ``none`` or a symmetric ``HS*``.
91
+ """
92
+ algorithm_list = tuple(algorithms)
93
+ _check_algorithms(algorithm_list)
94
+ if not issuers:
95
+ raise InvalidArgumentError("An OIDC token handler needs at least one trusted issuer.")
96
+ self._key_set_provider = key_set_provider
97
+ self._issuers = tuple(issuers)
98
+ self._audience = audience
99
+ self._algorithms = algorithm_list
100
+ self._claim = claim
101
+ self._leeway = leeway
102
+ self._enforce_at_jwt_type = enforce_at_jwt_type
103
+ self._clock = clock
104
+ self._user_provider = user_provider
105
+
106
+ async def get_user_badge_from(self, access_token: str) -> UserBadge:
107
+ """Verify ``access_token`` and return the badge naming its user.
108
+
109
+ Raises:
110
+ InvalidAccessTokenError: When the token is malformed, wrongly
111
+ signed, from an untrusted issuer, for another audience, expired,
112
+ not yet valid, of the wrong ``typ``, or missing its identifier.
113
+ """
114
+ decoded = await self._decode(access_token)
115
+ claims = cast("Mapping[str, object]", decoded.claims)
116
+ self._validate(decoded, claims)
117
+ return self._badge_for(claims)
118
+
119
+ async def _decode(self, access_token: str) -> Token:
120
+ """Verify the signature, refetching the key set once on an unknown ``kid``.
121
+
122
+ Raises:
123
+ InvalidAccessTokenError: When the token cannot be verified.
124
+ """
125
+ try:
126
+ key_set = await self._key_set_provider.get_key_set()
127
+ return jwt.decode(access_token, key_set, algorithms=list(self._algorithms))
128
+ except InvalidKeyIdError as error:
129
+ return await self._decode_after_refresh(access_token, error)
130
+ except (JoseError, OidcKeySetError, ValueError) as error:
131
+ raise InvalidAccessTokenError(f"The token could not be verified: {error}") from error
132
+
133
+ async def _decode_after_refresh(self, access_token: str, cause: InvalidKeyIdError) -> Token:
134
+ """Refetch the key set once for a rotated key, then verify again.
135
+
136
+ Raises:
137
+ InvalidAccessTokenError: When the token still cannot be verified.
138
+ """
139
+ try:
140
+ key_set = await self._key_set_provider.get_key_set(force_refresh=True)
141
+ return jwt.decode(access_token, key_set, algorithms=list(self._algorithms))
142
+ except (JoseError, OidcKeySetError, ValueError) as error:
143
+ raise InvalidAccessTokenError(
144
+ f"The token names an unknown key: {cause}",
145
+ ) from error
146
+
147
+ def _validate(self, decoded: Token, claims: Mapping[str, object]) -> None:
148
+ """Check the ``typ`` header and the registered claims.
149
+
150
+ Raises:
151
+ InvalidAccessTokenError: When the type or a claim does not hold.
152
+ """
153
+ if self._enforce_at_jwt_type:
154
+ typ = decoded.header.get("typ")
155
+ if not isinstance(typ, str) or typ.lower() not in _AT_JWT_TYPES:
156
+ raise InvalidAccessTokenError("The token is not an at+jwt access token.")
157
+ options: dict[str, ClaimsOption] = {
158
+ "iss": {"essential": True, "values": list(self._issuers)},
159
+ "aud": {"essential": True, "value": self._audience},
160
+ "exp": {"essential": True},
161
+ self._claim: {"essential": True},
162
+ }
163
+ registry = JWTClaimsRegistry(now=self._clock(), leeway=self._leeway, **options)
164
+ try:
165
+ registry.validate(dict(claims))
166
+ except (JoseError, ValueError) as error:
167
+ raise InvalidAccessTokenError(f"The token's claims are not valid: {error}") from error
168
+
169
+ def _badge_for(self, claims: Mapping[str, object]) -> UserBadge:
170
+ """Build the badge for a verified token's claims.
171
+
172
+ Raises:
173
+ InvalidAccessTokenError: When the identifier claim is missing.
174
+ """
175
+ identifier = claims.get(self._claim)
176
+ if not isinstance(identifier, str) or not identifier:
177
+ raise InvalidAccessTokenError(
178
+ f'The token carries no usable "{self._claim}" claim.',
179
+ )
180
+ attributes: dict[str, object] = {
181
+ "scope": _scopes(claims),
182
+ "client_id": claims.get("client_id"),
183
+ "jti": claims.get("jti"),
184
+ "claims": dict(claims),
185
+ }
186
+ return UserBadge(identifier, user_loader=self._loader(claims), attributes=attributes)
187
+
188
+ def _loader(self, claims: Mapping[str, object]) -> UserLoader:
189
+ """Return the loader that turns the identifier into a user.
190
+
191
+ With no provider the claims are the user; with an attributes-based one
192
+ the claims travel to it; with a plain one only the identifier does.
193
+ """
194
+ provider = self._user_provider
195
+ claim = self._claim
196
+ if provider is None:
197
+
198
+ def load_from_claims(identifier: str) -> UserInterface:
199
+ del identifier
200
+ return OidcUser(claims, identifier_claim=claim)
201
+
202
+ return load_from_claims
203
+ if AttributesBasedUserProviderInterface in type(provider).__mro__:
204
+ attributed = cast("AttributesBasedUserProviderInterface", provider)
205
+
206
+ async def load_with_attributes(identifier: str) -> UserInterface:
207
+ return await attributed.load_user_by_identifier(identifier, claims)
208
+
209
+ return load_with_attributes
210
+
211
+ plain = provider
212
+
213
+ async def load_by_identifier(identifier: str) -> UserInterface:
214
+ return await plain.load_user_by_identifier(identifier)
215
+
216
+ return load_by_identifier
217
+
218
+
219
+ def _check_algorithms(algorithms: tuple[str, ...]) -> None:
220
+ """Refuse an empty allow-list, ``none`` or a symmetric ``HS*``.
221
+
222
+ Raises:
223
+ InvalidArgumentError: When the allow-list is empty or holds a refused
224
+ algorithm.
225
+ """
226
+ if not algorithms:
227
+ raise InvalidArgumentError("An OIDC token handler needs at least one allowed algorithm.")
228
+ for algorithm in algorithms:
229
+ lowered = algorithm.lower()
230
+ if lowered == "none":
231
+ raise InvalidArgumentError('The algorithm "none" is never allowed.')
232
+ if lowered.startswith("hs"):
233
+ raise InvalidArgumentError(
234
+ f"The symmetric algorithm {algorithm!r} is not allowed for third-party tokens.",
235
+ )
236
+
237
+
238
+ def _scopes(claims: Mapping[str, object]) -> list[str]:
239
+ """Read the granted scopes from the ``scope`` string or the ``scp`` list."""
240
+ scope = claims.get("scope")
241
+ if isinstance(scope, str):
242
+ return scope.split()
243
+ scp = claims.get("scp")
244
+ if isinstance(scp, (list, tuple)):
245
+ return [str(one) for one in cast("Sequence[object]", scp)]
246
+ return []
@@ -0,0 +1,51 @@
1
+ """An extractor that reads a bearer token from a query parameter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from fastapi.security import APIKeyQuery
8
+ from typing_extensions import override
9
+
10
+ from .access_token_extractor_interface import AccessTokenExtractorInterface
11
+
12
+ if TYPE_CHECKING:
13
+ from fastapi.security.base import SecurityBase
14
+ from starlette.requests import Request
15
+
16
+ __all__ = ["QueryAccessTokenExtractor"]
17
+
18
+
19
+ @final
20
+ class QueryAccessTokenExtractor(AccessTokenExtractorInterface):
21
+ """Reads a token from a query parameter, ``access_token`` by default.
22
+
23
+ Built on FastAPI's :class:`~fastapi.security.APIKeyQuery` with
24
+ ``auto_error=False``, so a request without the parameter reads as ``None``.
25
+ Passing a bearer token in the URL is discouraged by RFC 6750, but some
26
+ clients can send it no other way; the extractor exists for them.
27
+
28
+ Attributes:
29
+ parameter_name: The query parameter the token is read from.
30
+ """
31
+
32
+ __slots__ = ("_parameter_name", "_query")
33
+
34
+ def __init__(self, parameter_name: str = "access_token") -> None:
35
+ """Build the extractor for the ``parameter_name`` query parameter."""
36
+ self._parameter_name = parameter_name
37
+ self._query = APIKeyQuery(
38
+ name=parameter_name,
39
+ auto_error=False,
40
+ scheme_name=parameter_name,
41
+ )
42
+
43
+ @override
44
+ async def extract_access_token(self, request: Request) -> str | None:
45
+ """Return the token in the query parameter, or ``None`` when it is absent."""
46
+ return await self._query(request)
47
+
48
+ @override
49
+ def scheme(self) -> SecurityBase:
50
+ """Return the query-parameter scheme this extractor reads and documents by."""
51
+ return self._query
@@ -0,0 +1,24 @@
1
+ """Running a firewall's authenticators, and the handlers around a login.
2
+
3
+ Also the security-level policy that decides which authentication failures are
4
+ hidden from the client, and the helpers that apply it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._sensitive import is_sensitive, mask
10
+ from .authentication_failure_handler_interface import AuthenticationFailureHandlerInterface
11
+ from .authentication_success_handler_interface import AuthenticationSuccessHandlerInterface
12
+ from .authenticator_manager import AuthenticatorManager
13
+ from .authenticator_manager_interface import AuthenticatorManagerInterface
14
+ from .expose_security_level import ExposeSecurityLevel
15
+
16
+ __all__ = [
17
+ "AuthenticationFailureHandlerInterface",
18
+ "AuthenticationSuccessHandlerInterface",
19
+ "AuthenticatorManager",
20
+ "AuthenticatorManagerInterface",
21
+ "ExposeSecurityLevel",
22
+ "is_sensitive",
23
+ "mask",
24
+ ]
@@ -0,0 +1,60 @@
1
+ """Deciding which authentication failures to hide, and hiding them.
2
+
3
+ Some failures name a fact worth hiding: that a user exists, or that an account
4
+ is only disabled. Whether such a failure reaches the client as itself or as a
5
+ plain rejection is set by an :class:`ExposeSecurityLevel`. These two helpers
6
+ are the whole of that rule — one reads it, the other applies it — and they are
7
+ private to the package: callers reach them through the re-exports on
8
+ :mod:`xtr_security_http.authentication`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING
14
+
15
+ from xtr_security_core.exception import (
16
+ AccountStatusError,
17
+ BadCredentialsError,
18
+ CustomUserMessageAccountStatusError,
19
+ UserNotFoundError,
20
+ )
21
+
22
+ from .expose_security_level import ExposeSecurityLevel
23
+
24
+ if TYPE_CHECKING:
25
+ from xtr_security_core.exception import AuthenticationError
26
+
27
+ __all__ = ["is_sensitive", "mask"]
28
+
29
+
30
+ def is_sensitive(error: AuthenticationError, level: ExposeSecurityLevel) -> bool:
31
+ """Tell whether ``error`` should be hidden from the client at ``level``.
32
+
33
+ An account-status failure that carries its own client-facing message is
34
+ never sensitive: it was raised to be shown. Otherwise, at ``ALL`` nothing
35
+ is hidden; at ``ACCOUNT_STATUS`` a disabled or locked account is revealed
36
+ but an unknown user is not; at ``NONE`` both are hidden.
37
+ """
38
+ if isinstance(error, CustomUserMessageAccountStatusError):
39
+ return False
40
+ if level is ExposeSecurityLevel.ALL:
41
+ return False
42
+ if isinstance(error, AccountStatusError):
43
+ return level is ExposeSecurityLevel.NONE
44
+ return isinstance(error, UserNotFoundError)
45
+
46
+
47
+ def mask(error: AuthenticationError, level: ExposeSecurityLevel) -> AuthenticationError:
48
+ """Return ``error``, or a bad-credentials error hiding it, at ``level``.
49
+
50
+ A hidden failure becomes a
51
+ :class:`~xtr_security_core.exception.BadCredentialsError` chained to the real
52
+ one, so the client learns only that its credentials failed while the cause
53
+ survives on ``__cause__`` for the log.
54
+ """
55
+ if not is_sensitive(error, level):
56
+ return error
57
+ masked = BadCredentialsError()
58
+ masked.__cause__ = error
59
+ masked.__suppress_context__ = True
60
+ return masked
@@ -0,0 +1,31 @@
1
+ """What answers a request once its authentication fails."""
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.exception import AuthenticationError
11
+
12
+ __all__ = ["AuthenticationFailureHandlerInterface"]
13
+
14
+
15
+ @runtime_checkable
16
+ class AuthenticationFailureHandlerInterface(Protocol):
17
+ """Turns a failed authentication into a response, or leaves it to the entry point.
18
+
19
+ An authenticator delegates its failure reaction here: a handler may answer
20
+ the request itself — a challenge, a redirect back to a form — or return
21
+ ``None`` to let the failure carry on and be turned into a response by the
22
+ firewall's entry point.
23
+ """
24
+
25
+ async def on_authentication_failure(
26
+ self,
27
+ request: Request,
28
+ error: AuthenticationError,
29
+ ) -> Response | None:
30
+ """Answer ``request`` for the failure ``error``, or return ``None``."""
31
+ ...
@@ -0,0 +1,31 @@
1
+ """What answers a request once its authentication succeeds."""
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
+
12
+ __all__ = ["AuthenticationSuccessHandlerInterface"]
13
+
14
+
15
+ @runtime_checkable
16
+ class AuthenticationSuccessHandlerInterface(Protocol):
17
+ """Turns a successful authentication into a response, or lets the request go on.
18
+
19
+ An authenticator delegates its success reaction here: a handler may answer
20
+ the request itself — a redirect after a form login — or return ``None`` to
21
+ let the request reach its endpoint with the token now set.
22
+ """
23
+
24
+ async def on_authentication_success(
25
+ self,
26
+ request: Request,
27
+ token: TokenInterface,
28
+ firewall_name: str,
29
+ ) -> Response | None:
30
+ """Answer ``request`` for the authenticated ``token``, or return ``None``."""
31
+ ...