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.
- pyevp/__init__.py +47 -0
- pyevp/__main__.py +28 -0
- pyevp/_email.py +65 -0
- pyevp/_httpsig.py +284 -0
- pyevp/_jose.py +145 -0
- pyevp/_sf.py +406 -0
- pyevp/adapters/__init__.py +4 -0
- pyevp/adapters/_doh.py +92 -0
- pyevp/adapters/_fetch.py +86 -0
- pyevp/adapters/_http.py +38 -0
- pyevp/adapters/dnspython.py +79 -0
- pyevp/adapters/doh.py +130 -0
- pyevp/adapters/httpx.py +147 -0
- pyevp/adapters/urllib.py +170 -0
- pyevp/cache.py +83 -0
- pyevp/cli/__init__.py +348 -0
- pyevp/contrib/__init__.py +4 -0
- pyevp/contrib/django/__init__.py +306 -0
- pyevp/contrib/django/apps.py +17 -0
- pyevp/contrib/django/issuer.py +314 -0
- pyevp/contrib/django/migrations/0001_initial.py +17 -0
- pyevp/contrib/django/migrations/__init__.py +0 -0
- pyevp/contrib/django/models.py +14 -0
- pyevp/contrib/django/templatetags/__init__.py +0 -0
- pyevp/contrib/django/templatetags/pyevp.py +32 -0
- pyevp/core.py +321 -0
- pyevp/diagnostics.py +182 -0
- pyevp/discovery.py +144 -0
- pyevp/errors.py +80 -0
- pyevp/issuer/__init__.py +39 -0
- pyevp/issuer/core.py +413 -0
- pyevp/issuer/errors.py +99 -0
- pyevp/issuer/fedcm.py +44 -0
- pyevp/issuer/keys.py +140 -0
- pyevp/issuer/profile.py +96 -0
- pyevp/nonce.py +25 -0
- pyevp/observability.py +89 -0
- pyevp/ports.py +54 -0
- pyevp/profile.py +153 -0
- pyevp/py.typed +0 -0
- pyevp/replay.py +69 -0
- pyevp/testing.py +343 -0
- pyevp/token.py +135 -0
- pyevp/types.py +37 -0
- pyevp/verifier.py +486 -0
- pyevp-0.1.0.dist-info/METADATA +171 -0
- pyevp-0.1.0.dist-info/RECORD +50 -0
- pyevp-0.1.0.dist-info/WHEEL +4 -0
- pyevp-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|
+
[](https://docs.pyevp.dev/en/latest/)
|
|
47
|
+
[](https://github.com/gaato/pyevp/actions/workflows/ci.yml)
|
|
48
|
+
[](https://github.com/gaato/pyevp/actions/workflows/drift.yml)
|
|
49
|
+
[](https://github.com/dickhardt/email-verification)
|
|
50
|
+

|
|
51
|
+
[](https://github.com/astral-sh/ty)
|
|
52
|
+
[](https://github.com/gaato/pyevp/blob/main/LICENSE)
|
|
53
|
+
[](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)
|