xtr-security-csrf 5.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 (21) hide show
  1. xtr_security_csrf-5.0.0/LICENSE +22 -0
  2. xtr_security_csrf-5.0.0/PKG-INFO +207 -0
  3. xtr_security_csrf-5.0.0/README.md +186 -0
  4. xtr_security_csrf-5.0.0/pyproject.toml +139 -0
  5. xtr_security_csrf-5.0.0/pyproject.toml.orig +104 -0
  6. xtr_security_csrf-5.0.0/src/xtr_security_csrf/__init__.py +45 -0
  7. xtr_security_csrf-5.0.0/src/xtr_security_csrf/csrf_token.py +46 -0
  8. xtr_security_csrf-5.0.0/src/xtr_security_csrf/csrf_token_manager.py +181 -0
  9. xtr_security_csrf-5.0.0/src/xtr_security_csrf/csrf_token_manager_interface.py +56 -0
  10. xtr_security_csrf-5.0.0/src/xtr_security_csrf/exception/__init__.py +19 -0
  11. xtr_security_csrf-5.0.0/src/xtr_security_csrf/exception/csrf_error.py +15 -0
  12. xtr_security_csrf-5.0.0/src/xtr_security_csrf/exception/invalid_argument_error.py +16 -0
  13. xtr_security_csrf-5.0.0/src/xtr_security_csrf/exception/token_not_found_error.py +33 -0
  14. xtr_security_csrf-5.0.0/src/xtr_security_csrf/py.typed +0 -0
  15. xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_generator/__init__.py +11 -0
  16. xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_generator/token_generator_interface.py +20 -0
  17. xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_generator/uri_safe_token_generator.py +50 -0
  18. xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_storage/__init__.py +13 -0
  19. xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_storage/clearable_token_storage_interface.py +24 -0
  20. xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_storage/session_token_storage.py +120 -0
  21. xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_storage/token_storage_interface.py +41 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+ Copyright (c) 2004-present Fabien Potencier
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-security-csrf
3
+ Version: 5.0.0
4
+ Summary: Cross-site request forgery protection for xtr applications: a manager that mints and checks tokens by id, and the session storage that keeps them.
5
+ Keywords: security,csrf,tokens,session
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: typing-extensions>=4.12
18
+ Requires-Dist: xtr-http-foundation>=5.0,<6
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+
22
+ <div align="center">
23
+
24
+ # xtr-security-csrf
25
+
26
+ **Cross-site request forgery protection: a manager that mints and checks tokens by id, and the session storage that keeps them.**
27
+
28
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
29
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
30
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
31
+
32
+ </div>
33
+
34
+ ---
35
+
36
+ ## Why?
37
+
38
+ A browser submits a form because a page told it to — and a page on someone else's site can
39
+ tell it to submit yours, carrying the user's cookies as though the request were the user's.
40
+ A **cross-site request forgery token** breaks that: the page that renders a form mints a
41
+ value into it, and the request that submits the form carries the value back, where it is
42
+ checked against what was minted. Another site's page cannot read the value — it is on your
43
+ origin's page, not theirs — so its forged submit does not check out.
44
+
45
+ This package is that mechanism, in three small pieces:
46
+
47
+ - 🎫 **A token** — `CsrfToken(token_id, value)`: the id names what the token guards, the
48
+ value answers for it.
49
+ - 🖋️ **A manager** — `CsrfTokenManager(storage, generator=None, namespace=None)` mints a
50
+ value per id, stores it, and masks what it hands out: every value a caller sees is
51
+ masked with a fresh key, so two renders of one form carry different strings — and both
52
+ check out, because what is *stored* never changes until `refresh_token` replaces it.
53
+ `is_token_valid` compares in constant time and never raises on what it is handed.
54
+ - 🗄️ **A storage** — `TokenStorageInterface` keeps values by id; `SessionTokenStorage`
55
+ keeps them in the [xtr-http-foundation](../xtr-http-foundation) session, under
56
+ `_csrf/<token_id>`, and `ClearableTokenStorageInterface.clear()` drops every token it
57
+ minted — the session strategy's call when a re-authentication rotates the session, so
58
+ a value stolen before the rotation cannot answer after it.
59
+
60
+ It ships no bundle of its own: it is a library member of the security family —
61
+ [xtr-security](../xtr-security) is the bundle that wires the family into an application on
62
+ [xtr-dependency-injection](../xtr-dependency-injection).
63
+
64
+ ## Install
65
+
66
+ ```sh
67
+ uv add xtr-security-csrf
68
+ ```
69
+
70
+ Requires Python 3.11+. One sibling comes with it:
71
+ [xtr-http-foundation](../xtr-http-foundation), whose session the storage keeps tokens in.
72
+
73
+ ## Quick start
74
+
75
+ Everything below runs without a container: a session on the memory storage, the storage
76
+ over it, the manager over that. Mint a token where a form renders, check it where the form
77
+ submits:
78
+
79
+ ```python
80
+ from __future__ import annotations
81
+
82
+ import asyncio
83
+
84
+ from xtr_http_foundation import MemorySessionStorage, Session
85
+ from xtr_security_csrf import CsrfToken, CsrfTokenManager, SessionTokenStorage
86
+
87
+
88
+ async def main() -> None:
89
+ session = Session(MemorySessionStorage())
90
+ manager = CsrfTokenManager(SessionTokenStorage(session))
91
+
92
+ # The page that renders the form mints the token into it.
93
+ token = await manager.get_token("newsletter")
94
+ print("render:", token.get_value())
95
+
96
+ # The request that submits the form carries the value back.
97
+ print("carried:", await manager.is_token_valid(token))
98
+
99
+ # A value nobody minted does not check out.
100
+ print("forged:", await manager.is_token_valid(CsrfToken("newsletter", "nope")))
101
+
102
+
103
+ asyncio.run(main())
104
+ ```
105
+
106
+ ```console
107
+ render: 3.kV3xtkP… (masked anew on every run)
108
+ carried: True
109
+ forged: False
110
+ ```
111
+
112
+ The manager's four calls:
113
+
114
+ | Call | What it does |
115
+ |---|---|
116
+ | `await get_token(token_id)` | Returns the token for the id, minting one where none is stored |
117
+ | `await refresh_token(token_id)` | Mints a fresh value for the id, stored or not — for once-only tokens |
118
+ | `await remove_token(token_id)` | Drops the value, returning what it held, or `None` |
119
+ | `await is_token_valid(token)` | Tells whether the token carries the value stored under its id |
120
+
121
+ ### The masked value
122
+
123
+ What a caller receives is never the stored value: `get_token` masks it with a fresh random
124
+ key every call — three dot-separated parts, a key-derived prefix, the key, the masked
125
+ value. The manager unmasks what comes back before comparing, so any string a request
126
+ carries can be handed to `is_token_valid` without a guard: one that does not read as a
127
+ masked value is compared as it came, and does not check out.
128
+
129
+ ### The namespace
130
+
131
+ Stored ids carry a prefix: `CsrfTokenManager(storage, namespace="https-")` stores under
132
+ `https-<token_id>`, so tokens minted for one origin do not answer for another. A string is
133
+ used as it is; a callable is asked each time — the scheme a request arrived under can
134
+ change between calls. `None`, the default, prefixes nothing.
135
+
136
+ ### The storage
137
+
138
+ `SessionTokenStorage(session, namespace="_csrf")` keeps each value under
139
+ `<namespace>/<token_id>` in the session's attributes, and keeps the list of its ids under
140
+ the namespace key itself — the session offers no way to walk its attributes, so the list
141
+ is what `clear()` drops them from: every token this storage minted, and nothing the rest of
142
+ the session holds. A token written under the namespace by something else than this storage
143
+ is not in the list, and survives a clear.
144
+
145
+ `clear()` is the session strategy's call, on the re-authentication that rotates the
146
+ session: the tokens minted under the old one must not answer for the new. `remove_token`
147
+ is the logout listener's — it drops the ids a listener knows by name.
148
+
149
+ ## Errors
150
+
151
+ Everything this library raises derives from `CsrfError`, and carries what went wrong as
152
+ typed attributes rather than only a message.
153
+
154
+ | Error | Base (besides `CsrfError`) | Raised when |
155
+ |---|---|---|
156
+ | `InvalidArgumentError` | `ValueError` | a declaration, configuration or call was malformed — a generator asked for less entropy than a byte holds |
157
+ | `TokenNotFoundError` | — | a value was asked for under an id that holds nothing; carries `get_token_id()` |
158
+
159
+ ## Layout
160
+
161
+ ```
162
+ xtr_security_csrf/
163
+ ├── csrf_token.py CsrfToken, the id and the value a caller holds
164
+ ├── csrf_token_manager_interface.py CsrfTokenManagerInterface, the four calls
165
+ ├── csrf_token_manager.py CsrfTokenManager, minting, masking, checking
166
+ ├── token_generator/
167
+ │ ├── token_generator_interface.py TokenGeneratorInterface, the minting seam
168
+ │ └── uri_safe_token_generator.py UriSafeTokenGenerator, URI-safe values
169
+ ├── token_storage/
170
+ │ ├── token_storage_interface.py TokenStorageInterface, values by id
171
+ │ ├── clearable_token_storage_interface.py ClearableTokenStorageInterface, + clear()
172
+ │ └── session_token_storage.py SessionTokenStorage, in the session
173
+ └── exception/ CsrfError, the root of everything raised
174
+ ```
175
+
176
+ ## Testing
177
+
178
+ The suite runs on `pytest` with `anyio`; the storages are exercised over a real
179
+ [xtr-http-foundation](../xtr-http-foundation) `Session` on `MemorySessionStorage`, so the
180
+ persist-and-reattach path a request actually takes is what the tests prove — not a mock
181
+ of it.
182
+
183
+ ## In an application
184
+
185
+ This package works on its own and ships no bundle. The
186
+ [`xtr-security`](../xtr-security#use-in-an-application) bundle wires it: a firewall that
187
+ asks for a token — a form login with `enable_csrf`, a logout's `enable_csrf` or
188
+ `clear_csrf_tokens`, a switch-user's `enable_csrf`, or the firewall's
189
+ `csrf_route_markers` for routes proving themselves with `IsCsrfTokenValid` — has the
190
+ bundle build the manager over the session the request
191
+ reattached, registered as the application's one `CsrfTokenManagerInterface` service. See
192
+ that package's [Use in an application](../xtr-security#use-in-an-application).
193
+
194
+ ## Development
195
+
196
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
197
+ `packages/xtr-security-csrf`; run the commands below from there. The `python-xtr-security-csrf`
198
+ repository is a read-only copy, so send issues and pull requests to the monorepo.
199
+
200
+ ```sh
201
+ uv sync
202
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
203
+ ```
204
+
205
+ ## License
206
+
207
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,186 @@
1
+ <div align="center">
2
+
3
+ # xtr-security-csrf
4
+
5
+ **Cross-site request forgery protection: a manager that mints and checks tokens by id, and the session storage that keeps them.**
6
+
7
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
8
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
9
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
10
+
11
+ </div>
12
+
13
+ ---
14
+
15
+ ## Why?
16
+
17
+ A browser submits a form because a page told it to — and a page on someone else's site can
18
+ tell it to submit yours, carrying the user's cookies as though the request were the user's.
19
+ A **cross-site request forgery token** breaks that: the page that renders a form mints a
20
+ value into it, and the request that submits the form carries the value back, where it is
21
+ checked against what was minted. Another site's page cannot read the value — it is on your
22
+ origin's page, not theirs — so its forged submit does not check out.
23
+
24
+ This package is that mechanism, in three small pieces:
25
+
26
+ - 🎫 **A token** — `CsrfToken(token_id, value)`: the id names what the token guards, the
27
+ value answers for it.
28
+ - 🖋️ **A manager** — `CsrfTokenManager(storage, generator=None, namespace=None)` mints a
29
+ value per id, stores it, and masks what it hands out: every value a caller sees is
30
+ masked with a fresh key, so two renders of one form carry different strings — and both
31
+ check out, because what is *stored* never changes until `refresh_token` replaces it.
32
+ `is_token_valid` compares in constant time and never raises on what it is handed.
33
+ - 🗄️ **A storage** — `TokenStorageInterface` keeps values by id; `SessionTokenStorage`
34
+ keeps them in the [xtr-http-foundation](../xtr-http-foundation) session, under
35
+ `_csrf/<token_id>`, and `ClearableTokenStorageInterface.clear()` drops every token it
36
+ minted — the session strategy's call when a re-authentication rotates the session, so
37
+ a value stolen before the rotation cannot answer after it.
38
+
39
+ It ships no bundle of its own: it is a library member of the security family —
40
+ [xtr-security](../xtr-security) is the bundle that wires the family into an application on
41
+ [xtr-dependency-injection](../xtr-dependency-injection).
42
+
43
+ ## Install
44
+
45
+ ```sh
46
+ uv add xtr-security-csrf
47
+ ```
48
+
49
+ Requires Python 3.11+. One sibling comes with it:
50
+ [xtr-http-foundation](../xtr-http-foundation), whose session the storage keeps tokens in.
51
+
52
+ ## Quick start
53
+
54
+ Everything below runs without a container: a session on the memory storage, the storage
55
+ over it, the manager over that. Mint a token where a form renders, check it where the form
56
+ submits:
57
+
58
+ ```python
59
+ from __future__ import annotations
60
+
61
+ import asyncio
62
+
63
+ from xtr_http_foundation import MemorySessionStorage, Session
64
+ from xtr_security_csrf import CsrfToken, CsrfTokenManager, SessionTokenStorage
65
+
66
+
67
+ async def main() -> None:
68
+ session = Session(MemorySessionStorage())
69
+ manager = CsrfTokenManager(SessionTokenStorage(session))
70
+
71
+ # The page that renders the form mints the token into it.
72
+ token = await manager.get_token("newsletter")
73
+ print("render:", token.get_value())
74
+
75
+ # The request that submits the form carries the value back.
76
+ print("carried:", await manager.is_token_valid(token))
77
+
78
+ # A value nobody minted does not check out.
79
+ print("forged:", await manager.is_token_valid(CsrfToken("newsletter", "nope")))
80
+
81
+
82
+ asyncio.run(main())
83
+ ```
84
+
85
+ ```console
86
+ render: 3.kV3xtkP… (masked anew on every run)
87
+ carried: True
88
+ forged: False
89
+ ```
90
+
91
+ The manager's four calls:
92
+
93
+ | Call | What it does |
94
+ |---|---|
95
+ | `await get_token(token_id)` | Returns the token for the id, minting one where none is stored |
96
+ | `await refresh_token(token_id)` | Mints a fresh value for the id, stored or not — for once-only tokens |
97
+ | `await remove_token(token_id)` | Drops the value, returning what it held, or `None` |
98
+ | `await is_token_valid(token)` | Tells whether the token carries the value stored under its id |
99
+
100
+ ### The masked value
101
+
102
+ What a caller receives is never the stored value: `get_token` masks it with a fresh random
103
+ key every call — three dot-separated parts, a key-derived prefix, the key, the masked
104
+ value. The manager unmasks what comes back before comparing, so any string a request
105
+ carries can be handed to `is_token_valid` without a guard: one that does not read as a
106
+ masked value is compared as it came, and does not check out.
107
+
108
+ ### The namespace
109
+
110
+ Stored ids carry a prefix: `CsrfTokenManager(storage, namespace="https-")` stores under
111
+ `https-<token_id>`, so tokens minted for one origin do not answer for another. A string is
112
+ used as it is; a callable is asked each time — the scheme a request arrived under can
113
+ change between calls. `None`, the default, prefixes nothing.
114
+
115
+ ### The storage
116
+
117
+ `SessionTokenStorage(session, namespace="_csrf")` keeps each value under
118
+ `<namespace>/<token_id>` in the session's attributes, and keeps the list of its ids under
119
+ the namespace key itself — the session offers no way to walk its attributes, so the list
120
+ is what `clear()` drops them from: every token this storage minted, and nothing the rest of
121
+ the session holds. A token written under the namespace by something else than this storage
122
+ is not in the list, and survives a clear.
123
+
124
+ `clear()` is the session strategy's call, on the re-authentication that rotates the
125
+ session: the tokens minted under the old one must not answer for the new. `remove_token`
126
+ is the logout listener's — it drops the ids a listener knows by name.
127
+
128
+ ## Errors
129
+
130
+ Everything this library raises derives from `CsrfError`, and carries what went wrong as
131
+ typed attributes rather than only a message.
132
+
133
+ | Error | Base (besides `CsrfError`) | Raised when |
134
+ |---|---|---|
135
+ | `InvalidArgumentError` | `ValueError` | a declaration, configuration or call was malformed — a generator asked for less entropy than a byte holds |
136
+ | `TokenNotFoundError` | — | a value was asked for under an id that holds nothing; carries `get_token_id()` |
137
+
138
+ ## Layout
139
+
140
+ ```
141
+ xtr_security_csrf/
142
+ ├── csrf_token.py CsrfToken, the id and the value a caller holds
143
+ ├── csrf_token_manager_interface.py CsrfTokenManagerInterface, the four calls
144
+ ├── csrf_token_manager.py CsrfTokenManager, minting, masking, checking
145
+ ├── token_generator/
146
+ │ ├── token_generator_interface.py TokenGeneratorInterface, the minting seam
147
+ │ └── uri_safe_token_generator.py UriSafeTokenGenerator, URI-safe values
148
+ ├── token_storage/
149
+ │ ├── token_storage_interface.py TokenStorageInterface, values by id
150
+ │ ├── clearable_token_storage_interface.py ClearableTokenStorageInterface, + clear()
151
+ │ └── session_token_storage.py SessionTokenStorage, in the session
152
+ └── exception/ CsrfError, the root of everything raised
153
+ ```
154
+
155
+ ## Testing
156
+
157
+ The suite runs on `pytest` with `anyio`; the storages are exercised over a real
158
+ [xtr-http-foundation](../xtr-http-foundation) `Session` on `MemorySessionStorage`, so the
159
+ persist-and-reattach path a request actually takes is what the tests prove — not a mock
160
+ of it.
161
+
162
+ ## In an application
163
+
164
+ This package works on its own and ships no bundle. The
165
+ [`xtr-security`](../xtr-security#use-in-an-application) bundle wires it: a firewall that
166
+ asks for a token — a form login with `enable_csrf`, a logout's `enable_csrf` or
167
+ `clear_csrf_tokens`, a switch-user's `enable_csrf`, or the firewall's
168
+ `csrf_route_markers` for routes proving themselves with `IsCsrfTokenValid` — has the
169
+ bundle build the manager over the session the request
170
+ reattached, registered as the application's one `CsrfTokenManagerInterface` service. See
171
+ that package's [Use in an application](../xtr-security#use-in-an-application).
172
+
173
+ ## Development
174
+
175
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
176
+ `packages/xtr-security-csrf`; run the commands below from there. The `python-xtr-security-csrf`
177
+ repository is a read-only copy, so send issues and pull requests to the monorepo.
178
+
179
+ ```sh
180
+ uv sync
181
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
182
+ ```
183
+
184
+ ## License
185
+
186
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,139 @@
1
+ [project]
2
+ name = "xtr-security-csrf"
3
+ version = "5.0.0"
4
+ description = "Cross-site request forgery protection for xtr applications: a manager that mints and checks tokens by id, and the session storage that keeps them."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = [
10
+ "security",
11
+ "csrf",
12
+ "tokens",
13
+ "session",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = [
25
+ "typing-extensions>=4.12",
26
+ "xtr-http-foundation>=5.0,<6",
27
+ ]
28
+
29
+ [[project.authors]]
30
+ name = "Xterr"
31
+ email = "me@xterr.dev"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "basedpyright>=1.21",
36
+ "ruff>=0.8",
37
+ "pytest>=8",
38
+ "pytest-cov>=5",
39
+ "anyio>=4.0",
40
+ "ty>=0.0.83",
41
+ ]
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.9.18,<0.10.0"]
45
+ build-backend = "uv_build"
46
+
47
+ [tool.basedpyright]
48
+ typeCheckingMode = "all"
49
+ pythonVersion = "3.11"
50
+ pythonPlatform = "All"
51
+ include = [
52
+ "src",
53
+ "tests",
54
+ ]
55
+ exclude = [
56
+ "**/__pycache__",
57
+ "**/.venv",
58
+ "**/build",
59
+ "**/dist",
60
+ ".tmp",
61
+ ]
62
+ reportUnusedCallResult = "warning"
63
+ reportUnnecessaryTypeIgnoreComment = "error"
64
+ reportUnusedVariable = "error"
65
+ reportMissingParameterType = "error"
66
+ reportPrivateUsage = "error"
67
+ reportImplicitStringConcatenation = "none"
68
+
69
+ [[tool.basedpyright.executionEnvironments]]
70
+ root = "tests"
71
+ extraPaths = ["."]
72
+ reportPrivateUsage = "none"
73
+
74
+ [tool.ruff]
75
+ target-version = "py311"
76
+ line-length = 100
77
+ src = [
78
+ "src",
79
+ "tests",
80
+ ]
81
+
82
+ [tool.ruff.lint]
83
+ select = ["ALL"]
84
+ ignore = [
85
+ "COM812",
86
+ "ISC001",
87
+ "D203",
88
+ "D213",
89
+ "CPY001",
90
+ "FBT001",
91
+ "FBT002",
92
+ "TD002",
93
+ "TD003",
94
+ "FIX002",
95
+ "TRY003",
96
+ "EM101",
97
+ "EM102",
98
+ ]
99
+ fixable = ["ALL"]
100
+ unfixable = []
101
+
102
+ [tool.ruff.lint.per-file-ignores]
103
+ "tests/**/*.py" = [
104
+ "S101",
105
+ "ARG",
106
+ "PLR2004",
107
+ "SLF001",
108
+ "D",
109
+ ]
110
+
111
+ [tool.ruff.lint.pydocstyle]
112
+ convention = "google"
113
+
114
+ [tool.ruff.format]
115
+ quote-style = "double"
116
+ indent-style = "space"
117
+
118
+ [tool.ty.src]
119
+ include = [
120
+ "src",
121
+ "tests",
122
+ ]
123
+
124
+ [tool.pytest.ini_options]
125
+ minversion = "8.0"
126
+ testpaths = ["tests"]
127
+ addopts = [
128
+ "-ra",
129
+ "--strict-config",
130
+ "--strict-markers",
131
+ ]
132
+ filterwarnings = ["error"]
133
+
134
+ [tool.coverage.run]
135
+ source = ["src"]
136
+ branch = true
137
+
138
+ [tool.coverage.report]
139
+ fail_under = 95
@@ -0,0 +1,104 @@
1
+ [project]
2
+ name = "xtr-security-csrf"
3
+ version = "5.0.0"
4
+ description = "Cross-site request forgery protection for xtr applications: a manager that mints and checks tokens by id, and the session storage that keeps them."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ { name = "Xterr", email = "me@xterr.dev" }
11
+ ]
12
+ keywords = ["security", "csrf", "tokens", "session"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "typing-extensions>=4.12",
24
+ "xtr-http-foundation>=5.0,<6",
25
+ ]
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "basedpyright>=1.21",
30
+ "ruff>=0.8",
31
+ "pytest>=8",
32
+ "pytest-cov>=5",
33
+ "anyio>=4.0",
34
+ "ty>=0.0.83",
35
+ ]
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.9.18,<0.10.0"]
39
+ build-backend = "uv_build"
40
+
41
+ [tool.basedpyright]
42
+ typeCheckingMode = "all"
43
+ pythonVersion = "3.11"
44
+ pythonPlatform = "All"
45
+ include = ["src", "tests"]
46
+ exclude = ["**/__pycache__", "**/.venv", "**/build", "**/dist", ".tmp"]
47
+ reportUnusedCallResult = "warning"
48
+ reportUnnecessaryTypeIgnoreComment = "error"
49
+ reportUnusedVariable = "error"
50
+ reportMissingParameterType = "error"
51
+ reportPrivateUsage = "error"
52
+
53
+ # An error message long enough to be useful has to wrap, and a 100-column
54
+ # limit puts the break inside the string.
55
+ reportImplicitStringConcatenation = "none"
56
+
57
+ # Tests reach into what a class keeps to itself, for the same reason ruff's
58
+ # SLF001 is off for tests; the extra path lets them import the support package.
59
+ [[tool.basedpyright.executionEnvironments]]
60
+ root = "tests"
61
+ extraPaths = ["."]
62
+ reportPrivateUsage = "none"
63
+
64
+ [tool.ruff]
65
+ target-version = "py311"
66
+ line-length = 100
67
+ src = ["src", "tests"]
68
+
69
+ [tool.ruff.lint]
70
+ select = ["ALL"]
71
+ ignore = [
72
+ "COM812", "ISC001", "D203", "D213", "CPY001", "FBT001", "FBT002", "TD002", "TD003", "FIX002",
73
+ # Errors compose their message from typed fields; the string at a raise site is a reason.
74
+ "TRY003", "EM101", "EM102",
75
+ ]
76
+ fixable = ["ALL"]
77
+ unfixable = []
78
+
79
+ [tool.ruff.lint.per-file-ignores]
80
+ "tests/**/*.py" = ["S101", "ARG", "PLR2004", "SLF001", "D"]
81
+
82
+ [tool.ruff.lint.pydocstyle]
83
+ convention = "google"
84
+
85
+ [tool.ruff.format]
86
+ quote-style = "double"
87
+ indent-style = "space"
88
+
89
+ [tool.ty.src]
90
+ include = ["src", "tests"]
91
+
92
+ [tool.pytest.ini_options]
93
+ minversion = "8.0"
94
+ testpaths = ["tests"]
95
+ addopts = ["-ra", "--strict-config", "--strict-markers"]
96
+ filterwarnings = ["error"]
97
+
98
+ [tool.coverage.run]
99
+ source = ["src"]
100
+ branch = true
101
+
102
+ [tool.coverage.report]
103
+ # CI runs the tests with --cov; a change taking coverage below this fails there.
104
+ fail_under = 95