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,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