PyEVP 0.1.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.
Files changed (50) hide show
  1. pyevp/__init__.py +47 -0
  2. pyevp/__main__.py +28 -0
  3. pyevp/_email.py +65 -0
  4. pyevp/_httpsig.py +284 -0
  5. pyevp/_jose.py +145 -0
  6. pyevp/_sf.py +406 -0
  7. pyevp/adapters/__init__.py +4 -0
  8. pyevp/adapters/_doh.py +92 -0
  9. pyevp/adapters/_fetch.py +86 -0
  10. pyevp/adapters/_http.py +38 -0
  11. pyevp/adapters/dnspython.py +79 -0
  12. pyevp/adapters/doh.py +130 -0
  13. pyevp/adapters/httpx.py +147 -0
  14. pyevp/adapters/urllib.py +170 -0
  15. pyevp/cache.py +83 -0
  16. pyevp/cli/__init__.py +348 -0
  17. pyevp/contrib/__init__.py +4 -0
  18. pyevp/contrib/django/__init__.py +306 -0
  19. pyevp/contrib/django/apps.py +17 -0
  20. pyevp/contrib/django/issuer.py +314 -0
  21. pyevp/contrib/django/migrations/0001_initial.py +17 -0
  22. pyevp/contrib/django/migrations/__init__.py +0 -0
  23. pyevp/contrib/django/models.py +14 -0
  24. pyevp/contrib/django/templatetags/__init__.py +0 -0
  25. pyevp/contrib/django/templatetags/pyevp.py +32 -0
  26. pyevp/core.py +321 -0
  27. pyevp/diagnostics.py +182 -0
  28. pyevp/discovery.py +144 -0
  29. pyevp/errors.py +80 -0
  30. pyevp/issuer/__init__.py +39 -0
  31. pyevp/issuer/core.py +413 -0
  32. pyevp/issuer/errors.py +99 -0
  33. pyevp/issuer/fedcm.py +44 -0
  34. pyevp/issuer/keys.py +140 -0
  35. pyevp/issuer/profile.py +96 -0
  36. pyevp/nonce.py +25 -0
  37. pyevp/observability.py +89 -0
  38. pyevp/ports.py +54 -0
  39. pyevp/profile.py +153 -0
  40. pyevp/py.typed +0 -0
  41. pyevp/replay.py +69 -0
  42. pyevp/testing.py +343 -0
  43. pyevp/token.py +135 -0
  44. pyevp/types.py +37 -0
  45. pyevp/verifier.py +486 -0
  46. pyevp-0.1.0.dist-info/METADATA +171 -0
  47. pyevp-0.1.0.dist-info/RECORD +50 -0
  48. pyevp-0.1.0.dist-info/WHEEL +4 -0
  49. pyevp-0.1.0.dist-info/entry_points.txt +3 -0
  50. pyevp-0.1.0.dist-info/licenses/LICENSE +21 -0
