pysigned 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pysigned
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Sign and verify URLs with an expiry, using HMAC or Ed25519
5
5
  Author: Meg Hicks
6
6
  Author-email: Meg Hicks <meg.d.hicks@gmail.com>
@@ -43,7 +43,7 @@ that can't be altered without invalidating the signature.
43
43
 
44
44
  ## Installation
45
45
 
46
- Requires Python 3.13+.
46
+ Requires Python 3.11+.
47
47
 
48
48
  ```sh
49
49
  uv add pysigned
@@ -162,6 +162,24 @@ pysigned-gen-key --hmac
162
162
  pysigned-gen-key --ed25519 --jwks # wrap in a JWKS for KeySet.from_jwks
163
163
  ```
164
164
 
165
+ ### Loading keys from the environment
166
+
167
+ `KeySet.from_env` reads a JWKS (as produced by `pysigned-gen-key --jwks`) from
168
+ an environment variable, so secrets never touch disk as plain key bytes:
169
+
170
+ ```sh
171
+ export APP_SIGNING_KEYS="$(pysigned-gen-key --ed25519 --jwks)"
172
+ ```
173
+
174
+ ```python
175
+ from pysigned import KeySet, URLAuth
176
+
177
+ keys = KeySet.from_env("APP_SIGNING_KEYS")
178
+ signer = URLAuth(keys, ttl=60)
179
+ ```
180
+
181
+ It raises `ValueError` if the variable is unset or empty.
182
+
165
183
  ## Examples
166
184
 
167
185
  A runnable demo of both backends lives in [examples/sign_urls.py](examples/sign_urls.py):
