xtr-security-http 3.0.0__tar.gz

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 (94) hide show
  1. xtr_security_http-3.0.0/LICENSE +21 -0
  2. xtr_security_http-3.0.0/PKG-INFO +427 -0
  3. xtr_security_http-3.0.0/README.md +396 -0
  4. xtr_security_http-3.0.0/pyproject.toml +159 -0
  5. xtr_security_http-3.0.0/pyproject.toml.orig +124 -0
  6. xtr_security_http-3.0.0/src/xtr_security_http/__init__.py +143 -0
  7. xtr_security_http-3.0.0/src/xtr_security_http/_runner.py +134 -0
  8. xtr_security_http-3.0.0/src/xtr_security_http/_state.py +57 -0
  9. xtr_security_http-3.0.0/src/xtr_security_http/access_map.py +52 -0
  10. xtr_security_http-3.0.0/src/xtr_security_http/access_map_interface.py +24 -0
  11. xtr_security_http-3.0.0/src/xtr_security_http/access_token/__init__.py +19 -0
  12. xtr_security_http-3.0.0/src/xtr_security_http/access_token/access_token_extractor_interface.py +31 -0
  13. xtr_security_http-3.0.0/src/xtr_security_http/access_token/access_token_handler_interface.py +34 -0
  14. xtr_security_http-3.0.0/src/xtr_security_http/access_token/chain_access_token_extractor.py +58 -0
  15. xtr_security_http-3.0.0/src/xtr_security_http/access_token/form_encoded_body_extractor.py +57 -0
  16. xtr_security_http-3.0.0/src/xtr_security_http/access_token/header_access_token_extractor.py +80 -0
  17. xtr_security_http-3.0.0/src/xtr_security_http/access_token/oidc/__init__.py +41 -0
  18. xtr_security_http-3.0.0/src/xtr_security_http/access_token/oidc/exception/__init__.py +7 -0
  19. xtr_security_http-3.0.0/src/xtr_security_http/access_token/oidc/exception/oidc_key_set_error.py +18 -0
  20. xtr_security_http-3.0.0/src/xtr_security_http/access_token/oidc/oidc_token_handler.py +246 -0
  21. xtr_security_http-3.0.0/src/xtr_security_http/access_token/query_access_token_extractor.py +51 -0
  22. xtr_security_http-3.0.0/src/xtr_security_http/authentication/__init__.py +24 -0
  23. xtr_security_http-3.0.0/src/xtr_security_http/authentication/_sensitive.py +60 -0
  24. xtr_security_http-3.0.0/src/xtr_security_http/authentication/authentication_failure_handler_interface.py +31 -0
  25. xtr_security_http-3.0.0/src/xtr_security_http/authentication/authentication_success_handler_interface.py +31 -0
  26. xtr_security_http-3.0.0/src/xtr_security_http/authentication/authenticator_manager.py +232 -0
  27. xtr_security_http-3.0.0/src/xtr_security_http/authentication/authenticator_manager_interface.py +40 -0
  28. xtr_security_http-3.0.0/src/xtr_security_http/authentication/expose_security_level.py +27 -0
  29. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/__init__.py +9 -0
  30. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/abstract_authenticator.py +66 -0
  31. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/access_token_authenticator.py +199 -0
  32. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/authenticator_interface.py +68 -0
  33. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/oidc/__init__.py +7 -0
  34. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/oidc/oidc_jwks.py +87 -0
  35. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/__init__.py +8 -0
  36. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/badge/__init__.py +16 -0
  37. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/badge/badge_interface.py +24 -0
  38. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/badge/password_upgrade_badge.py +59 -0
  39. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/badge/pre_authenticated_user_badge.py +27 -0
  40. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/badge/user_badge.py +163 -0
  41. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/credentials/__init__.py +9 -0
  42. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/credentials/credentials_interface.py +21 -0
  43. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/credentials/custom_credentials.py +64 -0
  44. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/credentials/password_credentials.py +51 -0
  45. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/passport.py +104 -0
  46. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/self_validating_passport.py +42 -0
  47. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/token/__init__.py +7 -0
  48. xtr_security_http-3.0.0/src/xtr_security_http/authenticator/token/post_authentication_token.py +43 -0
  49. xtr_security_http-3.0.0/src/xtr_security_http/authorization/__init__.py +15 -0
  50. xtr_security_http-3.0.0/src/xtr_security_http/authorization/access_denied_handler_interface.py +27 -0
  51. xtr_security_http-3.0.0/src/xtr_security_http/authorization/insufficient_scope_access_denied_handler.py +66 -0
  52. xtr_security_http-3.0.0/src/xtr_security_http/authorization/oauth2_scope_voter.py +122 -0
  53. xtr_security_http-3.0.0/src/xtr_security_http/decorator/__init__.py +16 -0
  54. xtr_security_http-3.0.0/src/xtr_security_http/decorator/current_user.py +83 -0
  55. xtr_security_http-3.0.0/src/xtr_security_http/decorator/is_granted.py +199 -0
  56. xtr_security_http-3.0.0/src/xtr_security_http/decorator/is_granted_context.py +14 -0
  57. xtr_security_http-3.0.0/src/xtr_security_http/entry_point/__init__.py +7 -0
  58. xtr_security_http-3.0.0/src/xtr_security_http/entry_point/authentication_entry_point_interface.py +33 -0
  59. xtr_security_http-3.0.0/src/xtr_security_http/event/__init__.py +20 -0
  60. xtr_security_http-3.0.0/src/xtr_security_http/event/authentication_token_created_event.py +48 -0
  61. xtr_security_http-3.0.0/src/xtr_security_http/event/check_passport_event.py +48 -0
  62. xtr_security_http-3.0.0/src/xtr_security_http/event/login_failure_event.py +79 -0
  63. xtr_security_http-3.0.0/src/xtr_security_http/event/login_success_event.py +79 -0
  64. xtr_security_http-3.0.0/src/xtr_security_http/event_listener/__init__.py +21 -0
  65. xtr_security_http-3.0.0/src/xtr_security_http/event_listener/check_credentials_listener.py +162 -0
  66. xtr_security_http-3.0.0/src/xtr_security_http/event_listener/password_migrating_listener.py +79 -0
  67. xtr_security_http-3.0.0/src/xtr_security_http/event_listener/user_checker_listener.py +60 -0
  68. xtr_security_http-3.0.0/src/xtr_security_http/event_listener/user_provider_listener.py +53 -0
  69. xtr_security_http-3.0.0/src/xtr_security_http/exception/__init__.py +18 -0
  70. xtr_security_http-3.0.0/src/xtr_security_http/exception/firewall_not_booted_error.py +18 -0
  71. xtr_security_http-3.0.0/src/xtr_security_http/exception/invalid_access_token_error.py +20 -0
  72. xtr_security_http-3.0.0/src/xtr_security_http/exception/unknown_firewall_error.py +34 -0
  73. xtr_security_http-3.0.0/src/xtr_security_http/firewall/__init__.py +15 -0
  74. xtr_security_http-3.0.0/src/xtr_security_http/firewall/access_listener.py +66 -0
  75. xtr_security_http-3.0.0/src/xtr_security_http/firewall/exception_listener.py +151 -0
  76. xtr_security_http-3.0.0/src/xtr_security_http/firewall/firewall.py +142 -0
  77. xtr_security_http-3.0.0/src/xtr_security_http/firewall_context_interface.py +64 -0
  78. xtr_security_http-3.0.0/src/xtr_security_http/firewall_map.py +93 -0
  79. xtr_security_http-3.0.0/src/xtr_security_http/firewall_map_interface.py +48 -0
  80. xtr_security_http-3.0.0/src/xtr_security_http/firewall_scheme.py +114 -0
  81. xtr_security_http-3.0.0/src/xtr_security_http/firewall_scheme_registry.py +87 -0
  82. xtr_security_http-3.0.0/src/xtr_security_http/oidc/__init__.py +7 -0
  83. xtr_security_http-3.0.0/src/xtr_security_http/oidc/oidc_discovery.py +238 -0
  84. xtr_security_http-3.0.0/src/xtr_security_http/py.typed +0 -0
  85. xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/__init__.py +28 -0
  86. xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/_pattern.py +27 -0
  87. xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/callable_request_matcher.py +37 -0
  88. xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/chain_request_matcher.py +37 -0
  89. xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/host_request_matcher.py +43 -0
  90. xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/ip_request_matcher.py +37 -0
  91. xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/method_request_matcher.py +32 -0
  92. xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/path_request_matcher.py +41 -0
  93. xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/request_matcher_interface.py +25 -0
  94. xtr_security_http-3.0.0/src/xtr_security_http/security_events.py +41 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,427 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-security-http
