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.
- millwork/__init__.py +45 -0
- millwork/_bundle/dist/.vite/manifest.json +92 -0
- millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
- millwork/_bundle/dist/favicon.svg +15 -0
- millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- millwork/_bundle/dist/icons.svg +24 -0
- millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
- millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
- millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
- millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
- millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
- millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
- millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
- millwork/app.py +726 -0
- millwork/auth.py +877 -0
- millwork/chat.py +397 -0
- millwork/cli.py +316 -0
- millwork/context.py +42 -0
- millwork/files.py +450 -0
- millwork/forms.py +349 -0
- millwork/manual/__init__.py +449 -0
- millwork/manual/components/Hello.jsx +24 -0
- millwork/manual/components/Voice.jsx +46 -0
- millwork/manual/pages/app/auth.md +188 -0
- millwork/manual/pages/app/pages.md +200 -0
- millwork/manual/pages/app/running.md +242 -0
- millwork/manual/pages/app/state.md +151 -0
- millwork/manual/pages/app/testing.md +147 -0
- millwork/manual/pages/app/theming.md +122 -0
- millwork/manual/pages/components/charts.md +263 -0
- millwork/manual/pages/components/chat.md +280 -0
- millwork/manual/pages/components/custom.md +148 -0
- millwork/manual/pages/components/data.md +112 -0
- millwork/manual/pages/components/feedback.md +212 -0
- millwork/manual/pages/components/files.md +194 -0
- millwork/manual/pages/components/form.md +135 -0
- millwork/manual/pages/components/inputs.md +360 -0
- millwork/manual/pages/components/layout.md +154 -0
- millwork/manual/pages/components/tasks.md +140 -0
- millwork/manual/pages/components/text.md +145 -0
- millwork/manual/pages/index.md +67 -0
- millwork/manual/public/fonts/InterVariable.woff2 +0 -0
- millwork/manual/public/fonts/LICENSE.txt +2 -0
- millwork/manual/public/mark.svg +15 -0
- millwork/manual/public/theme.json +97 -0
- millwork/nodes.py +300 -0
- millwork/protocol.py +238 -0
- millwork/proxy.py +122 -0
- millwork/py.typed +0 -0
- millwork/session.py +1422 -0
- millwork/shell.py +226 -0
- millwork/tasks.py +473 -0
- millwork/testing.py +336 -0
- millwork/theme.py +338 -0
- millwork/ui/__init__.py +725 -0
- millwork/ui/charts.py +214 -0
- millwork/ui/chat.py +97 -0
- millwork/ui/files.py +101 -0
- millwork/ui/form.py +102 -0
- millwork/ui/table.py +145 -0
- millwork/ws.py +220 -0
- millwork-0.1.0.dist-info/METADATA +131 -0
- millwork-0.1.0.dist-info/RECORD +70 -0
- millwork-0.1.0.dist-info/WHEEL +4 -0
- millwork-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|