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