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,48 @@
1
+ """What resolves a request's firewall: by name, or by matching the request.
2
+
3
+ The firewall runner asks this for the firewall to run — the one bound by name,
4
+ or the first whose matcher claims the request — and the OpenAPI scheme registry
5
+ and ``debug:firewall`` read the firewalls it holds. The concrete map that pairs
6
+ each firewall with its matcher is built by the bundle; the runtime here depends
7
+ only on this shape, so a firewall runs without knowing how the map was built.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
13
+
14
+ if TYPE_CHECKING:
15
+ from starlette.requests import Request
16
+
17
+ from xtr_security_http.firewall_context_interface import FirewallContextInterface
18
+
19
+ __all__ = ["FirewallMapInterface"]
20
+
21
+
22
+ @runtime_checkable
23
+ class FirewallMapInterface(Protocol):
24
+ """Holds an application's firewalls, found by name or by matching a request.
25
+
26
+ A firewall bound by name is looked up directly; an unbound one is matched to
27
+ the first firewall whose matcher claims the request, in registration order.
28
+ """
29
+
30
+ def has(self, name: str) -> bool:
31
+ """Tell whether a firewall is registered under ``name``."""
32
+ ...
33
+
34
+ def get(self, name: str) -> FirewallContextInterface:
35
+ """Return the firewall named ``name``.
36
+
37
+ Raises:
38
+ UnknownFirewallError: When no firewall carries that name.
39
+ """
40
+ ...
41
+
42
+ def match(self, request: Request) -> FirewallContextInterface | None:
43
+ """Return the first firewall whose matcher claims ``request``, or ``None``."""
44
+ ...
45
+
46
+ def names(self) -> tuple[str, ...]:
47
+ """Return the names of every firewall registered."""
48
+ ...
@@ -0,0 +1,114 @@
1
+ """The FastAPI security object a firewall is."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from fastapi.security import HTTPBearer, SecurityScopes
8
+ from fastapi.security.base import SecurityBase
9
+
10
+ # The framework reads the dependency's __call__ signature at runtime to inject
11
+ # these, so they — and the service types the container fills — cannot live
12
+ # behind TYPE_CHECKING.
13
+ from starlette.requests import Request # noqa: TC002
14
+ from xtr_dependency_injection import Injected # noqa: TC002
15
+ from xtr_security_core.authentication.token.storage.token_storage_interface import ( # noqa: TC002
16
+ TokenStorageInterface,
17
+ )
18
+ from xtr_security_core.authorization.access_decision_manager_interface import ( # noqa: TC002
19
+ AccessDecisionManagerInterface,
20
+ )
21
+
22
+ from ._runner import run_firewall
23
+ from .exception.firewall_not_booted_error import FirewallNotBootedError
24
+ from .firewall_map_interface import FirewallMapInterface # noqa: TC001 -- filled into __call__
25
+ from .firewall_scheme_registry import active_firewall_scheme_registry
26
+
27
+ if TYPE_CHECKING:
28
+ from fastapi.openapi.models import SecurityBase as SecurityBaseModel
29
+
30
+ __all__ = ["FirewallScheme"]
31
+
32
+ _GENERIC_SCHEME_NAME = "firewall"
33
+
34
+
35
+ @final
36
+ class FirewallScheme(SecurityBase):
37
+ """A firewall, seen by FastAPI as both a dependency and a security scheme.
38
+
39
+ As a dependency, calling it runs the firewall for the request — authenticate
40
+ once, then decide access and any accumulated scopes. As a
41
+ :class:`~fastapi.security.base.SecurityBase`, its ``model`` and
42
+ ``scheme_name`` — read only when the schema is generated — describe the
43
+ firewall in OpenAPI: a bound firewall's from the registry the kernel filled
44
+ at boot, an unbound one's a generic bearer scheme.
45
+ """
46
+
47
+ def __init__(self, name: str | None) -> None:
48
+ """Build the scheme for the firewall ``name``, or an unbound one for ``None``."""
49
+ self._name = name
50
+ self._generic = HTTPBearer(auto_error=False, bearerFormat="JWT", scheme_name="firewall")
51
+
52
+ async def __call__(
53
+ self,
54
+ request: Request,
55
+ security_scopes: SecurityScopes,
56
+ firewall_map: Injected[FirewallMapInterface],
57
+ token_storage: Injected[TokenStorageInterface],
58
+ access_decision_manager: Injected[AccessDecisionManagerInterface],
59
+ ) -> None:
60
+ """Run the firewall for ``request``, enforcing the accumulated scopes."""
61
+ await run_firewall(
62
+ self._name,
63
+ request,
64
+ tuple(security_scopes.scopes),
65
+ firewall_map=firewall_map,
66
+ token_storage=token_storage,
67
+ access_decision_manager=access_decision_manager,
68
+ )
69
+
70
+ # These read the OpenAPI model dynamically at schema-generation time, so they
71
+ # are properties over the plain attributes the base declares — the override is
72
+ # deliberate and the type checker cannot see it is compatible.
73
+ @property
74
+ def model( # pyright: ignore[reportIncompatibleVariableOverride, reportImplicitOverride]
75
+ self,
76
+ ) -> SecurityBaseModel:
77
+ """Return the firewall's OpenAPI model, resolved when the schema is built.
78
+
79
+ Raises:
80
+ FirewallNotBootedError: When a bound firewall's scheme is read
81
+ before the kernel filled the registry, or outside a request.
82
+ """
83
+ if self._name is None:
84
+ return self._generic.model
85
+ registry = active_firewall_scheme_registry()
86
+ entry = registry.get(self._name) if registry is not None else None
87
+ if entry is None:
88
+ raise FirewallNotBootedError(self._not_booted_message())
89
+ return entry.model
90
+
91
+ @property
92
+ def scheme_name( # pyright: ignore[reportIncompatibleVariableOverride, reportImplicitOverride]
93
+ self,
94
+ ) -> str:
95
+ """Return the firewall's OpenAPI scheme name, an unbound one's generic.
96
+
97
+ Raises:
98
+ FirewallNotBootedError: When a bound firewall's scheme is read
99
+ before the kernel filled the registry, or outside a request.
100
+ """
101
+ if self._name is None:
102
+ return _GENERIC_SCHEME_NAME
103
+ registry = active_firewall_scheme_registry()
104
+ entry = registry.get(self._name) if registry is not None else None
105
+ if entry is None:
106
+ raise FirewallNotBootedError(self._not_booted_message())
107
+ return entry.scheme_name
108
+
109
+ def _not_booted_message(self) -> str:
110
+ """Name the firewall whose scheme was read before the kernel registered it."""
111
+ return (
112
+ f'The OpenAPI scheme of the firewall "{self._name}" was read before the '
113
+ f"kernel registered it, or outside a request."
114
+ )
@@ -0,0 +1,87 @@
1
+ """The OpenAPI scheme each firewall contributes, and the holder a scheme reads it through."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, final
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Generator
11
+
12
+ from fastapi.openapi.models import SecurityBase as SecurityBaseModel
13
+
14
+ __all__ = [
15
+ "FirewallSchemeRegistry",
16
+ "active_firewall_scheme_registry",
17
+ "active_firewall_schemes",
18
+ ]
19
+
20
+
21
+ @final
22
+ @dataclass(frozen=True, slots=True)
23
+ class _SchemeEntry:
24
+ """The OpenAPI model and name one firewall contributes."""
25
+
26
+ model: SecurityBaseModel
27
+ scheme_name: str
28
+
29
+
30
+ @final
31
+ class FirewallSchemeRegistry:
32
+ """The OpenAPI scheme each firewall contributes, filled once when the kernel boots.
33
+
34
+ The generated schema is built synchronously, inside the request serving
35
+ ``/openapi.json``, where an ``await`` on the container is not possible. So
36
+ each firewall's model and name are resolved once at boot and kept here for
37
+ that synchronous read. The bundle fills one of these per kernel and makes
38
+ it the active registry for the requests that kernel serves; a test fills
39
+ one and activates it with :func:`active_firewall_schemes`.
40
+ """
41
+
42
+ __slots__ = ("_schemes",)
43
+
44
+ def __init__(self) -> None:
45
+ """Start with no firewall schemes registered."""
46
+ self._schemes: dict[str, _SchemeEntry] = {}
47
+
48
+ def register(self, name: str, model: SecurityBaseModel, scheme_name: str | None = None) -> None:
49
+ """Record the OpenAPI ``model`` and name the firewall ``name`` shows."""
50
+ self._schemes[name] = _SchemeEntry(model, scheme_name or name)
51
+
52
+ def get(self, name: str) -> _SchemeEntry | None:
53
+ """Return the scheme registered for ``name``, or ``None`` when there is none."""
54
+ return self._schemes.get(name)
55
+
56
+ def names(self) -> tuple[str, ...]:
57
+ """Return the names of every firewall with a registered scheme."""
58
+ return tuple(self._schemes)
59
+
60
+
61
+ # The OpenAPI scheme is read synchronously while the schema is generated, from
62
+ # a context the reader does not share with whoever activated the registry (a
63
+ # fixture, an async generator). A plain module-level holder — restored on exit
64
+ # — is read the same from any context; the Phase 5 bundle keys it per kernel so
65
+ # concurrent kernels stay isolated.
66
+ _ACTIVE: list[FirewallSchemeRegistry | None] = [None]
67
+
68
+
69
+ @contextmanager
70
+ def active_firewall_schemes(registry: FirewallSchemeRegistry) -> Generator[None, None, None]:
71
+ """Make ``registry`` the one a firewall scheme reads while the block is entered.
72
+
73
+ The bundle activates a kernel's registry for the span its requests are
74
+ served; a test activates one around the block it serves an application in.
75
+ The previous registry is restored on exit.
76
+ """
77
+ previous = _ACTIVE[0]
78
+ _ACTIVE[0] = registry
79
+ try:
80
+ yield
81
+ finally:
82
+ _ACTIVE[0] = previous
83
+
84
+
85
+ def active_firewall_scheme_registry() -> FirewallSchemeRegistry | None:
86
+ """Return the registry a firewall scheme reads now, or ``None`` when none is active."""
87
+ return _ACTIVE[0]
@@ -0,0 +1,7 @@
1
+ """OIDC discovery: finding and caching a third-party issuer's verifying keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .oidc_discovery import DiscoveryOidcKeySetProvider
6
+
7
+ __all__ = ["DiscoveryOidcKeySetProvider"]
@@ -0,0 +1,238 @@
1
+ """A key set provider that fetches and caches an issuer's JWKS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import TYPE_CHECKING, cast, final
7
+
8
+ import anyio
9
+ from joserfc.errors import JoseError
10
+ from joserfc.jwk import KeySet
11
+ from typing_extensions import override
12
+ from xtr_security_core.exception import InvalidArgumentError
13
+
14
+ from xtr_security_http.access_token.oidc.exception.oidc_key_set_error import (
15
+ OidcKeySetError,
16
+ )
17
+ from xtr_security_http.authenticator.oidc.oidc_jwks import OidcKeySetProviderInterface
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+ import httpx
23
+ from joserfc._keys import KeySetSerialization
24
+
25
+ __all__ = ["DiscoveryOidcKeySetProvider"]
26
+
27
+ _DISCOVERY_PATH = "/.well-known/openid-configuration"
28
+
29
+
30
+ @final
31
+ class DiscoveryOidcKeySetProvider(OidcKeySetProviderInterface):
32
+ """Fetches an issuer's JWKS, caches it, and refetches when a key is unknown.
33
+
34
+ Given an issuer's ``base_uri`` it reads the OpenID discovery document to
35
+ find the ``jwks_uri``; given a ``jwks_uri`` outright it fetches that. The
36
+ set is cached for ``ttl`` seconds. A ``force_refresh`` fetches again — the
37
+ handler asks for one when a token names a ``kid`` the cached set lacks, a
38
+ rotated key — but no more than once every ``refresh_cooldown`` seconds, so a
39
+ burst of tokens with an unknown ``kid`` triggers a single fetch. A lock
40
+ serialises fetches, so concurrent callers that all find the set stale share
41
+ the one fetch rather than each making their own.
42
+
43
+ Every endpoint must be ``https`` unless ``allow_insecure_http`` is set, a
44
+ door left only for a development or test issuer served over plain HTTP.
45
+ """
46
+
47
+ __slots__ = (
48
+ "_allow_insecure_http",
49
+ "_base_uri",
50
+ "_cooldown",
51
+ "_http_client_factory",
52
+ "_jwks_uri",
53
+ "_key_set",
54
+ "_last_error",
55
+ "_last_fetch",
56
+ "_lock",
57
+ "_monotonic",
58
+ "_resolved_jwks_uri",
59
+ "_ttl",
60
+ )
61
+
62
+ def __init__( # noqa: PLR0913 -- a wiring constructor; every knob has a default
63
+ self,
64
+ *,
65
+ base_uri: str | None = None,
66
+ jwks_uri: str | None = None,
67
+ http_client_factory: Callable[[], httpx.AsyncClient],
68
+ ttl: float = 600,
69
+ refresh_cooldown: float = 60,
70
+ allow_insecure_http: bool = False,
71
+ monotonic: Callable[[], float] = time.monotonic,
72
+ ) -> None:
73
+ """Record the endpoints, the client factory and the cache timings.
74
+
75
+ Raises:
76
+ InvalidArgumentError: When neither or both of ``base_uri`` and
77
+ ``jwks_uri`` are given, or a given endpoint is not ``https`` and
78
+ insecure HTTP is not allowed.
79
+ """
80
+ if (base_uri is None) == (jwks_uri is None):
81
+ raise InvalidArgumentError(
82
+ "A discovery key set provider needs exactly one of base_uri or jwks_uri.",
83
+ )
84
+ self._allow_insecure_http = allow_insecure_http
85
+ if base_uri is not None:
86
+ self._require_secure(base_uri)
87
+ if jwks_uri is not None:
88
+ self._require_secure(jwks_uri)
89
+ self._base_uri = base_uri
90
+ self._jwks_uri = jwks_uri
91
+ self._http_client_factory = http_client_factory
92
+ self._ttl = ttl
93
+ self._cooldown = refresh_cooldown
94
+ self._monotonic = monotonic
95
+ self._lock = anyio.Lock()
96
+ self._key_set: KeySet | None = None
97
+ self._resolved_jwks_uri: str | None = jwks_uri
98
+ self._last_fetch = 0.0
99
+ self._last_error: OidcKeySetError | None = None
100
+
101
+ @override
102
+ async def get_key_set(self, *, force_refresh: bool = False) -> KeySet:
103
+ """Return the cached key set, fetching a fresh one when stale or forced.
104
+
105
+ Raises:
106
+ OidcKeySetError: When the key set cannot be fetched, discovered or
107
+ read.
108
+ """
109
+ async with self._lock:
110
+ now = self._monotonic()
111
+ cached = self._fresh_cached(now, force_refresh=force_refresh)
112
+ if cached is not None:
113
+ return cached
114
+ held = self._held_failure(now)
115
+ if held is not None:
116
+ raise held
117
+ return await self._refresh(now)
118
+
119
+ async def _refresh(self, now: float) -> KeySet:
120
+ """Fetch a fresh key set, recording the attempt even when it fails.
121
+
122
+ The attempt time is recorded whether the fetch succeeds or fails, so the
123
+ cooldown applies to failures too and a burst of forced refreshes during
124
+ an outage makes one fetch, not one per call. When a fetch fails but a set
125
+ was cached before, that cached set keeps being served rather than the
126
+ request failing outright; when none was ever cached, the failure is
127
+ remembered so a burst during a cold-start outage re-raises it within the
128
+ cooldown rather than fetching again — see :meth:`_held_failure`.
129
+
130
+ Raises:
131
+ OidcKeySetError: When the fetch fails and no set was ever cached.
132
+ """
133
+ try:
134
+ key_set = await self._fetch()
135
+ except OidcKeySetError as error:
136
+ self._last_fetch = now
137
+ if self._key_set is not None:
138
+ return self._key_set
139
+ self._last_error = error
140
+ raise
141
+ self._key_set = key_set
142
+ self._last_fetch = now
143
+ self._last_error = None
144
+ return key_set
145
+
146
+ def _held_failure(self, now: float) -> OidcKeySetError | None:
147
+ """Return the remembered cold-start failure while its cooldown holds.
148
+
149
+ Only a provider that has never cached a set remembers one; within the
150
+ cooldown of that failure it is re-raised without a fetch, so a burst of
151
+ requests during a cold-start outage costs one fetch, not one each.
152
+ """
153
+ if self._key_set is not None or self._last_error is None:
154
+ return None
155
+ if now - self._last_fetch < self._cooldown:
156
+ return self._last_error
157
+ return None
158
+
159
+ def _fresh_cached(self, now: float, *, force_refresh: bool) -> KeySet | None:
160
+ """Return the cached set when it still answers this call, else ``None``."""
161
+ if self._key_set is None:
162
+ return None
163
+ window = self._cooldown if force_refresh else self._ttl
164
+ if now - self._last_fetch < window:
165
+ return self._key_set
166
+ return None
167
+
168
+ async def _fetch(self) -> KeySet:
169
+ """Discover the ``jwks_uri`` when needed, then fetch and read the set.
170
+
171
+ Raises:
172
+ OidcKeySetError: When discovery is needed but no base URI was given —
173
+ an impossible state the constructor forbids, guarded explicitly
174
+ in place of a bare assertion.
175
+ """
176
+ jwks_uri = self._resolved_jwks_uri
177
+ if jwks_uri is None:
178
+ base_uri = self._base_uri
179
+ if base_uri is None: # pragma: no cover -- the constructor enforces one of the two
180
+ raise OidcKeySetError("A discovery key set provider has no base URI to discover.")
181
+ jwks_uri = await self._discover(base_uri)
182
+ self._require_secure(jwks_uri)
183
+ self._resolved_jwks_uri = jwks_uri
184
+ document = await self._get_json(jwks_uri)
185
+ try:
186
+ serialization = cast("KeySetSerialization", cast("object", document))
187
+ return KeySet.import_key_set(serialization)
188
+ except (JoseError, ValueError) as error:
189
+ raise OidcKeySetError(
190
+ f"The JWKS document at {jwks_uri} could not be read: {error}",
191
+ ) from error
192
+
193
+ async def _discover(self, base_uri: str) -> str:
194
+ """Read the discovery document at ``base_uri`` and return the ``jwks_uri`` it names.
195
+
196
+ Raises:
197
+ OidcKeySetError: When the document cannot be read or names no
198
+ usable ``jwks_uri``.
199
+ """
200
+ document = await self._get_json(base_uri.rstrip("/") + _DISCOVERY_PATH)
201
+ jwks_uri = document.get("jwks_uri")
202
+ if not isinstance(jwks_uri, str) or not jwks_uri:
203
+ raise OidcKeySetError(
204
+ f"The discovery document at {base_uri} names no jwks_uri.",
205
+ )
206
+ return jwks_uri
207
+
208
+ async def _get_json(self, uri: str) -> dict[str, object]:
209
+ """Fetch ``uri`` and return its JSON body as a mapping.
210
+
211
+ Raises:
212
+ OidcKeySetError: When the request fails or the body is not a JSON
213
+ object.
214
+ """
215
+ import httpx # noqa: PLC0415 -- guarded behind the oidc extra
216
+
217
+ try:
218
+ async with self._http_client_factory() as client:
219
+ response = await client.get(uri)
220
+ _ = response.raise_for_status()
221
+ body = cast("object", response.json())
222
+ except (httpx.HTTPError, ValueError) as error:
223
+ raise OidcKeySetError(f"The request to {uri} failed: {error}") from error
224
+ if not isinstance(body, dict):
225
+ raise OidcKeySetError(f"The answer from {uri} is not a JSON object.")
226
+ return cast("dict[str, object]", body)
227
+
228
+ def _require_secure(self, uri: str) -> None:
229
+ """Refuse a non-``https`` endpoint unless insecure HTTP is allowed.
230
+
231
+ Raises:
232
+ InvalidArgumentError: When ``uri`` is not ``https`` and insecure
233
+ HTTP is not allowed.
234
+ """
235
+ if not self._allow_insecure_http and not uri.lower().startswith("https://"):
236
+ raise InvalidArgumentError(
237
+ f"The endpoint {uri} must be https; set allow_insecure_http for a dev issuer.",
238
+ )
File without changes
@@ -0,0 +1,28 @@
1
+ """Request matchers: which requests a firewall's access rule claims.
2
+
3
+ A rule pairs one of these with an attribute in an
4
+ :class:`~xtr_security_http.access_map.AccessMap`; the first matcher that claims
5
+ a request decides it. The matchers compose — a
6
+ :class:`~xtr_security_http.request_matcher.chain_request_matcher.ChainRequestMatcher`
7
+ holds a path, host, method and address matcher when a rule constrains all four.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .callable_request_matcher import CallableRequestMatcher
13
+ from .chain_request_matcher import ChainRequestMatcher
14
+ from .host_request_matcher import HostRequestMatcher
15
+ from .ip_request_matcher import IpRequestMatcher
16
+ from .method_request_matcher import MethodRequestMatcher
17
+ from .path_request_matcher import PathRequestMatcher
18
+ from .request_matcher_interface import RequestMatcherInterface
19
+
20
+ __all__ = [
21
+ "CallableRequestMatcher",
22
+ "ChainRequestMatcher",
23
+ "HostRequestMatcher",
24
+ "IpRequestMatcher",
25
+ "MethodRequestMatcher",
26
+ "PathRequestMatcher",
27
+ "RequestMatcherInterface",
28
+ ]
@@ -0,0 +1,27 @@
1
+ """Compile a request matcher's regular expression, naming a bad one where written."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ from xtr_security_core.exception import InvalidArgumentError
9
+
10
+ if TYPE_CHECKING:
11
+ from re import Pattern
12
+
13
+ __all__ = ["compile_pattern"]
14
+
15
+
16
+ def compile_pattern(pattern: str, field_name: str) -> Pattern[str]:
17
+ """Compile ``pattern``, naming ``field_name`` when it will not compile.
18
+
19
+ Raises:
20
+ InvalidArgumentError: When ``pattern`` is not a valid regular expression.
21
+ """
22
+ try:
23
+ return re.compile(pattern)
24
+ except re.error as error:
25
+ raise InvalidArgumentError(
26
+ f'A request matcher\'s "{field_name}" is not a valid pattern: {error}.',
27
+ ) from error
@@ -0,0 +1,37 @@
1
+ """A request matcher that defers the decision to a callable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from typing_extensions import override
8
+
9
+ from .request_matcher_interface import RequestMatcherInterface
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+
14
+ from starlette.requests import Request
15
+
16
+ __all__ = ["CallableRequestMatcher"]
17
+
18
+
19
+ @final
20
+ class CallableRequestMatcher(RequestMatcherInterface):
21
+ """Claims a request when a callable says so.
22
+
23
+ The escape hatch a firewall uses when path, host and method do not express
24
+ its claim — the callable reads the request on its own and may reach into the
25
+ route template through ``request.scope["route"]``.
26
+ """
27
+
28
+ __slots__ = ("_decide",)
29
+
30
+ def __init__(self, decide: Callable[[Request], bool]) -> None:
31
+ """Record the callable that decides the claim."""
32
+ self._decide = decide
33
+
34
+ @override
35
+ def matches(self, request: Request) -> bool:
36
+ """Tell whether the callable claims ``request``."""
37
+ return self._decide(request)
@@ -0,0 +1,37 @@
1
+ """A request matcher that claims a request only when every part does."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from typing_extensions import override
8
+
9
+ from .request_matcher_interface import RequestMatcherInterface
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Sequence
13
+
14
+ from starlette.requests import Request
15
+
16
+ __all__ = ["ChainRequestMatcher"]
17
+
18
+
19
+ @final
20
+ class ChainRequestMatcher(RequestMatcherInterface):
21
+ """Claims a request only when every matcher it chains claims it.
22
+
23
+ An empty chain claims every request — nothing constrains it — the way a
24
+ rule with only a path becomes a chain of one, and a rule with no
25
+ constraints at all matches anything.
26
+ """
27
+
28
+ __slots__ = ("_matchers",)
29
+
30
+ def __init__(self, matchers: Sequence[RequestMatcherInterface]) -> None:
31
+ """Record the matchers that must all claim a request."""
32
+ self._matchers = tuple(matchers)
33
+
34
+ @override
35
+ def matches(self, request: Request) -> bool:
36
+ """Tell whether every chained matcher claims ``request``."""
37
+ return all(matcher.matches(request) for matcher in self._matchers)
@@ -0,0 +1,43 @@
1
+ """A request matcher that claims a request by its host."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from typing_extensions import override
8
+
9
+ from ._pattern import compile_pattern
10
+ from .request_matcher_interface import RequestMatcherInterface
11
+
12
+ if TYPE_CHECKING:
13
+ from re import Pattern
14
+
15
+ from starlette.requests import Request
16
+
17
+ __all__ = ["HostRequestMatcher"]
18
+
19
+
20
+ @final
21
+ class HostRequestMatcher(RequestMatcherInterface):
22
+ """Claims a request whose host matches a regular expression.
23
+
24
+ A request that carries no host is claimed all the same — the host
25
+ constraint is enforced only when there is a host to read, so a rule stays
26
+ applicable to requests a transport leaves hostless.
27
+ """
28
+
29
+ __slots__ = ("_pattern",)
30
+
31
+ def __init__(self, pattern: str) -> None:
32
+ """Compile ``pattern`` as the host expression to match.
33
+
34
+ Raises:
35
+ InvalidArgumentError: When ``pattern`` will not compile.
36
+ """
37
+ self._pattern: Pattern[str] = compile_pattern(pattern, "host")
38
+
39
+ @override
40
+ def matches(self, request: Request) -> bool:
41
+ """Tell whether the request host matches the pattern, or is absent."""
42
+ host = request.url.hostname
43
+ return host is None or self._pattern.search(host) is not None