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
@@ -0,0 +1,314 @@
1
+ """Running an EVP issuer in Django.
2
+
3
+ :class:`IssuerSite` serves everything Chrome needs from an issuer, with the
4
+ logged-in Django user deciding which addresses get tokens::
5
+
6
+ # urls.py, on the issuer's origin
7
+ from pyevp.contrib.django.issuer import IssuerSite
8
+
9
+ class Site(IssuerSite):
10
+ def user_emails(self, request):
11
+ ... # addresses whose mail the user receives
12
+
13
+ evp = Site(issuer) # a pyevp.issuer.Issuer
14
+ urlpatterns = [path("", include(evp.urls)), ...]
15
+
16
+ Subclass it to say which addresses a user may get tokens for
17
+ (:meth:`IssuerSite.user_emails`, required) and, optionally, to choose the issuer
18
+ per request (:meth:`IssuerSite.get_issuer`). Each endpoint is also a view of
19
+ its own, for mounting it somewhere else::
20
+
21
+ path("fedcm/accounts", AccountsView.as_view(site=evp))
22
+
23
+ The session cookie must be ``SameSite=None; Secure``, because Chrome's requests
24
+ to the issuer are cross-site; a system check warns otherwise. Add
25
+ :class:`LoginStatusMiddleware` so that Chrome knows when users are signed in.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import weakref
31
+ from collections.abc import Callable, Sequence
32
+ from typing import Any, ClassVar
33
+ from urllib.parse import urlsplit
34
+
35
+ from django.conf import settings
36
+ from django.core import checks
37
+ from django.core.exceptions import ImproperlyConfigured, RequestDataTooBig
38
+ from django.http import HttpRequest, HttpResponse, JsonResponse
39
+ from django.shortcuts import resolve_url
40
+ from django.urls import URLPattern, get_resolver, path
41
+ from django.views import View
42
+ from django.views.decorators.csrf import csrf_exempt
43
+
44
+ from pyevp.issuer import (
45
+ FEDCM_FETCH_DEST,
46
+ IssuanceError,
47
+ IssuanceErrorCode,
48
+ IssuanceResponse,
49
+ Issuer,
50
+ accounts_document,
51
+ is_valid_email,
52
+ web_identity_document,
53
+ )
54
+
55
+ __all__ = [
56
+ "AccountsView",
57
+ "IssuanceView",
58
+ "IssuerSite",
59
+ "JWKSView",
60
+ "LoginStatusMiddleware",
61
+ "MetadataView",
62
+ "WebIdentityView",
63
+ ]
64
+
65
+ _MAX_BODY = 16 * 1024
66
+ _sites: weakref.WeakSet[IssuerSite] = weakref.WeakSet()
67
+
68
+
69
+ def _overrides(cls: type, name: str) -> bool:
70
+ return getattr(cls, name) is not getattr(IssuerSite, name)
71
+
72
+
73
+ class IssuerSite:
74
+ """The issuer's endpoints, bound to one :class:`~pyevp.issuer.Issuer` or chosen per request.
75
+
76
+ :param issuer: the issuer to serve. Leave it out and override :meth:`get_issuer`
77
+ when it depends on the request, e.g. on the host.
78
+ :param login_url: where Chrome sends users who are not signed in; defaults to
79
+ ``settings.LOGIN_URL``. A path is taken relative to the issuer's origin.
80
+
81
+ :attr:`urls` must be included at the root of the issuer's origin, and the paths
82
+ below must match the issuer's ``issuance_endpoint`` and ``jwks_uri``.
83
+ """
84
+
85
+ metadata_path: ClassVar[str] = ".well-known/email-verification"
86
+ jwks_path: ClassVar[str] = "email-verification/jwks"
87
+ issuance_path: ClassVar[str] = "email-verification/issuance"
88
+ accounts_path: ClassVar[str] = "fedcm/accounts"
89
+ web_identity_path: ClassVar[str] = ".well-known/web-identity"
90
+
91
+ def __init__(self, issuer: Issuer | None = None, *, login_url: str | None = None) -> None:
92
+ if issuer is not None:
93
+ for url, route in (
94
+ (issuer.issuance_endpoint, self.issuance_path),
95
+ (issuer.jwks_uri, self.jwks_path),
96
+ ):
97
+ if urlsplit(url).path != "/" + route:
98
+ raise ImproperlyConfigured(f"{url} is not served at /{route}")
99
+ self.issuer = issuer
100
+ self.login_url = login_url
101
+ if not _overrides(type(self), "user_emails"):
102
+ raise ImproperlyConfigured(
103
+ "subclass IssuerSite and override user_emails() with the addresses whose "
104
+ "mail the signed-in user receives"
105
+ )
106
+ _sites.add(self)
107
+
108
+ # --- hooks ---
109
+
110
+ def get_issuer(self, request: HttpRequest) -> Issuer:
111
+ """The issuer answering ``request``."""
112
+ if self.issuer is None:
113
+ raise ImproperlyConfigured("pass an Issuer to IssuerSite or override get_issuer")
114
+ return self.issuer
115
+
116
+ def user_emails(self, request: HttpRequest) -> Sequence[str]:
117
+ """Addresses the signed-in user may get tokens for; empty without a session.
118
+
119
+ Override this. A token tells relying parties that the user controls the address, so
120
+ return only addresses whose mail the user actually receives, for example the
121
+ verified addresses of django-allauth, or what your mail server delivers to the
122
+ user's mailbox. A user model's email field is not that unless your sign-up flow
123
+ verified it, which is why there is no default.
124
+ """
125
+ raise NotImplementedError
126
+
127
+ def owns(self, request: HttpRequest, email: str) -> bool:
128
+ """Whether the signed-in user controls ``email``, compared case-insensitively.
129
+
130
+ Addresses EVP cannot carry are ignored: lowercasing a non-ASCII one can turn it into
131
+ someone else's (``\\u212aate@`` with a KELVIN SIGN becomes ``kate@``).
132
+ """
133
+ return email.lower() in {e.lower() for e in self.user_emails(request) if is_valid_email(e)}
134
+
135
+ def get_login_url(self, request: HttpRequest) -> str:
136
+ url = resolve_url(self.login_url or settings.LOGIN_URL)
137
+ return self.get_issuer(request).issuer + url if url.startswith("/") else url
138
+
139
+ # --- URLs ---
140
+
141
+ @property
142
+ def urls(self) -> list[URLPattern]:
143
+ return [
144
+ path(self.metadata_path, MetadataView.as_view(site=self)),
145
+ path(self.jwks_path, JWKSView.as_view(site=self)),
146
+ path(self.issuance_path, IssuanceView.as_view(site=self)),
147
+ path(self.accounts_path, AccountsView.as_view(site=self)),
148
+ path(self.web_identity_path, WebIdentityView.as_view(site=self)),
149
+ ]
150
+
151
+
152
+ class _IssuerView(View):
153
+ site: IssuerSite | None = None
154
+
155
+ def _site(self) -> IssuerSite:
156
+ if self.site is None:
157
+ raise ImproperlyConfigured(f"{type(self).__name__}.as_view() needs site=")
158
+ return self.site
159
+
160
+
161
+ class MetadataView(_IssuerView):
162
+ """``/.well-known/email-verification``."""
163
+
164
+ def get(self, request: HttpRequest) -> HttpResponse:
165
+ return JsonResponse(self._site().get_issuer(request).metadata_document())
166
+
167
+
168
+ class JWKSView(_IssuerView):
169
+ """The issuer's ``jwks_uri``."""
170
+
171
+ def get(self, request: HttpRequest) -> HttpResponse:
172
+ return JsonResponse(self._site().get_issuer(request).jwks_document())
173
+
174
+
175
+ class IssuanceView(_IssuerView):
176
+ """The issuer's ``issuance_endpoint``: an EVT for a user who controls the address.
177
+
178
+ Exempt from CSRF protection and from ``ATOMIC_REQUESTS``; use a synchronous
179
+ replay guard such as :class:`~pyevp.contrib.django.EVPReplayGuard`.
180
+ Put per-IP rate limiting in front of it.
181
+ """
182
+
183
+ http_method_names = ["post"] # noqa: RUF012
184
+
185
+ @classmethod
186
+ def as_view(cls, **initkwargs: Any) -> Callable[..., HttpResponse]:
187
+ view = super().as_view(**initkwargs)
188
+ # A forged cross-site POST cannot pass: Sec-Fetch-Dest: email-verification can only
189
+ # come from the browser itself, and a JSON body needs a CORS preflight.
190
+ view = csrf_exempt(view)
191
+ # The replay guard must commit its record on its own; see EVPReplayGuard.
192
+ view._non_atomic_requests = set(settings.DATABASES)
193
+ return view
194
+
195
+ def post(self, request: HttpRequest) -> HttpResponse:
196
+ site = self._site()
197
+ issuer = site.get_issuer(request)
198
+ try:
199
+ body = _read_body(request)
200
+ parsed = issuer.parse_request(
201
+ method=request.method or "", headers=request.headers.items(), body=body
202
+ )
203
+ # One answer for every way this can fail, so responses do not reveal accounts.
204
+ if not site.owns(request, parsed.email):
205
+ raise IssuanceError.authentication_required()
206
+ result = issuer.success_response(issuer.issue(parsed))
207
+ except IssuanceError as exc:
208
+ result = exc.to_response()
209
+ return _to_http(result)
210
+
211
+ def http_method_not_allowed(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
212
+ error = IssuanceError(IssuanceErrorCode.INVALID_REQUEST, f"method {request.method}")
213
+ return _to_http(error.to_response())
214
+
215
+
216
+ class AccountsView(_IssuerView):
217
+ """The FedCM accounts endpoint Chrome checks before asking for a token."""
218
+
219
+ def get(self, request: HttpRequest) -> HttpResponse:
220
+ if request.headers.get("Sec-Fetch-Dest") != FEDCM_FETCH_DEST:
221
+ return JsonResponse({"error": "not a FedCM request"}, status=400)
222
+ emails = [e for e in self._site().user_emails(request) if is_valid_email(e)]
223
+ if not emails:
224
+ return JsonResponse({"accounts": []}, status=401)
225
+ return JsonResponse(accounts_document(emails))
226
+
227
+
228
+ class WebIdentityView(_IssuerView):
229
+ """``/.well-known/web-identity``, which Chrome reads on the issuer's registrable domain."""
230
+
231
+ def get(self, request: HttpRequest) -> HttpResponse:
232
+ site = self._site()
233
+ document = web_identity_document(
234
+ accounts_endpoint=f"{site.get_issuer(request).issuer}/{site.accounts_path}",
235
+ login_url=site.get_login_url(request),
236
+ )
237
+ return JsonResponse(document)
238
+
239
+
240
+ class LoginStatusMiddleware:
241
+ """Tells Chrome whether the user is signed in (FedCM Login Status API).
242
+
243
+ Adds ``Set-Login: logged-in`` or ``logged-out`` to page responses, so that
244
+ Chrome does not skip the issuer for a signed-in user. Place it after
245
+ ``AuthenticationMiddleware``.
246
+ """
247
+
248
+ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
249
+ self.get_response = get_response
250
+
251
+ def __call__(self, request: HttpRequest) -> HttpResponse:
252
+ response = self.get_response(request)
253
+ user = getattr(request, "user", None)
254
+ if (
255
+ user is not None
256
+ and request.headers.get("Sec-Fetch-Dest") == "document"
257
+ and "Set-Login" not in response
258
+ ):
259
+ response["Set-Login"] = "logged-in" if user.is_authenticated else "logged-out"
260
+ return response
261
+
262
+
263
+ def _read_body(request: HttpRequest) -> bytes:
264
+ # Refuse large bodies before reading them, whatever DATA_UPLOAD_MAX_MEMORY_SIZE says.
265
+ try:
266
+ too_large = int(request.META.get("CONTENT_LENGTH") or 0) > _MAX_BODY
267
+ body = b"" if too_large else request.body
268
+ except (ValueError, RequestDataTooBig):
269
+ too_large = True
270
+ if too_large or len(body) > _MAX_BODY:
271
+ raise IssuanceError(IssuanceErrorCode.INVALID_REQUEST, "body too large")
272
+ return body
273
+
274
+
275
+ def _to_http(result: IssuanceResponse) -> HttpResponse:
276
+ return HttpResponse(result.body, status=result.status, headers=result.headers)
277
+
278
+
279
+ _PER_RESPONSE = (
280
+ " If your session middleware sets the cookie's attributes per response instead, "
281
+ "add this check to SILENCED_SYSTEM_CHECKS."
282
+ )
283
+
284
+
285
+ def check_session_cookie(**kwargs: Any) -> list[checks.CheckMessage]:
286
+ """Warn when Chrome's cross-site requests to an :class:`IssuerSite` would lack the session.
287
+
288
+ Registered as a deployment check (``manage.py check --deploy``). It reads the
289
+ settings, so it cannot see attributes a middleware sets per response.
290
+ """
291
+ if getattr(settings, "ROOT_URLCONF", None):
292
+ get_resolver().url_patterns # noqa: B018 (importing the URLconf creates the sites)
293
+ if not _sites:
294
+ return []
295
+ warnings = []
296
+ if settings.SESSION_COOKIE_SAMESITE != "None":
297
+ warnings.append(
298
+ checks.Warning(
299
+ "SESSION_COOKIE_SAMESITE is not 'None'.",
300
+ hint="Chrome's FedCM and issuance requests to the issuer are cross-site; "
301
+ "without SameSite=None they carry no session and every request fails."
302
+ + _PER_RESPONSE,
303
+ id="pyevp.W001",
304
+ )
305
+ )
306
+ if not settings.SESSION_COOKIE_SECURE:
307
+ warnings.append(
308
+ checks.Warning(
309
+ "SESSION_COOKIE_SECURE is off.",
310
+ hint="Browsers drop SameSite=None cookies that are not Secure." + _PER_RESPONSE,
311
+ id="pyevp.W002",
312
+ )
313
+ )
314
+ return warnings
@@ -0,0 +1,17 @@
1
+ from django.db import migrations, models
2
+
3
+
4
+ class Migration(migrations.Migration):
5
+ initial = True
6
+
7
+ dependencies = []
8
+
9
+ operations = [
10
+ migrations.CreateModel(
11
+ name="UsedToken",
12
+ fields=[
13
+ ("key", models.CharField(max_length=64, primary_key=True, serialize=False)),
14
+ ("expires_at", models.DateTimeField(db_index=True)),
15
+ ],
16
+ ),
17
+ ]
File without changes
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import ClassVar
4
+
5
+ from django.db import models
6
+
7
+
8
+ class UsedToken(models.Model):
9
+ """A token accepted by :class:`pyevp.contrib.django.EVPReplayGuard`."""
10
+
11
+ key = models.CharField(max_length=64, primary_key=True)
12
+ expires_at = models.DateTimeField(db_index=True)
13
+
14
+ objects: ClassVar[models.Manager] = models.Manager()
File without changes
@@ -0,0 +1,32 @@
1
+ """Template tags for EVP forms, loaded with ``{% load pyevp %}``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django import template
6
+ from django.core.exceptions import ImproperlyConfigured
7
+ from django.utils.html import format_html
8
+ from django.utils.safestring import SafeString
9
+
10
+ from pyevp.contrib.django import get_nonce
11
+
12
+ register = template.Library()
13
+
14
+
15
+ @register.simple_tag(takes_context=True)
16
+ def evp_token_input(context: template.Context, field: str = "evt") -> SafeString:
17
+ """Render the hidden input that the browser fills with the token.
18
+
19
+ Put it inside the form, next to ``<input type="email" autocomplete="email">``.
20
+ All tags on a page share one nonce (see :func:`~pyevp.contrib.django.get_nonce`).
21
+ """
22
+ request = context.get("request")
23
+ if request is None:
24
+ raise ImproperlyConfigured(
25
+ "{% evp_token_input %} needs the request in the template context: enable "
26
+ "django.template.context_processors.request or render with a request."
27
+ )
28
+ return format_html(
29
+ '<input type="hidden" name="{}" autocomplete="email-verification-token" nonce="{}">',
30
+ field,
31
+ get_nonce(request),
32
+ )