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.
Files changed (55) hide show
  1. {pyevp-0.1.0 → pyevp-0.2.0}/PKG-INFO +41 -51
  2. {pyevp-0.1.0 → pyevp-0.2.0}/README.md +40 -50
  3. {pyevp-0.1.0 → pyevp-0.2.0}/pyproject.toml +1 -1
  4. {pyevp-0.1.0 → pyevp-0.2.0}/pyproject.toml.orig +1 -1
  5. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/__init__.py +12 -1
  6. pyevp-0.2.0/src/pyevp/_drive.py +132 -0
  7. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/_httpsig.py +11 -4
  8. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/cli/__init__.py +2 -1
  9. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/__init__.py +44 -51
  10. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/issuer.py +77 -86
  11. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/templatetags/pyevp.py +3 -8
  12. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/core.py +25 -10
  13. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/diagnostics.py +12 -43
  14. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/discovery.py +7 -2
  15. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/errors.py +3 -1
  16. pyevp-0.2.0/src/pyevp/issuer/__init__.py +53 -0
  17. pyevp-0.2.0/src/pyevp/issuer/core.py +669 -0
  18. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/issuer/errors.py +5 -24
  19. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/issuer/fedcm.py +28 -6
  20. pyevp-0.2.0/src/pyevp/issuer/response.py +35 -0
  21. pyevp-0.2.0/src/pyevp/nonce.py +168 -0
  22. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/profile.py +0 -2
  23. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/testing.py +16 -5
  24. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/verifier.py +253 -126
  25. pyevp-0.1.0/src/pyevp/issuer/__init__.py +0 -39
  26. pyevp-0.1.0/src/pyevp/issuer/core.py +0 -413
  27. pyevp-0.1.0/src/pyevp/nonce.py +0 -25
  28. {pyevp-0.1.0 → pyevp-0.2.0}/LICENSE +0 -0
  29. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/__main__.py +0 -0
  30. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/_email.py +0 -0
  31. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/_jose.py +0 -0
  32. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/_sf.py +0 -0
  33. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/__init__.py +0 -0
  34. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/_doh.py +0 -0
  35. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/_fetch.py +0 -0
  36. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/_http.py +0 -0
  37. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/dnspython.py +0 -0
  38. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/doh.py +0 -0
  39. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/httpx.py +0 -0
  40. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/adapters/urllib.py +0 -0
  41. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/cache.py +0 -0
  42. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/__init__.py +0 -0
  43. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/apps.py +0 -0
  44. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/migrations/0001_initial.py +0 -0
  45. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/migrations/__init__.py +0 -0
  46. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/models.py +0 -0
  47. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/contrib/django/templatetags/__init__.py +0 -0
  48. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/issuer/keys.py +0 -0
  49. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/issuer/profile.py +0 -0
  50. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/observability.py +0 -0
  51. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/ports.py +0 -0
  52. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/py.typed +0 -0
  53. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/replay.py +0 -0
  54. {pyevp-0.1.0 → pyevp-0.2.0}/src/pyevp/token.py +0 -0
  55. {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.1.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
- > and browser support (Chrome origin trial) are still changing. This library isolates every
66
- > moving part in a versioned `Profile` so it can follow along.
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. DNS and HTTP are pluggable; `[dns,httpx2]` installs
78
- the default adapters used by `Verifier.default()`. `[all]` installs every optional dependency,
79
- including the Django integration and the command line.
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 fresh nonce stored in the user's session:
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 Verifier, EVPError, generate_nonce
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.verify(form["evt"], nonce=session.pop("evp_nonce"), email=form["email"])
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 is an ErrorCode, e.g. "nonce_mismatch"; fall back to email confirmation
118
+ ... # exc.code says why, e.g. "nonce_mismatch"; fall back to email confirmation
110
119
  else:
111
- result.email, result.issuer # verified
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 verifier.verify(...)`.
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
- See the [CLI guide](https://docs.pyevp.dev/en/latest/guides/cli.html) for every command and option.
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): `FakeIssuer` and `FakeBrowser`, no network needed
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
- - [DNS, HTTP and caching](https://docs.pyevp.dev/en/latest/guides/transport.html): DNS over HTTPS, a standard-library-only setup, private networks
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, without server sessions, with password recovery
158
- - [`examples/fastapi_users`](https://github.com/gaato/pyevp/blob/main/examples/fastapi_users/app.py): fastapi-users registration that falls back to the usual verification email
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): plain Django with the template tag and `verify_request`
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
- > and browser support (Chrome origin trial) are still changing. This library isolates every
23
- > moving part in a versioned `Profile` so it can follow along.
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. DNS and HTTP are pluggable; `[dns,httpx2]` installs
35
- the default adapters used by `Verifier.default()`. `[all]` installs every optional dependency,
36
- including the Django integration and the command line.
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 fresh nonce stored in the user's session:
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 Verifier, EVPError, generate_nonce
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.verify(form["evt"], nonce=session.pop("evp_nonce"), email=form["email"])
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 is an ErrorCode, e.g. "nonce_mismatch"; fall back to email confirmation
75
+ ... # exc.code says why, e.g. "nonce_mismatch"; fall back to email confirmation
67
76
  else:
68
- result.email, result.issuer # verified
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 verifier.verify(...)`.
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
- See the [CLI guide](https://docs.pyevp.dev/en/latest/guides/cli.html) for every command and option.
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): `FakeIssuer` and `FakeBrowser`, no network needed
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
- - [DNS, HTTP and caching](https://docs.pyevp.dev/en/latest/guides/transport.html): DNS over HTTPS, a standard-library-only setup, private networks
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, without server sessions, with password recovery
115
- - [`examples/fastapi_users`](https://github.com/gaato/pyevp/blob/main/examples/fastapi_users/app.py): fastapi-users registration that falls back to the usual verification email
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): plain Django with the template tag and `verify_request`
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "PyEVP"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Python library for the Email Verification Protocol (EVP): verify tokens as a relying party, or issue them for your own email domains"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "PyEVP"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Python library for the Email Verification Protocol (EVP): verify tokens as a relying party, or issue them for your own email domains"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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 generate_nonce, nonces_equal
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
- components = _sf.InnerList(
276
- tuple(_sf.Item(c) for c in REQUIRED_COMPONENTS), {"created": int(created.timestamp())}
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": f"{built.issuer}/.well-known/email-verification",
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(),