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,87 @@
1
+ """The keys a third-party OIDC token is verified against, and a fixed source of them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Protocol, cast, final, runtime_checkable
7
+
8
+ from joserfc.errors import JoseError
9
+ from joserfc.jwk import KeySet
10
+ from typing_extensions import override
11
+ from xtr_security_core.exception import InvalidArgumentError
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping
15
+
16
+ from joserfc._keys import KeySetSerialization
17
+
18
+ __all__ = ["OidcKeySetProviderInterface", "StaticOidcKeySetProvider"]
19
+
20
+
21
+ @runtime_checkable
22
+ class OidcKeySetProviderInterface(Protocol):
23
+ """Supplies the public keys a third-party OIDC token is verified against.
24
+
25
+ A token names its key by ``kid``; the whole set is handed to the verifier,
26
+ which picks the one that matches. A remote provider caches the set, and
27
+ ``force_refresh`` asks it to fetch again — the handler does so once when a
28
+ token names a ``kid`` the cached set does not hold, a key rotation the
29
+ provider had not yet seen.
30
+ """
31
+
32
+ async def get_key_set(self, *, force_refresh: bool = False) -> KeySet:
33
+ """Return the key set a token is verified against.
34
+
35
+ Args:
36
+ force_refresh: Fetch a fresh set rather than answer from the cache,
37
+ subject to a provider's own cooldown.
38
+
39
+ Raises:
40
+ OidcKeySetError: When the key set cannot be fetched, discovered or
41
+ read.
42
+ """
43
+ ...
44
+
45
+
46
+ @final
47
+ class StaticOidcKeySetProvider(OidcKeySetProviderInterface):
48
+ """Holds a fixed key set read from a JWKS document.
49
+
50
+ For an issuer whose keys are known ahead of time and pinned in the
51
+ configuration, rather than fetched. The document is read once, as the
52
+ provider is made, so a malformed one fails here rather than at the first
53
+ token; :meth:`get_key_set` always answers the same set, ``force_refresh``
54
+ or not, since there is nowhere to refresh from.
55
+ """
56
+
57
+ __slots__ = ("_key_set",)
58
+
59
+ def __init__(self, jwks_json: str | Mapping[str, object]) -> None:
60
+ """Build the key set from ``jwks_json``, a JWKS string or mapping.
61
+
62
+ Raises:
63
+ InvalidArgumentError: When the document is not a readable JWK set.
64
+ """
65
+ self._key_set = _read_key_set(jwks_json)
66
+
67
+ @override
68
+ async def get_key_set(self, *, force_refresh: bool = False) -> KeySet:
69
+ """Return the fixed key set, whether or not a refresh is asked for."""
70
+ del force_refresh
71
+ return self._key_set
72
+
73
+
74
+ def _read_key_set(jwks_json: str | Mapping[str, object]) -> KeySet:
75
+ """Turn a JWKS string or mapping into a joserfc key set.
76
+
77
+ Raises:
78
+ InvalidArgumentError: When the document is not a readable JWK set.
79
+ """
80
+ try:
81
+ document: object = json.loads(jwks_json) if isinstance(jwks_json, str) else dict(jwks_json)
82
+ if not isinstance(document, dict):
83
+ raise InvalidArgumentError("A JWK set document must be a JSON object.")
84
+ serialization = cast("KeySetSerialization", cast("object", document))
85
+ return KeySet.import_key_set(serialization)
86
+ except (JoseError, ValueError) as error:
87
+ raise InvalidArgumentError(f"The JWK set document could not be read: {error}") from error
@@ -0,0 +1,8 @@
1
+ """Passports and the badges they carry, produced by an authenticator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .passport import Passport
6
+ from .self_validating_passport import SelfValidatingPassport
7
+
8
+ __all__ = ["Passport", "SelfValidatingPassport"]
@@ -0,0 +1,16 @@
1
+ """The badges a passport carries: the user, and notes about the authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .badge_interface import BadgeInterface
6
+ from .password_upgrade_badge import PasswordUpgradeBadge
7
+ from .pre_authenticated_user_badge import PreAuthenticatedUserBadge
8
+ from .user_badge import MAX_USERNAME_LENGTH, UserBadge
9
+
10
+ __all__ = [
11
+ "MAX_USERNAME_LENGTH",
12
+ "BadgeInterface",
13
+ "PasswordUpgradeBadge",
14
+ "PreAuthenticatedUserBadge",
15
+ "UserBadge",
16
+ ]
@@ -0,0 +1,24 @@
1
+ """What a badge — a single fact a passport carries — answers to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ __all__ = ["BadgeInterface"]
8
+
9
+
10
+ @runtime_checkable
11
+ class BadgeInterface(Protocol):
12
+ """One fact about an authentication, resolved before a token is made.
13
+
14
+ A passport is a bag of badges: the user's identifier, the password to
15
+ verify, a note that a password should be upgraded. Each badge starts
16
+ unresolved and is marked resolved by whoever handles it — a listener on the
17
+ passport-check event, or the badge itself when it needs nothing. Every
18
+ badge must report itself resolved by the time the check is done, or
19
+ authentication fails.
20
+ """
21
+
22
+ def is_resolved(self) -> bool:
23
+ """Tell whether this badge has been handled and needs nothing more."""
24
+ ...
@@ -0,0 +1,59 @@
1
+ """A badge asking that a verified password be re-stored under a fresh hash."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from typing_extensions import override
8
+
9
+ from .badge_interface import BadgeInterface
10
+
11
+ if TYPE_CHECKING:
12
+ from xtr_security_core.user.password_upgrader_interface import PasswordUpgraderInterface
13
+
14
+ __all__ = ["PasswordUpgradeBadge"]
15
+
16
+
17
+ @final
18
+ class PasswordUpgradeBadge(BadgeInterface):
19
+ """Carries the plaintext and the place to write its upgraded hash to.
20
+
21
+ Added to a passport when a password verified against an outdated hash, so
22
+ the migrating listener can re-hash the plaintext with the current
23
+ algorithm and hand it to an upgrader to store. It resolves on its own — an
24
+ upgrade is opportunistic, never a condition of authentication.
25
+
26
+ Attributes:
27
+ plaintext_password: The verified plaintext, to be re-hashed.
28
+ password_upgrader: Where the fresh hash is written, when one is known.
29
+ """
30
+
31
+ __slots__ = ("_password_upgrader", "_plaintext_password")
32
+
33
+ def __init__(
34
+ self,
35
+ plaintext_password: str,
36
+ password_upgrader: PasswordUpgraderInterface | None = None,
37
+ ) -> None:
38
+ """Record the plaintext and, when known, where to store its new hash."""
39
+ self._plaintext_password = plaintext_password
40
+ self._password_upgrader = password_upgrader
41
+
42
+ def get_and_erase_plaintext_password(self) -> str:
43
+ """Return the plaintext once, then drop it so it is not held longer."""
44
+ plaintext = self._plaintext_password
45
+ self._plaintext_password = ""
46
+ return plaintext
47
+
48
+ def get_password_upgrader(self) -> PasswordUpgraderInterface | None:
49
+ """Return the upgrader the new hash is written to, if one was set."""
50
+ return self._password_upgrader
51
+
52
+ def set_password_upgrader(self, password_upgrader: PasswordUpgraderInterface) -> None:
53
+ """Set the upgrader the migrating listener writes the new hash to."""
54
+ self._password_upgrader = password_upgrader
55
+
56
+ @override
57
+ def is_resolved(self) -> bool:
58
+ """Report resolved always: an upgrade never gates authentication."""
59
+ return True
@@ -0,0 +1,27 @@
1
+ """A badge marking a user pre-authenticated, needing no credential check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import final
6
+
7
+ from typing_extensions import override
8
+
9
+ from .badge_interface import BadgeInterface
10
+
11
+ __all__ = ["PreAuthenticatedUserBadge"]
12
+
13
+
14
+ @final
15
+ class PreAuthenticatedUserBadge(BadgeInterface):
16
+ """States that the user was already authenticated elsewhere.
17
+
18
+ A bearer token was verified by its handler before ever reaching a
19
+ passport; there are no credentials on the passport to check. This badge
20
+ stands for that fact — it is resolved from the moment it exists, so the
21
+ credentials listener knows there is nothing to verify.
22
+ """
23
+
24
+ @override
25
+ def is_resolved(self) -> bool:
26
+ """Report resolved always: a pre-authenticated user needs no check."""
27
+ return True
@@ -0,0 +1,163 @@
1
+ """The badge naming the user an authentication is for."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from typing import TYPE_CHECKING, Final, final
7
+
8
+ from typing_extensions import override
9
+ from xtr_security_core.exception import AuthenticationServiceError
10
+ from xtr_security_core.user.user_interface import UserInterface
11
+
12
+ from .badge_interface import BadgeInterface
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Awaitable, Callable, Mapping
16
+
17
+ __all__ = ["MAX_USERNAME_LENGTH", "UserBadge"]
18
+
19
+ MAX_USERNAME_LENGTH: Final = 4096
20
+ """The longest identifier a badge accepts, guarding against a runaway input."""
21
+
22
+
23
+ @final
24
+ class UserBadge(BadgeInterface):
25
+ """Names the user an authentication is for, and loads them on demand.
26
+
27
+ The identifier is what authentication proved — a username, a token's
28
+ ``sub``. A loader turns it into a user: passed in by the authenticator, or
29
+ set by a user-provider listener during the passport check. Attributes
30
+ carried alongside — a token's claims — are handed to the loader, so an
31
+ attributes-based provider can build the user from more than the identifier.
32
+
33
+ The badge resolves as soon as its loader is set; :meth:`get_user` runs the
34
+ loader once and caches the user. An identifier longer than
35
+ :data:`MAX_USERNAME_LENGTH` is refused at construction.
36
+ """
37
+
38
+ __slots__ = ("_attributes", "_identifier", "_user", "_user_loader")
39
+
40
+ def __init__(
41
+ self,
42
+ identifier: str,
43
+ user_loader: Callable[..., UserInterface | Awaitable[UserInterface]] | None = None,
44
+ attributes: Mapping[str, object] | None = None,
45
+ identifier_normalizer: Callable[[str], str] | None = None,
46
+ ) -> None:
47
+ """Record the identifier, an optional loader and the attributes.
48
+
49
+ Args:
50
+ identifier: What authentication proved the caller to be.
51
+ user_loader: Turns the identifier (and attributes) into a user;
52
+ a provider listener sets one during the check when omitted.
53
+ attributes: Extra data — a token's claims — handed to the loader.
54
+ identifier_normalizer: Applied to the identifier before it is
55
+ stored, for a provider that folds case or trims it.
56
+
57
+ Raises:
58
+ BadCredentialsError: When the identifier is empty.
59
+ InvalidArgumentError: When the identifier is longer than
60
+ :data:`MAX_USERNAME_LENGTH`.
61
+ """
62
+ from xtr_security_core.exception import ( # noqa: PLC0415 -- local: only the constructor validates
63
+ BadCredentialsError,
64
+ InvalidArgumentError,
65
+ )
66
+
67
+ if identifier_normalizer is not None:
68
+ identifier = identifier_normalizer(identifier)
69
+ if not identifier:
70
+ raise BadCredentialsError("The user identifier must not be empty.")
71
+ if len(identifier) > MAX_USERNAME_LENGTH:
72
+ raise InvalidArgumentError(
73
+ f"The user identifier is too long, {MAX_USERNAME_LENGTH} characters at most.",
74
+ )
75
+ self._identifier = identifier
76
+ self._user_loader = user_loader
77
+ self._attributes: dict[str, object] = dict(attributes) if attributes is not None else {}
78
+ self._user: UserInterface | None = None
79
+
80
+ def get_user_identifier(self) -> str:
81
+ """Return the identifier this badge names."""
82
+ return self._identifier
83
+
84
+ def get_loaded_user(self) -> UserInterface:
85
+ """Return the user :meth:`get_user` already loaded, without doing I/O.
86
+
87
+ Raises:
88
+ AuthenticationServiceError: When the user has not been loaded yet,
89
+ which a token being created before the passport check would be.
90
+ """
91
+ if self._user is None:
92
+ raise AuthenticationServiceError(
93
+ "The user badge's user has not been loaded; the passport check must run first.",
94
+ )
95
+ return self._user
96
+
97
+ def get_attributes(self) -> Mapping[str, object]:
98
+ """Return the attributes handed to the loader."""
99
+ return dict(self._attributes)
100
+
101
+ def get_user_loader(self) -> Callable[..., UserInterface | Awaitable[UserInterface]] | None:
102
+ """Return the loader set for this badge, or ``None``."""
103
+ return self._user_loader
104
+
105
+ def set_user_loader(
106
+ self,
107
+ user_loader: Callable[..., UserInterface | Awaitable[UserInterface]],
108
+ ) -> None:
109
+ """Set the loader a provider listener resolved for this badge."""
110
+ self._user_loader = user_loader
111
+
112
+ async def get_user(self) -> UserInterface:
113
+ """Load the user this badge names, once, and return it.
114
+
115
+ The loader is called with the identifier, and with the attributes when
116
+ it accepts a second argument. Its result is awaited when it is an
117
+ awaitable.
118
+
119
+ Raises:
120
+ AuthenticationServiceError: When no loader was set, or the loader
121
+ returned something that is not a user.
122
+ UserNotFoundError: When the loader reports no such user.
123
+ """
124
+ if self._user is not None:
125
+ return self._user
126
+ if self._user_loader is None:
127
+ raise AuthenticationServiceError(
128
+ "No user loader is set on the user badge; a user provider must set one.",
129
+ )
130
+ result = self._call_loader(self._user_loader)
131
+ loaded: object = await result if inspect.isawaitable(result) else result
132
+ if not isinstance(loaded, UserInterface):
133
+ raise AuthenticationServiceError("The user loader did not return a user.")
134
+ self._user = loaded
135
+ return loaded
136
+
137
+ def _call_loader(
138
+ self,
139
+ loader: Callable[..., UserInterface | Awaitable[UserInterface]],
140
+ ) -> object:
141
+ """Call ``loader`` with the identifier, and the attributes when it takes them."""
142
+ try:
143
+ signature = inspect.signature(loader)
144
+ except (TypeError, ValueError):
145
+ return loader(self._identifier)
146
+ positional = [
147
+ parameter
148
+ for parameter in signature.parameters.values()
149
+ if parameter.kind
150
+ in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
151
+ ]
152
+ takes_varargs = any(
153
+ parameter.kind is inspect.Parameter.VAR_POSITIONAL
154
+ for parameter in signature.parameters.values()
155
+ )
156
+ if len(positional) >= 2 or takes_varargs: # noqa: PLR2004 -- identifier plus attributes
157
+ return loader(self._identifier, dict(self._attributes))
158
+ return loader(self._identifier)
159
+
160
+ @override
161
+ def is_resolved(self) -> bool:
162
+ """Tell whether a loader is set, so the user can be loaded."""
163
+ return self._user_loader is not None
@@ -0,0 +1,9 @@
1
+ """The credentials a passport carries for a listener to verify."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .credentials_interface import CredentialsInterface
6
+ from .custom_credentials import CustomCredentials
7
+ from .password_credentials import PasswordCredentials
8
+
9
+ __all__ = ["CredentialsInterface", "CustomCredentials", "PasswordCredentials"]
@@ -0,0 +1,21 @@
1
+ """What a set of credentials on a passport answers to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ from xtr_security_http.authenticator.passport.badge.badge_interface import BadgeInterface
8
+
9
+ __all__ = ["CredentialsInterface"]
10
+
11
+
12
+ @runtime_checkable
13
+ class CredentialsInterface(BadgeInterface, Protocol):
14
+ """A credential to verify, carried on a passport as a badge.
15
+
16
+ A password to check, a signature to validate. It is a
17
+ :class:`~xtr_security_http.authenticator.passport.badge.badge_interface.BadgeInterface`
18
+ like any other, unresolved until the listener that knows how verifies it —
19
+ at which point it reports resolved and drops whatever secret it held, so a
20
+ credential is consumed exactly once.
21
+ """
@@ -0,0 +1,64 @@
1
+ """A credential verified by a caller-supplied check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from typing import TYPE_CHECKING, final
7
+
8
+ from typing_extensions import override
9
+
10
+ from .credentials_interface import CredentialsInterface
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Awaitable, Callable
14
+
15
+ __all__ = ["CustomCredentials"]
16
+
17
+
18
+ @final
19
+ class CustomCredentials(CredentialsInterface):
20
+ """A credential whose verification an authenticator supplies as a check.
21
+
22
+ The escape hatch for a scheme the built-in credentials do not cover: the
23
+ check is a callable given the credentials and the resolved user, returning
24
+ whether they are valid — awaited when it returns an awaitable. The
25
+ credentials listener runs it, and a falsy result fails authentication.
26
+
27
+ Attributes:
28
+ checker: The callable that verifies ``credentials`` for a user.
29
+ credentials: The value handed to the checker.
30
+ """
31
+
32
+ __slots__ = ("_checker", "_credentials", "_resolved")
33
+
34
+ def __init__(
35
+ self,
36
+ checker: Callable[..., bool | Awaitable[bool]],
37
+ credentials: object,
38
+ ) -> None:
39
+ """Record the check and the credentials it verifies."""
40
+ self._checker = checker
41
+ self._credentials = credentials
42
+ self._resolved = False
43
+
44
+ async def verify(self, user: object) -> bool:
45
+ """Run the check for ``user`` and mark the credentials resolved.
46
+
47
+ The checker is called with the credentials and the user; a result that
48
+ is an awaitable is awaited. Resolving happens whatever the outcome — a
49
+ credential is consumed by being checked, valid or not.
50
+ """
51
+ result = self._checker(self._credentials, user)
52
+ if inspect.isawaitable(result):
53
+ result = await result
54
+ self._resolved = True
55
+ return bool(result)
56
+
57
+ def get_credentials(self) -> object:
58
+ """Return the credentials the checker verifies."""
59
+ return self._credentials
60
+
61
+ @override
62
+ def is_resolved(self) -> bool:
63
+ """Tell whether the check has run."""
64
+ return self._resolved
@@ -0,0 +1,51 @@
1
+ """A plaintext password to verify, consumed once."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import final
6
+
7
+ from typing_extensions import override
8
+ from xtr_security_core.exception import InvalidArgumentError
9
+
10
+ from .credentials_interface import CredentialsInterface
11
+
12
+ __all__ = ["PasswordCredentials"]
13
+
14
+
15
+ @final
16
+ class PasswordCredentials(CredentialsInterface):
17
+ """A plaintext password waiting to be verified.
18
+
19
+ Carried on a passport for the credentials listener to check against the
20
+ user's stored hash. Verifying it calls :meth:`mark_resolved`, which drops
21
+ the plaintext: it is held only until the one check that reads it, never
22
+ longer. Reading the plaintext after it is resolved is refused.
23
+ """
24
+
25
+ __slots__ = ("_password", "_resolved")
26
+
27
+ def __init__(self, password: str) -> None:
28
+ """Record the plaintext password to verify."""
29
+ self._password = password
30
+ self._resolved = False
31
+
32
+ def get_password(self) -> str:
33
+ """Return the plaintext to verify, before it is consumed.
34
+
35
+ Raises:
36
+ InvalidArgumentError: When the credentials are already resolved and
37
+ the plaintext has been dropped.
38
+ """
39
+ if self._resolved:
40
+ raise InvalidArgumentError("The password credentials have already been resolved.")
41
+ return self._password
42
+
43
+ def mark_resolved(self) -> None:
44
+ """Mark the credentials verified and drop the plaintext."""
45
+ self._resolved = True
46
+ self._password = ""
47
+
48
+ @override
49
+ def is_resolved(self) -> bool:
50
+ """Tell whether the credentials have been verified."""
51
+ return self._resolved
@@ -0,0 +1,104 @@
1
+ """The bag of badges an authenticator hands to the passport check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, ClassVar, TypeVar
6
+
7
+ from xtr_security_core.exception import BadCredentialsError
8
+
9
+ from xtr_security_http.authenticator.passport.badge.user_badge import UserBadge
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterable, Mapping
13
+
14
+ from xtr_security_core.user.user_interface import UserInterface
15
+
16
+ from .badge.badge_interface import BadgeInterface
17
+
18
+ __all__ = ["Passport"]
19
+
20
+ _BadgeT = TypeVar("_BadgeT", bound="BadgeInterface")
21
+
22
+
23
+ class Passport:
24
+ """What an authenticator produced from a request, before a token exists.
25
+
26
+ A passport always carries a
27
+ :class:`~xtr_security_http.authenticator.passport.badge.user_badge.UserBadge`
28
+ naming the user, and any number of other badges — credentials to verify, a
29
+ note to upgrade a password. Listeners resolve the badges during the
30
+ passport check; :meth:`get_user` loads the user through the user badge.
31
+ Attributes are a scratch space listeners and the authenticator share while
32
+ building the token.
33
+
34
+ Badges are keyed by class: a passport carries at most one of each kind.
35
+ """
36
+
37
+ __slots__: ClassVar[tuple[str, ...]] = ("_attributes", "_badges")
38
+
39
+ def __init__(
40
+ self,
41
+ user_badge: UserBadge,
42
+ badges: Iterable[BadgeInterface] = (),
43
+ attributes: Mapping[str, object] | None = None,
44
+ ) -> None:
45
+ """Record the user badge and any other badges, keyed by their class."""
46
+ self._badges: dict[type, BadgeInterface] = {}
47
+ _ = self.add_badge(user_badge)
48
+ for badge in badges:
49
+ _ = self.add_badge(badge)
50
+ self._attributes: dict[str, object] = dict(attributes) if attributes is not None else {}
51
+
52
+ def add_badge(self, badge: BadgeInterface) -> Passport:
53
+ """Attach ``badge``, replacing any badge of its class, and return self."""
54
+ self._badges[type(badge)] = badge
55
+ return self
56
+
57
+ def has_badge(self, badge_class: type[BadgeInterface]) -> bool:
58
+ """Tell whether a badge of ``badge_class`` is attached."""
59
+ return badge_class in self._badges
60
+
61
+ def get_badge(self, badge_class: type[_BadgeT]) -> _BadgeT | None:
62
+ """Return the badge of ``badge_class``, or ``None`` when none is attached."""
63
+ badge = self._badges.get(badge_class)
64
+ return badge if isinstance(badge, badge_class) else None
65
+
66
+ def get_badges(self) -> Mapping[type, BadgeInterface]:
67
+ """Return every badge attached, keyed by class."""
68
+ return dict(self._badges)
69
+
70
+ def get_user_badge(self) -> UserBadge:
71
+ """Return the user badge every passport carries."""
72
+ badge = self._badges[UserBadge]
73
+ assert isinstance(badge, UserBadge) # noqa: S101 -- construction guarantees it
74
+ return badge
75
+
76
+ async def get_user(self) -> UserInterface:
77
+ """Load and return the user through the user badge."""
78
+ return await self.get_user_badge().get_user()
79
+
80
+ def get_attribute(self, name: str, default: object = None) -> object:
81
+ """Return the attribute ``name``, or ``default`` when it is not set."""
82
+ return self._attributes.get(name, default)
83
+
84
+ def set_attribute(self, name: str, value: object) -> None:
85
+ """Attach ``value`` under ``name`` on the passport."""
86
+ self._attributes[name] = value
87
+
88
+ def get_attributes(self) -> Mapping[str, object]:
89
+ """Return every attribute attached to the passport."""
90
+ return dict(self._attributes)
91
+
92
+ def check_if_completely_resolved(self) -> None:
93
+ """Raise unless every badge reports itself resolved.
94
+
95
+ Raises:
96
+ BadCredentialsError: When any badge is still unresolved once the
97
+ passport check is done — a credential nobody verified, a user
98
+ badge no provider gave a loader.
99
+ """
100
+ for badge in self._badges.values():
101
+ if not badge.is_resolved():
102
+ raise BadCredentialsError(
103
+ f"The badge {type(badge).__name__} was not resolved by the passport check.",
104
+ )