3
+ Version: 3.0.0
4
+ Summary: The HTTP edge of xtr security: firewalls, authenticators, bearer access tokens and the request surface.
5
+ Keywords: security,authentication,firewall,fastapi,oauth2
6
+ Author: Xterr
7
+ Author-email: Xterr <me@xterr.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: xtr-security-core>=3.0,<4
18
+ Requires-Dist: xtr-http-kernel>=3.0,<4
19
+ Requires-Dist: xtr-event-dispatcher-contracts>=3.0,<4
20
+ Requires-Dist: xtr-logging-contracts>=3.0,<4
21
+ Requires-Dist: xtr-password-hasher>=3.0,<4
22
+ Requires-Dist: fastapi>=0.123
23
+ Requires-Dist: starlette>=0.40
24
+ Requires-Dist: anyio>=4.0
25
+ Requires-Dist: typing-extensions>=4.12
26
+ Requires-Dist: joserfc>=1.7 ; extra == 'oidc'
27
+ Requires-Dist: httpx>=0.27 ; extra == 'oidc'
28
+ Requires-Python: >=3.11
29
+ Provides-Extra: oidc
30
+ Description-Content-Type: text/markdown
31
+
32
+ <div align="center">
33
+
34
+ # xtr-security-http
35
+
36
+ **The HTTP edge of security: firewalls, authenticators, bearer access tokens and the request surface.**
37
+
38
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
39
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
40
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
41
+
42
+ </div>
43
+
44
+ ---
45
+
46
+ ## Why?
47
+
48
+ FastAPI already parses an `Authorization` header, documents a security scheme in OpenAPI, and
49
+ fills a dependency on a route. What it does not do is turn a credential into a *user*, enforce a
50
+ *rule* against that user before the endpoint runs, and answer an unauthenticated or denied
51
+ request with the right challenge. That part — a **firewall** over a slice of the request space,
52
+ the **authenticators** it runs, and the response it gives when authentication or authorization
53
+ fails — is this package.
54
+
55
+ It builds on [xtr-security-core](../xtr-security-core)'s users, tokens and voters, and on
56
+ [xtr-http-kernel](../xtr-http-kernel)'s request lifecycle:
57
+
58
+ - 🔥 **Firewalls** — a `FirewallMap` matches a request to one firewall; the firewall runs its
59
+ authenticators once per request and checks the request against an access map.
60
+ - 🎟️ **Authenticators and passports** — an authenticator reads a request into a `Passport` of
61
+ badges and credentials; a fixed event order resolves and checks it, then mints a token.
62
+ - 🪙 **Bearer access tokens** — extractors pull a token from a header, query or form; a handler
63
+ turns it into a user. An OIDC handler verifies a third-party issuer's tokens.
64
+ - 🛂 **The FastAPI surface stays FastAPI's** — `Firewall`, `IsGranted` and `CurrentUser` are
65
+ dependencies and decorators; the generated OpenAPI schema names each firewall's scheme.
66
+ - 🧩 **RFC 6750 challenges** — a `401` with `WWW-Authenticate: Bearer`, an `invalid_token` or
67
+ `insufficient_scope` challenge, written for you.
68
+
69
+ What FastAPI already does, it keeps doing: scheme parsing, argument resolution and the generated
70
+ schema stay the framework's. This package is the user, the rule and the challenge around them.
71
+
72
+ ## Install
73
+
74
+ ```sh
75
+ uv add xtr-security-http # firewalls, authenticators, access tokens
76
+ uv add "xtr-security-http[oidc]" # + verifying a third-party OIDC issuer's tokens
77
+ ```
78
+
79
+ Requires Python 3.11+. The web framework and the security core come with the package; the `oidc`
80
+ extra adds [joserfc](https://jose.authlib.org/) and [httpx](https://www.python-httpx.org/) for
81
+ verifying and discovering an issuer's keys.
82
+
83
+ ## Quick start
84
+
85
+ The HTTP edge is built from plain objects, and the pieces that turn a credential into a token
86
+ run without a container. Below: an access-token authenticator over a bearer-header extractor and
87
+ a handler you write, an access map matched with a request matcher, and the OAuth2 scope helpers —
88
+ all standalone.
89
+
90
+ ```python
91
+ from __future__ import annotations
92
+
93
+ import asyncio
94
+
95
+ from starlette.requests import Request
96
+ from xtr_event_dispatcher import EventDispatcher
97
+ from xtr_security_core import InMemoryUser, TokenStorage
98
+ from xtr_security_core.authorization.access_decision_manager import AccessDecisionManager
99
+ from xtr_security_http import (
100
+ AccessListener,
101
+ AccessMap,
102
+ AccessTokenAuthenticator,
103
+ AccessTokenHandlerInterface,
104
+ AuthenticatorManager,
105
+ HeaderAccessTokenExtractor,
106
+ InvalidAccessTokenError,
107
+ OAuth2ScopeVoter,
108
+ UserBadge,
109
+ oauth2_scope,
110
+ )
111
+ from xtr_security_http.authorization.oauth2_scope_voter import parse_oauth2_scope
112
+ from xtr_security_http.request_matcher import PathRequestMatcher
113
+
114
+
115
+ class MyTokenHandler(AccessTokenHandlerInterface):
116
+ async def get_user_badge_from(self, access_token: str) -> UserBadge:
117
+ if access_token != "s3cret":
118
+ raise InvalidAccessTokenError("Unknown token.")
119
+ return UserBadge("ada", user_loader=lambda i: InMemoryUser(i, roles=["ROLE_USER"]))
120
+
121
+
122
+ def make_request(path: str, headers: dict[str, str]) -> Request:
123
+ raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
124
+ return Request(
125
+ {"type": "http", "method": "GET", "path": path, "headers": raw, "query_string": b""}
126
+ )
127
+
128
+
129
+ async def main() -> None:
130
+ storage = TokenStorage()
131
+ manager = AuthenticatorManager(
132
+ authenticators=[
133
+ AccessTokenAuthenticator(
134
+ handler=MyTokenHandler(),
135
+ extractor=HeaderAccessTokenExtractor(),
136
+ )
137
+ ],
138
+ token_storage=storage,
139
+ event_dispatcher=EventDispatcher(),
140
+ firewall_name="api",
141
+ )
142
+
143
+ await manager.authenticate_request(make_request("/api/me", {"Authorization": "Bearer s3cret"}))
144
+ token = storage.get_token()
145
+ print("authenticated:", token is not None and token.get_user_identifier())
146
+
147
+ access_map = AccessMap()
148
+ access_map.add(PathRequestMatcher(r"^/api"), "ROLE_USER")
149
+ AccessListener(access_map, AccessDecisionManager()) # decides a token against the rules
150
+ print("rule attribute:", access_map.get_attribute(make_request("/api/me", {})))
151
+
152
+ attribute = oauth2_scope("books:read", "books:write")
153
+ print("oauth2_scope:", attribute)
154
+ print("parse_oauth2_scope:", parse_oauth2_scope(attribute))
155
+ print("voter supports:", OAuth2ScopeVoter().supports_attribute(attribute))
156
+
157
+
158
+ asyncio.run(main())
159
+ ```
160
+
161
+ ```console
162
+ authenticated: ada
163
+ rule attribute: ROLE_USER
164
+ oauth2_scope: OAUTH2_SCOPE(books:read books:write)
165
+ parse_oauth2_scope: ('books:read', 'books:write')
166
+ voter supports: True
167
+ ```
168
+
169
+ ### The full HTTP path needs the bundle
170
+
171
+ Running an actual firewall *on a FastAPI route* is more than these primitives. The `Firewall`
172
+ dependency resolves the `FirewallMap`, the token storage and the decision manager from the
173
+ container, and the `ExceptionListener` turns a security error into a challenge on the
174
+ [xtr-http-kernel](../xtr-http-kernel) lifecycle. Wiring all of that by hand is exactly what the
175
+ [`xtr-security`](../xtr-security) bundle exists to do: an application lists `SecurityBundle`,
176
+ writes firewalls as configuration, and uses `Firewall`, `IsGranted` and `CurrentUser` on its
177
+ routes. See [xtr-security's Quick start](../xtr-security#quick-start) for the whole loop, and its
178
+ [Use in an application](../xtr-security#use-in-an-application) for what adding it takes.
179
+
180
+ ## Concepts
181
+
182
+ ### Firewalls and the map
183
+
184
+ A **`FirewallContextInterface`** is one firewall's runtime pieces: its `name`, its
185
+ `authenticator_manager`, its `access_listener`, its own event `dispatcher`, the FastAPI `scheme`
186
+ it shows in OpenAPI, whether it has `security` at all, and the optional `entry_point`,
187
+ `access_denied_handler` and `scope_denied_handler` that answer a failure. A **`FirewallMap`**
188
+ holds those contexts: `match(request)` returns the first whose matcher claims the request,
189
+ `get(name)` fetches one by name (raising `UnknownFirewallError`), `has(name)` and `names()`
190
+ report what is registered. `FirewallMapInterface` is the protocol the bundle wires a
191
+ container-backed map to.
192
+
193
+ **`Firewall`** is the FastAPI dependency an application puts on a route or router.
194
+ `Firewall()` runs whichever firewall matches the request; `Firewall("api")` runs that one by
195
+ name and shows its exact scheme in OpenAPI. `firewall.scoped("books:read")` returns a variant
196
+ requiring scopes, and `Firewall` used as a decorator attaches to every route of a router or to a
197
+ single endpoint. Authentication runs **once per request** however many firewall dependencies a
198
+ route carries, memoised by firewall name. `FirewallScheme` is the `SecurityBase` a firewall
199
+ contributes to the schema; `FirewallSchemeRegistry` and `active_firewall_schemes(registry)` let
200
+ the generated schema name each firewall's scheme while the application is built.
201
+
202
+ ### Authenticators, passports, badges and credentials
203
+
204
+ An **`AuthenticatorInterface`** answers `supports(request)` — `True` to handle and stop others,
205
+ `False` to skip, `None` to handle lazily (let a later authenticator try if this one yields no
206
+ token) — reads the request into a `Passport` with `authenticate(request)`, and mints a token
207
+ with `create_token(passport, firewall_name)`. `AbstractAuthenticator` is the base that builds a
208
+ `PostAuthenticationToken` carrying the user and roles.
209
+
210
+ A **`Passport`** gathers a `UserBadge` and other `BadgeInterface` badges, keyed by class
211
+ (`add_badge`, `get_badge`, `has_badge`, `get_user`, attributes). A badge
212
+ `is_resolved()` reports whether it has been handled; `check_if_completely_resolved()` raises
213
+ `BadCredentialsError` if any badge is still open. The badges:
214
+
215
+ | Badge | Resolves when | Carries |
216
+ |---|---|---|
217
+ | `UserBadge(identifier, user_loader=None, ...)` | a loader is set | the identifier and how to load the user |
218
+ | `PasswordUpgradeBadge(plaintext, upgrader=None)` | always | a verified plaintext to rehash and store |
219
+ | `PreAuthenticatedUserBadge()` | always | nothing — the user is already trusted |
220
+
221
+ A **`CredentialsInterface`** is a badge to verify: `PasswordCredentials(password)` holds a
222
+ plaintext (`get_password()`, `mark_resolved()`), `CustomCredentials(checker, credentials)` runs
223
+ a callable `verify(user)`. A **`SelfValidatingPassport`** carries a `PreAuthenticatedUserBadge`,
224
+ for an authenticator — like the access-token one — that already trusts the credential.
225
+
226
+ ### The authenticator manager and its event order
227
+
228
+ **`AuthenticatorManager(authenticators, token_storage, event_dispatcher, firewall_name, ...)`**
229
+ runs authentication for one firewall. `authenticate_request(request)` drives a fixed order over
230
+ the firewall's own dispatcher:
231
+
232
+ 1. the first authenticator whose `supports()` is not `False` reads the request into a passport;
233
+ 2. **`CheckPassportEvent`** — listeners resolve the badges (load the user, verify the password);
234
+ 3. every badge must report resolved, and every `required_badges` type must be present, else
235
+ `BadCredentialsError`;
236
+ 4. a token is created, then **`AuthenticationTokenCreatedEvent`** lets a listener replace it;
237
+ 5. **`AuthenticationSuccessEvent`** ([core](../xtr-security-core)) runs the post-authentication
238
+ account check;
239
+ 6. the token is stored, the authenticator's success handler runs, and **`LoginSuccessEvent`**
240
+ lets a listener migrate a password or replace the response.
241
+
242
+ A lazy authenticator that raises a plain `BadCredentialsError` is read as *no credential
243
+ presented* — it did not apply, so the next authenticator is tried. Any other failure is masked to
244
+ the configured `ExposeSecurityLevel` (`NONE` hides everything as bad credentials,
245
+ `ACCOUNT_STATUS` reveals disabled/locked/expired, `ALL` reveals the failure), announced as
246
+ **`LoginFailureEvent`**, offered to the authenticator's failure handler, and re-raised for the
247
+ entry point to turn into a challenge. `AuthenticationSuccessHandlerInterface` and
248
+ `AuthenticationFailureHandlerInterface` are the two handler shapes a route may answer with.
249
+
250
+ ### Listeners
251
+
252
+ The bundle registers these on each firewall's dispatcher; they are the badge-resolving half of
253
+ the event order:
254
+
255
+ | Listener | Resolves |
256
+ |---|---|
257
+ | `UserProviderListener` | gives a `UserBadge` its loader from the firewall's user provider, unless it has one |
258
+ | `CheckCredentialsListener` | verifies a password passport's `PasswordCredentials` and `CustomCredentials` |
259
+ | `UserCheckerListener` | runs the account's pre- and post-authentication checks |
260
+ | `PasswordMigratingListener` | rehashes a password flagged by a `PasswordUpgradeBadge`, best-effort |
261
+
262
+ **The dummy-hash timing guard.** When a password passport names an *unknown* user,
263
+ `CheckCredentialsListener` still verifies against a dummy hash, so the timing of a bad username
264
+ and a bad password is the same — neither reveals which was wrong. The dummy is hashed once per
265
+ listener instance and kept, so no request pays the cost twice.
266
+
267
+ ### Access map and request matchers
268
+
269
+ An **`AccessMap`** is an ordered list of rules: `add(request_matcher, attribute)` appends one,
270
+ `get_attribute(request)` returns the attribute the first matching rule requires. An
271
+ **`AccessListener(access_map, access_decision_manager)`** decides a token against that attribute
272
+ with `check_access(request, token)`, raising `AccessDeniedError` when it is not granted. A
273
+ **`RequestMatcherInterface`** answers `matches(request)`:
274
+
275
+ | Matcher | Claims a request by |
276
+ |---|---|
277
+ | `PathRequestMatcher(pattern)` | its path (searched, not anchored) |
278
+ | `HostRequestMatcher(pattern)` | its host |
279
+ | `MethodRequestMatcher(methods)` | its method |
280
+ | `IpRequestMatcher(ips)` | its client address |
281
+ | `ChainRequestMatcher(matchers)` | all of several matchers (an empty chain claims everything) |
282
+ | `CallableRequestMatcher(decide)` | a callable `(request) -> bool` |
283
+
284
+ ### The exception listener: 401, 403 and RFC 6750
285
+
286
+ **`ExceptionListener`** is a subscriber on the [xtr-http-kernel](../xtr-http-kernel) exception
287
+ event. It turns a security error raised while handling into the right response:
288
+
289
+ - an **`AuthenticationError`** becomes the firewall's entry-point challenge, or a bare `401`
290
+ with `WWW-Authenticate: Bearer` when the firewall has no entry point;
291
+ - an **`AccessDeniedError`** from a caller who is not fully authenticated becomes the entry-point
292
+ challenge for insufficient authentication; from a fully authenticated caller, a scope challenge
293
+ (when the denied attribute is a scope), the firewall's access-denied handler, or a plain `403`;
294
+ - a response an authentication handler produced is carried straight out.
295
+
296
+ `AccessTokenAuthenticator` is itself an `AuthenticationEntryPointInterface`: `start(request,
297
+ error)` answers a request with an RFC 6750 bearer challenge, naming `invalid_token` when a
298
+ present token was rejected, and the `realm` when one is configured.
299
+
300
+ ### Decorators
301
+
302
+ Four markers put the edge on a route, and stay out of the generated schema:
303
+
304
+ | Decorator | Does |
305
+ |---|---|
306
+ | `Firewall(name=None, *, scopes=())` | run a firewall (matched or named) before the endpoint |
307
+ | `IsGranted(attribute, subject=None, *, message=None, status_code=None)` | require an attribute, raising `AccessDeniedError` (or answering `status_code`) |
308
+ | `CurrentUser(user_class=None, *, optional=False)` | inject the current user; `optional` yields `None` when anonymous |
309
+ | `IsGrantedContext` | the context a closure `IsGranted` attribute is handed (from [core](../xtr-security-core)) |
310
+
311
+ `IsGranted`'s `subject` is `None`, the name of a path parameter, or a callable used as a
312
+ dependency; its `attribute` is a string a voter matches or a callable
313
+ `(IsGrantedContext, subject) -> bool`. `CurrentUser` raises
314
+ `AuthenticationCredentialsNotFoundError` on an anonymous request (a `401`), or
315
+ `UnsupportedUserError` when the user is not the asked-for class.
316
+
317
+ ### Access tokens
318
+
319
+ An **`AccessTokenExtractorInterface`** pulls a bearer token out of a request and names the
320
+ FastAPI `scheme()` it documents by; an **`AccessTokenHandlerInterface`** turns a token string
321
+ into a `UserBadge` with `get_user_badge_from(token)`, raising `InvalidAccessTokenError` when it
322
+ cannot be trusted. The extractors:
323
+
324
+ | Extractor | Reads a token from |
325
+ |---|---|
326
+ | `HeaderAccessTokenExtractor(header_name="Authorization", token_type="Bearer")` | a header, after a scheme prefix |
327
+ | `QueryAccessTokenExtractor(parameter_name="access_token")` | a query parameter |
328
+ | `FormEncodedBodyExtractor(field_name="access_token")` | a form field |
329
+ | `ChainAccessTokenExtractor(extractors)` | the first that finds one |
330
+
331
+ **`AccessTokenAuthenticator(handler, extractor, user_provider=None, ..., realm=None)`** reads the
332
+ token, validates it through the handler, and builds a `SelfValidatingPassport`; it copies the
333
+ badge's granted `scope` onto the token as `oauth2_scope`.
334
+
335
+ ### OIDC
336
+
337
+ With the `oidc` extra, **`OidcTokenHandler`** verifies a third-party OpenID issuer's tokens:
338
+
339
+ ```python
340
+ from xtr_security_http.access_token.oidc.oidc_token_handler import OidcTokenHandler
341
+ from xtr_security_http.oidc.oidc_discovery import DiscoveryOidcKeySetProvider
342
+ ```
343
+
344
+ Its keys come from an `OidcKeySetProviderInterface` — `StaticOidcKeySetProvider(jwks)` for a
345
+ fixed JWKS, or `DiscoveryOidcKeySetProvider(base_uri=... | jwks_uri=..., ...)` which reads an
346
+ issuer's OpenID discovery document to find the `jwks_uri`, fetches the key set, caches it for a
347
+ `ttl`, and refreshes on a `kid` it does not know — no more than once every `refresh_cooldown`,
348
+ with a lock so concurrent callers share the one fetch. The handler checks the signature against
349
+ an explicit **algorithm allow-list** (refusing `none` and symmetric `HS*`), the `iss` against
350
+ the trusted issuers, the `aud` against the audience, and the time claims against a clock with
351
+ leeway. **`exp` is required** — a token that never expires is refused. A key set that cannot be
352
+ fetched, discovered or read raises `OidcKeySetError`; every discovery and JWKS endpoint must be
353
+ `https` unless insecure HTTP is explicitly allowed.
354
+
355
+ ### OAuth2 scopes
356
+
357
+ A scope requirement is written as an access-control attribute. `oauth2_scope("books:read",
358
+ "books:write")` builds the string `"OAUTH2_SCOPE(books:read books:write)"`;
359
+ `parse_oauth2_scope(attribute)` reads the scopes back out, or `None` for anything not shaped like
360
+ it. **`OAuth2ScopeVoter`** grants when the token holds every asked-for scope, reading the
361
+ `oauth2_scope` attribute an authenticator copied onto it; **`InsufficientScopeAccessDeniedHandler(realm=None)`**
362
+ answers a denied scope with a `403` carrying the RFC 6750 `insufficient_scope` challenge.
363
+
364
+ ### Security events
365
+
366
+ The event name constants a dispatcher keys on live in `security_events.py`: `CHECK_PASSPORT`,
367
+ `AUTHENTICATION_TOKEN_CREATED`, `LOGIN_SUCCESS`, `LOGIN_FAILURE`. A listener names the constant
368
+ rather than importing the event class, so a listener chosen at runtime stays in step.
369
+
370
+ ## Errors
371
+
372
+ Everything this library raises derives from `SecurityError` (from
373
+ [xtr-security-core](../xtr-security-core)); authentication failures additionally derive from its
374
+ `AuthenticationError`, so one `except AuthenticationError` catches the token failures.
375
+
376
+ | Error | Base (besides `SecurityError`) | Raised when |
377
+ |---|---|---|
378
+ | `InvalidAccessTokenError` | `AuthenticationError` | a bearer token was present but could not be trusted — answered with an `invalid_token` challenge |
379
+ | `UnknownFirewallError` | `LookupError` | a firewall was asked for by a name none is registered under; carries `name` and `configured` |
380
+ | `FirewallNotBootedError` | `RuntimeError` | a firewall's OpenAPI scheme was read before the kernel booted or outside a request |
381
+
382
+ ## Layout
383
+
384
+ ```
385
+ xtr_security_http/
386
+ ├── firewall_map.py FirewallMap, matched or fetched by name
387
+ ├── firewall_context_interface.py one firewall's runtime pieces
388
+ ├── firewall_scheme.py the SecurityBase a firewall shows in OpenAPI
389
+ ├── firewall/ Firewall, AccessListener, ExceptionListener
390
+ ├── authentication/ AuthenticatorManager, handlers, ExposeSecurityLevel
391
+ ├── authenticator/ AuthenticatorInterface, AccessTokenAuthenticator, passport/, token/
392
+ ├── access_token/ extractors, handler interface, oidc/ (OidcTokenHandler)
393
+ ├── oidc/ DiscoveryOidcKeySetProvider
394
+ ├── access_map.py the ordered access-control rules
395
+ ├── request_matcher/ path, host, method, ip, chain, callable
396
+ ├── authorization/ OAuth2ScopeVoter, oauth2_scope, the scope-denied handler
397
+ ├── decorator/ Firewall, IsGranted, CurrentUser, IsGrantedContext
398
+ ├── entry_point/ AuthenticationEntryPointInterface
399
+ ├── event/ the four firewall events
400
+ ├── security_events.py the name each is dispatched under
401
+ ├── event_listener/ the listeners the bundle registers (incl. the timing guard)
402
+ └── exception/ the errors this package adds
403
+ ```
404
+
405
+ ## In an application
406
+
407
+ This package works on its own and ships no bundle. The
408
+ [`xtr-security`](../xtr-security#use-in-an-application) bundle wires it — the firewalls, the
409
+ authenticators, the access map, the exception listener, the `Firewall`/`IsGranted`/`CurrentUser`
410
+ surface — into an application on [xtr-dependency-injection](../xtr-dependency-injection), with
411
+ firewalls written as configuration. See that package's
412
+ [Use in an application](../xtr-security#use-in-an-application).
413
+
414
+ ## Development
415
+
416
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
417
+ `packages/xtr-security-http`; run the commands below from there. The `python-xtr-security-http`
418
+ repository is a read-only copy, so send issues and pull requests to the monorepo.
419
+
420
+ ```sh
421
+ uv sync --all-extras
422
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
423
+ ```
424
+
425
+ ## License
426
+
427
+ MIT — see [LICENSE](LICENSE).