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.
- xtr_security_http/__init__.py +143 -0
- xtr_security_http/_runner.py +134 -0
- xtr_security_http/_state.py +57 -0
- xtr_security_http/access_map.py +52 -0
- xtr_security_http/access_map_interface.py +24 -0
- xtr_security_http/access_token/__init__.py +19 -0
- xtr_security_http/access_token/access_token_extractor_interface.py +31 -0
- xtr_security_http/access_token/access_token_handler_interface.py +34 -0
- xtr_security_http/access_token/chain_access_token_extractor.py +58 -0
- xtr_security_http/access_token/form_encoded_body_extractor.py +57 -0
- xtr_security_http/access_token/header_access_token_extractor.py +80 -0
- xtr_security_http/access_token/oidc/__init__.py +41 -0
- xtr_security_http/access_token/oidc/exception/__init__.py +7 -0
- xtr_security_http/access_token/oidc/exception/oidc_key_set_error.py +18 -0
- xtr_security_http/access_token/oidc/oidc_token_handler.py +246 -0
- xtr_security_http/access_token/query_access_token_extractor.py +51 -0
- xtr_security_http/authentication/__init__.py +24 -0
- xtr_security_http/authentication/_sensitive.py +60 -0
- xtr_security_http/authentication/authentication_failure_handler_interface.py +31 -0
- xtr_security_http/authentication/authentication_success_handler_interface.py +31 -0
- xtr_security_http/authentication/authenticator_manager.py +232 -0
- xtr_security_http/authentication/authenticator_manager_interface.py +40 -0
- xtr_security_http/authentication/expose_security_level.py +27 -0
- xtr_security_http/authenticator/__init__.py +9 -0
- xtr_security_http/authenticator/abstract_authenticator.py +66 -0
- xtr_security_http/authenticator/access_token_authenticator.py +199 -0
- xtr_security_http/authenticator/authenticator_interface.py +68 -0
- xtr_security_http/authenticator/oidc/__init__.py +7 -0
- xtr_security_http/authenticator/oidc/oidc_jwks.py +87 -0
- xtr_security_http/authenticator/passport/__init__.py +8 -0
- xtr_security_http/authenticator/passport/badge/__init__.py +16 -0
- xtr_security_http/authenticator/passport/badge/badge_interface.py +24 -0
- xtr_security_http/authenticator/passport/badge/password_upgrade_badge.py +59 -0
- xtr_security_http/authenticator/passport/badge/pre_authenticated_user_badge.py +27 -0
- xtr_security_http/authenticator/passport/badge/user_badge.py +163 -0
- xtr_security_http/authenticator/passport/credentials/__init__.py +9 -0
- xtr_security_http/authenticator/passport/credentials/credentials_interface.py +21 -0
- xtr_security_http/authenticator/passport/credentials/custom_credentials.py +64 -0
- xtr_security_http/authenticator/passport/credentials/password_credentials.py +51 -0
- xtr_security_http/authenticator/passport/passport.py +104 -0
- xtr_security_http/authenticator/passport/self_validating_passport.py +42 -0
- xtr_security_http/authenticator/token/__init__.py +7 -0
- xtr_security_http/authenticator/token/post_authentication_token.py +43 -0
- xtr_security_http/authorization/__init__.py +15 -0
- xtr_security_http/authorization/access_denied_handler_interface.py +27 -0
- xtr_security_http/authorization/insufficient_scope_access_denied_handler.py +66 -0
- xtr_security_http/authorization/oauth2_scope_voter.py +122 -0
- xtr_security_http/decorator/__init__.py +16 -0
- xtr_security_http/decorator/current_user.py +83 -0
- xtr_security_http/decorator/is_granted.py +199 -0
- xtr_security_http/decorator/is_granted_context.py +14 -0
- xtr_security_http/entry_point/__init__.py +7 -0
- xtr_security_http/entry_point/authentication_entry_point_interface.py +33 -0
- xtr_security_http/event/__init__.py +20 -0
- xtr_security_http/event/authentication_token_created_event.py +48 -0
- xtr_security_http/event/check_passport_event.py +48 -0
- xtr_security_http/event/login_failure_event.py +79 -0
- xtr_security_http/event/login_success_event.py +79 -0
- xtr_security_http/event_listener/__init__.py +21 -0
- xtr_security_http/event_listener/check_credentials_listener.py +162 -0
- xtr_security_http/event_listener/password_migrating_listener.py +79 -0
- xtr_security_http/event_listener/user_checker_listener.py +60 -0
- xtr_security_http/event_listener/user_provider_listener.py +53 -0
- xtr_security_http/exception/__init__.py +18 -0
- xtr_security_http/exception/firewall_not_booted_error.py +18 -0
- xtr_security_http/exception/invalid_access_token_error.py +20 -0
- xtr_security_http/exception/unknown_firewall_error.py +34 -0
- xtr_security_http/firewall/__init__.py +15 -0
- xtr_security_http/firewall/access_listener.py +66 -0
- xtr_security_http/firewall/exception_listener.py +151 -0
- xtr_security_http/firewall/firewall.py +142 -0
- xtr_security_http/firewall_context_interface.py +64 -0
- xtr_security_http/firewall_map.py +93 -0
- xtr_security_http/firewall_map_interface.py +48 -0
- xtr_security_http/firewall_scheme.py +114 -0
- xtr_security_http/firewall_scheme_registry.py +87 -0
- xtr_security_http/oidc/__init__.py +7 -0
- xtr_security_http/oidc/oidc_discovery.py +238 -0
- xtr_security_http/py.typed +0 -0
- xtr_security_http/request_matcher/__init__.py +28 -0
- xtr_security_http/request_matcher/_pattern.py +27 -0
- xtr_security_http/request_matcher/callable_request_matcher.py +37 -0
- xtr_security_http/request_matcher/chain_request_matcher.py +37 -0
- xtr_security_http/request_matcher/host_request_matcher.py +43 -0
- xtr_security_http/request_matcher/ip_request_matcher.py +37 -0
- xtr_security_http/request_matcher/method_request_matcher.py +32 -0
- xtr_security_http/request_matcher/path_request_matcher.py +41 -0
- xtr_security_http/request_matcher/request_matcher_interface.py +25 -0
- xtr_security_http/security_events.py +41 -0
- xtr_security_http-3.0.0.dist-info/METADATA +427 -0
- xtr_security_http-3.0.0.dist-info/RECORD +93 -0
- xtr_security_http-3.0.0.dist-info/WHEEL +4 -0
- 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,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
|
+
...
|