millwork 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 (70) hide show
  1. millwork/__init__.py +45 -0
  2. millwork/_bundle/dist/.vite/manifest.json +92 -0
  3. millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
  4. millwork/_bundle/dist/favicon.svg +15 -0
  5. millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  6. millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  7. millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  8. millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  9. millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  10. millwork/_bundle/dist/icons.svg +24 -0
  11. millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
  12. millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
  13. millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
  14. millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
  15. millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
  16. millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
  17. millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
  18. millwork/app.py +726 -0
  19. millwork/auth.py +877 -0
  20. millwork/chat.py +397 -0
  21. millwork/cli.py +316 -0
  22. millwork/context.py +42 -0
  23. millwork/files.py +450 -0
  24. millwork/forms.py +349 -0
  25. millwork/manual/__init__.py +449 -0
  26. millwork/manual/components/Hello.jsx +24 -0
  27. millwork/manual/components/Voice.jsx +46 -0
  28. millwork/manual/pages/app/auth.md +188 -0
  29. millwork/manual/pages/app/pages.md +200 -0
  30. millwork/manual/pages/app/running.md +242 -0
  31. millwork/manual/pages/app/state.md +151 -0
  32. millwork/manual/pages/app/testing.md +147 -0
  33. millwork/manual/pages/app/theming.md +122 -0
  34. millwork/manual/pages/components/charts.md +263 -0
  35. millwork/manual/pages/components/chat.md +280 -0
  36. millwork/manual/pages/components/custom.md +148 -0
  37. millwork/manual/pages/components/data.md +112 -0
  38. millwork/manual/pages/components/feedback.md +212 -0
  39. millwork/manual/pages/components/files.md +194 -0
  40. millwork/manual/pages/components/form.md +135 -0
  41. millwork/manual/pages/components/inputs.md +360 -0
  42. millwork/manual/pages/components/layout.md +154 -0
  43. millwork/manual/pages/components/tasks.md +140 -0
  44. millwork/manual/pages/components/text.md +145 -0
  45. millwork/manual/pages/index.md +67 -0
  46. millwork/manual/public/fonts/InterVariable.woff2 +0 -0
  47. millwork/manual/public/fonts/LICENSE.txt +2 -0
  48. millwork/manual/public/mark.svg +15 -0
  49. millwork/manual/public/theme.json +97 -0
  50. millwork/nodes.py +300 -0
  51. millwork/protocol.py +238 -0
  52. millwork/proxy.py +122 -0
  53. millwork/py.typed +0 -0
  54. millwork/session.py +1422 -0
  55. millwork/shell.py +226 -0
  56. millwork/tasks.py +473 -0
  57. millwork/testing.py +336 -0
  58. millwork/theme.py +338 -0
  59. millwork/ui/__init__.py +725 -0
  60. millwork/ui/charts.py +214 -0
  61. millwork/ui/chat.py +97 -0
  62. millwork/ui/files.py +101 -0
  63. millwork/ui/form.py +102 -0
  64. millwork/ui/table.py +145 -0
  65. millwork/ws.py +220 -0
  66. millwork-0.1.0.dist-info/METADATA +131 -0
  67. millwork-0.1.0.dist-info/RECORD +70 -0
  68. millwork-0.1.0.dist-info/WHEEL +4 -0
  69. millwork-0.1.0.dist-info/entry_points.txt +2 -0
  70. millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
