xtr-security-csrf 5.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ """Cross-site request forgery tokens, minted and checked by id.
2
+
3
+ A manager mints a value per token id, masks what it hands out, and checks
4
+ what comes back against what it stored; a storage keeps the values — in the
5
+ session a request carries, or wherever the application points them. This
6
+ module gathers the public surface so an application imports it from one
7
+ place.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib.metadata import PackageNotFoundError, version
13
+
14
+ from .csrf_token import CsrfToken
15
+ from .csrf_token_manager import CsrfTokenManager
16
+ from .csrf_token_manager_interface import CsrfTokenManagerInterface
17
+ from .exception import CsrfError, InvalidArgumentError, TokenNotFoundError
18
+ from .token_generator import TokenGeneratorInterface, UriSafeTokenGenerator
19
+ from .token_storage import (
20
+ ClearableTokenStorageInterface,
21
+ SessionTokenStorage,
22
+ TokenStorageInterface,
23
+ )
24
+
25
+ __all__ = [
26
+ "ClearableTokenStorageInterface",
27
+ "CsrfError",
28
+ "CsrfToken",
29
+ "CsrfTokenManager",
30
+ "CsrfTokenManagerInterface",
31
+ "InvalidArgumentError",
32
+ "SessionTokenStorage",
33
+ "TokenGeneratorInterface",
34
+ "TokenNotFoundError",
35
+ "TokenStorageInterface",
36
+ "UriSafeTokenGenerator",
37
+ "__version__",
38
+ ]
39
+
40
+ try:
41
+ __version__ = version("xtr-security-csrf")
42
+ except PackageNotFoundError: # pragma: no cover
43
+ # Running from a source tree with no installed metadata to read; having no
44
+ # version is better than refusing to import.
45
+ __version__ = "0+unknown"
@@ -0,0 +1,46 @@
1
+ """A cross-site request forgery token, as a caller holds one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import final
6
+
7
+ from typing_extensions import override
8
+
9
+ __all__ = ["CsrfToken"]
10
+
11
+
12
+ @final
13
+ class CsrfToken:
14
+ """The id a token is known under, and the value that answers for it.
15
+
16
+ A token id names what the token guards — a form, an action, a listener —
17
+ and the value is what a request carries to prove that the page which
18
+ rendered it asked for it. The pair travels together: the id says which
19
+ stored value the carried one is checked against.
20
+ """
21
+
22
+ __slots__ = ("_id", "_value")
23
+
24
+ def __init__(self, token_id: str, value: str = "") -> None:
25
+ """Record the id and the value that answers for it.
26
+
27
+ Args:
28
+ token_id: What the token guards, chosen by whoever mints it.
29
+ value: The value that answers for the id; the empty value is
30
+ the token a caller holds before one has been minted.
31
+ """
32
+ self._id = token_id
33
+ self._value = value
34
+
35
+ def get_id(self) -> str:
36
+ """Return the id this token is known under."""
37
+ return self._id
38
+
39
+ def get_value(self) -> str:
40
+ """Return the value that answers for the id."""
41
+ return self._value
42
+
43
+ @override
44
+ def __str__(self) -> str:
45
+ """Return the value, so a token renders straight into a form or a header."""
46
+ return self._value
@@ -0,0 +1,181 @@
1
+ """The default token manager: values minted per id, masked on the way out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import secrets
9
+ from typing import TYPE_CHECKING, final
10
+
11
+ from typing_extensions import override
12
+
13
+ from .csrf_token import CsrfToken
14
+ from .csrf_token_manager_interface import CsrfTokenManagerInterface
15
+ from .token_generator.uri_safe_token_generator import UriSafeTokenGenerator
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable
19
+
20
+ from .token_generator.token_generator_interface import TokenGeneratorInterface
21
+ from .token_storage.token_storage_interface import TokenStorageInterface
22
+
23
+ __all__ = ["CsrfTokenManager"]
24
+
25
+ # The key a handed-out value is masked with. Long enough that guessing it is
26
+ # hopeless, short enough that masking costs nothing.
27
+ _MASK_KEY_LENGTH: int = 32
28
+ # A masked value is three dot-separated parts: the prefix, the key, the value.
29
+ _MASKED_PARTS: int = 3
30
+
31
+
32
+ @final
33
+ class CsrfTokenManager(CsrfTokenManagerInterface):
34
+ """Mints a value per token id, stores it, and masks what it hands out.
35
+
36
+ What answers for an id is not the value that is stored: every value
37
+ handed out is masked with a fresh key, so two renders of the same form
38
+ carry different strings, and a page that compresses its response cannot
39
+ be made to leak a token by echoing one back. The masked value is all a
40
+ caller ever sees; the stored one is all the manager ever compares.
41
+
42
+ The id a value is stored under is the namespace followed by the token
43
+ id, so tokens minted for one origin do not answer for another.
44
+ """
45
+
46
+ __slots__ = ("_generator", "_namespace", "_storage")
47
+
48
+ def __init__(
49
+ self,
50
+ storage: TokenStorageInterface,
51
+ generator: TokenGeneratorInterface | None = None,
52
+ namespace: str | Callable[[], str] | None = None,
53
+ ) -> None:
54
+ """Record where values are kept, what mints them, and how ids are prefixed.
55
+
56
+ Args:
57
+ storage: Where token values are kept, by namespaced id.
58
+ generator: What mints a fresh value; a URI-safe generator when
59
+ nothing is given.
60
+ namespace: The prefix every stored id carries, so tokens minted
61
+ for one origin do not answer for another: a string used as
62
+ it is, or a callable asked each time — the scheme a request
63
+ arrived under can change between calls, so it is read per
64
+ call, not once. ``None`` prefixes nothing.
65
+ """
66
+ self._storage = storage
67
+ self._generator = generator if generator is not None else UriSafeTokenGenerator()
68
+ self._namespace = namespace
69
+
70
+ @override
71
+ async def get_token(self, token_id: str) -> CsrfToken:
72
+ """Return the token for ``token_id``, minting one where none is stored.
73
+
74
+ A value already stored is handed out again — masked anew, so the
75
+ string differs, but it answers for the same stored value.
76
+ """
77
+ namespaced_id = self._namespaced_id(token_id)
78
+ if await self._storage.has_token(namespaced_id):
79
+ value = await self._storage.get_token(namespaced_id)
80
+ else:
81
+ value = self._generator.generate_token()
82
+ await self._storage.set_token(namespaced_id, value)
83
+
84
+ return CsrfToken(token_id, self._randomize(value))
85
+
86
+ @override
87
+ async def refresh_token(self, token_id: str) -> CsrfToken:
88
+ """Mint a fresh value for ``token_id``, replacing whatever was stored."""
89
+ namespaced_id = self._namespaced_id(token_id)
90
+ value = self._generator.generate_token()
91
+ await self._storage.set_token(namespaced_id, value)
92
+
93
+ return CsrfToken(token_id, self._randomize(value))
94
+
95
+ @override
96
+ async def remove_token(self, token_id: str) -> str | None:
97
+ """Drop the value under ``token_id``, returning what it held."""
98
+ return await self._storage.remove_token(self._namespaced_id(token_id))
99
+
100
+ @override
101
+ async def is_token_valid(self, token: CsrfToken) -> bool:
102
+ """Tell whether ``token`` carries the value stored under its id.
103
+
104
+ The comparison is constant-time, and the value it is handed is never
105
+ trusted to be well-formed: one that does not read as a value this
106
+ manager minted is compared as it came, and does not check out.
107
+ """
108
+ namespaced_id = self._namespaced_id(token.get_id())
109
+ if not await self._storage.has_token(namespaced_id):
110
+ return False
111
+
112
+ stored = await self._storage.get_token(namespaced_id)
113
+ return hmac.compare_digest(
114
+ stored.encode(),
115
+ self._derandomize(token.get_value()).encode(),
116
+ )
117
+
118
+ def _namespaced_id(self, token_id: str) -> str:
119
+ """Return the id as it is stored: the namespace, then the id."""
120
+ namespace = self._namespace
121
+ if namespace is None:
122
+ return token_id
123
+ if isinstance(namespace, str):
124
+ return namespace + token_id
125
+ return namespace() + token_id
126
+
127
+ def _randomize(self, value: str) -> str:
128
+ """Mask ``value`` with a fresh key, so what is handed out differs every call.
129
+
130
+ The key travels with the masked value — there is nothing to keep
131
+ between calls — and a short hex prefix of key-derived length heads
132
+ the three parts, so two values of the same token are not even the
133
+ same length. The prefix is never read back; only the key and the
134
+ masked value are.
135
+ """
136
+ key = secrets.token_bytes(_MASK_KEY_LENGTH)
137
+ masked = _xor(value.encode(), key)
138
+ prefix = hashlib.sha256(key).hexdigest()[: 1 + key[0] % 32]
139
+ return f"{prefix}.{_encode(key)}.{_encode(masked)}"
140
+
141
+ def _derandomize(self, value: str) -> str:
142
+ """Return the stored value a handed-out string masks, or the string itself.
143
+
144
+ Anything that does not read as a masked value — not three parts, a
145
+ part that is not the encoding it claims, bytes that are not text —
146
+ is returned exactly as it came, and so fails the comparison that
147
+ follows. A caller's string is never trusted to be well-formed.
148
+ """
149
+ parts = value.split(".")
150
+ if len(parts) != _MASKED_PARTS:
151
+ return value
152
+
153
+ try:
154
+ key = _decode(parts[1])
155
+ if not key:
156
+ return value
157
+ masked = _decode(parts[2])
158
+ return _xor(masked, key).decode()
159
+ except ValueError:
160
+ return value
161
+
162
+
163
+ def _xor(value: bytes, key: bytes) -> bytes:
164
+ """Return ``value`` masked with ``key``, byte for byte.
165
+
166
+ The key is repeated until it covers the value, and the result is as
167
+ long as the shorter of the two — the value, once it is.
168
+ """
169
+ if len(value) > len(key):
170
+ key = key * ((len(value) + len(key) - 1) // len(key))
171
+ return bytes(left ^ right for left, right in zip(value, key, strict=False))
172
+
173
+
174
+ def _encode(data: bytes) -> str:
175
+ """Return ``data`` as URI-safe base64, without padding."""
176
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
177
+
178
+
179
+ def _decode(data: str) -> bytes:
180
+ """Return the bytes a URI-safe, unpadded value encodes."""
181
+ return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
@@ -0,0 +1,56 @@
1
+ """The one surface every token manager answers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
6
+
7
+ if TYPE_CHECKING:
8
+ from .csrf_token import CsrfToken
9
+
10
+ __all__ = ["CsrfTokenManagerInterface"]
11
+
12
+
13
+ @runtime_checkable
14
+ class CsrfTokenManagerInterface(Protocol):
15
+ """Mints, renews, drops and checks tokens, by id.
16
+
17
+ Every member is awaited: a manager reaches a storage to answer any of
18
+ them, and one storage must be swappable for another — a remote one is
19
+ awaited — exactly as every matcher is awaited even when it only reads
20
+ the head of a request.
21
+ """
22
+
23
+ async def get_token(self, token_id: str) -> CsrfToken:
24
+ """Return the token for ``token_id``, minting one where none is stored.
25
+
26
+ A token already stored is returned with the value it holds — the
27
+ same stored value, though not the same string, since what is handed
28
+ out is masked per call — so asking twice answers two tokens that
29
+ both check out.
30
+ """
31
+ ...
32
+
33
+ async def refresh_token(self, token_id: str) -> CsrfToken:
34
+ """Mint a fresh value for ``token_id``, stored or not.
35
+
36
+ For once-only tokens, in places with high security needs: whatever
37
+ was stored under the id is replaced, independent of whether a value
38
+ existed before.
39
+ """
40
+ ...
41
+
42
+ async def remove_token(self, token_id: str) -> str | None:
43
+ """Drop the token under ``token_id``, if one is stored.
44
+
45
+ Returns:
46
+ The value the id held, or ``None`` when it held nothing.
47
+ """
48
+ ...
49
+
50
+ async def is_token_valid(self, token: CsrfToken) -> bool:
51
+ """Tell whether ``token`` carries the value stored under its id.
52
+
53
+ Never raises on the value it is handed: one that does not read as
54
+ a value this manager minted simply does not check out.
55
+ """
56
+ ...
@@ -0,0 +1,19 @@
1
+ """Every error this library raises.
2
+
3
+ All of them derive from :class:`CsrfError`, so one ``except`` catches
4
+ anything minting or checking a token can go wrong with, and a narrower one
5
+ handles a single cause. Each carries the data a caller needs as typed
6
+ attributes rather than forcing a message to be parsed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .csrf_error import CsrfError
12
+ from .invalid_argument_error import InvalidArgumentError
13
+ from .token_not_found_error import TokenNotFoundError
14
+
15
+ __all__ = [
16
+ "CsrfError",
17
+ "InvalidArgumentError",
18
+ "TokenNotFoundError",
19
+ ]
@@ -0,0 +1,15 @@
1
+ """The root every error in this library derives from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["CsrfError"]
6
+
7
+
8
+ class CsrfError(Exception):
9
+ """Base class for every error raised by this library.
10
+
11
+ Catch this to handle anything minting or checking a cross-site request
12
+ forgery token can go wrong with; catch a subclass to handle one cause.
13
+ Every subclass carries the data a caller needs as typed attributes and
14
+ composes its own message from them.
15
+ """
@@ -0,0 +1,16 @@
1
+ """Something was declared or configured in a way this library cannot work with."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .csrf_error import CsrfError
6
+
7
+ __all__ = ["InvalidArgumentError"]
8
+
9
+
10
+ class InvalidArgumentError(CsrfError, ValueError):
11
+ """A declaration, configuration or call was malformed.
12
+
13
+ A generator asked for less entropy than a byte holds — mistakes caught
14
+ where they are made. Also a :class:`ValueError`, so code guarding its
15
+ configuration with ``except ValueError`` keeps working.
16
+ """
@@ -0,0 +1,33 @@
1
+ """A token was asked for under an id nothing is stored under."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .csrf_error import CsrfError
6
+
7
+ __all__ = ["TokenNotFoundError"]
8
+
9
+
10
+ class TokenNotFoundError(CsrfError):
11
+ """No token is stored under the id a caller asked to read.
12
+
13
+ The manager never raises it — where nothing is stored it mints — it is
14
+ the error a storage raises when its ``get_token`` is called directly for
15
+ an id that holds nothing.
16
+ """
17
+
18
+ __slots__: tuple[str, ...] = ("_token_id",)
19
+
20
+ _token_id: str
21
+
22
+ def __init__(self, token_id: str) -> None:
23
+ """Record which id was asked about and found empty.
24
+
25
+ Args:
26
+ token_id: The id whose stored value was asked for.
27
+ """
28
+ super().__init__(f"The CSRF token with ID {token_id} does not exist.")
29
+ self._token_id = token_id
30
+
31
+ def get_token_id(self) -> str:
32
+ """Return the id no token is stored under."""
33
+ return self._token_id
File without changes
@@ -0,0 +1,11 @@
1
+ """Generators that mint the values tokens answer with."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .token_generator_interface import TokenGeneratorInterface
6
+ from .uri_safe_token_generator import UriSafeTokenGenerator
7
+
8
+ __all__ = [
9
+ "TokenGeneratorInterface",
10
+ "UriSafeTokenGenerator",
11
+ ]
@@ -0,0 +1,20 @@
1
+ """The one surface every token generator answers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ __all__ = ["TokenGeneratorInterface"]
8
+
9
+
10
+ @runtime_checkable
11
+ class TokenGeneratorInterface(Protocol):
12
+ """Mints the value a token id is answered with.
13
+
14
+ Synchronous, and kept that way: minting is pure randomness, it reaches
15
+ nothing — the storage is what a caller waits on.
16
+ """
17
+
18
+ def generate_token(self) -> str:
19
+ """Return a fresh value, never seen before."""
20
+ ...
@@ -0,0 +1,50 @@
1
+ """A generator whose values carry no character a URI would have to encode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import secrets
7
+ from typing import Final, final
8
+
9
+ from typing_extensions import override
10
+
11
+ from xtr_security_csrf.exception import InvalidArgumentError
12
+
13
+ from .token_generator_interface import TokenGeneratorInterface
14
+
15
+ __all__ = ["UriSafeTokenGenerator"]
16
+
17
+ # A value drawn from fewer than eight bits could be guessed.
18
+ _MINIMUM_ENTROPY: Final = 8
19
+
20
+
21
+ @final
22
+ class UriSafeTokenGenerator(TokenGeneratorInterface):
23
+ """Mints URI-safe values: base64 with ``+``, ``/`` and ``=`` gone.
24
+
25
+ A value that carries none of the characters a URI encodes sits in a
26
+ query, a form field or a header as it is, without a reader having to
27
+ decode anything first — which is why the padding is stripped too.
28
+ """
29
+
30
+ __slots__ = ("_entropy",)
31
+
32
+ def __init__(self, entropy: int = 256) -> None:
33
+ """Record how much entropy each minted value carries.
34
+
35
+ Args:
36
+ entropy: The bits of randomness each value is drawn from.
37
+
38
+ Raises:
39
+ InvalidArgumentError: When ``entropy`` is 7 bits or fewer — a
40
+ value that short could be guessed.
41
+ """
42
+ if entropy < _MINIMUM_ENTROPY:
43
+ raise InvalidArgumentError("The entropy should be greater than 7.")
44
+ self._entropy = entropy
45
+
46
+ @override
47
+ def generate_token(self) -> str:
48
+ """Return a fresh URI-safe value of the configured entropy."""
49
+ raw = secrets.token_bytes(self._entropy // 8)
50
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
@@ -0,0 +1,13 @@
1
+ """Storages that keep the values tokens answer with."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .clearable_token_storage_interface import ClearableTokenStorageInterface
6
+ from .session_token_storage import SessionTokenStorage
7
+ from .token_storage_interface import TokenStorageInterface
8
+
9
+ __all__ = [
10
+ "ClearableTokenStorageInterface",
11
+ "SessionTokenStorage",
12
+ "TokenStorageInterface",
13
+ ]
@@ -0,0 +1,24 @@
1
+ """A token storage that can drop every token it holds at once."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ from .token_storage_interface import TokenStorageInterface
8
+
9
+ __all__ = ["ClearableTokenStorageInterface"]
10
+
11
+
12
+ @runtime_checkable
13
+ class ClearableTokenStorageInterface(TokenStorageInterface, Protocol):
14
+ """A token storage that can also drop everything it holds.
15
+
16
+ The session strategy is the caller: when a re-authentication rotates
17
+ the session, the tokens minted under the old one must not answer for
18
+ the new — a value stolen before the rotation would otherwise still
19
+ check out after it.
20
+ """
21
+
22
+ async def clear(self) -> None:
23
+ """Drop every token this storage holds."""
24
+ ...
@@ -0,0 +1,120 @@
1
+ """A token storage that keeps its tokens in the session a request carries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast, final
6
+
7
+ from typing_extensions import override
8
+
9
+ from xtr_security_csrf.exception import TokenNotFoundError
10
+
11
+ from .clearable_token_storage_interface import ClearableTokenStorageInterface
12
+
13
+ if TYPE_CHECKING:
14
+ from xtr_http_foundation import SessionInterface
15
+
16
+ __all__ = ["SessionTokenStorage"]
17
+
18
+
19
+ @final
20
+ class SessionTokenStorage(ClearableTokenStorageInterface):
21
+ """Keeps token values in the session, under the id they answer for.
22
+
23
+ Each value lives under ``<namespace>/<token_id>``: the namespace keeps
24
+ the tokens out of the attributes the rest of the application keeps in
25
+ the same session, and lets two applications sharing one session keep
26
+ their tokens apart.
27
+
28
+ The storage also keeps the list of its ids under the namespace key
29
+ itself. The session offers no way to walk the attributes it carries, so
30
+ the list is what ``clear()`` drops them from — every token this storage
31
+ minted, and nothing the rest of the session holds. A value written
32
+ under the namespace by something else than this storage is not in the
33
+ list, and survives a clear.
34
+ """
35
+
36
+ __slots__ = ("_namespace", "_session")
37
+
38
+ def __init__(self, session: SessionInterface, namespace: str = "_csrf") -> None:
39
+ """Record the session the tokens travel in, and their namespace.
40
+
41
+ Args:
42
+ session: The session whose attributes carry the tokens.
43
+ namespace: The prefix every stored id carries.
44
+ """
45
+ self._session = session
46
+ self._namespace = namespace
47
+
48
+ @override
49
+ async def get_token(self, token_id: str) -> str:
50
+ """Return the value stored under ``token_id``.
51
+
52
+ Raises:
53
+ TokenNotFoundError: When no value is stored under the id.
54
+ """
55
+ name = self._key(token_id)
56
+ if not await self._session.has(name):
57
+ raise TokenNotFoundError(token_id)
58
+ return str(await self._session.get(name))
59
+
60
+ @override
61
+ async def set_token(self, token_id: str, token: str) -> None:
62
+ """Store ``token`` under ``token_id``, and remember the id."""
63
+ await self._session.set(self._key(token_id), token)
64
+ await self._remember(token_id)
65
+
66
+ @override
67
+ async def has_token(self, token_id: str) -> bool:
68
+ """Tell whether a value is stored under ``token_id``."""
69
+ return await self._session.has(self._key(token_id))
70
+
71
+ @override
72
+ async def remove_token(self, token_id: str) -> str | None:
73
+ """Drop the value under ``token_id``, returning what it held."""
74
+ value = await self._session.remove(self._key(token_id))
75
+ await self._forget(token_id)
76
+ return None if value is None else str(value)
77
+
78
+ @override
79
+ async def clear(self) -> None:
80
+ """Drop every token this storage minted, and nothing else.
81
+
82
+ The ids come from the list kept under the namespace key, so the
83
+ clear is complete across the requests that minted them — a token
84
+ stored two requests ago goes with one stored now. The session
85
+ strategy calls this when a re-authentication rotates the session:
86
+ the tokens minted under the old one must not answer for the new.
87
+ """
88
+ for token_id in await self._stored_ids():
89
+ _ = await self._session.remove(self._key(token_id))
90
+ _ = await self._session.remove(self._namespace)
91
+
92
+ def _key(self, token_id: str) -> str:
93
+ """Return the name the value for ``token_id`` is stored under."""
94
+ return f"{self._namespace}/{token_id}"
95
+
96
+ async def _stored_ids(self) -> tuple[str, ...]:
97
+ """Return every id this storage has minted a token under."""
98
+ ids = await self._session.get(self._namespace)
99
+ if not isinstance(ids, list):
100
+ return ()
101
+ # The list is written by this class, of the ids it minted; the cast
102
+ # says so once rather than at every element the loop reads.
103
+ return tuple(cast("list[str]", ids))
104
+
105
+ async def _remember(self, token_id: str) -> None:
106
+ """Add ``token_id`` to the ids a ``clear()`` drops, if it is not there."""
107
+ ids = await self._stored_ids()
108
+ if token_id not in ids:
109
+ await self._session.set(self._namespace, [*ids, token_id])
110
+
111
+ async def _forget(self, token_id: str) -> None:
112
+ """Take ``token_id`` out of the ids a ``clear()`` drops."""
113
+ ids = await self._stored_ids()
114
+ if token_id not in ids:
115
+ return
116
+ remaining = [stored for stored in ids if stored != token_id]
117
+ if remaining:
118
+ await self._session.set(self._namespace, remaining)
119
+ else:
120
+ _ = await self._session.remove(self._namespace)
@@ -0,0 +1,41 @@
1
+ """The one surface every token storage answers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ __all__ = ["TokenStorageInterface"]
8
+
9
+
10
+ @runtime_checkable
11
+ class TokenStorageInterface(Protocol):
12
+ """Keeps token values by id, wherever the application points them.
13
+
14
+ Every member is awaited: one storage must be swappable for another, and
15
+ a remote one is awaited — the session is only the one this library
16
+ ships a storage for.
17
+ """
18
+
19
+ async def get_token(self, token_id: str) -> str:
20
+ """Return the value stored under ``token_id``.
21
+
22
+ Raises:
23
+ TokenNotFoundError: When no value is stored under the id.
24
+ """
25
+ ...
26
+
27
+ async def set_token(self, token_id: str, token: str) -> None:
28
+ """Store ``token`` under ``token_id``, replacing what was there."""
29
+ ...
30
+
31
+ async def remove_token(self, token_id: str) -> str | None:
32
+ """Drop the value under ``token_id``.
33
+
34
+ Returns:
35
+ The value it held, or ``None`` when it held nothing.
36
+ """
37
+ ...
38
+
39
+ async def has_token(self, token_id: str) -> bool:
40
+ """Tell whether a value is stored under ``token_id``."""
41
+ ...
@@ -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,20 @@
1
+ xtr_security_csrf/__init__.py,sha256=YSIqDdt3kdDIUd9MI9iu_Hsl5cnOs-y-p-gLZHHfxH8,1486
2
+ xtr_security_csrf/csrf_token.py,sha256=q7bnHKIRtBjzowFCxdycudwPxxPRYrEHggx-qbHjrbA,1422
3
+ xtr_security_csrf/csrf_token_manager.py,sha256=RFAfpfG6djy74s0ZqNrx345YVE5HTw5BYHotgibgcTo,7244
4
+ xtr_security_csrf/csrf_token_manager_interface.py,sha256=we3cx-pWakwwhiFPHr4cqWBmAyZ8ZsecLv2vWOFQ--U,1907
5
+ xtr_security_csrf/exception/__init__.py,sha256=gEPvUT7lVok3qDCw7s5uSxYXD261-FBIKWWk5lALp-I,579
6
+ xtr_security_csrf/exception/csrf_error.py,sha256=4ETPOn481Z90UPNJNd9lzSYGHx37qdDL8NLEfPmSr8s,480
7
+ xtr_security_csrf/exception/invalid_argument_error.py,sha256=0mAu-l5wIwg-r8bJmQbfe9ivMHAJP6Chk_h2sT_sHtA,522
8
+ xtr_security_csrf/exception/token_not_found_error.py,sha256=IuIwBMHVqQCN2ymOMJwH6CNJxAaL0al_oSZa5dAnT8E,967
9
+ xtr_security_csrf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ xtr_security_csrf/token_generator/__init__.py,sha256=BFC4ueg-ZftFKdFdtcGGqNdlD9eEQTHIlLQ6GWQ7AHA,293
11
+ xtr_security_csrf/token_generator/token_generator_interface.py,sha256=Cmdnbx-4PpbNYTxYVVwEKxiuh1ziOWiakERC2wOiQ8U,534
12
+ xtr_security_csrf/token_generator/uri_safe_token_generator.py,sha256=0DFT21UOyTatpNMJp_3KFdHXZ6TJVX97PvgYBUhsg9w,1611
13
+ xtr_security_csrf/token_storage/__init__.py,sha256=DRCYy0_yo91LmAnpM9G3bDtUc9R1WE_fG5aDP7akdr0,394
14
+ xtr_security_csrf/token_storage/clearable_token_storage_interface.py,sha256=SA1w7m9I0A-6Wsw9COTq34vZ52rptwvON7CX9CPt-_4,762
15
+ xtr_security_csrf/token_storage/session_token_storage.py,sha256=Skojw3BAR0QOtQVApszaoZvw5QPrnu1bR1SswN45P5I,4794
16
+ xtr_security_csrf/token_storage/token_storage_interface.py,sha256=rSzWMPJa9Gqg4WiPtR8EjlDCuihWIBk3JoIJxEykH5w,1210
17
+ xtr_security_csrf-5.0.0.dist-info/licenses/LICENSE,sha256=N6mW05zc_GYFRdNovzY4zFuWyBKavZLcouPmDOBHK5o,1106
18
+ xtr_security_csrf-5.0.0.dist-info/WHEEL,sha256=3NO1oYsF7mANFUWVdyEQLM-zmecczWWEmmF5rEbXrWk,80
19
+ xtr_security_csrf-5.0.0.dist-info/METADATA,sha256=5pmMy_ktVCNnx7qc8pq99REuqJ38UfuvRPvFk-aWJjc,9044
20
+ xtr_security_csrf-5.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.6
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.