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.
- {pysigned-0.2.0 → pysigned-0.2.2}/PKG-INFO +61 -2
- {pysigned-0.2.0 → pysigned-0.2.2}/README.md +60 -1
- {pysigned-0.2.0 → pysigned-0.2.2}/pyproject.toml +1 -1
- {pysigned-0.2.0 → pysigned-0.2.2}/src/pysigned/extensions/fastapi.py +4 -2
- {pysigned-0.2.0 → pysigned-0.2.2}/src/pysigned/signature.py +1 -1
- {pysigned-0.2.0 → pysigned-0.2.2}/LICENSE +0 -0
- {pysigned-0.2.0 → pysigned-0.2.2}/THIRD-PARTY-LICENSES +0 -0
- {pysigned-0.2.0 → pysigned-0.2.2}/src/pysigned/__init__.py +0 -0
- {pysigned-0.2.0 → pysigned-0.2.2}/src/pysigned/backends.py +0 -0
- {pysigned-0.2.0 → pysigned-0.2.2}/src/pysigned/extensions/__init__.py +0 -0
- {pysigned-0.2.0 → pysigned-0.2.2}/src/pysigned/keys.py +0 -0
- {pysigned-0.2.0 → pysigned-0.2.2}/src/pysigned/py.typed +0 -0
- {pysigned-0.2.0 → pysigned-0.2.2}/src/pysigned/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pysigned
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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.
|
|
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,
|
|
@@ -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 =
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|