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,143 @@
|
|
|
1
|
+
"""The HTTP edge of xtr security: firewalls, authenticators and access tokens.
|
|
2
|
+
|
|
3
|
+
The :class:`Firewall`, :class:`IsGranted` and :class:`CurrentUser` route
|
|
4
|
+
surface, the authenticator and passport machinery a firewall runs, the bearer
|
|
5
|
+
access-token extractors and the :class:`AccessTokenAuthenticator`, and the
|
|
6
|
+
listeners and events around a login. Built on the security core; needs FastAPI
|
|
7
|
+
and xtr-http-kernel.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .access_map import AccessMap
|
|
13
|
+
from .access_map_interface import AccessMapInterface
|
|
14
|
+
from .access_token import (
|
|
15
|
+
AccessTokenExtractorInterface,
|
|
16
|
+
AccessTokenHandlerInterface,
|
|
17
|
+
ChainAccessTokenExtractor,
|
|
18
|
+
FormEncodedBodyExtractor,
|
|
19
|
+
HeaderAccessTokenExtractor,
|
|
20
|
+
QueryAccessTokenExtractor,
|
|
21
|
+
)
|
|
22
|
+
from .authentication import (
|
|
23
|
+
AuthenticationFailureHandlerInterface,
|
|
24
|
+
AuthenticationSuccessHandlerInterface,
|
|
25
|
+
AuthenticatorManager,
|
|
26
|
+
AuthenticatorManagerInterface,
|
|
27
|
+
ExposeSecurityLevel,
|
|
28
|
+
is_sensitive,
|
|
29
|
+
mask,
|
|
30
|
+
)
|
|
31
|
+
from .authenticator import (
|
|
32
|
+
AbstractAuthenticator,
|
|
33
|
+
AccessTokenAuthenticator,
|
|
34
|
+
AuthenticatorInterface,
|
|
35
|
+
)
|
|
36
|
+
from .authenticator.passport import Passport, SelfValidatingPassport
|
|
37
|
+
from .authenticator.passport.badge import (
|
|
38
|
+
BadgeInterface,
|
|
39
|
+
PasswordUpgradeBadge,
|
|
40
|
+
PreAuthenticatedUserBadge,
|
|
41
|
+
UserBadge,
|
|
42
|
+
)
|
|
43
|
+
from .authenticator.passport.credentials import (
|
|
44
|
+
CredentialsInterface,
|
|
45
|
+
CustomCredentials,
|
|
46
|
+
PasswordCredentials,
|
|
47
|
+
)
|
|
48
|
+
from .authenticator.token import PostAuthenticationToken
|
|
49
|
+
from .authorization import (
|
|
50
|
+
AccessDeniedHandlerInterface,
|
|
51
|
+
InsufficientScopeAccessDeniedHandler,
|
|
52
|
+
OAuth2ScopeVoter,
|
|
53
|
+
oauth2_scope,
|
|
54
|
+
)
|
|
55
|
+
from .decorator.current_user import CurrentUser
|
|
56
|
+
from .decorator.is_granted import IsGranted
|
|
57
|
+
from .decorator.is_granted_context import IsGrantedContext
|
|
58
|
+
from .entry_point import AuthenticationEntryPointInterface
|
|
59
|
+
from .event import (
|
|
60
|
+
AuthenticationTokenCreatedEvent,
|
|
61
|
+
CheckPassportEvent,
|
|
62
|
+
LoginFailureEvent,
|
|
63
|
+
LoginSuccessEvent,
|
|
64
|
+
)
|
|
65
|
+
from .event_listener import (
|
|
66
|
+
CheckCredentialsListener,
|
|
67
|
+
PasswordMigratingListener,
|
|
68
|
+
UserCheckerListener,
|
|
69
|
+
UserProviderListener,
|
|
70
|
+
)
|
|
71
|
+
from .exception import (
|
|
72
|
+
FirewallNotBootedError,
|
|
73
|
+
InvalidAccessTokenError,
|
|
74
|
+
UnknownFirewallError,
|
|
75
|
+
)
|
|
76
|
+
from .firewall import AccessListener, ExceptionListener, Firewall
|
|
77
|
+
from .firewall_context_interface import FirewallContextInterface
|
|
78
|
+
from .firewall_map import FirewallMap
|
|
79
|
+
from .firewall_map_interface import FirewallMapInterface
|
|
80
|
+
from .firewall_scheme import FirewallScheme
|
|
81
|
+
from .firewall_scheme_registry import (
|
|
82
|
+
FirewallSchemeRegistry,
|
|
83
|
+
active_firewall_schemes,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"AbstractAuthenticator",
|
|
88
|
+
"AccessDeniedHandlerInterface",
|
|
89
|
+
"AccessListener",
|
|
90
|
+
"AccessMap",
|
|
91
|
+
"AccessMapInterface",
|
|
92
|
+
"AccessTokenAuthenticator",
|
|
93
|
+
"AccessTokenExtractorInterface",
|
|
94
|
+
"AccessTokenHandlerInterface",
|
|
95
|
+
"AuthenticationEntryPointInterface",
|
|
96
|
+
"AuthenticationFailureHandlerInterface",
|
|
97
|
+
"AuthenticationSuccessHandlerInterface",
|
|
98
|
+
"AuthenticationTokenCreatedEvent",
|
|
99
|
+
"AuthenticatorInterface",
|
|
100
|
+
"AuthenticatorManager",
|
|
101
|
+
"AuthenticatorManagerInterface",
|
|
102
|
+
"BadgeInterface",
|
|
103
|
+
"ChainAccessTokenExtractor",
|
|
104
|
+
"CheckCredentialsListener",
|
|
105
|
+
"CheckPassportEvent",
|
|
106
|
+
"CredentialsInterface",
|
|
107
|
+
"CurrentUser",
|
|
108
|
+
"CustomCredentials",
|
|
109
|
+
"ExceptionListener",
|
|
110
|
+
"ExposeSecurityLevel",
|
|
111
|
+
"Firewall",
|
|
112
|
+
"FirewallContextInterface",
|
|
113
|
+
"FirewallMap",
|
|
114
|
+
"FirewallMapInterface",
|
|
115
|
+
"FirewallNotBootedError",
|
|
116
|
+
"FirewallScheme",
|
|
117
|
+
"FirewallSchemeRegistry",
|
|
118
|
+
"FormEncodedBodyExtractor",
|
|
119
|
+
"HeaderAccessTokenExtractor",
|
|
120
|
+
"InsufficientScopeAccessDeniedHandler",
|
|
121
|
+
"InvalidAccessTokenError",
|
|
122
|
+
"IsGranted",
|
|
123
|
+
"IsGrantedContext",
|
|
124
|
+
"LoginFailureEvent",
|
|
125
|
+
"LoginSuccessEvent",
|
|
126
|
+
"OAuth2ScopeVoter",
|
|
127
|
+
"Passport",
|
|
128
|
+
"PasswordCredentials",
|
|
129
|
+
"PasswordMigratingListener",
|
|
130
|
+
"PasswordUpgradeBadge",
|
|
131
|
+
"PostAuthenticationToken",
|
|
132
|
+
"PreAuthenticatedUserBadge",
|
|
133
|
+
"QueryAccessTokenExtractor",
|
|
134
|
+
"SelfValidatingPassport",
|
|
135
|
+
"UnknownFirewallError",
|
|
136
|
+
"UserBadge",
|
|
137
|
+
"UserCheckerListener",
|
|
138
|
+
"UserProviderListener",
|
|
139
|
+
"active_firewall_schemes",
|
|
140
|
+
"is_sensitive",
|
|
141
|
+
"mask",
|
|
142
|
+
"oauth2_scope",
|
|
143
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""The one pass a firewall makes over a request: authenticate, decide, scope.
|
|
2
|
+
|
|
3
|
+
Shared by the bound and the unbound firewall dependency. Authentication runs
|
|
4
|
+
once per firewall per request — memoised on ``request.state`` — while the
|
|
5
|
+
access and scope decisions run on every call, since two schemes in one request
|
|
6
|
+
carry different scope sets.
|
|
7
|
+
|
|
8
|
+
The firewall map, the token storage and the access-decision manager are handed
|
|
9
|
+
in by the caller — the firewall scheme fills them from the request scope with
|
|
10
|
+
``Injected[...]`` markers — so nothing here reaches into the container.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from xtr_security_core.authentication.token.null_token import NullToken
|
|
18
|
+
from xtr_security_core.authorization.access_decision import AccessDecision
|
|
19
|
+
from xtr_security_core.exception import AccessDeniedError, AuthenticationError
|
|
20
|
+
|
|
21
|
+
from ._state import (
|
|
22
|
+
AUTHENTICATED_FIREWALLS_KEY,
|
|
23
|
+
FIREWALL_CONTEXT_KEY,
|
|
24
|
+
TOKEN_KEY,
|
|
25
|
+
CarriedResponse,
|
|
26
|
+
)
|
|
27
|
+
from .authorization.oauth2_scope_voter import oauth2_scope
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Sequence
|
|
31
|
+
|
|
32
|
+
from starlette.requests import Request
|
|
33
|
+
from xtr_security_core.authentication.token.storage.token_storage_interface import (
|
|
34
|
+
TokenStorageInterface,
|
|
35
|
+
)
|
|
36
|
+
from xtr_security_core.authentication.token.token_interface import TokenInterface
|
|
37
|
+
from xtr_security_core.authorization.access_decision_manager_interface import (
|
|
38
|
+
AccessDecisionManagerInterface,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from xtr_security_http.firewall_context_interface import FirewallContextInterface
|
|
42
|
+
from xtr_security_http.firewall_map_interface import FirewallMapInterface
|
|
43
|
+
|
|
44
|
+
__all__ = ["run_firewall"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def run_firewall( # noqa: PLR0913 -- the firewall's request, scopes and its three injected services
|
|
48
|
+
name: str | None,
|
|
49
|
+
request: Request,
|
|
50
|
+
scopes: Sequence[str],
|
|
51
|
+
*,
|
|
52
|
+
firewall_map: FirewallMapInterface,
|
|
53
|
+
token_storage: TokenStorageInterface,
|
|
54
|
+
access_decision_manager: AccessDecisionManagerInterface,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Run the firewall ``name`` (or the one matching ``request``) over ``request``.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
UnknownFirewallError: When ``name`` names no configured firewall.
|
|
60
|
+
AuthenticationError: When authentication fails and no handler answered.
|
|
61
|
+
AccessDeniedError: When an access rule or a scope is not granted.
|
|
62
|
+
CarriedResponse: When a success or failure handler produced a response.
|
|
63
|
+
"""
|
|
64
|
+
context = _resolve(firewall_map, name, request)
|
|
65
|
+
if context is None or not context.security:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
setattr(request.state, FIREWALL_CONTEXT_KEY, context)
|
|
69
|
+
await _authenticate_once(context, request)
|
|
70
|
+
token = token_storage.get_token() or NullToken()
|
|
71
|
+
setattr(request.state, TOKEN_KEY, token)
|
|
72
|
+
await context.access_listener.check_access(request, token)
|
|
73
|
+
if scopes:
|
|
74
|
+
await _check_scopes(access_decision_manager, request, token, scopes)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve(
|
|
78
|
+
firewall_map: FirewallMapInterface, name: str | None, request: Request
|
|
79
|
+
) -> FirewallContextInterface | None:
|
|
80
|
+
"""Return the firewall to run: the one named, or the first that matches."""
|
|
81
|
+
if name is not None:
|
|
82
|
+
return firewall_map.get(name)
|
|
83
|
+
return firewall_map.match(request)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def _authenticate_once(context: FirewallContextInterface, request: Request) -> None:
|
|
87
|
+
"""Authenticate the request under ``context`` at most once, memoised by name.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
AuthenticationError: When authentication fails, on this call and every
|
|
91
|
+
later one this request.
|
|
92
|
+
CarriedResponse: When a handler produced a response to send.
|
|
93
|
+
"""
|
|
94
|
+
memo: dict[str, BaseException | None] = getattr(request.state, AUTHENTICATED_FIREWALLS_KEY, {})
|
|
95
|
+
if not memo:
|
|
96
|
+
setattr(request.state, AUTHENTICATED_FIREWALLS_KEY, memo)
|
|
97
|
+
if context.name in memo:
|
|
98
|
+
outcome = memo[context.name]
|
|
99
|
+
if isinstance(outcome, BaseException):
|
|
100
|
+
raise outcome
|
|
101
|
+
return
|
|
102
|
+
try:
|
|
103
|
+
response = await context.authenticator_manager.authenticate_request(request)
|
|
104
|
+
except AuthenticationError as error:
|
|
105
|
+
memo[context.name] = error
|
|
106
|
+
raise
|
|
107
|
+
memo[context.name] = None
|
|
108
|
+
if response is not None:
|
|
109
|
+
raise CarriedResponse(response)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _check_scopes(
|
|
113
|
+
access_decision_manager: AccessDecisionManagerInterface,
|
|
114
|
+
request: Request,
|
|
115
|
+
token: TokenInterface,
|
|
116
|
+
scopes: Sequence[str],
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Decide the accumulated scopes against the token, denying when short.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
AccessDeniedError: When the token lacks a required scope, carrying the
|
|
122
|
+
scope attribute so the exception listener answers with a scope
|
|
123
|
+
challenge.
|
|
124
|
+
"""
|
|
125
|
+
del request
|
|
126
|
+
attribute = oauth2_scope(*scopes)
|
|
127
|
+
decision = AccessDecision()
|
|
128
|
+
granted = await access_decision_manager.decide(token, [attribute], access_decision=decision)
|
|
129
|
+
if not granted:
|
|
130
|
+
raise AccessDeniedError(
|
|
131
|
+
"The token does not carry the scope this resource requires.",
|
|
132
|
+
attributes=(attribute,),
|
|
133
|
+
access_decision=decision,
|
|
134
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The keys the firewall stashes its per-request state on ``request.state`` under.
|
|
2
|
+
|
|
3
|
+
Authentication runs once per request; the firewall that ran and the token it
|
|
4
|
+
authenticated are stashed here for the dependencies and the exception listener
|
|
5
|
+
that read them later in the same request. Every key is prefixed so it never
|
|
6
|
+
collides with what an application keeps on the same object.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Final
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from starlette.responses import Response
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AUTHENTICATED_FIREWALLS_KEY",
|
|
18
|
+
"FIREWALL_CONTEXT_KEY",
|
|
19
|
+
"TOKEN_KEY",
|
|
20
|
+
"CarriedResponse",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_PREFIX: Final = "_xtr_security_"
|
|
24
|
+
|
|
25
|
+
#: Maps a firewall name to the outcome of authenticating this request under it —
|
|
26
|
+
#: the error authentication raised, or ``None`` for a plain success — so
|
|
27
|
+
#: authentication stays one pass per firewall.
|
|
28
|
+
AUTHENTICATED_FIREWALLS_KEY: Final = f"{_PREFIX}authenticated_firewalls"
|
|
29
|
+
|
|
30
|
+
#: The firewall context in charge of the request, stashed by the firewall so the
|
|
31
|
+
#: exception listener — a singleton with no access to the request scope — reads
|
|
32
|
+
#: the entry point and handlers it needs without touching the container.
|
|
33
|
+
FIREWALL_CONTEXT_KEY: Final = f"{_PREFIX}firewall_context"
|
|
34
|
+
|
|
35
|
+
#: The token the firewall authenticated for the request, stashed alongside the
|
|
36
|
+
#: context so the exception listener can weigh it without the token storage.
|
|
37
|
+
TOKEN_KEY: Final = f"{_PREFIX}token"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CarriedResponse(Exception): # noqa: N818 -- a control-flow carrier, not an error condition
|
|
41
|
+
"""Carries a response an authentication handler produced out of the firewall.
|
|
42
|
+
|
|
43
|
+
A firewall runs as a dependency, which cannot itself answer a request; when
|
|
44
|
+
a success or failure handler produced a response, the firewall raises this
|
|
45
|
+
to carry it to the exception listener, which emits the response it holds.
|
|
46
|
+
Internal to the HTTP layer.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
response: The response to send.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
response: Response
|
|
53
|
+
|
|
54
|
+
def __init__(self, response: Response) -> None:
|
|
55
|
+
"""Record the response to carry out to the exception listener."""
|
|
56
|
+
super().__init__("A security handler produced a response.")
|
|
57
|
+
self.response = response
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The ordered access-control rules of a firewall, matched against a request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from .access_map_interface import AccessMapInterface
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
|
|
14
|
+
from .request_matcher.request_matcher_interface import RequestMatcherInterface
|
|
15
|
+
|
|
16
|
+
__all__ = ["AccessMap"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@final
|
|
20
|
+
class AccessMap(AccessMapInterface):
|
|
21
|
+
"""Holds a firewall's access-control rules and finds the first a request matches.
|
|
22
|
+
|
|
23
|
+
A rule is a request matcher and the one attribute a claimed request must
|
|
24
|
+
satisfy, added with :meth:`add`. Rules are tried in the order they were
|
|
25
|
+
added; the first whose matcher claims the request decides it, so a broad
|
|
26
|
+
``^/api`` rule must be added after the narrow ``^/api/admin`` it would
|
|
27
|
+
otherwise shadow. A request matching no rule has no attribute to satisfy —
|
|
28
|
+
the firewall lets it through, its access left to whatever the route itself
|
|
29
|
+
requires.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
__slots__ = ("_rules",)
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
"""Start with no rules; add each with :meth:`add`."""
|
|
36
|
+
self._rules: list[tuple[RequestMatcherInterface, str]] = []
|
|
37
|
+
|
|
38
|
+
def add(self, request_matcher: RequestMatcherInterface, attribute: str) -> None:
|
|
39
|
+
"""Append a rule: ``attribute`` is required of a request ``request_matcher`` claims.
|
|
40
|
+
|
|
41
|
+
One attribute per rule is deliberate — a rule that needs two conditions
|
|
42
|
+
is two rules, or a single closure attribute.
|
|
43
|
+
"""
|
|
44
|
+
self._rules.append((request_matcher, attribute))
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
def get_attribute(self, request: Request) -> str | None:
|
|
48
|
+
"""Return the attribute the first matching rule requires, or ``None``."""
|
|
49
|
+
for request_matcher, attribute in self._rules:
|
|
50
|
+
if request_matcher.matches(request):
|
|
51
|
+
return attribute
|
|
52
|
+
return None
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""What answers which attribute a request must satisfy for a firewall."""
|
|
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
|
+
|
|
10
|
+
__all__ = ["AccessMapInterface"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class AccessMapInterface(Protocol):
|
|
15
|
+
"""Answers the attribute a request must hold, from a firewall's access rules.
|
|
16
|
+
|
|
17
|
+
The access listener asks this after authentication: the first rule whose
|
|
18
|
+
matcher claims the request names the one attribute the token must satisfy,
|
|
19
|
+
and a request no rule claims has none.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def get_attribute(self, request: Request) -> str | None:
|
|
23
|
+
"""Return the attribute the first matching rule requires, or ``None``."""
|
|
24
|
+
...
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Reading and validating bearer access tokens at the HTTP edge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .access_token_extractor_interface import AccessTokenExtractorInterface
|
|
6
|
+
from .access_token_handler_interface import AccessTokenHandlerInterface
|
|
7
|
+
from .chain_access_token_extractor import ChainAccessTokenExtractor
|
|
8
|
+
from .form_encoded_body_extractor import FormEncodedBodyExtractor
|
|
9
|
+
from .header_access_token_extractor import HeaderAccessTokenExtractor
|
|
10
|
+
from .query_access_token_extractor import QueryAccessTokenExtractor
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AccessTokenExtractorInterface",
|
|
14
|
+
"AccessTokenHandlerInterface",
|
|
15
|
+
"ChainAccessTokenExtractor",
|
|
16
|
+
"FormEncodedBodyExtractor",
|
|
17
|
+
"HeaderAccessTokenExtractor",
|
|
18
|
+
"QueryAccessTokenExtractor",
|
|
19
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""What reads a bearer access token out of a request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from fastapi.security.base import SecurityBase
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
|
|
11
|
+
__all__ = ["AccessTokenExtractorInterface"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class AccessTokenExtractorInterface(Protocol):
|
|
16
|
+
"""Reads the token string from a request, and names the scheme it read it by.
|
|
17
|
+
|
|
18
|
+
An extractor knows where a token lives — an ``Authorization`` header, a
|
|
19
|
+
query parameter, a form field — and returns it as a string, or ``None``
|
|
20
|
+
when the request carries none. :meth:`scheme` returns the FastAPI security
|
|
21
|
+
object the extractor is built on, so the same object both parses the
|
|
22
|
+
request and describes the scheme in the generated OpenAPI.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
async def extract_access_token(self, request: Request) -> str | None:
|
|
26
|
+
"""Return the token in ``request``, or ``None`` when there is none."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def scheme(self) -> SecurityBase:
|
|
30
|
+
"""Return the FastAPI security object this extractor parses and documents by."""
|
|
31
|
+
...
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""What turns a bearer access token into the badge naming its user."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from xtr_security_http.authenticator.passport.badge.user_badge import UserBadge
|
|
9
|
+
|
|
10
|
+
__all__ = ["AccessTokenHandlerInterface"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class AccessTokenHandlerInterface(Protocol):
|
|
15
|
+
"""Validates an access token and reports who it was issued for.
|
|
16
|
+
|
|
17
|
+
A handler is the whole of what makes a bearer token trustworthy: it
|
|
18
|
+
verifies the token — a JWT's signature and claims, an introspection
|
|
19
|
+
call — and returns a
|
|
20
|
+
:class:`~xtr_security_http.authenticator.passport.badge.user_badge.UserBadge`
|
|
21
|
+
naming the user, its attributes carrying the token's ``scope``,
|
|
22
|
+
``client_id``, ``jti`` and remaining claims. This is the seam an OAuth2
|
|
23
|
+
resource server plugs its own token validation into.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def get_user_badge_from(self, access_token: str) -> UserBadge:
|
|
27
|
+
"""Return the user badge ``access_token`` proves.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
AuthenticationError: When the token cannot be trusted — most often
|
|
31
|
+
an :class:`InvalidAccessTokenError` for a malformed, expired or
|
|
32
|
+
wrongly signed token.
|
|
33
|
+
"""
|
|
34
|
+
...
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""An extractor that tries several extractors in order."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
from xtr_security_core.exception import InvalidArgumentError
|
|
9
|
+
|
|
10
|
+
from .access_token_extractor_interface import AccessTokenExtractorInterface
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
from fastapi.security.base import SecurityBase
|
|
16
|
+
from starlette.requests import Request
|
|
17
|
+
|
|
18
|
+
__all__ = ["ChainAccessTokenExtractor"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@final
|
|
22
|
+
class ChainAccessTokenExtractor(AccessTokenExtractorInterface):
|
|
23
|
+
"""Returns the first token any of its extractors finds.
|
|
24
|
+
|
|
25
|
+
Tries each extractor in turn and returns the first non-``None`` token, so a
|
|
26
|
+
firewall can accept a token from a header or a query parameter without
|
|
27
|
+
caring which the client used. Its OpenAPI scheme is the first extractor's —
|
|
28
|
+
the one a client is nudged towards.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("_extractors",)
|
|
32
|
+
|
|
33
|
+
def __init__(self, extractors: Sequence[AccessTokenExtractorInterface]) -> None:
|
|
34
|
+
"""Record the extractors to try, in order.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
InvalidArgumentError: When no extractor is given, which would find
|
|
38
|
+
no token ever.
|
|
39
|
+
"""
|
|
40
|
+
if not extractors:
|
|
41
|
+
raise InvalidArgumentError(
|
|
42
|
+
"A chain access-token extractor needs at least one extractor."
|
|
43
|
+
)
|
|
44
|
+
self._extractors = tuple(extractors)
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
async def extract_access_token(self, request: Request) -> str | None:
|
|
48
|
+
"""Return the first token any extractor finds, or ``None`` when none do."""
|
|
49
|
+
for extractor in self._extractors:
|
|
50
|
+
token = await extractor.extract_access_token(request)
|
|
51
|
+
if token is not None:
|
|
52
|
+
return token
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
@override
|
|
56
|
+
def scheme(self) -> SecurityBase:
|
|
57
|
+
"""Return the first extractor's scheme, the one documented for the firewall."""
|
|
58
|
+
return self._extractors[0].scheme()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""An extractor that reads a bearer token from a form-encoded body."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
from urllib.parse import parse_qs
|
|
7
|
+
|
|
8
|
+
from fastapi.security import HTTPBearer
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
from .access_token_extractor_interface import AccessTokenExtractorInterface
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from fastapi.security.base import SecurityBase
|
|
15
|
+
from starlette.requests import Request
|
|
16
|
+
|
|
17
|
+
__all__ = ["FormEncodedBodyExtractor"]
|
|
18
|
+
|
|
19
|
+
_FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@final
|
|
23
|
+
class FormEncodedBodyExtractor(AccessTokenExtractorInterface):
|
|
24
|
+
"""Reads a token from a form field of an ``x-www-form-urlencoded`` body.
|
|
25
|
+
|
|
26
|
+
RFC 6750 allows a bearer token in a form body; this reads the
|
|
27
|
+
``access_token`` field of one, when the request carries that content type.
|
|
28
|
+
The body is parsed directly, so no form-parsing dependency is needed. There
|
|
29
|
+
is no dedicated OpenAPI scheme for a body-carried token, so it documents
|
|
30
|
+
itself as the standard bearer scheme.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
field_name: The form field the token is read from.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__slots__ = ("_bearer", "_field_name")
|
|
37
|
+
|
|
38
|
+
def __init__(self, field_name: str = "access_token") -> None:
|
|
39
|
+
"""Build the extractor for the ``field_name`` form field."""
|
|
40
|
+
self._field_name = field_name
|
|
41
|
+
self._bearer = HTTPBearer(auto_error=False, bearerFormat="JWT", scheme_name="Bearer")
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
async def extract_access_token(self, request: Request) -> str | None:
|
|
45
|
+
"""Return the token in the form body, or ``None`` when it is absent."""
|
|
46
|
+
content_type = request.headers.get("content-type", "")
|
|
47
|
+
if not content_type.startswith(_FORM_CONTENT_TYPE):
|
|
48
|
+
return None
|
|
49
|
+
body = await request.body()
|
|
50
|
+
fields = parse_qs(body.decode("utf-8"))
|
|
51
|
+
values = fields.get(self._field_name)
|
|
52
|
+
return values[0] if values else None
|
|
53
|
+
|
|
54
|
+
@override
|
|
55
|
+
def scheme(self) -> SecurityBase:
|
|
56
|
+
"""Return the bearer scheme this extractor documents by."""
|
|
57
|
+
return self._bearer
|