@@ -170,6 +188,47 @@ A runnable demo of both backends lives in [examples/sign_urls.py](examples/sign_
170
188
  uv run python examples/sign_urls.py
171
189
  ```
172
190
 
191
+ ## FastAPI extension
192
+
193
+ `pysigned[fastapi]` adds `SignedRoute`, a FastAPI dependency that verifies a
194
+ request's URL signature and raises `403 Forbidden` on failure:
195
+
196
+ ```sh
197
+ uv add "pysigned[fastapi]"
198
+ ```
199
+
200
+ ```python
201
+ from fastapi import Depends, FastAPI
202
+ from pysigned import KeySet
203
+ from pysigned.extensions.fastapi import SignedRoute
204
+
205
+ keys = KeySet.from_env("APP_SIGNING_KEYS")
206
+ verify_signature = SignedRoute(keyset=keys)
207
+
208
+ app = FastAPI()
209
+
210
+
211
+ @app.get("/download", dependencies=[Depends(verify_signature)])
212
+ def download():
213
+ ...
214
+ ```
215
+
216
+ If the keys aren't known until request time — e.g. looking them up per
217
+ tenant — pass `keyset_getter` instead of `keyset`:
218
+
219
+ ```python
220
+ async def keys_for_request(request) -> KeySet:
221
+ tenant = request.headers["x-tenant-id"]
222
+ return await load_keys_for_tenant(tenant)
223
+
224
+
225
+ verify_signature = SignedRoute(keyset_getter=keys_for_request)
226
+ ```
227
+
228
+ `signing_key_id`, `ignore_query_params`, and `ttl` are forwarded to the
229
+ underlying `URLAuth`; `error_status` overrides the status code raised on a
230
+ failed verification (default: 403).
231
+
173
232
  ## How it works
174
233
 
175
234
  `URLAuth.sign` builds a canonical byte string from the URL's scheme, host, path,
@@ -16,7 +16,7 @@ that can't be altered without invalidating the signature.
16
16
 
17
17
  ## Installation
18
18
 
19
- Requires Python 3.13+.
19
+ Requires Python 3.11+.
20
20
 
21
21
  ```sh
22
22
  uv add pysigned
@@ -135,6 +135,24 @@ pysigned-gen-key --hmac
135
135
  pysigned-gen-key --ed25519 --jwks # wrap in a JWKS for KeySet.from_jwks
136
136
  ```
137
137
 
138
+ ### Loading keys from the environment
139
+
140
+ `KeySet.from_env` reads a JWKS (as produced by `pysigned-gen-key --jwks`) from
141
+ an environment variable, so secrets never touch disk as plain key bytes:
142
+
143
+ ```sh
144
+ export APP_SIGNING_KEYS="$(pysigned-gen-key --ed25519 --jwks)"
145
+ ```
146
+
147
+ ```python
148
+ from pysigned import KeySet, URLAuth
149
+
150
+ keys = KeySet.from_env("APP_SIGNING_KEYS")
151
+ signer = URLAuth(keys, ttl=60)
152
+ ```
153
+
154
+ It raises `ValueError` if the variable is unset or empty.
155
+
138
156
  ## Examples
139
157
 
140
158
  A runnable demo of both backends lives in [examples/sign_urls.py](examples/sign_urls.py):
@@ -143,6 +161,47 @@ A runnable demo of both backends lives in [examples/sign_urls.py](examples/sign_
143
161
  uv run python examples/sign_urls.py
144
162
  ```
145
163
 
164
+ ## FastAPI extension
165
+
166
+ `pysigned[fastapi]` adds `SignedRoute`, a FastAPI dependency that verifies a
167
+ request's URL signature and raises `403 Forbidden` on failure:
168
+
169
+ ```sh
170
+ uv add "pysigned[fastapi]"
171
+ ```
172
+
173
+ ```python
174
+ from fastapi import Depends, FastAPI
175
+ from pysigned import KeySet
176
+ from pysigned.extensions.fastapi import SignedRoute
177
+
178
+ keys = KeySet.from_env("APP_SIGNING_KEYS")
179
+ verify_signature = SignedRoute(keyset=keys)
180
+
181
+ app = FastAPI()
182
+
183
+
184
+ @app.get("/download", dependencies=[Depends(verify_signature)])
185
+ def download():
186
+ ...
187
+ ```
188
+
189
+ If the keys aren't known until request time — e.g. looking them up per
190
+ tenant — pass `keyset_getter` instead of `keyset`:
191
+
192
+ ```python
193
+ async def keys_for_request(request) -> KeySet:
194
+ tenant = request.headers["x-tenant-id"]
195
+ return await load_keys_for_tenant(tenant)
196
+
197
+
198
+ verify_signature = SignedRoute(keyset_getter=keys_for_request)
199
+ ```
200
+
201
+ `signing_key_id`, `ignore_query_params`, and `ttl` are forwarded to the
202
+ underlying `URLAuth`; `error_status` overrides the status code raised on a
203
+ failed verification (default: 403).
204
+
146
205
  ## How it works
147
206
 
148
207
  `URLAuth.sign` builds a canonical byte string from the URL's scheme, host, path,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pysigned"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Sign and verify URLs with an expiry, using HMAC or Ed25519"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -5,6 +5,8 @@ module is only imported by code that explicitly opts into it, so the core
5
5
  ``pysigned`` package stays installable without FastAPI.
6
6
  """
7
7
 
8
+ import collections.abc
9
+
8
10
  from typing import Iterable, Protocol
9
11
 
10
12
  from fastapi import Request, status
@@ -21,7 +23,7 @@ class KeysetGetter(Protocol):
21
23
  per-request state, e.g. fetching keys for a tenant from a database.
22
24
  """
23
25
 
24
- async def __call__(self, request: Request) -> KeySet:
26
+ async def __call__(self, request: Request) -> KeySet | Iterable:
25
27
  pass # pragma: no cover
26
28
 
27
29
 
@@ -54,7 +56,7 @@ class SignedRoute:
54
56
  def __init__(
55
57
  self,
56
58
  *,
57
- keyset: KeySet | None = None,
59
+ keyset: KeySet | collections.abc.Iterable | None = None,
58
60
  keyset_getter: KeysetGetter | None = None,
59
61
  signing_key_id: str = "",
60
62
  ignore_query_params: Iterable[str] | None = None,
@@ -99,7 +99,7 @@ class URLAuth:
99
99
  Components are joined with a newline (a byte that cannot appear in a URL
100
100
  component) so that different field boundaries can never collide.
101
101
  """
102
- pairs = [(k, v) for k, v in parse_qsl(parsed.query) if k not in self._excluded]
102
+ pairs = sorted((k, v) for k, v in parse_qsl(parsed.query) if k not in self._excluded)
103
103
  canonical = parsed._replace(query=urlencode(pairs))
104
104
  parts = (
105
105
  str(exp),
File without changes
File without changes
File without changes
File without changes
File without changes