millwork/auth.py ADDED
@@ -0,0 +1,877 @@
1
+ """Auth: who may open an app, and what a page may ask about them.
2
+
3
+ Providers are configured in Python on the ``App``; one
4
+ ``on_login`` hook decides access and shapes the ``User``; the cookie is a
5
+ compact signed token; the websocket binds to it. The cookie and OAuth
6
+ mechanics are lifted from Chainlit's ``auth/`` and ``oauth_providers.py``,
7
+ without the chunked cookies, the stored refresh token or the photo bytes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import base64
14
+ import binascii
15
+ import datetime as dt
16
+ import hashlib
17
+ import hmac
18
+ import inspect
19
+ import json
20
+ import logging
21
+ import math
22
+ import os
23
+ import secrets
24
+ from collections.abc import Awaitable, Callable, Mapping
25
+ from contextlib import AbstractAsyncContextManager
26
+ from typing import Any, Literal
27
+ from urllib.parse import unquote, urlencode, urlsplit
28
+
29
+ import httpx
30
+ from pydantic import BaseModel, ConfigDict, Field
31
+ from starlette.requests import Request
32
+ from starlette.responses import JSONResponse, RedirectResponse, Response
33
+
34
+ from millwork.shell import root_of
35
+
36
+ log = logging.getLogger("millwork.auth")
37
+
38
+ CLAIMS_LIMIT = 2048
39
+ STATE_LIFETIME = 180
40
+ AUTH_COOKIE = "mw_auth"
41
+ STATE_COOKIE = "mw_oauth_state"
42
+ TOKEN_LIMIT = 8192
43
+
44
+
45
+ def _b64(data: bytes) -> str:
46
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
47
+
48
+
49
+ def _unb64(text: str) -> bytes:
50
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
51
+
52
+
53
+ def _key(secret: str, purpose: str) -> bytes:
54
+ """A signing key for one purpose, so a token issued for one use never reads as another."""
55
+ if not secret:
56
+ raise ValueError("a signing secret is required")
57
+ return hmac.new(secret.encode(), b"millwork:" + purpose.encode(), hashlib.sha256).digest()
58
+
59
+
60
+ def _mac(secret: str, purpose: str, body: str) -> str:
61
+ return _b64(hmac.new(_key(secret, purpose), body.encode(), hashlib.sha256).digest())
62
+
63
+
64
+ def _expiry(payload: dict[str, Any]) -> float | None:
65
+ """The payload's ``exp`` when it is a finite number, else None."""
66
+ exp = payload.get("exp")
67
+ if isinstance(exp, bool) or not isinstance(exp, (int, float)) or not math.isfinite(exp):
68
+ return None
69
+ return exp
70
+
71
+
72
+ def sign(secret: str, purpose: str, payload: dict[str, Any]) -> str:
73
+ """A signed token: the JSON payload and an HMAC-SHA256 over it, both base64url.
74
+
75
+ The payload must carry ``exp`` as a finite unix timestamp.
76
+ """
77
+ if _expiry(payload) is None:
78
+ raise ValueError("a token needs a numeric exp")
79
+ body = _b64(json.dumps(payload, separators=(",", ":"), allow_nan=False).encode())
80
+ return f"{body}.{_mac(secret, purpose, body)}"
81
+
82
+
83
+ def verify(secret: str, purpose: str, token: str | None) -> dict[str, Any] | None:
84
+ """The payload of a token signed for ``purpose`` and not expired, else None.
85
+
86
+ The signature is compared in constant time, as the exact text ``sign``
87
+ produced, before the payload is decoded: nothing an attacker wrote is parsed.
88
+ """
89
+ if not token or len(token) > TOKEN_LIMIT or not token.isascii() or token.count(".") != 1:
90
+ return None
91
+ body, sig = token.split(".")
92
+ if not hmac.compare_digest(sig.encode(), _mac(secret, purpose, body).encode()):
93
+ return None
94
+ try:
95
+ payload = json.loads(_unb64(body))
96
+ except (ValueError, binascii.Error):
97
+ return None
98
+ if not isinstance(payload, dict):
99
+ return None
100
+ exp = _expiry(payload)
101
+ if exp is None or exp <= dt.datetime.now(dt.UTC).timestamp():
102
+ return None
103
+ return payload
104
+
105
+
106
+ class User(BaseModel):
107
+ """What ``on_login`` returns and ``s.user`` holds."""
108
+
109
+ model_config = ConfigDict(extra="forbid")
110
+
111
+ id: str
112
+ name: str
113
+ claims: dict[str, Any] = Field(default_factory=dict)
114
+ picture_url: str | None = None
115
+
116
+
117
+ class Identity(BaseModel):
118
+ """The normalised result of a provider, handed to ``on_login``."""
119
+
120
+ model_config = ConfigDict(extra="forbid")
121
+
122
+ provider: str
123
+ subject: str
124
+ email: str | None = None
125
+ name: str | None = None
126
+ picture_url: str | None = None
127
+ raw: dict[str, Any] = Field(default_factory=dict)
128
+ intent: str | None = None
129
+
130
+
131
+ class Forbidden(Exception):
132
+ """Raised from ``connect`` when the user may not open this page.
133
+
134
+ The session lands on the app's forbidden page, which shows ``message``
135
+ when one is given and the theme's standard text otherwise.
136
+ """
137
+
138
+ def __init__(self, message: str | None = None) -> None:
139
+ self.message = message
140
+ super().__init__(message or "")
141
+
142
+
143
+ class NotFound(Exception):
144
+ """Raised from ``connect`` when what the path names does not exist; the
145
+ session lands on the app's not-found page."""
146
+
147
+ def __init__(self, message: str | None = None) -> None:
148
+ self.message = message
149
+ super().__init__(message or "")
150
+
151
+
152
+ def email_of(raw: dict[str, Any]) -> str | None:
153
+ """The address that identifies the signed-in user, from whichever claim carries an ``@``."""
154
+ for key in ("mail", "email", "userPrincipalName", "preferred_username", "upn"):
155
+ value = raw.get(key)
156
+ if isinstance(value, str) and "@" in value:
157
+ return value.strip().lower()
158
+ return None
159
+
160
+
161
+ # ------------------------------------------------------------------ providers
162
+
163
+
164
+ class Provider:
165
+ id: str
166
+ label: str
167
+ kind: Literal["oauth", "password", "header", "dev"]
168
+ icon: str | None = None
169
+
170
+ def public(self) -> dict[str, Any]:
171
+ return {"id": self.id, "label": self.label, "kind": self.kind, "icon": self.icon}
172
+
173
+
174
+ class OAuth(Provider):
175
+ """One OAuth2 authorization-code provider; any OIDC issuer fits."""
176
+
177
+ kind: Literal["oauth", "password", "header", "dev"] = "oauth"
178
+
179
+ def __init__(
180
+ self,
181
+ id: str,
182
+ *,
183
+ label: str,
184
+ authorize_url: str,
185
+ token_url: str,
186
+ userinfo_url: str,
187
+ client_id: str,
188
+ client_secret: str,
189
+ scopes: str,
190
+ extra_params: dict[str, str] | None = None,
191
+ icon: str | None = None,
192
+ http: httpx.AsyncClient | None = None,
193
+ ) -> None:
194
+ self.id = id
195
+ self.label = label
196
+ self.icon = icon
197
+ self.authorize_url = authorize_url
198
+ self.token_url = token_url
199
+ self.userinfo_url = userinfo_url
200
+ self.client_id = client_id
201
+ self.client_secret = client_secret
202
+ self.scopes = scopes
203
+ self.extra_params = extra_params or {}
204
+ self._http = http
205
+
206
+ def authorize(self, redirect_uri: str, state: str) -> str:
207
+ params = {
208
+ "client_id": self.client_id,
209
+ "redirect_uri": redirect_uri,
210
+ "state": state,
211
+ "response_type": "code",
212
+ "scope": self.scopes,
213
+ **self.extra_params,
214
+ }
215
+ return f"{self.authorize_url}?{urlencode(params)}"
216
+
217
+ async def exchange(self, code: str, redirect_uri: str) -> dict[str, Any]:
218
+ payload = {
219
+ "client_id": self.client_id,
220
+ "client_secret": self.client_secret,
221
+ "code": code,
222
+ "grant_type": "authorization_code",
223
+ "redirect_uri": redirect_uri,
224
+ }
225
+ async with self._client() as client:
226
+ r = await client.post(self.token_url, data=payload)
227
+ r.raise_for_status()
228
+ data: dict[str, Any] = r.json()
229
+ if not data.get("access_token"):
230
+ raise RuntimeError("the provider returned no access token")
231
+ return data
232
+
233
+ async def userinfo(self, access_token: str) -> dict[str, Any]:
234
+ async with self._client() as client:
235
+ r = await client.get(
236
+ self.userinfo_url, headers={"Authorization": f"Bearer {access_token}"}
237
+ )
238
+ r.raise_for_status()
239
+ data: dict[str, Any] = r.json()
240
+ return data
241
+
242
+ def identity(self, raw: dict[str, Any]) -> Identity:
243
+ return Identity(
244
+ provider=self.id,
245
+ subject=str(raw.get("sub") or raw.get("id") or raw.get("oid") or email_of(raw) or ""),
246
+ email=email_of(raw),
247
+ name=raw.get("name") or raw.get("displayName"),
248
+ picture_url=raw.get("picture") if isinstance(raw.get("picture"), str) else None,
249
+ raw=raw,
250
+ )
251
+
252
+ def _client(self) -> AbstractAsyncContextManager[httpx.AsyncClient]:
253
+ if self._http is not None:
254
+ return _NonClosing(self._http)
255
+ return httpx.AsyncClient(timeout=15)
256
+
257
+
258
+ class _NonClosing:
259
+ """Use an injected client inside ``async with`` without closing it."""
260
+
261
+ def __init__(self, client: httpx.AsyncClient) -> None:
262
+ self._client = client
263
+
264
+ async def __aenter__(self) -> httpx.AsyncClient:
265
+ return self._client
266
+
267
+ async def __aexit__(self, *exc: object) -> None:
268
+ return None
269
+
270
+
271
+ def AzureAD(
272
+ *,
273
+ tenant: str,
274
+ client_id: str,
275
+ client_secret: str,
276
+ single_tenant: bool = True,
277
+ scopes: str = "openid profile email User.Read",
278
+ http: httpx.AsyncClient | None = None,
279
+ ) -> OAuth:
280
+ """Microsoft Entra ID (Azure AD). ``single_tenant`` keeps other tenants out at the issuer."""
281
+ realm = tenant if single_tenant else "common"
282
+ return OAuth(
283
+ "azure-ad",
284
+ label="Microsoft",
285
+ authorize_url=f"https://login.microsoftonline.com/{realm}/oauth2/v2.0/authorize",
286
+ token_url=f"https://login.microsoftonline.com/{realm}/oauth2/v2.0/token",
287
+ userinfo_url="https://graph.microsoft.com/v1.0/me",
288
+ client_id=client_id,
289
+ client_secret=client_secret,
290
+ scopes=scopes,
291
+ extra_params={"response_mode": "query"},
292
+ http=http,
293
+ )
294
+
295
+
296
+ def Google(*, client_id: str, client_secret: str, http: httpx.AsyncClient | None = None) -> OAuth:
297
+ return OAuth(
298
+ "google",
299
+ label="Google",
300
+ authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
301
+ token_url="https://oauth2.googleapis.com/token",
302
+ userinfo_url="https://www.googleapis.com/userinfo/v2/me",
303
+ client_id=client_id,
304
+ client_secret=client_secret,
305
+ scopes="openid email profile",
306
+ http=http,
307
+ )
308
+
309
+
310
+ class Password(Provider):
311
+ """A username and password checked by the app's callable."""
312
+
313
+ kind: Literal["oauth", "password", "header", "dev"] = "password"
314
+
315
+ def __init__(
316
+ self, verify: Callable[[str, str], bool | Awaitable[bool]], label: str = "Password"
317
+ ) -> None:
318
+ self.id = "password"
319
+ self.label = label
320
+ self.verify = verify
321
+
322
+
323
+ class Headers(Provider):
324
+ """The visitor as an authenticating reverse proxy names them in request headers.
325
+
326
+ The proxy (oauth2-proxy, a platform's built-in sign-in, an access gateway)
327
+ does the signing in and passes who it saw: a header with the user name
328
+ or id, one with the e-mail, and perhaps the display name, the groups and a
329
+ picture. Name the headers the proxy uses; ``user`` or ``email`` is required.
330
+ The headers are read when the app is behind a proxy (``App(proxied=True)``,
331
+ ``millwork run --proxied``) and go through ``on_login`` on every page load
332
+ and socket connect, so keep that hook cheap.
333
+ ``sign_out`` is the proxy's own sign-out URL, where the user menu sends the
334
+ person; without one the menu has no sign-out, since the app has nothing to
335
+ clear.
336
+ """
337
+
338
+ kind: Literal["oauth", "password", "header", "dev"] = "header"
339
+ VALUE_LIMIT = 1024
340
+ GROUPS_LIMIT = 8192
341
+
342
+ def __init__(
343
+ self,
344
+ *,
345
+ user: str | None = None,
346
+ email: str | None = None,
347
+ name: str | None = None,
348
+ groups: str | None = None,
349
+ picture: str | None = None,
350
+ sign_out: str | None = None,
351
+ label: str = "Proxy",
352
+ ) -> None:
353
+ if not (user or email):
354
+ raise ValueError("Headers needs the name of the user header, the e-mail header or both")
355
+ if sign_out is not None and not _sign_out_ok(sign_out):
356
+ raise ValueError("sign_out is a path on this host or an http(s) URL")
357
+ self.id = "header"
358
+ self.label = label
359
+ self.headers = {
360
+ role: value.lower()
361
+ for role, value in (
362
+ ("user", user),
363
+ ("email", email),
364
+ ("name", name),
365
+ ("groups", groups),
366
+ ("picture", picture),
367
+ )
368
+ if value
369
+ }
370
+ self.sign_out = sign_out
371
+ self._dropped: set[str] = set()
372
+
373
+ def present(self, headers: Any) -> bool:
374
+ return any(headers.get(h) for h in self.headers.values())
375
+
376
+ def identity(self, headers: Any) -> Identity | None:
377
+ """What the proxy says, or None when it names nobody. A value that is
378
+ not plain text of a sane length is treated as absent, and so is a header
379
+ that arrives more than once: a proxy that appends rather than replaces
380
+ would otherwise let the visitor's own value through first."""
381
+ raw: dict[str, Any] = {}
382
+ for role, header in self.headers.items():
383
+ given = _single(headers, header)
384
+ limit = self.GROUPS_LIMIT if role == "groups" else self.VALUE_LIMIT
385
+ value = _header_text(given, limit)
386
+ if value is None:
387
+ if given and role not in self._dropped:
388
+ self._dropped.add(role)
389
+ log.warning(
390
+ "the %s header (%s) was ignored: empty, over %d characters, "
391
+ "not printable, or sent more than once",
392
+ role,
393
+ header,
394
+ limit,
395
+ )
396
+ continue
397
+ raw[role] = (
398
+ [g.strip() for g in value.split(",") if g.strip()] if role == "groups" else value
399
+ )
400
+ user, email = raw.get("user"), raw.get("email")
401
+ if email is not None:
402
+ email = email.lower()
403
+ elif user is not None and "@" in user:
404
+ email = user.lower()
405
+ subject = user or email
406
+ if subject is None:
407
+ return None
408
+ return Identity(
409
+ provider=self.id,
410
+ subject=subject,
411
+ email=email,
412
+ name=raw.get("name"),
413
+ picture_url=raw.get("picture"),
414
+ raw=raw,
415
+ )
416
+
417
+
418
+ def _single(headers: Any, name: str) -> str | None:
419
+ values = headers.getlist(name) if hasattr(headers, "getlist") else [headers.get(name)]
420
+ return values[0] if len(values) == 1 else None
421
+
422
+
423
+ def _sign_out_ok(url: str) -> bool:
424
+ """A path on this host (no scheme-relative or backslash spelling) or an http(s) URL."""
425
+ if not url.isprintable() or " " in url or "\\" in url:
426
+ return False
427
+ if url.startswith("/"):
428
+ return not url.startswith("//")
429
+ return url.startswith(("https://", "http://")) and len(url) > 8
430
+
431
+
432
+ def _header_text(value: str | None, limit: int) -> str | None:
433
+ """A header value as text: UTF-8 when the proxy sent it that way, trimmed,
434
+ refused when empty, over the limit or not printable."""
435
+ if not value:
436
+ return None
437
+ try:
438
+ value = value.encode("latin-1").decode("utf-8")
439
+ except UnicodeError:
440
+ pass
441
+ value = value.strip()
442
+ if not value or len(value) > limit or not value.isprintable():
443
+ return None
444
+ return value
445
+
446
+
447
+ class Dev(Provider):
448
+ """Every visitor is this user. Refused outside debug mode."""
449
+
450
+ kind: Literal["oauth", "password", "header", "dev"] = "dev"
451
+
452
+ def __init__(self, user: User) -> None:
453
+ self.id = "dev"
454
+ self.label = "Development"
455
+ self.user = user
456
+
457
+
458
+ # ------------------------------------------------------------------ passwords
459
+
460
+
461
+ def _scrypt(password: str, salt: bytes) -> bytes:
462
+ return hashlib.scrypt(
463
+ password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32, maxmem=64 * 1024 * 1024
464
+ )
465
+
466
+
467
+ def hash_password(password: str) -> str:
468
+ salt = secrets.token_bytes(16)
469
+ digest = hashlib.scrypt(
470
+ password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32, maxmem=64 * 1024 * 1024
471
+ )
472
+ return "scrypt$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(digest).decode()
473
+
474
+
475
+ def check_password(password: str, stored: str) -> bool:
476
+ try:
477
+ scheme, salt_b64, digest_b64 = stored.split("$")
478
+ if scheme != "scrypt":
479
+ return False
480
+ salt = base64.b64decode(salt_b64)
481
+ expected = base64.b64decode(digest_b64)
482
+ except ValueError:
483
+ return False
484
+ digest = hashlib.scrypt(
485
+ password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32, maxmem=64 * 1024 * 1024
486
+ )
487
+ return hmac.compare_digest(digest, expected)
488
+
489
+
490
+ # ------------------------------------------------------------------ the configuration
491
+
492
+
493
+ OnLogin = Callable[[Identity], User | None | Awaitable[User | None]]
494
+
495
+
496
+ class Auth:
497
+ def __init__(
498
+ self,
499
+ providers: list[Provider],
500
+ *,
501
+ on_login: OnLogin | None = None,
502
+ session: dt.timedelta = dt.timedelta(days=7),
503
+ log_identity: Literal["plain", "hashed"] = "plain",
504
+ intents: dict[str, str] | None = None,
505
+ ) -> None:
506
+ self.providers = {p.id: p for p in providers}
507
+ self.on_login = on_login
508
+ self.session = session
509
+ self.log_identity = log_identity
510
+ self.intents = intents or {}
511
+ self.secret = ""
512
+ self.debug = False
513
+ self._warned_unproxied = False
514
+
515
+ def bind(
516
+ self, secret: str, debug: bool, origins: list[str] | None = None, app: str = ""
517
+ ) -> None:
518
+ # Tokens are signed for this app by name, so apps that share a secret
519
+ # still keep their sign-ins apart.
520
+ self.secret = secret
521
+ self.app = app
522
+ self.debug = debug
523
+ self.origins = list(origins or [])
524
+ if any(isinstance(p, Dev) for p in self.providers.values()) and not debug:
525
+ raise RuntimeError("the Dev auth provider is only allowed with App(debug=True)")
526
+
527
+ # ------------------------------------------------------------- tokens
528
+
529
+ def issue(self, user: User) -> str:
530
+ if len(json.dumps(user.claims)) > CLAIMS_LIMIT:
531
+ raise ValueError("user claims exceed 2 KB; keep the record small")
532
+ now = dt.datetime.now(dt.UTC)
533
+ payload = {
534
+ **user.model_dump(mode="json"),
535
+ "iat": int(now.timestamp()),
536
+ "exp": int((now + self.session).timestamp()),
537
+ "jti": secrets.token_urlsafe(8),
538
+ }
539
+ return sign(self.secret, f"auth:{self.app}", payload)
540
+
541
+ def read(self, token: str | None) -> User | None:
542
+ data = verify(self.secret, f"auth:{self.app}", token)
543
+ if data is None:
544
+ return None
545
+ data = {k: v for k, v in data.items() if k in User.model_fields}
546
+ try:
547
+ return User(**data)
548
+ except Exception:
549
+ return None
550
+
551
+ def user_from(self, request: Request) -> User | None:
552
+ """The signed-in user of an HTTP request (or a websocket's handshake)."""
553
+ return self.read(request.cookies.get(cookie_name(request.scope, AUTH_COOKIE)))
554
+
555
+ def set_cookie(self, request: Request, response: Response, user: User) -> None:
556
+ response.set_cookie(
557
+ cookie_name(request.scope, AUTH_COOKIE),
558
+ self.issue(user),
559
+ httponly=True,
560
+ secure=request.url.scheme == "https",
561
+ samesite="lax",
562
+ max_age=int(self.session.total_seconds()),
563
+ path=_cookie_path(request),
564
+ )
565
+
566
+ def clear_cookie(self, request: Request, response: Response) -> None:
567
+ response.delete_cookie(cookie_name(request.scope, AUTH_COOKIE), path=_cookie_path(request))
568
+
569
+ # ------------------------------------------------------------- login
570
+
571
+ def describe(self, next_path: str, error: str | None = None) -> dict[str, Any]:
572
+ """What the login page needs, and nothing a browser should not see."""
573
+ return {
574
+ "providers": [
575
+ p.public() for p in self.providers.values() if p.kind in ("oauth", "password")
576
+ ],
577
+ "intents": [{"id": k, "label": v} for k, v in self.intents.items()],
578
+ "proxied": any(isinstance(p, Headers) for p in self.providers.values()),
579
+ "next": next_path,
580
+ "error": error,
581
+ }
582
+
583
+ async def login(self, identity: Identity) -> User | None:
584
+ """Run the hook, log the outcome, enforce the record size."""
585
+ if self.on_login is None:
586
+ if identity.email is None:
587
+ log.warning(
588
+ "login denied: provider %s gave no e-mail and there is no on_login",
589
+ identity.provider,
590
+ )
591
+ return None
592
+ user: User | None = User(
593
+ id=identity.email,
594
+ name=identity.name or identity.email,
595
+ picture_url=identity.picture_url,
596
+ )
597
+ else:
598
+ # A sync hook runs off the loop: with a proxy identity it runs on
599
+ # every page load, and a slow one would stall every session.
600
+ if inspect.iscoroutinefunction(self.on_login):
601
+ user = await self.on_login(identity)
602
+ else:
603
+ result = await asyncio.to_thread(self.on_login, identity)
604
+ user = await result if inspect.isawaitable(result) else result
605
+ who = self._log_id(identity.email or identity.subject)
606
+ if user is None:
607
+ log.warning("login denied: %s via %s", who, identity.provider)
608
+ return None
609
+ if len(json.dumps(user.claims)) > CLAIMS_LIMIT:
610
+ log.error("login denied: claims for %s exceed 2 KB", who)
611
+ return None
612
+ # A proxy identity is re-read on every page load, so it is not news.
613
+ log.log(
614
+ logging.DEBUG if identity.provider == "header" else logging.INFO,
615
+ "login: %s via %s",
616
+ who,
617
+ identity.provider,
618
+ )
619
+ return user
620
+
621
+ def _log_id(self, value: str) -> str:
622
+ if self.log_identity == "hashed":
623
+ return hashlib.sha256(value.encode()).hexdigest()[:12]
624
+ return value
625
+
626
+ # ------------------------------------------------------------- the routes
627
+
628
+ async def route_login(self, request: Request) -> Response:
629
+ """``POST /_mw/auth/login`` with a username and password."""
630
+ provider = next((p for p in self.providers.values() if isinstance(p, Password)), None)
631
+ if provider is None:
632
+ return JSONResponse({"reason": "password sign-in is not configured"}, status_code=404)
633
+ # Only the app's own page can post here: a JSON body (a cross-site form
634
+ # cannot send one without a preflight) from the same origin.
635
+ content_type = request.headers.get("content-type", "").split(";")[0].strip().lower()
636
+ if content_type != "application/json" or not _same_origin(request, self.origins):
637
+ return JSONResponse({"reason": "bad request"}, status_code=400)
638
+ try:
639
+ body = await request.json()
640
+ except Exception:
641
+ return JSONResponse({"reason": "bad request"}, status_code=400)
642
+ if not isinstance(body, dict):
643
+ return JSONResponse({"reason": "bad request"}, status_code=400)
644
+ username = str(body.get("username") or "").strip()
645
+ password = str(body.get("password") or "")
646
+ if not username or not password:
647
+ return JSONResponse({"reason": "username and password are required"}, status_code=400)
648
+ # A password check is slow by design (scrypt): off the loop, so a burst of
649
+ # wrong passwords stalls nothing else.
650
+ if inspect.iscoroutinefunction(provider.verify):
651
+ ok = await provider.verify(username, password)
652
+ else:
653
+ ok = await asyncio.to_thread(provider.verify, username, password)
654
+ if inspect.isawaitable(ok):
655
+ ok = await ok
656
+ if not ok:
657
+ log.warning("login denied: %s via password", self._log_id(username))
658
+ return JSONResponse({"reason": "wrong username or password"}, status_code=401)
659
+ user = await self.login(
660
+ Identity(
661
+ provider="password",
662
+ subject=username,
663
+ email=username if "@" in username else None,
664
+ name=username,
665
+ )
666
+ )
667
+ if user is None:
668
+ return JSONResponse({"reason": "access denied"}, status_code=403)
669
+ response = JSONResponse({"ok": True, "user": user.model_dump(mode="json")})
670
+ self.set_cookie(request, response, user)
671
+ return response
672
+
673
+ async def route_logout(self, request: Request) -> Response:
674
+ """Clear the cookie, and end the user's live sessions on this process. The
675
+ cookie itself stays valid until it expires: it is signed, not stored."""
676
+ response = JSONResponse({"ok": True})
677
+ self.clear_cookie(request, response)
678
+ user = await self.identify(request)
679
+ app = getattr(request.app.state, "millwork", None)
680
+ if user is not None and app is not None:
681
+ for session in list(app.sessions.values()):
682
+ if session.user is not None and session.user.id == user.id:
683
+ await session.end(app.theme.text("signed_out"))
684
+ return response
685
+
686
+ async def route_oauth_start(self, request: Request) -> Response:
687
+ provider = self.providers.get(request.path_params["provider"])
688
+ if not isinstance(provider, OAuth):
689
+ return JSONResponse({"reason": "no such provider"}, status_code=404)
690
+ state = secrets.token_urlsafe(24)
691
+ next_path = _safe_next(request.query_params.get("next"))
692
+ intent = request.query_params.get("intent")
693
+ if intent and intent not in self.intents:
694
+ intent = None
695
+ payload = {
696
+ "state": state,
697
+ "next": next_path,
698
+ "intent": intent,
699
+ "exp": int(dt.datetime.now(dt.UTC).timestamp()) + STATE_LIFETIME,
700
+ }
701
+ signed = sign(self.secret, f"oauth-state:{self.app}", payload)
702
+ response = RedirectResponse(
703
+ provider.authorize(self._callback_url(request, provider), state), status_code=302
704
+ )
705
+ response.set_cookie(
706
+ cookie_name(request.scope, STATE_COOKIE),
707
+ signed,
708
+ httponly=True,
709
+ secure=request.url.scheme == "https",
710
+ samesite="lax",
711
+ max_age=STATE_LIFETIME,
712
+ path=_cookie_path(request),
713
+ )
714
+ return response
715
+
716
+ async def route_oauth_callback(self, request: Request) -> Response:
717
+ provider = self.providers.get(request.path_params["provider"])
718
+ if not isinstance(provider, OAuth):
719
+ return JSONResponse({"reason": "no such provider"}, status_code=404)
720
+ q = request.query_params
721
+ state = request.cookies.get(cookie_name(request.scope, STATE_COOKIE))
722
+ state_data = verify(self.secret, f"oauth-state:{self.app}", state)
723
+ if state_data is None:
724
+ return self._login_redirect(request, "/", "Sign-in expired, try again.")
725
+ next_path = _safe_next(state_data.get("next"))
726
+ if q.get("error") or not q.get("code") or not q.get("state"):
727
+ log.warning("oauth %s returned %s", provider.id, q.get("error") or "no code")
728
+ return self._login_redirect(request, next_path, "Sign-in was cancelled.")
729
+ if not hmac.compare_digest(str(state_data.get("state")).encode(), q["state"].encode()):
730
+ return self._login_redirect(request, next_path, "Sign-in state did not match.")
731
+ try:
732
+ tokens = await provider.exchange(q["code"], self._callback_url(request, provider))
733
+ raw = await provider.userinfo(tokens["access_token"])
734
+ except Exception:
735
+ log.exception("oauth %s exchange failed", provider.id)
736
+ return self._login_redirect(request, next_path, "Sign-in failed at the provider.")
737
+ identity = provider.identity(raw)
738
+ identity.intent = state_data.get("intent")
739
+ user = await self.login(identity)
740
+ if user is None:
741
+ return self._login_redirect(request, next_path, "Access denied.")
742
+ response = RedirectResponse(f"{root_of(request.scope)}{next_path}", status_code=302)
743
+ self.set_cookie(request, response, user)
744
+ response.delete_cookie(cookie_name(request.scope, STATE_COOKIE), path=_cookie_path(request))
745
+ return response
746
+
747
+ def _callback_url(self, request: Request, provider: OAuth) -> str:
748
+ # The scheme and host as the proxy forwarded them, then the prefix from
749
+ # the scope: base_url would take the outer app's root under a mount.
750
+ url = request.url
751
+ return f"{url.scheme}://{url.netloc}{root_of(request.scope)}/_mw/auth/oauth/{provider.id}/callback"
752
+
753
+ def _login_redirect(self, request: Request, next_path: str, error: str) -> Response:
754
+ root = root_of(request.scope)
755
+ return RedirectResponse(
756
+ f"{root}/_mw/login?{urlencode({'next': next_path, 'error': error})}", status_code=302
757
+ )
758
+
759
+ # ------------------------------------------------------------- request-time identity
760
+
761
+ async def identify(self, request_or_scope: Any) -> User | None:
762
+ """The user behind a request or websocket: the cookie, the proxy's headers, or Dev.
763
+
764
+ The proxy's headers go through ``on_login`` like any other provider, on
765
+ every request: the hook still decides access and shapes the user. They
766
+ are read when the app is behind a proxy.
767
+ """
768
+ headers = request_or_scope.headers
769
+ scope = request_or_scope.scope
770
+ user = self.read(request_or_scope.cookies.get(cookie_name(scope, AUTH_COOKIE)))
771
+ if user is not None:
772
+ scope["millwork.identified_by"] = "cookie"
773
+ return user
774
+ for provider in self.providers.values():
775
+ if isinstance(provider, Dev):
776
+ scope["millwork.identified_by"] = "dev"
777
+ return provider.user
778
+ if isinstance(provider, Headers):
779
+ if not scope.get("millwork.proxied"):
780
+ if provider.present(headers) and not self._warned_unproxied:
781
+ self._warned_unproxied = True
782
+ log.warning(
783
+ "identity headers arrived and were ignored: the app is not "
784
+ "marked as behind a proxy (App(proxied=True), millwork run --proxied)"
785
+ )
786
+ continue
787
+ identity = provider.identity(headers)
788
+ if identity is not None:
789
+ user = await self.login(identity)
790
+ if user is not None:
791
+ scope["millwork.identified_by"] = "header"
792
+ return user
793
+ return None
794
+
795
+ def proxy_identity(self, request: Request) -> bool:
796
+ """Whether the proxy named a visitor on this request, whatever the hook
797
+ then said."""
798
+ if not request.scope.get("millwork.proxied"):
799
+ return False
800
+ return any(
801
+ isinstance(p, Headers) and p.present(request.headers) for p in self.providers.values()
802
+ )
803
+
804
+ @property
805
+ def sign_out(self) -> str | None | bool:
806
+ """Where sign-out goes: the proxy's URL with a Headers provider, False
807
+ when that provider has none, None when millwork's own login is in charge."""
808
+ for provider in self.providers.values():
809
+ if isinstance(provider, Headers):
810
+ return provider.sign_out or False
811
+ return None
812
+
813
+
814
+ def _same_origin(request: Request, origins: list[str]) -> bool:
815
+ """No Origin header (not a browser), the request's own host, or an allowed origin."""
816
+ origin = request.headers.get("origin")
817
+ if origin is None:
818
+ return True
819
+ if origins:
820
+ return origin in origins
821
+ host = request.headers.get("host")
822
+ return host is not None and urlsplit(origin).netloc.lower() == host.lower()
823
+
824
+
825
+ def _safe_next(value: str | None) -> str:
826
+ """Only a path inside the app: never an absolute URL, a scheme-relative one,
827
+ a dot segment that could climb out of the app's prefix, or a framework route.
828
+
829
+ A backslash is refused outright: browsers read a slash-backslash prefix as
830
+ a scheme-relative URL. Dot segments are matched after percent-decoding,
831
+ since a browser decodes ``%2e`` before it resolves the path.
832
+ """
833
+ if not (value and value.startswith("/") and not value.startswith("//")):
834
+ return "/"
835
+ if "\\" in value or not value.isprintable():
836
+ return "/"
837
+ path = value.split("?", 1)[0].split("#", 1)[0]
838
+ segments = [unquote(seg).lower() for seg in path.split("/")]
839
+ if any(seg in (".", "..") for seg in segments):
840
+ return "/"
841
+ if len(segments) > 1 and segments[1] == "_mw":
842
+ return "/"
843
+ return value
844
+
845
+
846
+ def cookie_name(scope: Mapping[str, Any], base: str) -> str:
847
+ """The cookie's name carries the app's prefix: ``mw_auth`` at the root,
848
+ ``mw_auth_sales`` under ``/sales``. Apps side by side on one host then each
849
+ read their own cookie, whatever the browser sends along. Two prefixes can
850
+ share a name (``/a/b`` and ``/a_b``), which is harmless: their paths never
851
+ overlap, so a browser never sends both cookies to one app."""
852
+ return base + root_of(scope).replace("/", "_")
853
+
854
+
855
+ def _cookie_path(request: Request) -> str:
856
+ """The cookie's path is the app's prefix, so the browser sends it there only."""
857
+ return root_of(request.scope) or "/"
858
+
859
+
860
+ __all__ = [
861
+ "Auth",
862
+ "AzureAD",
863
+ "Dev",
864
+ "Forbidden",
865
+ "Google",
866
+ "Headers",
867
+ "Identity",
868
+ "NotFound",
869
+ "OAuth",
870
+ "Password",
871
+ "User",
872
+ "check_password",
873
+ "email_of",
874
+ "hash_password",
875
+ ]
876
+
877
+ _ = os # the module reads no environment variables of its own; the name is kept for grep