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.
- xtr_security_http-3.0.0/LICENSE +21 -0
- xtr_security_http-3.0.0/PKG-INFO +427 -0
- xtr_security_http-3.0.0/README.md +396 -0
- xtr_security_http-3.0.0/pyproject.toml +159 -0
- xtr_security_http-3.0.0/pyproject.toml.orig +124 -0
- xtr_security_http-3.0.0/src/xtr_security_http/__init__.py +143 -0
- xtr_security_http-3.0.0/src/xtr_security_http/_runner.py +134 -0
- xtr_security_http-3.0.0/src/xtr_security_http/_state.py +57 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_map.py +52 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_map_interface.py +24 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_token/__init__.py +19 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_token/access_token_extractor_interface.py +31 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_token/access_token_handler_interface.py +34 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_token/chain_access_token_extractor.py +58 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_token/form_encoded_body_extractor.py +57 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_token/header_access_token_extractor.py +80 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_token/oidc/__init__.py +41 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_token/oidc/exception/__init__.py +7 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_token/oidc/exception/oidc_key_set_error.py +18 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_token/oidc/oidc_token_handler.py +246 -0
- xtr_security_http-3.0.0/src/xtr_security_http/access_token/query_access_token_extractor.py +51 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authentication/__init__.py +24 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authentication/_sensitive.py +60 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authentication/authentication_failure_handler_interface.py +31 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authentication/authentication_success_handler_interface.py +31 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authentication/authenticator_manager.py +232 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authentication/authenticator_manager_interface.py +40 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authentication/expose_security_level.py +27 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/__init__.py +9 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/abstract_authenticator.py +66 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/access_token_authenticator.py +199 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/authenticator_interface.py +68 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/oidc/__init__.py +7 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/oidc/oidc_jwks.py +87 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/__init__.py +8 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/badge/__init__.py +16 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/badge/badge_interface.py +24 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/badge/password_upgrade_badge.py +59 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/badge/pre_authenticated_user_badge.py +27 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/badge/user_badge.py +163 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/credentials/__init__.py +9 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/credentials/credentials_interface.py +21 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/credentials/custom_credentials.py +64 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/credentials/password_credentials.py +51 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/passport.py +104 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/passport/self_validating_passport.py +42 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/token/__init__.py +7 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authenticator/token/post_authentication_token.py +43 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authorization/__init__.py +15 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authorization/access_denied_handler_interface.py +27 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authorization/insufficient_scope_access_denied_handler.py +66 -0
- xtr_security_http-3.0.0/src/xtr_security_http/authorization/oauth2_scope_voter.py +122 -0
- xtr_security_http-3.0.0/src/xtr_security_http/decorator/__init__.py +16 -0
- xtr_security_http-3.0.0/src/xtr_security_http/decorator/current_user.py +83 -0
- xtr_security_http-3.0.0/src/xtr_security_http/decorator/is_granted.py +199 -0
- xtr_security_http-3.0.0/src/xtr_security_http/decorator/is_granted_context.py +14 -0
- xtr_security_http-3.0.0/src/xtr_security_http/entry_point/__init__.py +7 -0
- xtr_security_http-3.0.0/src/xtr_security_http/entry_point/authentication_entry_point_interface.py +33 -0
- xtr_security_http-3.0.0/src/xtr_security_http/event/__init__.py +20 -0
- xtr_security_http-3.0.0/src/xtr_security_http/event/authentication_token_created_event.py +48 -0
- xtr_security_http-3.0.0/src/xtr_security_http/event/check_passport_event.py +48 -0
- xtr_security_http-3.0.0/src/xtr_security_http/event/login_failure_event.py +79 -0
- xtr_security_http-3.0.0/src/xtr_security_http/event/login_success_event.py +79 -0
- xtr_security_http-3.0.0/src/xtr_security_http/event_listener/__init__.py +21 -0
- xtr_security_http-3.0.0/src/xtr_security_http/event_listener/check_credentials_listener.py +162 -0
- xtr_security_http-3.0.0/src/xtr_security_http/event_listener/password_migrating_listener.py +79 -0
- xtr_security_http-3.0.0/src/xtr_security_http/event_listener/user_checker_listener.py +60 -0
- xtr_security_http-3.0.0/src/xtr_security_http/event_listener/user_provider_listener.py +53 -0
- xtr_security_http-3.0.0/src/xtr_security_http/exception/__init__.py +18 -0
- xtr_security_http-3.0.0/src/xtr_security_http/exception/firewall_not_booted_error.py +18 -0
- xtr_security_http-3.0.0/src/xtr_security_http/exception/invalid_access_token_error.py +20 -0
- xtr_security_http-3.0.0/src/xtr_security_http/exception/unknown_firewall_error.py +34 -0
- xtr_security_http-3.0.0/src/xtr_security_http/firewall/__init__.py +15 -0
- xtr_security_http-3.0.0/src/xtr_security_http/firewall/access_listener.py +66 -0
- xtr_security_http-3.0.0/src/xtr_security_http/firewall/exception_listener.py +151 -0
- xtr_security_http-3.0.0/src/xtr_security_http/firewall/firewall.py +142 -0
- xtr_security_http-3.0.0/src/xtr_security_http/firewall_context_interface.py +64 -0
- xtr_security_http-3.0.0/src/xtr_security_http/firewall_map.py +93 -0
- xtr_security_http-3.0.0/src/xtr_security_http/firewall_map_interface.py +48 -0
- xtr_security_http-3.0.0/src/xtr_security_http/firewall_scheme.py +114 -0
- xtr_security_http-3.0.0/src/xtr_security_http/firewall_scheme_registry.py +87 -0
- xtr_security_http-3.0.0/src/xtr_security_http/oidc/__init__.py +7 -0
- xtr_security_http-3.0.0/src/xtr_security_http/oidc/oidc_discovery.py +238 -0
- xtr_security_http-3.0.0/src/xtr_security_http/py.typed +0 -0
- xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/__init__.py +28 -0
- xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/_pattern.py +27 -0
- xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/callable_request_matcher.py +37 -0
- xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/chain_request_matcher.py +37 -0
- xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/host_request_matcher.py +43 -0
- xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/ip_request_matcher.py +37 -0
- xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/method_request_matcher.py +32 -0
- xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/path_request_matcher.py +41 -0
- xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/request_matcher_interface.py +25 -0
- 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).
|