pyevp/verifier.py ADDED
@@ -0,0 +1,486 @@
1
+ """Drivers that run :func:`pyevp.core.verification_steps` against real ports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import logging
7
+ import threading
8
+ import time
9
+ from collections.abc import Iterator
10
+ from contextlib import AsyncExitStack, ExitStack, contextmanager
11
+ from datetime import datetime, timedelta
12
+ from types import TracebackType
13
+ from typing import Self
14
+ from urllib.parse import urlsplit
15
+
16
+ from pyevp.cache import AsyncCache, Cache, CacheEntry, InMemoryCache
17
+ from pyevp.core import Effect, FetchJson, MarkUsed, ResolveTxt, Steps, verification_steps
18
+ from pyevp.errors import DiscoveryError, ErrorCode, EVPError, TokenError
19
+ from pyevp.observability import Observer, VerificationEvent, claimed_email_domain
20
+ from pyevp.ports import (
21
+ AsyncJsonFetcher,
22
+ AsyncTxtResolver,
23
+ Clock,
24
+ JsonFetcher,
25
+ TxtResolver,
26
+ system_clock,
27
+ )
28
+ from pyevp.profile import DEFAULT_PROFILE, Profile
29
+ from pyevp.replay import AsyncReplayGuard, ReplayGuard
30
+ from pyevp.types import VerifiedEmail
31
+
32
+ __all__ = ["AsyncVerifier", "Verifier"]
33
+
34
+ logger = logging.getLogger("pyevp")
35
+
36
+
37
+ def _validate_origin(origin: str) -> str:
38
+ parts = urlsplit(origin)
39
+ if (
40
+ parts.scheme not in ("https", "http")
41
+ or not parts.hostname
42
+ or parts.path
43
+ or parts.query
44
+ or parts.fragment
45
+ or parts.username
46
+ ):
47
+ raise ValueError(
48
+ f"audience must be a serialized origin like 'https://rp.example': {origin!r}"
49
+ )
50
+ return origin
51
+
52
+
53
+ class _Base:
54
+ def __init__(
55
+ self,
56
+ *,
57
+ audience: str,
58
+ profile: Profile,
59
+ clock: Clock,
60
+ cache_ttl: timedelta,
61
+ min_refresh_interval: timedelta,
62
+ replay_protection: bool,
63
+ observer: Observer | None,
64
+ ) -> None:
65
+ self.audience = _validate_origin(audience)
66
+ self._replay_protection = replay_protection
67
+ self._observer = observer
68
+ self.profile = profile
69
+ self._clock = clock
70
+ self._cache_ttl = cache_ttl
71
+ self._min_refresh_interval = min_refresh_interval
72
+ self._refresh_lock = threading.Lock()
73
+ self._refresh_attempts: dict[str, datetime] = {}
74
+ # Ports created by default(); ports passed in belong to the caller.
75
+ self._owned: tuple[object, ...] = ()
76
+
77
+ def _steps(self, token: str, nonce: str, email: str | None, audience: str | None) -> Steps:
78
+ return verification_steps(
79
+ token,
80
+ audience=_validate_origin(audience) if audience is not None else self.audience,
81
+ nonce=nonce,
82
+ now=self._clock(),
83
+ profile=self.profile,
84
+ email=email,
85
+ replay_protection=self._replay_protection,
86
+ )
87
+
88
+ def _notify(
89
+ self, token: str, result: VerifiedEmail | None, error: Exception | None, started: float
90
+ ) -> None:
91
+ if self._observer is None:
92
+ return
93
+ try:
94
+ self._observer(
95
+ VerificationEvent(
96
+ ok=result is not None,
97
+ code=error.code if isinstance(error, EVPError) else None,
98
+ issuer=result.issuer if result is not None else None,
99
+ email_domain=claimed_email_domain(token),
100
+ profile=self.profile.name,
101
+ duration=timedelta(seconds=time.perf_counter() - started),
102
+ )
103
+ )
104
+ except Exception:
105
+ logger.exception("EVP observer raised; ignoring")
106
+
107
+ def _reuse(self, effect: FetchJson, entry: CacheEntry | None) -> CacheEntry | None:
108
+ """Decide whether the cached ``entry`` for ``effect.url`` answers the fetch."""
109
+ if entry is None or not effect.refresh:
110
+ return entry
111
+ # A forced refresh is reserved before fetching, so failed fetches and concurrent
112
+ # verifications count against min_refresh_interval too. Within it, keep the cached
113
+ # value.
114
+ now = self._clock()
115
+ with self._refresh_lock:
116
+ last = max(entry.stored_at, self._refresh_attempts.get(effect.url, entry.stored_at))
117
+ if now - last < self._min_refresh_interval:
118
+ return entry
119
+ self._refresh_attempts = {
120
+ url: at
121
+ for url, at in self._refresh_attempts.items()
122
+ if now - at < self._min_refresh_interval
123
+ }
124
+ self._refresh_attempts[effect.url] = now
125
+ return None
126
+
127
+ def _check_marked(self, effect: MarkUsed, marked: bool) -> bool:
128
+ # Freshness was judged when verification started. If the token expired since,
129
+ # the record just written may already be gone, and a replay would find nothing.
130
+ if marked and self._clock() >= effect.expires_at:
131
+ raise TokenError(ErrorCode.TOKEN_EXPIRED, "token expired during verification")
132
+ return marked
133
+
134
+ def _entry(self, value: object) -> CacheEntry:
135
+ return CacheEntry(value, self._clock())
136
+
137
+
138
+ @contextmanager
139
+ def _issuer_io(effect: ResolveTxt | FetchJson) -> Iterator[None]:
140
+ """Report a failed DNS or HTTP request to the issuer as ``ISSUER_UNREACHABLE``."""
141
+ try:
142
+ yield
143
+ except EVPError:
144
+ raise
145
+ except Exception as exc:
146
+ raise _unreachable(effect, exc) from exc
147
+
148
+
149
+ def _unreachable(effect: ResolveTxt | FetchJson, exc: Exception) -> DiscoveryError:
150
+ target = effect.name if isinstance(effect, ResolveTxt) else effect.url
151
+ err = DiscoveryError(ErrorCode.ISSUER_UNREACHABLE, f"lookup of {target} failed: {exc}")
152
+ err.__cause__ = exc
153
+ return err
154
+
155
+
156
+ def _missing_extras(cls: type, what: str) -> ImportError:
157
+ return ImportError(
158
+ f"{cls.__name__}.default() needs {what}: pip install 'pyevp[dns,httpx2]', or pass "
159
+ "resolver= and fetcher= yourself (pyevp.adapters.urllib needs no extra dependencies)"
160
+ )
161
+
162
+
163
+ class Verifier(_Base):
164
+ """Synchronous verifier (Django, Flask, scripts).
165
+
166
+ Thread-safe as long as the injected ports and cache are. A context manager:
167
+ leaving it calls :meth:`close`.
168
+ """
169
+
170
+ def __init__(
171
+ self,
172
+ *,
173
+ audience: str,
174
+ resolver: TxtResolver,
175
+ fetcher: JsonFetcher,
176
+ profile: Profile = DEFAULT_PROFILE,
177
+ cache: Cache | None = None,
178
+ clock: Clock = system_clock,
179
+ cache_ttl: timedelta = timedelta(minutes=10),
180
+ min_refresh_interval: timedelta = timedelta(minutes=1),
181
+ replay_guard: ReplayGuard | None = None,
182
+ observer: Observer | None = None,
183
+ ) -> None:
184
+ super().__init__(
185
+ audience=audience,
186
+ profile=profile,
187
+ clock=clock,
188
+ cache_ttl=cache_ttl,
189
+ min_refresh_interval=min_refresh_interval,
190
+ replay_protection=replay_guard is not None,
191
+ observer=observer,
192
+ )
193
+ self._resolver = resolver
194
+ self._fetcher = fetcher
195
+ self._replay_guard = replay_guard
196
+ self._cache = cache if cache is not None else InMemoryCache(clock=clock)
197
+
198
+ @classmethod
199
+ def default(
200
+ cls,
201
+ *,
202
+ audience: str,
203
+ resolver: TxtResolver | None = None,
204
+ fetcher: JsonFetcher | None = None,
205
+ profile: Profile = DEFAULT_PROFILE,
206
+ cache: Cache | None = None,
207
+ clock: Clock = system_clock,
208
+ cache_ttl: timedelta = timedelta(minutes=10),
209
+ min_refresh_interval: timedelta = timedelta(minutes=1),
210
+ replay_guard: ReplayGuard | None = None,
211
+ observer: Observer | None = None,
212
+ ) -> Self:
213
+ """Build a verifier using dnspython and httpx2 or httpx (``pyevp[dns,httpx2]``).
214
+
215
+ ``resolver`` and ``fetcher`` default to those adapters, which :meth:`close` closes;
216
+ the other arguments are the constructor's.
217
+ """
218
+ owned: list[object] = []
219
+ if resolver is None:
220
+ try:
221
+ from pyevp.adapters.dnspython import DnsPythonResolver # noqa: PLC0415
222
+ except ImportError as exc:
223
+ raise _missing_extras(cls, "dnspython") from exc
224
+ resolver = DnsPythonResolver()
225
+ owned.append(resolver)
226
+ if fetcher is None:
227
+ try:
228
+ from pyevp.adapters.httpx import HttpxFetcher # noqa: PLC0415
229
+ except ImportError as exc:
230
+ raise _missing_extras(cls, "httpx2 or httpx") from exc
231
+ fetcher = HttpxFetcher()
232
+ owned.append(fetcher)
233
+ verifier = cls(
234
+ audience=audience,
235
+ resolver=resolver,
236
+ fetcher=fetcher,
237
+ profile=profile,
238
+ cache=cache,
239
+ clock=clock,
240
+ cache_ttl=cache_ttl,
241
+ min_refresh_interval=min_refresh_interval,
242
+ replay_guard=replay_guard,
243
+ observer=observer,
244
+ )
245
+ verifier._owned = tuple(owned)
246
+ return verifier
247
+
248
+ def close(self) -> None:
249
+ """Close the resolver and fetcher that :meth:`default` created.
250
+
251
+ Ports passed in belong to the caller, who closes them. Safe to call twice.
252
+ """
253
+ owned, self._owned = self._owned, ()
254
+ with ExitStack() as stack:
255
+ for port in owned:
256
+ if callable(close := getattr(port, "close", None)):
257
+ stack.callback(close)
258
+
259
+ def __enter__(self) -> Self:
260
+ return self
261
+
262
+ def __exit__(
263
+ self,
264
+ exc_type: type[BaseException] | None,
265
+ exc: BaseException | None,
266
+ tb: TracebackType | None,
267
+ ) -> None:
268
+ self.close()
269
+
270
+ def verify(
271
+ self, token: str, *, nonce: str, email: str | None, audience: str | None = None
272
+ ) -> VerifiedEmail:
273
+ """Verify a presentation token.
274
+
275
+ :param nonce: the nonce this server put on the form (from the session).
276
+ :param email: the address the user submitted; checked against the token. Pass
277
+ ``None`` explicitly to skip the check and use the asserted address instead.
278
+ :param audience: override the configured origin (multi-host deployments).
279
+ :raises pyevp.TokenError: the token is malformed, stale, mis-bound or badly signed.
280
+ :raises pyevp.PolicyError: the token does not satisfy the profile, e.g. it asserts
281
+ another address. Raised before the issuer's signature is checked, so it says
282
+ nothing about authenticity.
283
+ :raises pyevp.DiscoveryError: the issuer could not be used. ``ISSUER_UNREACHABLE``
284
+ means its DNS or HTTPS failed and may be transient; other codes mean its
285
+ records, metadata or keys are unusable.
286
+ :raises Exception: anything raised by the cache or the replay guard, unchanged:
287
+ a failure of your own infrastructure, not a verdict on the token.
288
+
289
+ Any :class:`~pyevp.EVPError` means "do not trust this token".
290
+ """
291
+ started, result, error = time.perf_counter(), None, None
292
+ try:
293
+ result = self._run(self._steps(token, nonce, email, audience))
294
+ return result
295
+ except Exception as exc:
296
+ error = exc
297
+ raise
298
+ finally:
299
+ self._notify(token, result, error, started)
300
+
301
+ def _run(self, steps: Steps) -> VerifiedEmail:
302
+ try:
303
+ effect = next(steps)
304
+ while True:
305
+ effect = steps.send(self._perform(effect))
306
+ except StopIteration as stop:
307
+ return stop.value
308
+ finally:
309
+ steps.close()
310
+
311
+ def _perform(self, effect: Effect) -> object:
312
+ # Failures of the application's own replay store and cache propagate unchanged.
313
+ match effect:
314
+ case MarkUsed():
315
+ assert self._replay_guard is not None
316
+ return self._check_marked(
317
+ effect, self._replay_guard.mark_used(effect.key, effect.expires_at)
318
+ )
319
+ case ResolveTxt(name=name):
320
+ with _issuer_io(effect):
321
+ return self._resolver.resolve_txt(name)
322
+ case FetchJson(url=url):
323
+ if (entry := self._reuse(effect, self._cache.get(url))) is not None:
324
+ return entry.value
325
+ with _issuer_io(effect):
326
+ value = self._fetcher.fetch_json(url)
327
+ self._cache.set(url, self._entry(value), self._cache_ttl)
328
+ return value
329
+
330
+
331
+ class AsyncVerifier(_Base):
332
+ """Asynchronous verifier (FastAPI, Starlette, Django async views).
333
+
334
+ An async context manager: leaving it calls :meth:`aclose`.
335
+ """
336
+
337
+ def __init__(
338
+ self,
339
+ *,
340
+ audience: str,
341
+ resolver: AsyncTxtResolver,
342
+ fetcher: AsyncJsonFetcher,
343
+ profile: Profile = DEFAULT_PROFILE,
344
+ cache: Cache | AsyncCache | None = None,
345
+ clock: Clock = system_clock,
346
+ cache_ttl: timedelta = timedelta(minutes=10),
347
+ min_refresh_interval: timedelta = timedelta(minutes=1),
348
+ replay_guard: ReplayGuard | AsyncReplayGuard | None = None,
349
+ observer: Observer | None = None,
350
+ ) -> None:
351
+ super().__init__(
352
+ audience=audience,
353
+ profile=profile,
354
+ clock=clock,
355
+ cache_ttl=cache_ttl,
356
+ min_refresh_interval=min_refresh_interval,
357
+ replay_protection=replay_guard is not None,
358
+ observer=observer,
359
+ )
360
+ self._resolver = resolver
361
+ self._fetcher = fetcher
362
+ self._replay_guard = replay_guard
363
+ self._cache = cache if cache is not None else InMemoryCache(clock=clock)
364
+
365
+ @classmethod
366
+ def default(
367
+ cls,
368
+ *,
369
+ audience: str,
370
+ resolver: AsyncTxtResolver | None = None,
371
+ fetcher: AsyncJsonFetcher | None = None,
372
+ profile: Profile = DEFAULT_PROFILE,
373
+ cache: Cache | AsyncCache | None = None,
374
+ clock: Clock = system_clock,
375
+ cache_ttl: timedelta = timedelta(minutes=10),
376
+ min_refresh_interval: timedelta = timedelta(minutes=1),
377
+ replay_guard: ReplayGuard | AsyncReplayGuard | None = None,
378
+ observer: Observer | None = None,
379
+ ) -> Self:
380
+ """Build a verifier using dnspython and httpx2 or httpx (``pyevp[dns,httpx2]``).
381
+
382
+ ``resolver`` and ``fetcher`` default to those adapters, which :meth:`aclose` closes;
383
+ the other arguments are the constructor's.
384
+ """
385
+ owned: list[object] = []
386
+ if resolver is None:
387
+ try:
388
+ from pyevp.adapters.dnspython import AsyncDnsPythonResolver # noqa: PLC0415
389
+ except ImportError as exc:
390
+ raise _missing_extras(cls, "dnspython") from exc
391
+ resolver = AsyncDnsPythonResolver()
392
+ owned.append(resolver)
393
+ if fetcher is None:
394
+ try:
395
+ from pyevp.adapters.httpx import AsyncHttpxFetcher # noqa: PLC0415
396
+ except ImportError as exc:
397
+ raise _missing_extras(cls, "httpx2 or httpx") from exc
398
+ fetcher = AsyncHttpxFetcher()
399
+ owned.append(fetcher)
400
+ verifier = cls(
401
+ audience=audience,
402
+ resolver=resolver,
403
+ fetcher=fetcher,
404
+ profile=profile,
405
+ cache=cache,
406
+ clock=clock,
407
+ cache_ttl=cache_ttl,
408
+ min_refresh_interval=min_refresh_interval,
409
+ replay_guard=replay_guard,
410
+ observer=observer,
411
+ )
412
+ verifier._owned = tuple(owned)
413
+ return verifier
414
+
415
+ async def aclose(self) -> None:
416
+ """Close the resolver and fetcher that :meth:`default` created.
417
+
418
+ Ports passed in belong to the caller, who closes them. Safe to call twice.
419
+ """
420
+ owned, self._owned = self._owned, ()
421
+ async with AsyncExitStack() as stack:
422
+ for port in owned:
423
+ if callable(aclose := getattr(port, "aclose", None)):
424
+ stack.push_async_callback(aclose)
425
+ elif callable(close := getattr(port, "close", None)):
426
+ stack.callback(close)
427
+
428
+ async def __aenter__(self) -> Self:
429
+ return self
430
+
431
+ async def __aexit__(
432
+ self,
433
+ exc_type: type[BaseException] | None,
434
+ exc: BaseException | None,
435
+ tb: TracebackType | None,
436
+ ) -> None:
437
+ await self.aclose()
438
+
439
+ async def verify(
440
+ self, token: str, *, nonce: str, email: str | None, audience: str | None = None
441
+ ) -> VerifiedEmail:
442
+ """Async counterpart of :meth:`Verifier.verify`."""
443
+ started, result, error = time.perf_counter(), None, None
444
+ try:
445
+ result = await self._run(self._steps(token, nonce, email, audience))
446
+ return result
447
+ except Exception as exc:
448
+ error = exc
449
+ raise
450
+ finally:
451
+ self._notify(token, result, error, started)
452
+
453
+ async def _run(self, steps: Steps) -> VerifiedEmail:
454
+ try:
455
+ effect = next(steps)
456
+ while True:
457
+ effect = steps.send(await self._perform(effect))
458
+ except StopIteration as stop:
459
+ return stop.value
460
+ finally:
461
+ steps.close()
462
+
463
+ async def _perform(self, effect: Effect) -> object:
464
+ # Failures of the application's own replay store and cache propagate unchanged.
465
+ match effect:
466
+ case MarkUsed():
467
+ assert self._replay_guard is not None
468
+ marked = self._replay_guard.mark_used(effect.key, effect.expires_at)
469
+ return self._check_marked(
470
+ effect, await marked if inspect.isawaitable(marked) else marked
471
+ )
472
+ case ResolveTxt(name=name):
473
+ with _issuer_io(effect):
474
+ return await self._resolver.resolve_txt(name)
475
+ case FetchJson(url=url):
476
+ cached = self._cache.get(url)
477
+ if inspect.isawaitable(cached):
478
+ cached = await cached
479
+ if (entry := self._reuse(effect, cached)) is not None:
480
+ return entry.value
481
+ with _issuer_io(effect):
482
+ value = await self._fetcher.fetch_json(url)
483
+ stored = self._cache.set(url, self._entry(value), self._cache_ttl)
484
+ if inspect.isawaitable(stored):
485
+ await stored
486
+ return value
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: PyEVP
3
+ Version: 0.1.0
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
+ Keywords: email,verification,evp,sd-jwt,jose,authentication
6
+ Author: Gakuto Furuya
7
+ Author-email: Gakuto Furuya <g.furuya@gaato.net>
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
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Classifier: Topic :: Security
20
+ Classifier: Typing :: Typed
21
+ Requires-Dist: idna>=3.7
22
+ Requires-Dist: joserfc>=1.7
23
+ Requires-Dist: pyevp[dns,httpx2,django,cli] ; extra == 'all'
24
+ Requires-Dist: pyevp[dns,httpx2] ; extra == 'cli'
25
+ Requires-Dist: typer>=0.27 ; extra == 'cli'
26
+ Requires-Dist: django>=4.2 ; extra == 'django'
27
+ Requires-Dist: dnspython>=2.7 ; extra == 'dns'
28
+ Requires-Dist: anyio>=4.5 ; extra == 'httpx'
29
+ Requires-Dist: httpx>=0.28 ; extra == 'httpx'
30
+ Requires-Dist: anyio>=4.5 ; extra == 'httpx2'
31
+ Requires-Dist: httpx2>=2.13 ; extra == 'httpx2'
32
+ Requires-Python: >=3.11
33
+ Project-URL: Documentation, https://docs.pyevp.dev/
34
+ Project-URL: Repository, https://github.com/gaato/pyevp
35
+ Project-URL: Changelog, https://github.com/gaato/pyevp/blob/main/CHANGELOG.md
36
+ Provides-Extra: all
37
+ Provides-Extra: cli
38
+ Provides-Extra: django
39
+ Provides-Extra: dns
40
+ Provides-Extra: httpx
41
+ Provides-Extra: httpx2
42
+ Description-Content-Type: text/markdown
43
+
44
+ # PyEVP
45
+
46
+ [![Documentation](https://app.readthedocs.org/projects/pyevp/badge/?version=latest)](https://docs.pyevp.dev/en/latest/)
47
+ [![CI](https://github.com/gaato/pyevp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gaato/pyevp/actions/workflows/ci.yml)
48
+ [![Spec drift](https://github.com/gaato/pyevp/actions/workflows/drift.yml/badge.svg)](https://github.com/gaato/pyevp/actions/workflows/drift.yml)
49
+ [![spec: draft-hardt-02](https://img.shields.io/badge/spec-draft--hardt--02-blue)](https://github.com/dickhardt/email-verification)
50
+ ![status: alpha](https://img.shields.io/badge/status-alpha-orange)
51
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
52
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/gaato/pyevp/blob/main/LICENSE)
53
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/gaato/pyevp)
54
+
55
+ [日本語](https://github.com/gaato/pyevp/blob/main/README.ja.md)
56
+
57
+ Python library for the **Email Verification Protocol** (EVP): verify tokens as a relying party,
58
+ or issue them for your own email domains. With EVP, the browser obtains a token from the user's
59
+ email provider proving they control an address, and your server verifies it, with no confirmation
60
+ email round-trip.
61
+
62
+ **Documentation: <https://docs.pyevp.dev/>**
63
+
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.
67
+
68
+ [draft-hardt-email-verification]: https://github.com/dickhardt/email-verification
69
+ [WICG Email Verification API]: https://github.com/WICG/email-verification
70
+
71
+ ## Install
72
+
73
+ ```sh
74
+ pip install "pyevp[dns,httpx2]" # core + the DNS and HTTP adapters
75
+ ```
76
+
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
86
+
87
+ [joserfc]: https://jose.authlib.org/
88
+
89
+ ## How it works
90
+
91
+ 1. Render a form with a fresh nonce stored in the user's session:
92
+
93
+ ```html
94
+ <input type="email" name="email" autocomplete="email">
95
+ <input type="hidden" name="evt" autocomplete="email-verification-token" nonce="{{ nonce }}">
96
+ ```
97
+
98
+ 2. When the user picks an address, the browser fills `evt` with `<EVT>~<KB-JWT>`.
99
+ 3. On submit, verify it:
100
+
101
+ ```python
102
+ from pyevp import Verifier, EVPError, generate_nonce
103
+
104
+ verifier = Verifier.default(audience="https://example.com") # your origin
105
+
106
+ try:
107
+ result = verifier.verify(form["evt"], nonce=session.pop("evp_nonce"), email=form["email"])
108
+ except EVPError as exc:
109
+ ... # exc.code is an ErrorCode, e.g. "nonce_mismatch"; fall back to email confirmation
110
+ else:
111
+ result.email, result.issuer # verified
112
+ ```
113
+
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
+ ```
138
+
139
+ See the [CLI guide](https://docs.pyevp.dev/en/latest/guides/cli.html) for every command and option.
140
+
141
+ ## More
142
+
143
+ - [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
145
+ - [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
148
+ - [Running an issuer](https://docs.pyevp.dev/en/latest/guides/issuer-operations.html) for your own mail domains
149
+ - [Compatibility policy](https://docs.pyevp.dev/en/latest/compatibility.html)
150
+
151
+ ## Examples
152
+
153
+ Each example is a standalone project with its own tests:
154
+
155
+ - [`examples/fastapi`](https://github.com/gaato/pyevp/blob/main/examples/fastapi/app.py): FastAPI with session nonces
156
+ - [`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
159
+ - [`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`
161
+ - [`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
164
+
165
+ ## Contributing
166
+
167
+ See [CONTRIBUTING.md](https://github.com/gaato/pyevp/blob/main/CONTRIBUTING.md).
168
+
169
+ ## License
170
+
171
+ [MIT](https://github.com/gaato/pyevp/blob/main/LICENSE)