PyEVP 0.1.0__tar.gz → 0.2.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.
- {pyevp-0.1.0 → pyevp-0.2.0}/PKG-INFO +41 -51
- {pyevp-0.1.0 → pyevp-0.2.0}/README.md +40 -50
- {pyevp-0.1.0 → pyevp-0.2.0}/pyproject.toml +1 -1
- {pyevp-0.1.0 → pyevp-0.2.0}/pyproject.toml.orig +1 -1
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/__init__.py +12 -1
- pyevp-0.2.0/src/pyevp/_drive.py +132 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/_httpsig.py +11 -4
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/cli/__init__.py +2 -1
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/__init__.py +44 -51
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/issuer.py +77 -86
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/templatetags/pyevp.py +3 -8
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/core.py +25 -10
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/diagnostics.py +12 -43
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/discovery.py +7 -2
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/errors.py +3 -1
- pyevp-0.2.0/src/pyevp/issuer/__init__.py +53 -0
- pyevp-0.2.0/src/pyevp/issuer/core.py +669 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/issuer/errors.py +5 -24
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/issuer/fedcm.py +28 -6
- pyevp-0.2.0/src/pyevp/issuer/response.py +35 -0
- pyevp-0.2.0/src/pyevp/nonce.py +168 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/profile.py +0 -2
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/testing.py +16 -5
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/verifier.py +253 -126
- pyevp-0.1.0/src/pyevp/issuer/__init__.py +0 -39
- pyevp-0.1.0/src/pyevp/issuer/core.py +0 -413
- pyevp-0.1.0/src/pyevp/nonce.py +0 -25
- {pyevp-0.1.0 → pyevp-0.2.0}/LICENSE +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/__main__.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/_email.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/_jose.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/_sf.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/__init__.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/_doh.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/_fetch.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/_http.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/dnspython.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/doh.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/httpx.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/urllib.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/cache.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/__init__.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/apps.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/migrations/0001_initial.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/migrations/__init__.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/models.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/templatetags/__init__.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/issuer/keys.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/issuer/profile.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/observability.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/ports.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/py.typed +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/replay.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/token.py +0 -0
- {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyEVP
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python library for the Email Verification Protocol (EVP): verify tokens as a relying party, or issue them for your own email domains
|
|
5
5
|
Keywords: email,verification,evp,sd-jwt,jose,authentication
|
|
6
6
|
Author: Gakuto Furuya
|
|
@@ -59,11 +59,15 @@ or issue them for your own email domains. With EVP, the browser obtains a token
|
|
|
59
59
|
email provider proving they control an address, and your server verifies it, with no confirmation
|
|
60
60
|
email round-trip.
|
|
61
61
|
|
|
62
|
-
**Documentation: <https://docs.pyevp.dev/>**
|
|
62
|
+
**Documentation: <https://docs.pyevp.dev/>** · **Live demo: <https://pyevp.dev/demo>**
|
|
63
63
|
|
|
64
64
|
> **Status: alpha.** The protocol ([draft-hardt-email-verification], [WICG Email Verification API])
|
|
65
|
-
>
|
|
66
|
-
|
|
65
|
+
> is still changing. PyEVP keeps every moving part in a versioned `Profile` so it can follow along.
|
|
66
|
+
|
|
67
|
+
- **Browsers:** Chrome, with `chrome://flags/#email-verification-protocol` enabled or on a site
|
|
68
|
+
registered for the origin trial. Tested with Chrome 154. Other browsers send no token.
|
|
69
|
+
- **Email providers:** Gmail issues tokens today. Your own domains can too, with `pyevp.issuer`.
|
|
70
|
+
- **Python:** 3.11 or newer.
|
|
67
71
|
|
|
68
72
|
[draft-hardt-email-verification]: https://github.com/dickhardt/email-verification
|
|
69
73
|
[WICG Email Verification API]: https://github.com/WICG/email-verification
|
|
@@ -74,77 +78,64 @@ email round-trip.
|
|
|
74
78
|
pip install "pyevp[dns,httpx2]" # core + the DNS and HTTP adapters
|
|
75
79
|
```
|
|
76
80
|
|
|
77
|
-
The core depends only on [joserfc] and idna.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
The HTTP adapters work with [httpx2] (pydantic's maintained fork of httpx) or httpx and prefer
|
|
82
|
-
httpx2 when both are installed, so `pip install "pyevp[dns,httpx]"` works too. A client from either
|
|
83
|
-
library can be passed explicitly, e.g. `HttpxFetcher(httpx.Client(...))`.
|
|
84
|
-
|
|
85
|
-
[httpx2]: https://github.com/pydantic/httpx2
|
|
81
|
+
The core depends only on [joserfc] and idna. `[dns,httpx2]` adds the adapters
|
|
82
|
+
`Verifier.default()` uses; httpx works too (see [DNS, HTTP and caching]). `[all]` adds the Django
|
|
83
|
+
integration and the command line.
|
|
86
84
|
|
|
87
85
|
[joserfc]: https://jose.authlib.org/
|
|
86
|
+
[DNS, HTTP and caching]: https://docs.pyevp.dev/en/latest/guides/transport.html
|
|
88
87
|
|
|
89
88
|
## How it works
|
|
90
89
|
|
|
91
|
-
1. Render a form with a
|
|
90
|
+
1. Render a form with a nonce kept in the user's session:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from pyevp import SessionNonces, token_input
|
|
94
|
+
|
|
95
|
+
nonce = SessionNonces(session).issue() # Flask, Starlette and Django sessions all work
|
|
96
|
+
```
|
|
92
97
|
|
|
93
98
|
```html
|
|
94
99
|
<input type="email" name="email" autocomplete="email">
|
|
95
100
|
<input type="hidden" name="evt" autocomplete="email-verification-token" nonce="{{ nonce }}">
|
|
96
101
|
```
|
|
97
102
|
|
|
103
|
+
(`token_input(nonce)` renders the hidden input.)
|
|
104
|
+
|
|
98
105
|
2. When the user picks an address, the browser fills `evt` with `<EVT>~<KB-JWT>`.
|
|
99
106
|
3. On submit, verify it:
|
|
100
107
|
|
|
101
108
|
```python
|
|
102
|
-
from pyevp import
|
|
109
|
+
from pyevp import EVPError, SessionNonces, Verifier
|
|
103
110
|
|
|
104
111
|
verifier = Verifier.default(audience="https://example.com") # your origin
|
|
105
112
|
|
|
106
113
|
try:
|
|
107
|
-
result = verifier.
|
|
114
|
+
result = verifier.verify_submission(
|
|
115
|
+
form.get("evt"), nonces=SessionNonces(session), email=form["email"]
|
|
116
|
+
)
|
|
108
117
|
except EVPError as exc:
|
|
109
|
-
... # exc.code
|
|
118
|
+
... # exc.code says why, e.g. "nonce_mismatch"; fall back to email confirmation
|
|
110
119
|
else:
|
|
111
|
-
|
|
120
|
+
if result is None:
|
|
121
|
+
... # no token: fall back to email confirmation
|
|
122
|
+
else:
|
|
123
|
+
result.email, result.issuer # verified
|
|
112
124
|
```
|
|
113
125
|
|
|
114
|
-
`AsyncVerifier` has the same API with `await
|
|
115
|
-
|
|
116
|
-
Verification checks the key-binding JWT (audience, nonce, freshness, `sd_hash`, holder signature)
|
|
117
|
-
before doing any I/O. It then discovers the issuer from DNS (`_email-verification.<domain>`
|
|
118
|
-
TXT `iss=…`), fetches its metadata and JWKS (cached), and verifies the issuer's signature. Only
|
|
119
|
-
hosts derived from DNS are ever contacted, never hosts named in the token. Before connecting, the
|
|
120
|
-
default fetchers also check that the host resolves only to public addresses; see
|
|
121
|
-
[private networks](https://docs.pyevp.dev/en/latest/guides/transport.html#ssrf) for what this check
|
|
122
|
-
does not catch.
|
|
123
|
-
|
|
124
|
-
Every rejected token raises an `EVPError` with an `ErrorCode`. The safe default is to fall back to your existing
|
|
125
|
-
verification flow; the [error table](https://docs.pyevp.dev/en/latest/quickstart.html#handle-failures) tells which codes
|
|
126
|
-
the user can retry and which point at your configuration.
|
|
127
|
-
|
|
128
|
-
## Command line
|
|
129
|
-
|
|
130
|
-
`pyevp[cli]` installs a `pyevp` command for relying-party developers and operators, and for
|
|
131
|
-
issuer operators checking their own setup. It runs without installing anything into your project:
|
|
132
|
-
|
|
133
|
-
```sh
|
|
134
|
-
uvx --from "pyevp[cli]" pyevp discover gmail.com # DNS record, metadata, keys vs. profile
|
|
135
|
-
pbpaste | uvx --from "pyevp[cli]" pyevp inspect # decode a token offline (no signature checks)
|
|
136
|
-
uvx --from "pyevp[cli]" pyevp verify "$TOKEN" --audience https://example.com --nonce "$NONCE"
|
|
137
|
-
```
|
|
126
|
+
`AsyncVerifier` has the same API with `await`.
|
|
138
127
|
|
|
139
|
-
|
|
128
|
+
Everything that can be checked offline is checked first, and only hosts found through DNS are
|
|
129
|
+
ever contacted, never hosts named in the token. Every rejection raises an `EVPError` with an
|
|
130
|
+
[error code](https://docs.pyevp.dev/en/latest/quickstart.html#handle-failures).
|
|
140
131
|
|
|
141
132
|
## More
|
|
142
133
|
|
|
134
|
+
- [Quickstart](https://docs.pyevp.dev/en/latest/quickstart.html) and [concepts](https://docs.pyevp.dev/en/latest/concepts.html)
|
|
143
135
|
- [Frameworks](https://docs.pyevp.dev/en/latest/guides/frameworks.html): FastAPI, Flask, fastapi-users, AuthX and Django
|
|
144
|
-
- [Testing your application](https://docs.pyevp.dev/en/latest/guides/testing.html)
|
|
136
|
+
- [Testing your application](https://docs.pyevp.dev/en/latest/guides/testing.html) without network access
|
|
145
137
|
- [Replay protection](https://docs.pyevp.dev/en/latest/guides/replay.html), [logging and metrics](https://docs.pyevp.dev/en/latest/guides/observability.html)
|
|
146
|
-
- [
|
|
147
|
-
- [Profiles](https://docs.pyevp.dev/en/latest/concepts.html#profiles): how PyEVP follows a protocol that is still changing
|
|
138
|
+
- [Command line](https://docs.pyevp.dev/en/latest/guides/cli.html): `uvx --from "pyevp[cli]" pyevp discover gmail.com` checks a domain's issuer
|
|
148
139
|
- [Running an issuer](https://docs.pyevp.dev/en/latest/guides/issuer-operations.html) for your own mail domains
|
|
149
140
|
- [Compatibility policy](https://docs.pyevp.dev/en/latest/compatibility.html)
|
|
150
141
|
|
|
@@ -154,13 +145,12 @@ Each example is a standalone project with its own tests:
|
|
|
154
145
|
|
|
155
146
|
- [`examples/fastapi`](https://github.com/gaato/pyevp/blob/main/examples/fastapi/app.py): FastAPI with session nonces
|
|
156
147
|
- [`examples/flask`](https://github.com/gaato/pyevp/blob/main/examples/flask/app.py): the same flow with the synchronous `Verifier`
|
|
157
|
-
- [`examples/fastapi_spa`](https://github.com/gaato/pyevp/blob/main/examples/fastapi_spa/app.py): a JSON API for a single-page app,
|
|
158
|
-
- [`examples/fastapi_users`](https://github.com/gaato/pyevp/blob/main/examples/fastapi_users/app.py): fastapi-users registration
|
|
148
|
+
- [`examples/fastapi_spa`](https://github.com/gaato/pyevp/blob/main/examples/fastapi_spa/app.py): a JSON API for a single-page app, with password recovery
|
|
149
|
+
- [`examples/fastapi_users`](https://github.com/gaato/pyevp/blob/main/examples/fastapi_users/app.py): fastapi-users registration
|
|
159
150
|
- [`examples/authx`](https://github.com/gaato/pyevp/blob/main/examples/authx/app.py): passwordless login with AuthX
|
|
160
|
-
- [`examples/django`](https://github.com/gaato/pyevp/blob/main/examples/django/views.py):
|
|
151
|
+
- [`examples/django`](https://github.com/gaato/pyevp/blob/main/examples/django/views.py): Django with the template tag
|
|
161
152
|
- [`examples/django_allauth`](https://github.com/gaato/pyevp/blob/main/examples/django_allauth/evp_allauth.py): a django-allauth adapter
|
|
162
|
-
- [`examples/issuer_fastapi`](https://github.com/gaato/pyevp/blob/main/examples/issuer_fastapi/app.py): an issuer for your own domains
|
|
163
|
-
- [`examples/issuer_django`](https://github.com/gaato/pyevp/blob/main/examples/issuer_django/urls.py): the same issuer on Django, with Django's own users
|
|
153
|
+
- [`examples/issuer_fastapi`](https://github.com/gaato/pyevp/blob/main/examples/issuer_fastapi/app.py), [`examples/issuer_django`](https://github.com/gaato/pyevp/blob/main/examples/issuer_django/urls.py): an issuer for your own domains
|
|
164
154
|
|
|
165
155
|
## Contributing
|
|
166
156
|
|
|
@@ -16,11 +16,15 @@ or issue them for your own email domains. With EVP, the browser obtains a token
|
|
|
16
16
|
email provider proving they control an address, and your server verifies it, with no confirmation
|
|
17
17
|
email round-trip.
|
|
18
18
|
|
|
19
|
-
**Documentation: <https://docs.pyevp.dev/>**
|
|
19
|
+
**Documentation: <https://docs.pyevp.dev/>** · **Live demo: <https://pyevp.dev/demo>**
|
|
20
20
|
|
|
21
21
|
> **Status: alpha.** The protocol ([draft-hardt-email-verification], [WICG Email Verification API])
|
|
22
|
-
>
|
|
23
|
-
|
|
22
|
+
> is still changing. PyEVP keeps every moving part in a versioned `Profile` so it can follow along.
|
|
23
|
+
|
|
24
|
+
- **Browsers:** Chrome, with `chrome://flags/#email-verification-protocol` enabled or on a site
|
|
25
|
+
registered for the origin trial. Tested with Chrome 154. Other browsers send no token.
|
|
26
|
+
- **Email providers:** Gmail issues tokens today. Your own domains can too, with `pyevp.issuer`.
|
|
27
|
+
- **Python:** 3.11 or newer.
|
|
24
28
|
|
|
25
29
|
[draft-hardt-email-verification]: https://github.com/dickhardt/email-verification
|
|
26
30
|
[WICG Email Verification API]: https://github.com/WICG/email-verification
|
|
@@ -31,77 +35,64 @@ email round-trip.
|
|
|
31
35
|
pip install "pyevp[dns,httpx2]" # core + the DNS and HTTP adapters
|
|
32
36
|
```
|
|
33
37
|
|
|
34
|
-
The core depends only on [joserfc] and idna.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
The HTTP adapters work with [httpx2] (pydantic's maintained fork of httpx) or httpx and prefer
|
|
39
|
-
httpx2 when both are installed, so `pip install "pyevp[dns,httpx]"` works too. A client from either
|
|
40
|
-
library can be passed explicitly, e.g. `HttpxFetcher(httpx.Client(...))`.
|
|
41
|
-
|
|
42
|
-
[httpx2]: https://github.com/pydantic/httpx2
|
|
38
|
+
The core depends only on [joserfc] and idna. `[dns,httpx2]` adds the adapters
|
|
39
|
+
`Verifier.default()` uses; httpx works too (see [DNS, HTTP and caching]). `[all]` adds the Django
|
|
40
|
+
integration and the command line.
|
|
43
41
|
|
|
44
42
|
[joserfc]: https://jose.authlib.org/
|
|
43
|
+
[DNS, HTTP and caching]: https://docs.pyevp.dev/en/latest/guides/transport.html
|
|
45
44
|
|
|
46
45
|
## How it works
|
|
47
46
|
|
|
48
|
-
1. Render a form with a
|
|
47
|
+
1. Render a form with a nonce kept in the user's session:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from pyevp import SessionNonces, token_input
|
|
51
|
+
|
|
52
|
+
nonce = SessionNonces(session).issue() # Flask, Starlette and Django sessions all work
|
|
53
|
+
```
|
|
49
54
|
|
|
50
55
|
```html
|
|
51
56
|
<input type="email" name="email" autocomplete="email">
|
|
52
57
|
<input type="hidden" name="evt" autocomplete="email-verification-token" nonce="{{ nonce }}">
|
|
53
58
|
```
|
|
54
59
|
|
|
60
|
+
(`token_input(nonce)` renders the hidden input.)
|
|
61
|
+
|
|
55
62
|
2. When the user picks an address, the browser fills `evt` with `<EVT>~<KB-JWT>`.
|
|
56
63
|
3. On submit, verify it:
|
|
57
64
|
|
|
58
65
|
```python
|
|
59
|
-
from pyevp import
|
|
66
|
+
from pyevp import EVPError, SessionNonces, Verifier
|
|
60
67
|
|
|
61
68
|
verifier = Verifier.default(audience="https://example.com") # your origin
|
|
62
69
|
|
|
63
70
|
try:
|
|
64
|
-
result = verifier.
|
|
71
|
+
result = verifier.verify_submission(
|
|
72
|
+
form.get("evt"), nonces=SessionNonces(session), email=form["email"]
|
|
73
|
+
)
|
|
65
74
|
except EVPError as exc:
|
|
66
|
-
... # exc.code
|
|
75
|
+
... # exc.code says why, e.g. "nonce_mismatch"; fall back to email confirmation
|
|
67
76
|
else:
|
|
68
|
-
|
|
77
|
+
if result is None:
|
|
78
|
+
... # no token: fall back to email confirmation
|
|
79
|
+
else:
|
|
80
|
+
result.email, result.issuer # verified
|
|
69
81
|
```
|
|
70
82
|
|
|
71
|
-
`AsyncVerifier` has the same API with `await
|
|
72
|
-
|
|
73
|
-
Verification checks the key-binding JWT (audience, nonce, freshness, `sd_hash`, holder signature)
|
|
74
|
-
before doing any I/O. It then discovers the issuer from DNS (`_email-verification.<domain>`
|
|
75
|
-
TXT `iss=…`), fetches its metadata and JWKS (cached), and verifies the issuer's signature. Only
|
|
76
|
-
hosts derived from DNS are ever contacted, never hosts named in the token. Before connecting, the
|
|
77
|
-
default fetchers also check that the host resolves only to public addresses; see
|
|
78
|
-
[private networks](https://docs.pyevp.dev/en/latest/guides/transport.html#ssrf) for what this check
|
|
79
|
-
does not catch.
|
|
80
|
-
|
|
81
|
-
Every rejected token raises an `EVPError` with an `ErrorCode`. The safe default is to fall back to your existing
|
|
82
|
-
verification flow; the [error table](https://docs.pyevp.dev/en/latest/quickstart.html#handle-failures) tells which codes
|
|
83
|
-
the user can retry and which point at your configuration.
|
|
84
|
-
|
|
85
|
-
## Command line
|
|
86
|
-
|
|
87
|
-
`pyevp[cli]` installs a `pyevp` command for relying-party developers and operators, and for
|
|
88
|
-
issuer operators checking their own setup. It runs without installing anything into your project:
|
|
89
|
-
|
|
90
|
-
```sh
|
|
91
|
-
uvx --from "pyevp[cli]" pyevp discover gmail.com # DNS record, metadata, keys vs. profile
|
|
92
|
-
pbpaste | uvx --from "pyevp[cli]" pyevp inspect # decode a token offline (no signature checks)
|
|
93
|
-
uvx --from "pyevp[cli]" pyevp verify "$TOKEN" --audience https://example.com --nonce "$NONCE"
|
|
94
|
-
```
|
|
83
|
+
`AsyncVerifier` has the same API with `await`.
|
|
95
84
|
|
|
96
|
-
|
|
85
|
+
Everything that can be checked offline is checked first, and only hosts found through DNS are
|
|
86
|
+
ever contacted, never hosts named in the token. Every rejection raises an `EVPError` with an
|
|
87
|
+
[error code](https://docs.pyevp.dev/en/latest/quickstart.html#handle-failures).
|
|
97
88
|
|
|
98
89
|
## More
|
|
99
90
|
|
|
91
|
+
- [Quickstart](https://docs.pyevp.dev/en/latest/quickstart.html) and [concepts](https://docs.pyevp.dev/en/latest/concepts.html)
|
|
100
92
|
- [Frameworks](https://docs.pyevp.dev/en/latest/guides/frameworks.html): FastAPI, Flask, fastapi-users, AuthX and Django
|
|
101
|
-
- [Testing your application](https://docs.pyevp.dev/en/latest/guides/testing.html)
|
|
93
|
+
- [Testing your application](https://docs.pyevp.dev/en/latest/guides/testing.html) without network access
|
|
102
94
|
- [Replay protection](https://docs.pyevp.dev/en/latest/guides/replay.html), [logging and metrics](https://docs.pyevp.dev/en/latest/guides/observability.html)
|
|
103
|
-
- [
|
|
104
|
-
- [Profiles](https://docs.pyevp.dev/en/latest/concepts.html#profiles): how PyEVP follows a protocol that is still changing
|
|
95
|
+
- [Command line](https://docs.pyevp.dev/en/latest/guides/cli.html): `uvx --from "pyevp[cli]" pyevp discover gmail.com` checks a domain's issuer
|
|
105
96
|
- [Running an issuer](https://docs.pyevp.dev/en/latest/guides/issuer-operations.html) for your own mail domains
|
|
106
97
|
- [Compatibility policy](https://docs.pyevp.dev/en/latest/compatibility.html)
|
|
107
98
|
|
|
@@ -111,13 +102,12 @@ Each example is a standalone project with its own tests:
|
|
|
111
102
|
|
|
112
103
|
- [`examples/fastapi`](https://github.com/gaato/pyevp/blob/main/examples/fastapi/app.py): FastAPI with session nonces
|
|
113
104
|
- [`examples/flask`](https://github.com/gaato/pyevp/blob/main/examples/flask/app.py): the same flow with the synchronous `Verifier`
|
|
114
|
-
- [`examples/fastapi_spa`](https://github.com/gaato/pyevp/blob/main/examples/fastapi_spa/app.py): a JSON API for a single-page app,
|
|
115
|
-
- [`examples/fastapi_users`](https://github.com/gaato/pyevp/blob/main/examples/fastapi_users/app.py): fastapi-users registration
|
|
105
|
+
- [`examples/fastapi_spa`](https://github.com/gaato/pyevp/blob/main/examples/fastapi_spa/app.py): a JSON API for a single-page app, with password recovery
|
|
106
|
+
- [`examples/fastapi_users`](https://github.com/gaato/pyevp/blob/main/examples/fastapi_users/app.py): fastapi-users registration
|
|
116
107
|
- [`examples/authx`](https://github.com/gaato/pyevp/blob/main/examples/authx/app.py): passwordless login with AuthX
|
|
117
|
-
- [`examples/django`](https://github.com/gaato/pyevp/blob/main/examples/django/views.py):
|
|
108
|
+
- [`examples/django`](https://github.com/gaato/pyevp/blob/main/examples/django/views.py): Django with the template tag
|
|
118
109
|
- [`examples/django_allauth`](https://github.com/gaato/pyevp/blob/main/examples/django_allauth/evp_allauth.py): a django-allauth adapter
|
|
119
|
-
- [`examples/issuer_fastapi`](https://github.com/gaato/pyevp/blob/main/examples/issuer_fastapi/app.py): an issuer for your own domains
|
|
120
|
-
- [`examples/issuer_django`](https://github.com/gaato/pyevp/blob/main/examples/issuer_django/urls.py): the same issuer on Django, with Django's own users
|
|
110
|
+
- [`examples/issuer_fastapi`](https://github.com/gaato/pyevp/blob/main/examples/issuer_fastapi/app.py), [`examples/issuer_django`](https://github.com/gaato/pyevp/blob/main/examples/issuer_django/urls.py): an issuer for your own domains
|
|
121
111
|
|
|
122
112
|
## Contributing
|
|
123
113
|
|
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
from pyevp._email import emails_match
|
|
4
4
|
from pyevp.cache import AsyncCache, Cache, CacheEntry, InMemoryCache, NullCache
|
|
5
5
|
from pyevp.errors import DiscoveryError, ErrorCode, EVPError, PolicyError, TokenError
|
|
6
|
-
from pyevp.nonce import
|
|
6
|
+
from pyevp.nonce import (
|
|
7
|
+
AsyncNonceStore,
|
|
8
|
+
NonceStore,
|
|
9
|
+
SessionNonces,
|
|
10
|
+
generate_nonce,
|
|
11
|
+
nonces_equal,
|
|
12
|
+
token_input,
|
|
13
|
+
)
|
|
7
14
|
from pyevp.observability import LoggingObserver, Observer, VerificationEvent
|
|
8
15
|
from pyevp.ports import AsyncJsonFetcher, AsyncTxtResolver, Clock, JsonFetcher, TxtResolver
|
|
9
16
|
from pyevp.profile import DEFAULT_PROFILE, EmailComparison, IssuerFormat, Profile
|
|
@@ -15,6 +22,7 @@ __all__ = [
|
|
|
15
22
|
"DEFAULT_PROFILE",
|
|
16
23
|
"AsyncCache",
|
|
17
24
|
"AsyncJsonFetcher",
|
|
25
|
+
"AsyncNonceStore",
|
|
18
26
|
"AsyncReplayGuard",
|
|
19
27
|
"AsyncTxtResolver",
|
|
20
28
|
"AsyncVerifier",
|
|
@@ -31,11 +39,13 @@ __all__ = [
|
|
|
31
39
|
"IssuerMetadata",
|
|
32
40
|
"JsonFetcher",
|
|
33
41
|
"LoggingObserver",
|
|
42
|
+
"NonceStore",
|
|
34
43
|
"NullCache",
|
|
35
44
|
"Observer",
|
|
36
45
|
"PolicyError",
|
|
37
46
|
"Profile",
|
|
38
47
|
"ReplayGuard",
|
|
48
|
+
"SessionNonces",
|
|
39
49
|
"TokenError",
|
|
40
50
|
"TxtResolver",
|
|
41
51
|
"VerificationEvent",
|
|
@@ -44,4 +54,5 @@ __all__ = [
|
|
|
44
54
|
"emails_match",
|
|
45
55
|
"generate_nonce",
|
|
46
56
|
"nonces_equal",
|
|
57
|
+
"token_input",
|
|
47
58
|
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Drivers that answer the effects a sans-I/O flow yields, synchronously or asynchronously.
|
|
2
|
+
|
|
3
|
+
A flow is a generator: it yields an effect, is sent the answer, and finally returns its
|
|
4
|
+
result. ``perform`` answers one effect, with a value or, for asynchronous ports, an
|
|
5
|
+
awaitable. The two drivers differ only in what they do with an awaitable: :func:`adrive`
|
|
6
|
+
awaits it, and :func:`drive` refuses it, because a synchronous caller cannot wait for it
|
|
7
|
+
and taking it as the answer would be wrong (a coroutine is truthy).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
from collections.abc import Callable, Generator
|
|
14
|
+
from typing import Any, TypeAlias, TypeVar
|
|
15
|
+
|
|
16
|
+
from pyevp.core import FetchJson, ResolveTxt
|
|
17
|
+
from pyevp.errors import DiscoveryError, ErrorCode, EVPError
|
|
18
|
+
from pyevp.ports import AsyncJsonFetcher, AsyncTxtResolver, JsonFetcher, TxtResolver
|
|
19
|
+
|
|
20
|
+
E = TypeVar("E")
|
|
21
|
+
R = TypeVar("R")
|
|
22
|
+
|
|
23
|
+
# TODO(py3.12): back to ``type`` statements once 3.11 support is dropped.
|
|
24
|
+
Translate: TypeAlias = Callable[[Any, Exception], Exception | None]
|
|
25
|
+
"""Maps an exception raised while answering an effect to the one to raise, or ``None``."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_async(function: object) -> bool:
|
|
29
|
+
"""Whether calling ``function`` returns a coroutine, as far as can be told beforehand."""
|
|
30
|
+
return inspect.iscoroutinefunction(function) or inspect.iscoroutinefunction(
|
|
31
|
+
getattr(function, "__call__", None) # noqa: B004 (a callable object)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def drive(
|
|
36
|
+
steps: Generator[E, Any, R],
|
|
37
|
+
perform: Callable[[E], object],
|
|
38
|
+
*,
|
|
39
|
+
hint: str,
|
|
40
|
+
translate: Translate | None = None,
|
|
41
|
+
) -> R:
|
|
42
|
+
"""Run ``steps`` to completion, answering each effect with ``perform``.
|
|
43
|
+
|
|
44
|
+
An awaitable answer is a :class:`TypeError` that ends with ``hint``, such as
|
|
45
|
+
``"use AsyncVerifier"``.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
effect = next(steps)
|
|
49
|
+
while True:
|
|
50
|
+
reply = _answer(effect, perform, translate)
|
|
51
|
+
if inspect.isawaitable(reply):
|
|
52
|
+
if inspect.iscoroutine(reply):
|
|
53
|
+
reply.close()
|
|
54
|
+
raise TypeError(
|
|
55
|
+
f"the port answering {type(effect).__name__.lstrip('_')} returned an "
|
|
56
|
+
f"awaitable, which a synchronous call cannot wait for; {hint}"
|
|
57
|
+
)
|
|
58
|
+
effect = steps.send(reply)
|
|
59
|
+
except StopIteration as stop: # the flow's own: a port's is a RuntimeError by now
|
|
60
|
+
return stop.value
|
|
61
|
+
finally:
|
|
62
|
+
steps.close()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def adrive(
|
|
66
|
+
steps: Generator[E, Any, R],
|
|
67
|
+
perform: Callable[[E], object],
|
|
68
|
+
*,
|
|
69
|
+
translate: Translate | None = None,
|
|
70
|
+
) -> R:
|
|
71
|
+
"""Run ``steps`` to completion, awaiting the answers that are awaitable."""
|
|
72
|
+
try:
|
|
73
|
+
effect = next(steps)
|
|
74
|
+
while True:
|
|
75
|
+
effect = steps.send(await _aanswer(effect, perform, translate))
|
|
76
|
+
except StopIteration as stop: # the flow's own: a port's is a RuntimeError by now
|
|
77
|
+
return stop.value
|
|
78
|
+
finally:
|
|
79
|
+
steps.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _answer(effect: E, perform: Callable[[E], object], translate: Translate | None) -> object:
|
|
83
|
+
try:
|
|
84
|
+
return perform(effect)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
raise _failure(effect, exc, translate) from exc
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _failure(effect: object, exc: Exception, translate: Translate | None) -> Exception:
|
|
90
|
+
mapped = translate(effect, exc) if translate is not None else None
|
|
91
|
+
if mapped is not None:
|
|
92
|
+
return mapped
|
|
93
|
+
if isinstance(exc, StopIteration):
|
|
94
|
+
# Would otherwise look like the flow finishing, with the port's value as its result.
|
|
95
|
+
return RuntimeError(f"the port answering {type(effect).__name__} raised StopIteration")
|
|
96
|
+
return exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _aanswer(
|
|
100
|
+
effect: E, perform: Callable[[E], object], translate: Translate | None
|
|
101
|
+
) -> object:
|
|
102
|
+
try:
|
|
103
|
+
reply = perform(effect)
|
|
104
|
+
return await reply if inspect.isawaitable(reply) else reply
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
raise _failure(effect, exc, translate) from exc
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def lookup(
|
|
110
|
+
effect: object,
|
|
111
|
+
*,
|
|
112
|
+
resolver: TxtResolver | AsyncTxtResolver,
|
|
113
|
+
fetcher: JsonFetcher | AsyncJsonFetcher,
|
|
114
|
+
) -> object:
|
|
115
|
+
"""Answer a DNS or HTTP lookup with the ports, as a value or an awaitable."""
|
|
116
|
+
if isinstance(effect, ResolveTxt):
|
|
117
|
+
return resolver.resolve_txt(effect.name)
|
|
118
|
+
if isinstance(effect, FetchJson):
|
|
119
|
+
return fetcher.fetch_json(effect.url)
|
|
120
|
+
raise TypeError(f"unexpected effect {effect!r}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def unreachable(effect: object, exc: Exception) -> DiscoveryError | None:
|
|
124
|
+
"""A failed DNS or HTTP request to the issuer, as ``ISSUER_UNREACHABLE``.
|
|
125
|
+
|
|
126
|
+
``None`` for anything else, which then propagates unchanged: a verdict already
|
|
127
|
+
reached (:class:`~pyevp.EVPError`), or a failure of the cache, store or guard.
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(exc, EVPError) or not isinstance(effect, ResolveTxt | FetchJson):
|
|
130
|
+
return None
|
|
131
|
+
target = effect.name if isinstance(effect, ResolveTxt) else effect.url
|
|
132
|
+
return DiscoveryError(ErrorCode.ISSUER_UNREACHABLE, f"lookup of {target} failed: {exc}")
|
|
@@ -54,6 +54,8 @@ class SignedRequest:
|
|
|
54
54
|
public_jwk: dict[str, str]
|
|
55
55
|
"""The signer's key as a JWK, always with ``alg``."""
|
|
56
56
|
created: datetime
|
|
57
|
+
deadline: datetime
|
|
58
|
+
"""The last moment the request is fresh: ``created`` plus the maximum age, or ``expires``."""
|
|
57
59
|
signature: bytes
|
|
58
60
|
base: bytes
|
|
59
61
|
"""The signature base that ``signature`` was verified over."""
|
|
@@ -231,6 +233,9 @@ def verify_request(
|
|
|
231
233
|
not isinstance(expires, int) or isinstance(expires, bool) or expires < now.timestamp()
|
|
232
234
|
):
|
|
233
235
|
raise SignatureError("invalid_signature", "signature has expired")
|
|
236
|
+
deadline = created_at + max_age
|
|
237
|
+
if expires is not None and expires < deadline.timestamp():
|
|
238
|
+
deadline = datetime.fromtimestamp(expires, UTC)
|
|
234
239
|
|
|
235
240
|
base = _signature_base(components, method=method, endpoint=endpoint, lines=lines)
|
|
236
241
|
if not _jose.verify_raw(base, signature.value, jwk, alg):
|
|
@@ -239,7 +244,7 @@ def verify_request(
|
|
|
239
244
|
raise SignatureError("invalid_signature", "HTTP Message Signature verification failed")
|
|
240
245
|
# Only now that the body is known to be what was signed is the digest worth checking.
|
|
241
246
|
_check_digest(lines.get("content-digest"), body)
|
|
242
|
-
return SignedRequest(label, alg, jwk, created_at, signature.value, base)
|
|
247
|
+
return SignedRequest(label, alg, jwk, created_at, deadline, signature.value, base)
|
|
243
248
|
|
|
244
249
|
|
|
245
250
|
def sign_request(
|
|
@@ -251,6 +256,7 @@ def sign_request(
|
|
|
251
256
|
public_jwk: Mapping[str, Any],
|
|
252
257
|
alg: str,
|
|
253
258
|
created: datetime,
|
|
259
|
+
expires: datetime | None = None,
|
|
254
260
|
label: str = "sig",
|
|
255
261
|
include_alg: bool = True,
|
|
256
262
|
signature_key: str | None = None,
|
|
@@ -272,9 +278,10 @@ def sign_request(
|
|
|
272
278
|
"Content-Digest": content_digest(body) if digest is None else digest,
|
|
273
279
|
"Signature-Key": signature_key,
|
|
274
280
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
281
|
+
params: dict[str, Any] = {"created": int(created.timestamp())}
|
|
282
|
+
if expires is not None:
|
|
283
|
+
params["expires"] = int(expires.timestamp())
|
|
284
|
+
components = _sf.InnerList(tuple(_sf.Item(c) for c in REQUIRED_COMPONENTS), params)
|
|
278
285
|
base = _signature_base(
|
|
279
286
|
components, method=method, endpoint=endpoint, lines=_field_lines(headers)
|
|
280
287
|
)
|
|
@@ -22,6 +22,7 @@ from joserfc import jwk
|
|
|
22
22
|
from rich.console import Console
|
|
23
23
|
from rich.table import Table
|
|
24
24
|
|
|
25
|
+
from pyevp import discovery
|
|
25
26
|
from pyevp.diagnostics import IssuerReport, discover
|
|
26
27
|
from pyevp.errors import EVPError
|
|
27
28
|
from pyevp.issuer import SIGNING_ALGORITHMS, Issuer, SigningKey
|
|
@@ -239,7 +240,7 @@ def _issuer_app() -> typer.Typer:
|
|
|
239
240
|
raise typer.BadParameter(str(exc)) from None
|
|
240
241
|
_json(
|
|
241
242
|
{
|
|
242
|
-
"metadata_url":
|
|
243
|
+
"metadata_url": discovery.metadata_url(built.issuer),
|
|
243
244
|
"metadata": built.metadata_document(),
|
|
244
245
|
"jwks_uri": built.jwks_uri,
|
|
245
246
|
"jwks": built.jwks_document(),
|