messagefoundry-webconsole 0.2.15__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 (40) hide show
  1. messagefoundry_webconsole/__init__.py +128 -0
  2. messagefoundry_webconsole/_auth.py +841 -0
  3. messagefoundry_webconsole/_html.py +539 -0
  4. messagefoundry_webconsole/_security.py +253 -0
  5. messagefoundry_webconsole/_service.py +22 -0
  6. messagefoundry_webconsole/_static.py +78 -0
  7. messagefoundry_webconsole/mount.py +103 -0
  8. messagefoundry_webconsole/pages/__init__.py +25 -0
  9. messagefoundry_webconsole/pages/_common.py +21 -0
  10. messagefoundry_webconsole/pages/account.py +771 -0
  11. messagefoundry_webconsole/pages/admin.py +480 -0
  12. messagefoundry_webconsole/pages/audit.py +66 -0
  13. messagefoundry_webconsole/pages/config.py +113 -0
  14. messagefoundry_webconsole/pages/connections.py +519 -0
  15. messagefoundry_webconsole/pages/messages.py +689 -0
  16. messagefoundry_webconsole/pages/monitoring.py +853 -0
  17. messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
  18. messagefoundry_webconsole/routes/__init__.py +6 -0
  19. messagefoundry_webconsole/routes/_common.py +45 -0
  20. messagefoundry_webconsole/routes/account.py +451 -0
  21. messagefoundry_webconsole/routes/admin.py +505 -0
  22. messagefoundry_webconsole/routes/audit.py +39 -0
  23. messagefoundry_webconsole/routes/config.py +67 -0
  24. messagefoundry_webconsole/routes/connection_writes.py +248 -0
  25. messagefoundry_webconsole/routes/core.py +1077 -0
  26. messagefoundry_webconsole/routes/monitoring.py +94 -0
  27. messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
  28. messagefoundry_webconsole/routes/oidc.py +172 -0
  29. messagefoundry_webconsole/routes/search.py +204 -0
  30. messagefoundry_webconsole/routes/sso.py +88 -0
  31. messagefoundry_webconsole/routes/status.py +178 -0
  32. messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
  33. messagefoundry_webconsole/static/app.css +345 -0
  34. messagefoundry_webconsole/static/app.js +1506 -0
  35. messagefoundry_webconsole/static/csp-probe.js +9 -0
  36. messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
  37. messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
  38. messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
  39. messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
  40. messagefoundry_webconsole-0.2.15.dist-info/licenses/NOTICE +31 -0
@@ -0,0 +1,841 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Cookie-based auth for the /ui ops dashboard, CONFINED to /ui (ADR 0065 §3).
4
+
5
+ ``require_ui(*perms, phi=...)`` mirrors ``api.security.require`` / ``require_phi_read`` but reads the
6
+ ``mf_session`` HttpOnly cookie **instead of** the ``Authorization`` header. It is used **only** by /ui
7
+ HTML routes. The shared ``bearer_token()`` stays header-only, so a JSON API route presented with only
8
+ the cookie still 401s (the hard boundary the security review flagged, test-enforced): the cookie is not
9
+ a JSON-API credential and SameSite is never the sole CSRF defense for the JSON API.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ from collections.abc import Awaitable, Callable
17
+ from dataclasses import dataclass
18
+ from urllib.parse import quote, urlsplit
19
+
20
+ from fastapi import HTTPException, Request, Response, WebSocket, status
21
+ from fastapi.responses import RedirectResponse
22
+
23
+ from messagefoundry.api.security import get_auth
24
+ from messagefoundry.auth import Identity, Permission
25
+ from messagefoundry.auth.service import AuthService
26
+
27
+ __all__ = [
28
+ "BROWSER_HARDENING_OPT_OUT_ENV",
29
+ "CLEAR_SITE_DATA_HEADER",
30
+ "CLEAR_SITE_DATA_VALUE",
31
+ "COOKIE_NAME",
32
+ "FLOW_COOKIE_NAME",
33
+ "HOST_COOKIE_NAME",
34
+ "HOST_FLOW_COOKIE_NAME",
35
+ "UI_CSP",
36
+ "UiWriteAction",
37
+ "allow_reauth_attempt",
38
+ "assert_not_cross_site",
39
+ "assert_same_origin",
40
+ "authorize_ui_ws",
41
+ "browser_hardening_enabled",
42
+ "clear_oidc_flow_cookie",
43
+ "clear_session_cookie",
44
+ "effective_https",
45
+ "is_safe_ui_action",
46
+ "is_unlock_action",
47
+ "login_redirect_response",
48
+ "lookup_ui_action",
49
+ "oidc_flow_cookie_name",
50
+ "register_ui_action",
51
+ "require_ui",
52
+ "require_ui_reauth_only",
53
+ "require_ui_reauth_only_action",
54
+ "require_ui_step_up",
55
+ "require_ui_step_up_action",
56
+ "security_headers_context",
57
+ "session_cookie_name",
58
+ "session_token",
59
+ "set_oidc_flow_cookie",
60
+ "set_session_cookie",
61
+ ]
62
+
63
+ COOKIE_NAME = "mf_session"
64
+
65
+ #: The ``__Host-`` prefixed session cookie name, used ONLY in an effective-https context (ADR 0065
66
+ #: §hardening / BACKLOG #192, ASVS 3.4.3). A browser REJECTS a ``__Host-`` cookie unless it is Secure +
67
+ #: Path=/ + carries no Domain — all three hold here — so the prefix is a browser-enforced binding of the
68
+ #: session to THIS exact host over TLS. Over cleartext loopback the plain :data:`COOKIE_NAME` is kept
69
+ #: (byte-identity): ``__Host-`` can never be set without Secure, which cleartext cannot carry.
70
+ HOST_COOKIE_NAME = "__Host-mf_session"
71
+
72
+ #: Org opt-out for the #192 /ui browser hardening. DEFAULT is hardening ON (secure-by-default); set this
73
+ #: env truthy to REVERT the /ui surface to the pre-#192 posture — plain :data:`COOKIE_NAME` (still Secure
74
+ #: over https, so transport security is never downgraded) + the engine's static self-CSP, and no
75
+ #: per-response nonce / COOP / CSP-reporting. The escape hatch for a legacy proxy/browser that cannot
76
+ #: tolerate ``__Host-``/nonce-CSP, per the secure-by-default-with-explicit-opt-out rule.
77
+ BROWSER_HARDENING_OPT_OUT_ENV = "MEFOR_WEBCONSOLE_DISABLE_BROWSER_HARDENING"
78
+
79
+
80
+ def browser_hardening_enabled() -> bool:
81
+ """Whether the #192 /ui browser hardening is active (default ``True``). Disabled only by an explicit
82
+ truthy :data:`BROWSER_HARDENING_OPT_OUT_ENV`."""
83
+ return os.environ.get(BROWSER_HARDENING_OPT_OUT_ENV, "").strip().lower() not in (
84
+ "1",
85
+ "true",
86
+ "yes",
87
+ "on",
88
+ )
89
+
90
+
91
+ def effective_https(app_state: object, scheme: str) -> bool:
92
+ """Whether this connection is an EFFECTIVE-https context — the single signal the cookie name +
93
+ Secure flag + the /ui security-header hardening all key on (ADR 0065 §hardening / #192).
94
+
95
+ Mirrors the engine's ``api.app._cookie_secure`` decision READ-ONLY: the per-request/handshake scheme
96
+ is https/wss, OR the operator declared the browser-facing scheme https via
97
+ ``app.state.exposure_protected`` (set once in ``create_app`` — the proxy-TLS case where a request
98
+ that omits ``X-Forwarded-Proto`` would otherwise read as cleartext). Reads only the public
99
+ ``app.state`` attribute the engine already exposes; imports no engine module.
100
+ """
101
+ return scheme in ("https", "wss") or bool(getattr(app_state, "exposure_protected", False))
102
+
103
+
104
+ def security_headers_context(app_state: object, scheme: str) -> bool:
105
+ """Whether this connection may receive the http-SAFE /ui browser-security headers — the nonce CSP,
106
+ COOP, CORP and Reporting-Endpoints bundle (ADR 0143). True in an :func:`effective_https` context OR
107
+ over a **loopback secure-context** (``http://127.0.0.1`` — a W3C *potentially-trustworthy* origin,
108
+ signalled read-only by ``app.state.loopback``).
109
+
110
+ Deliberately BROADER than :func:`effective_https`, which still SOLELY gates the session cookie's
111
+ Secure flag + ``__Host-`` prefix. Splitting the two lets the header hardening engage over cleartext
112
+ loopback (a trustworthy origin where a conformant browser honours these headers) while the cookie
113
+ stays the plain ``mf_session`` — a browser REJECTS a Secure / ``__Host-`` cookie over http, so keying
114
+ the cookie on loopback would break login. HSTS is unaffected (the engine emits it only over real
115
+ https / ``exposure_protected``, never on loopback). Reads only the public ``app.state`` attribute the
116
+ engine exposes; imports no engine module (a graceful default keeps it compatible with an older
117
+ engine that predates the ``loopback`` seam).
118
+ """
119
+ return effective_https(app_state, scheme) or bool(getattr(app_state, "loopback", False))
120
+
121
+
122
+ def session_cookie_name(conn: Request | WebSocket) -> str:
123
+ """The session cookie name for this connection: ``__Host-mf_session`` in an effective-https context
124
+ (unless the org opt-out is set), else the plain ``mf_session`` (unchanged over cleartext loopback —
125
+ byte-identity). The ONE resolver every set/clear/read site threads through, so the name a response
126
+ writes and the name a later request reads always agree."""
127
+ if effective_https(conn.app.state, conn.url.scheme) and browser_hardening_enabled():
128
+ return HOST_COOKIE_NAME
129
+ return COOKIE_NAME
130
+
131
+
132
+ def session_token(conn: Request | WebSocket) -> str | None:
133
+ """Read the session token from whichever cookie name applies to this connection's scheme."""
134
+ return conn.cookies.get(session_cookie_name(conn))
135
+
136
+
137
+ # Strict, self-only CSP for the /ui surface — no 'unsafe-eval'/'unsafe-inline' (ADR 0065 §5). The only
138
+ # script is the first-party /ui/static/app.js (no inline script, no on* handlers), so 'self' suffices.
139
+ # Everything (script/style/font/img) is served from /ui/static, same origin.
140
+ UI_CSP = (
141
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
142
+ "connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; "
143
+ "form-action 'self'; object-src 'none'"
144
+ )
145
+
146
+ # Sec-Fetch-Site values that mean the request did NOT originate from our own origin — rejected on
147
+ # state-changing /ui POSTs (M2). "same-site" (a sibling subdomain) is rejected too: /ui is strictly
148
+ # same-origin. "same-origin" and "none" (a user-initiated navigation) are allowed.
149
+ _CROSS_ORIGIN_FETCH = frozenset({"cross-site", "same-site"})
150
+
151
+
152
+ #: ASVS 14.3.1 — the header emitted on every response that ENDS a session's browser-visible life.
153
+ #: There are exactly THREE emitters, and between them they cover every shape a termination takes:
154
+ #: :func:`clear_session_cookie` (a route that deletes the cookie — sign-out, and the password change
155
+ #: that revokes every session), :func:`_login_redirect` / :func:`login_redirect_response` (every
156
+ #: SERVER-driven expiry, revoke or disable redirect), and the login RENDER in :mod:`.routes.core`
157
+ #: (a post-termination landing reached by bookmark or Back, recognised by its outcome code or by a
158
+ #: session cookie that no longer authenticates). Deletion and this header are deliberately fused in
159
+ #: one helper rather than left as two lines a new route must remember to write together.
160
+ #: ``"cache"`` ONLY: ``"cookies"`` is already covered by the explicit cookie deletion (and would clear
161
+ #: the in-flight OIDC flow cookie), and ``"storage"`` would wipe the deliberately-persistent, PHI-free
162
+ #: ``mfcols:v2`` column preferences (14.3.3), which are not session state. Defense-in-depth against
163
+ #: Back/bfcache resurrection of a terminated session's rendered PHI page.
164
+ CLEAR_SITE_DATA_HEADER = "Clear-Site-Data"
165
+ CLEAR_SITE_DATA_VALUE = '"cache"'
166
+
167
+
168
+ def _login_redirect(note: str = "") -> HTTPException:
169
+ """A 303 redirect (as an exception, to short-circuit a dependency) to the /ui login page.
170
+
171
+ Carries :data:`CLEAR_SITE_DATA_HEADER` on the REDIRECT ITSELF (ASVS 14.3.1). Every SERVER-driven
172
+ session termination lands here — idle timeout, absolute timeout, revoke ("sign out everywhere
173
+ else"), admin disable, AD-reconcile revoke — and those are exactly the paths the residual names.
174
+ Attaching it to the 303 rather than only to the landing page means the header cannot depend on
175
+ which login URL the browser is sent to, or on the client watchdog having run at all (with
176
+ JavaScript blocked it never does).
177
+ """
178
+ location = "/ui/login" + (f"?e={note}" if note else "")
179
+ return HTTPException(
180
+ status.HTTP_303_SEE_OTHER,
181
+ headers={"Location": location, CLEAR_SITE_DATA_HEADER: CLEAR_SITE_DATA_VALUE},
182
+ )
183
+
184
+
185
+ def login_redirect_response(note: str = "expired") -> RedirectResponse:
186
+ """The RESPONSE form of :func:`_login_redirect`, for a ROUTE that discovers the session is gone
187
+ (rather than a dependency, which raises). Same 14.3.1 header, same default outcome code, so the
188
+ two paths cannot drift apart."""
189
+ resp = RedirectResponse("/ui/login" + (f"?e={note}" if note else ""), status_code=303)
190
+ resp.headers[CLEAR_SITE_DATA_HEADER] = CLEAR_SITE_DATA_VALUE
191
+ return resp
192
+
193
+
194
+ def _mfa_redirect() -> HTTPException:
195
+ """A 303 to the /ui second-factor page (ASVS 6.3.3), the cookie-plane twin of the JSON gate's
196
+ 403 + ``X-MFA-Required``.
197
+
198
+ Deliberately NOT :func:`_login_redirect`: the session is ALIVE and merely half-proven, so it must
199
+ not carry ``Clear-Site-Data`` — that header marks a TERMINATED session (14.3.1), and firing it
200
+ here would wipe the cache on an ordinary sign-in step and mislabel the state in the login page's
201
+ ``?e=`` code."""
202
+ return HTTPException(status.HTTP_303_SEE_OTHER, headers={"Location": "/ui/mfa"})
203
+
204
+
205
+ def require_ui(
206
+ *permissions: Permission,
207
+ phi: bool = False,
208
+ allow_must_change: bool = False,
209
+ allow_mfa_pending: bool = False,
210
+ activity: bool = True,
211
+ ) -> Callable[[Request], Awaitable[Identity]]:
212
+ """Authenticate a /ui request from the session cookie and assert ``permissions``.
213
+
214
+ ``phi=True`` also applies the same per-actor anti-automation throttle as ``require_phi_read`` (the
215
+ /ui PHI views call the JSON handlers directly, which skips their own ``Depends`` gate, so this
216
+ dependency must re-apply the equivalent permission + throttle — otherwise a cookie session could
217
+ read PHI it lacks the permission/quota for). Unauthenticated/expired → 303 to the login page;
218
+ forbidden → 403; throttled → 429.
219
+
220
+ A ``must_change_password`` account is 303'd to the browser change-password page (L4b) from every
221
+ /ui route — ``allow_must_change=True`` is set ONLY by that page's own GET/POST (so the rotation
222
+ can actually happen; anything else would loop).
223
+
224
+ ``activity=False`` (ASVS 14.3.1) validates the session WITHOUT refreshing its idle clock — the
225
+ same contract the engine's /ws/stats keepalive uses. Set it on the console's **timer-driven
226
+ background refreshes**, which no human initiates: the nav status/health poll, the live connections
227
+ fragment and the live flow fragment, plus the session-status probe the client watchdog runs. With
228
+ those polling on the default nav — i.e. on every PHI page — an abandoned tab otherwise slid
229
+ ``last_used_at`` forever and the idle deadline could never fire, so the automatic-logoff control
230
+ was defeated exactly where it matters most. Leave it True for anything an operator actually
231
+ clicks (navigation, the log tail/level controls, export): those ARE user activity.
232
+ """
233
+
234
+ async def dependency(request: Request) -> Identity:
235
+ auth = get_auth(request)
236
+ if auth is None or not auth.enabled:
237
+ # The browser UI always needs a real session — no allow_no_auth shortcut here.
238
+ raise _login_redirect()
239
+ token = session_token(request)
240
+ identity = await auth.identity_for_token(token, activity=activity)
241
+ if identity is None:
242
+ # A cookie that no longer authenticates IS a post-termination landing (idle/absolute
243
+ # expiry, revoke, admin disable), so name it: the login page then explains itself
244
+ # instead of appearing as an unexplained form, and its render carries Clear-Site-Data
245
+ # too (14.3.1). A visitor with NO cookie never had a session — plain form, no code.
246
+ raise _login_redirect("expired" if token else "")
247
+ if identity.must_change_password and not allow_must_change:
248
+ # A flagged account can go nowhere but the change-password page (L4b) until it rotates.
249
+ raise HTTPException(
250
+ status.HTTP_303_SEE_OTHER, headers={"Location": "/ui/account/password"}
251
+ )
252
+ # ASVS 6.3.3, the cookie mirror of the JSON gate. Ordering matches require(): must_change
253
+ # above, permissions below — a pending session must not learn whether it holds a permission.
254
+ if not allow_mfa_pending and not await auth.mfa_satisfied(token):
255
+ raise _mfa_redirect()
256
+ for permission in permissions:
257
+ if not identity.has(permission):
258
+ await auth.audit_permission_denied(identity, permission, request.url.path)
259
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden")
260
+ if phi and not auth.allow_phi_read(identity.user_id):
261
+ raise HTTPException(
262
+ status.HTTP_429_TOO_MANY_REQUESTS,
263
+ "too many requests; please slow down",
264
+ headers={"Retry-After": "10"},
265
+ )
266
+ return identity
267
+
268
+ return dependency
269
+
270
+
271
+ def allow_reauth_attempt(auth: AuthService, identity: Identity, client: str | None) -> bool:
272
+ """Per-ACTOR budget for the POST-session credential ceremonies (re-auth, password change, MFA
273
+ enrolment confirm), replacing the shared unauthenticated sign-in budget those used to draw on —
274
+ which let anyone able to reach the login page lock every signed-in operator out of step-up.
275
+
276
+ Resolved via ``getattr`` because the console ships as a **separately-versioned wheel**: mounted on
277
+ an engine that predates ``allow_reauth_attempt`` it degrades to the previous behaviour rather than
278
+ failing, so no ENGINE_UI_SEAM bump is required."""
279
+ gate = getattr(auth, "allow_reauth_attempt", None)
280
+ if gate is None: # older engine — previous (shared sign-in budget) behaviour
281
+ return bool(auth.allow_login_attempt(client))
282
+ return bool(gate(identity.user_id))
283
+
284
+
285
+ def _origin_matches(app_state: object, origin: str, host: str | None) -> bool:
286
+ """Whether the browser ``Origin`` is our own origin (ADR 0065 off-loopback defaults).
287
+
288
+ When ``[api].public_origin`` is configured — the off-loopback case, behind a reverse proxy that may
289
+ not preserve ``Host`` — it is **authoritative** (exact, normalized match). Otherwise (loopback, or a
290
+ Host-preserving proxy) fall back to comparing the ``Origin`` host[:port] to the request ``Host``.
291
+ """
292
+ public_origin: str | None = getattr(app_state, "public_origin", None)
293
+ if public_origin:
294
+ # Scheme + host are case-INSENSITIVE (RFC 6454 §4 / RFC 3986 §3.2.2). Canonicalize the incoming
295
+ # Origin the same way the validator canonicalizes public_origin (lowercased), so a case variant
296
+ # is treated as the same origin — fail-closed either way, but this avoids rejecting a legit
297
+ # browser (browsers lowercase the host) or a mixed-case configured public_origin.
298
+ parts = urlsplit(origin)
299
+ return f"{parts.scheme.lower()}://{parts.netloc.lower()}" == public_origin
300
+ return host is not None and urlsplit(origin).netloc.lower() == host.lower()
301
+
302
+
303
+ def assert_not_cross_site(request: Request) -> None:
304
+ """Reject a request the BROWSER ITSELF labels cross-site — the ``Sec-Fetch-Site`` half of
305
+ :func:`assert_same_origin`, and nothing else (ASVS 3.5.1).
306
+
307
+ Deliberately NARROWER than :func:`assert_same_origin`: it consults only ``Sec-Fetch-Site`` and
308
+ never falls back to comparing ``Origin`` against our own origin. That fallback is right for a
309
+ document-initiated form POST, where an absent ``Sec-Fetch-Site`` means an OLD browser and the
310
+ ``Origin`` is authoritative — but it is WRONG for a browser-agent delivery such as a CSP violation
311
+ report, which is sent out-of-band by the user agent's reporting agent rather than by the document:
312
+ those carry no ``Sec-Fetch-*`` at all and may carry ``Origin: null``, which the ``Origin``
313
+ comparison would reject, silently killing every violation report (including the 3.7.5 enforcement
314
+ canary's). So the check keeps exactly the property that matters for such a sink — a report a
315
+ FOREIGN site's CSP aimed at us is refused instead of amplifying our log — and lets conforming
316
+ same-origin and agent-initiated delivery through.
317
+ """
318
+ if request.headers.get("sec-fetch-site") in _CROSS_ORIGIN_FETCH:
319
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "cross-site request rejected")
320
+
321
+
322
+ def assert_same_origin(request: Request) -> None:
323
+ """Reject a cross-site state-changing /ui request — CSRF defense-in-depth (M2, ADR 0065 §M2).
324
+
325
+ The **primary** CSRF defense is the SameSite=Strict session cookie: a cross-site POST carries no
326
+ ``mf_session`` cookie, so ``require_ui`` already fails it (303 to login) before any action runs.
327
+ This adds an explicit origin check on top: modern browsers send ``Sec-Fetch-Site`` on every request
328
+ (reject ``cross-site``/``same-site``); for older clients that omit it, fall back to comparing the
329
+ ``Origin`` to our own origin (``[api].public_origin`` when set, else the request ``Host``). A
330
+ same-origin form POST (the only way the /ui buttons submit) passes both. Token-free — so it needs no
331
+ crypto import (avoids the ASVS 11.1.3 inventory gate).
332
+
333
+ It needs **no session**, which is why it is also the first statement of the two UNAUTHENTICATED
334
+ state-changing POSTs (ASVS 3.5.1): ``POST /ui/login``, where SameSite=Strict supplies nothing
335
+ because no session cookie exists yet, and ``POST /ui/logout``, which has no ``Depends`` gate at all
336
+ (by design — a must-change-confined session must be able to revoke itself, ASVS 7.4.4) and so has
337
+ no other request-provenance control. The header-less fallthrough is safe by construction: a browser
338
+ attaches ``Sec-Fetch-Site`` or ``Origin`` to every cross-site POST, so only a non-browser client —
339
+ which cannot be CSRF-ridden — passes header-less.
340
+ """
341
+ sec_fetch_site = request.headers.get("sec-fetch-site")
342
+ if sec_fetch_site is not None:
343
+ assert_not_cross_site(request)
344
+ return
345
+ origin = request.headers.get("origin")
346
+ if origin and not _origin_matches(request.app.state, origin, request.headers.get("host")):
347
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "cross-origin request rejected")
348
+
349
+
350
+ @dataclass(frozen=True, slots=True)
351
+ class UiWriteAction:
352
+ """One registered state-changing /ui action (ADR 0065 §multi-session-build): the anchored path
353
+ pattern it is served at, the RBAC permission it needs, whether it requires a fresh step-up, and how
354
+ the step-up re-auth flow hands control back to it after a successful re-verification.
355
+
356
+ Exactly one continuation style applies:
357
+
358
+ * ``auto_retry`` — a URL-complete, **body-less POST** the re-auth flow may **re-POST** (auto-submit)
359
+ once the window is fresh (replay, purge, config-reload). The re-POST carries no body, so every
360
+ parameter must live in the PATH.
361
+ * ``unlock`` — a **GET form page** (L4a admin forms) the re-auth flow may **303-GET-redirect** to
362
+ after step-up, so the form re-opens inside a fresh window and the operator submits the body-carrying
363
+ POST (incl. a create-user password) in a single request that never crosses ``/ui/reauth``. This is
364
+ the stateless confirm-after-step-up primitive: no body — and no password — is ever preserved across
365
+ the redirect.
366
+
367
+ The two are mutually exclusive (``__post_init__`` enforces it): a GET page must never be auto-POSTed,
368
+ and a POST action must never be GET-redirected to.
369
+ """
370
+
371
+ path_re: re.Pattern[str]
372
+ # None = a self-scoped account action (L4b): any authenticated session may continue it after
373
+ # re-auth — there is no RBAC permission beyond holding a valid (re-verified) session. The field is
374
+ # descriptive either way: enforcement lives in each route's require_ui* dependency, and the
375
+ # continuation gates (is_safe_ui_action / is_unlock_action) match on pattern + flags only.
376
+ permission: Permission | None
377
+ step_up: bool = True
378
+ auto_retry: bool = True
379
+ unlock: bool = False
380
+ # 7.5.1 (ADR 0077): the single-use step-up ACTION grant string /ui/reauth mints for this
381
+ # continuation (None = mint nothing = a pure session-window refresh, today's default). Set on the
382
+ # browser factor-binding lanes so their /ui/reauth re-proof mints exactly that action's grant.
383
+ action: str | None = None
384
+
385
+ def __post_init__(self) -> None:
386
+ # Guard against a mis-registration that would let the re-auth flow POST-auto-submit a GET form
387
+ # page (or GET-redirect to a state-changing POST): the continuation branch keys off these flags.
388
+ if self.auto_retry and self.unlock:
389
+ raise ValueError("a /ui action cannot be both auto_retry (POST) and unlock (GET)")
390
+
391
+
392
+ # The write-action registry — the extensible replacement for the former single ``_SAFE_UI_ACTION_RE``
393
+ # literal. A write-page lane registers its action (co-located with its route, or here) instead of editing
394
+ # a central allow-list, so parallel lanes never collide. This is also the ONLY source of truth for which
395
+ # actions the step-up re-auth flow may hand control back to (see ``is_safe_ui_action``): the gate that
396
+ # stops the re-auth becoming an open POST/redirect gadget.
397
+ _UI_WRITE_ACTIONS: list[UiWriteAction] = []
398
+
399
+
400
+ def register_ui_action(
401
+ pattern: str,
402
+ permission: Permission | None,
403
+ *,
404
+ step_up: bool = True,
405
+ auto_retry: bool = True,
406
+ unlock: bool = False,
407
+ action: str | None = None,
408
+ ) -> UiWriteAction:
409
+ """Register a state-changing /ui action into the write-action registry. Idempotent by ``pattern``.
410
+
411
+ ``pattern`` MUST be a fully-anchored regex for the exact path (e.g. ``r"^/ui/alerts/[^/?#]+/ack$"``).
412
+ ``auto_retry`` entries are the only paths the step-up re-auth may **re-POST** — keep it ``True`` only
413
+ for body-less actions whose params all live in the PATH. ``unlock`` entries are **GET form pages** the
414
+ re-auth may **303-GET-redirect** to after step-up (the confirm-after-step-up primitive for
415
+ body-carrying admin forms); register those as ``auto_retry=False, unlock=True`` (the two are mutually
416
+ exclusive — :class:`UiWriteAction` rejects both).
417
+ """
418
+ entry = UiWriteAction(re.compile(pattern), permission, step_up, auto_retry, unlock, action)
419
+ if not any(a.path_re.pattern == entry.path_re.pattern for a in _UI_WRITE_ACTIONS):
420
+ _UI_WRITE_ACTIONS.append(entry)
421
+ return entry
422
+
423
+
424
+ # Phase-0 replay actions (migrated verbatim from the former _SAFE_UI_ACTION_RE): a single message, all
425
+ # dead deliveries for one channel, or the dead deliveries for one (channel, destination). All params are
426
+ # in the PATH (opaque ids / channel + destination names, each a single slash/query/fragment-free
427
+ # segment), so the body-less auto-retry re-POST carries everything it needs.
428
+ register_ui_action(r"^/ui/messages/[^/?#]+/replay$", Permission.MESSAGES_REPLAY)
429
+ register_ui_action(r"^/ui/dead-letters/[^/?#]+(/[^/?#]+)?/replay$", Permission.MESSAGES_REPLAY)
430
+ # L6b (#75 parity): replay ALL dead deliveries across EVERY channel — a body-less step-up POST, so
431
+ # the /ui/reauth flow may auto-retry it. The literal `replay-all` segment is NOT matched by the
432
+ # per-channel pattern above (it has no `/replay` suffix), so it needs its own allow-list entry.
433
+ register_ui_action(r"^/ui/dead-letters/replay-all$", Permission.MESSAGES_REPLAY)
434
+
435
+
436
+ def is_safe_ui_action(next_path: str | None) -> bool:
437
+ """Whether ``next_path`` is a same-origin /ui action the re-auth flow may auto-retry.
438
+
439
+ Rejects any ``..`` outright: a segment like ``CH/..`` is normalized by the browser before the POST, so
440
+ it must never be treated as a valid retry target (defense-in-depth over the anchored patterns). Then
441
+ scans the write-action registry for an ``auto_retry`` entry whose anchored pattern fully matches — so a
442
+ write-page lane extends the allow-list by *registering* its action, never by editing this function.
443
+ """
444
+ if not next_path or ".." in next_path:
445
+ return False
446
+ return any(
447
+ action.auto_retry and action.path_re.fullmatch(next_path) for action in _UI_WRITE_ACTIONS
448
+ )
449
+
450
+
451
+ def lookup_ui_action(next_path: str | None) -> UiWriteAction | None:
452
+ """The registered continuation action ``next_path`` resolves to (auto_retry OR unlock), or None.
453
+
454
+ Used by the /ui/reauth flow to read an action's metadata — notably ``step_up``: a ``step_up=False``
455
+ continuation is password-only (require_ui_reauth_only, e.g. MFA enrollment), so a required-but-
456
+ unenrolled session can complete it; a ``step_up=True`` continuation needs the full step-up (MFA
457
+ leg), which such a session can NEVER satisfy — the flow sends it to enroll instead of looping. Same
458
+ ``..`` guard as the boolean gates.
459
+ """
460
+ if not next_path or ".." in next_path:
461
+ return None
462
+ for action in _UI_WRITE_ACTIONS:
463
+ if (action.auto_retry or action.unlock) and action.path_re.fullmatch(next_path):
464
+ return action
465
+ return None
466
+
467
+
468
+ def is_unlock_action(next_path: str | None) -> bool:
469
+ """Whether ``next_path`` is a registered /ui **GET form page** the re-auth may 303-GET-redirect to.
470
+
471
+ The GET analogue of :func:`is_safe_ui_action`: it gates the confirm-after-step-up primitive so the
472
+ re-auth flow can only redirect back to a form page a lane explicitly registered (``unlock=True``),
473
+ never to an arbitrary URL. Same ``..`` rejection (a normalized ``/..`` segment is never a valid
474
+ target) and same append-only registry as the only source of truth — a lane opts a form page in by
475
+ *registering* it, not by editing this function.
476
+ """
477
+ if not next_path or ".." in next_path:
478
+ return False
479
+ return any(
480
+ action.unlock and action.path_re.fullmatch(next_path) for action in _UI_WRITE_ACTIONS
481
+ )
482
+
483
+
484
+ def _reauth_redirect(request: Request, next_path: str | None = None) -> HTTPException:
485
+ """303 a browser to the /ui re-auth page, remembering the action to continue with.
486
+
487
+ ``next_path`` (when given) overrides the default of this request's own path — used by body-carrying
488
+ POST actions to point the re-auth at their **unlock form page** instead of the POST path (which is
489
+ deliberately not a registered continuation). Either way the value is only ever *acted on* after
490
+ ``/ui/reauth`` re-validates it against the write-action registry, so a bad override fails closed.
491
+ """
492
+ nxt = quote(next_path if next_path is not None else request.url.path, safe="/")
493
+ return HTTPException(status.HTTP_303_SEE_OTHER, headers={"Location": f"/ui/reauth?next={nxt}"})
494
+
495
+
496
+ def require_ui_step_up(
497
+ *permissions: Permission,
498
+ reauth_next: Callable[[Request], str] | None = None,
499
+ ) -> Callable[[Request], Awaitable[Identity]]:
500
+ """Authenticate a /ui request, assert ``permissions``, AND require a fresh step-up — the cookie-world
501
+ analogue of ``api.security.require_step_up`` for sensitive /ui actions (replay).
502
+
503
+ The /ui action routes call the JSON handlers directly, which SKIPS the handler's own
504
+ ``require_step_up`` gate — so this dependency re-applies the exact same checks (MFA satisfied +
505
+ recent password step-up + new-client-IP contextual risk) that ``require_step_up`` does. The only
506
+ difference is the *response*: instead of a 403 with ``X-MFA-Required``/``X-Step-Up-Required`` (which
507
+ a browser can't act on), it **redirects** to the /ui re-auth page carrying ``next=<this action>`` so
508
+ the browser can re-authenticate and auto-retry.
509
+
510
+ ``reauth_next`` overrides *which* continuation the re-auth page is pointed at. A **body-carrying**
511
+ POST (create-user, set-roles) cannot be auto-retried (the re-POST would carry no body) and its POST
512
+ path is deliberately not a registered continuation — so it maps the redirect to its **unlock form
513
+ page** (e.g. ``POST /ui/users`` → ``/ui/users/new``): after re-verification the browser is GET-
514
+ redirected to the form, which re-opens inside a fresh window for a clean re-submit. The mapped value
515
+ gets no trust: ``/ui/reauth`` still validates it against the write-action registry (fail-closed).
516
+ """
517
+ # allow_mfa_pending: the base must NOT fire the 6.3.3 redirect for a step-up route. This
518
+ # factory runs its OWN mfa_satisfied check below, which 303s to /ui/reauth *carrying the
519
+ # next= continuation*; letting the base pre-empt it would drop that continuation and land the
520
+ # operator on /ui instead of the action they clicked. Same refusal, better destination.
521
+ base = require_ui(*permissions, allow_mfa_pending=True)
522
+
523
+ async def dependency(request: Request) -> Identity:
524
+ identity = await base(request) # cookie auth + permission (+ must-change gate)
525
+ auth = get_auth(request)
526
+ if auth is None or not auth.enabled: # pragma: no cover - base already handled this
527
+ raise _login_redirect()
528
+ token = session_token(request)
529
+ nxt = reauth_next(request) if reauth_next is not None else None
530
+ # Second factor first (mirrors require_step_up): an MFA-required session must have verified TOTP.
531
+ if not await auth.mfa_satisfied(token):
532
+ raise _reauth_redirect(request, nxt)
533
+ # Contextual risk + password step-up window: a new client IP or a stale window forces re-auth.
534
+ client = request.client.host if request.client else None
535
+ new_ip = await auth.flag_new_client_ip(token, client, path=request.url.path)
536
+ if new_ip or not await auth.has_recent_step_up(token):
537
+ raise _reauth_redirect(request, nxt)
538
+ return identity
539
+
540
+ return dependency
541
+
542
+
543
+ def require_ui_reauth_only(
544
+ *permissions: Permission,
545
+ reauth_next: Callable[[Request], str] | None = None,
546
+ ) -> Callable[[Request], Awaitable[Identity]]:
547
+ """Like :func:`require_ui_step_up` but with **only** the password step-up — **not** the MFA gate;
548
+ the cookie-world analogue of ``api.security.require_reauth_only``.
549
+
550
+ Used by the browser MFA *enrollment* routes (L4b): a user enrolling their first second factor (or
551
+ a ``require_mfa`` account that has not enrolled yet) cannot satisfy an MFA gate, so
552
+ ``require_ui_step_up`` there would deadlock. Re-proving the password still defends a stolen cookie
553
+ session from silently enrolling an attacker-controlled authenticator (WP-14). Same contextual
554
+ new-client-IP layer; a stale window 303s to /ui/reauth (which only asks for a TOTP code when one
555
+ is actually enrolled).
556
+ """
557
+ # allow_mfa_pending: a genuine exemption, not a re-route. These gate the ENROLLMENT path, and
558
+ # an un-enrolled user can never satisfy a gate standing in front of the route that enrolls them.
559
+ base = require_ui(*permissions, allow_mfa_pending=True)
560
+
561
+ async def dependency(request: Request) -> Identity:
562
+ identity = await base(request) # cookie auth + permission (+ must-change gate)
563
+ auth = get_auth(request)
564
+ if auth is None or not auth.enabled: # pragma: no cover - base already handled this
565
+ raise _login_redirect()
566
+ token = session_token(request)
567
+ client = request.client.host if request.client else None
568
+ new_ip = await auth.flag_new_client_ip(token, client, path=request.url.path)
569
+ if new_ip or not await auth.has_recent_step_up(token):
570
+ raise _reauth_redirect(
571
+ request, reauth_next(request) if reauth_next is not None else None
572
+ )
573
+ return identity
574
+
575
+ return dependency
576
+
577
+
578
+ async def _ui_action_step_up_ok(auth: AuthService, token: str | None, action: str) -> bool:
579
+ """The /ui step-up decision for a per-action lane (ADR 0077), mirroring
580
+ ``api.security._action_step_up_ok``: when action-binding is enforced (default) a fresh single-use
581
+ grant BOUND to ``action`` (consumed here); when the org opted out
582
+ (``[auth].require_action_step_up = false``) the legacy session-window recency. Uses only PUBLIC
583
+ ``AuthService`` members, so no cross-package private import is needed."""
584
+ if auth.action_step_up_required:
585
+ return await auth.has_action_step_up(token, action)
586
+ return await auth.has_recent_step_up(token)
587
+
588
+
589
+ def require_ui_step_up_action(
590
+ action: str,
591
+ *permissions: Permission,
592
+ reauth_next: Callable[[Request], str] | None = None,
593
+ ) -> Callable[[Request], Awaitable[Identity]]:
594
+ """Like :func:`require_ui_step_up`, but the step-up must be a fresh proof **bound to** ``action``
595
+ (single-use, ADR 0077 / ASVS 7.5.1), not the shared session window. Keeps the MFA gate — used for
596
+ the durable-takeover browser factor ops (**disable-MFA**, **webauthn-delete**). Falls back to the
597
+ session window under ``[auth].require_action_step_up = false``. ``new_ip`` is checked FIRST so a
598
+ forced new-IP step-up short-circuits and leaves the single-use grant UNCONSUMED (mirrors
599
+ ``api.security.require_step_up_action``)."""
600
+ # allow_mfa_pending: the base must NOT fire the 6.3.3 redirect for a step-up route. This
601
+ # factory runs its OWN mfa_satisfied check below, which 303s to /ui/reauth *carrying the
602
+ # next= continuation*; letting the base pre-empt it would drop that continuation and land the
603
+ # operator on /ui instead of the action they clicked. Same refusal, better destination.
604
+ base = require_ui(*permissions, allow_mfa_pending=True)
605
+
606
+ async def dependency(request: Request) -> Identity:
607
+ identity = await base(request) # cookie auth + permission (+ must-change gate)
608
+ auth = get_auth(request)
609
+ if auth is None or not auth.enabled: # pragma: no cover - base already handled this
610
+ raise _login_redirect()
611
+ token = session_token(request)
612
+ nxt = reauth_next(request) if reauth_next is not None else None
613
+ if not await auth.mfa_satisfied(token):
614
+ raise _reauth_redirect(request, nxt)
615
+ client = request.client.host if request.client else None
616
+ new_ip = await auth.flag_new_client_ip(token, client, path=request.url.path)
617
+ if new_ip or not await _ui_action_step_up_ok(auth, token, action):
618
+ raise _reauth_redirect(request, nxt)
619
+ return identity
620
+
621
+ return dependency
622
+
623
+
624
+ def require_ui_reauth_only_action(
625
+ action: str,
626
+ *permissions: Permission,
627
+ reauth_next: Callable[[Request], str] | None = None,
628
+ ) -> Callable[[Request], Awaitable[Identity]]:
629
+ """Like :func:`require_ui_reauth_only` (password-only, **no MFA gate** so a required-but-unenrolled
630
+ session can still enroll its first factor), but the step-up must be a fresh proof **bound to**
631
+ ``action`` (single-use, ADR 0077 / ASVS 7.5.1). Used by the browser factor-binding enroll lanes.
632
+ Falls back to the session window under ``[auth].require_action_step_up = false``. Same ``new_ip``-
633
+ first short-circuit so a forced new-IP step-up leaves the single-use grant UNCONSUMED."""
634
+ # allow_mfa_pending: a genuine exemption, not a re-route. These gate the ENROLLMENT path, and
635
+ # an un-enrolled user can never satisfy a gate standing in front of the route that enrolls them.
636
+ base = require_ui(*permissions, allow_mfa_pending=True)
637
+
638
+ async def dependency(request: Request) -> Identity:
639
+ identity = await base(request) # cookie auth + permission (+ must-change gate)
640
+ auth = get_auth(request)
641
+ if auth is None or not auth.enabled: # pragma: no cover - base already handled this
642
+ raise _login_redirect()
643
+ token = session_token(request)
644
+ client = request.client.host if request.client else None
645
+ new_ip = await auth.flag_new_client_ip(token, client, path=request.url.path)
646
+ if new_ip or not await _ui_action_step_up_ok(auth, token, action):
647
+ raise _reauth_redirect(
648
+ request, reauth_next(request) if reauth_next is not None else None
649
+ )
650
+ return identity
651
+
652
+ return dependency
653
+
654
+
655
+ async def authorize_ui_ws(
656
+ websocket: WebSocket, *permissions: Permission
657
+ ) -> tuple[Identity | None, str | None]:
658
+ """Authorize a **same-origin browser** WebSocket handshake via the ``mf_session`` cookie.
659
+
660
+ Browsers cannot set the ``Authorization`` header on a WebSocket handshake (and the ``?token=`` query
661
+ fallback was removed for ASVS), so the browser's only header-free credential is the cookie the
662
+ handshake carries. Returns ``(identity, token)`` for an authorized same-origin browser, else
663
+ ``(None, None)`` — the caller then falls back to the native (header) ``authorize_ws`` path.
664
+
665
+ **CSWSH defense (two independent layers):** (1) the handshake ``Origin`` must be same-origin as the
666
+ ``Host`` — a cross-site page's WS is rejected here; (2) ``mf_session`` is ``SameSite=Strict``, so a
667
+ cross-site-initiated handshake carries **no** cookie at all. A native client sends no ``Origin``, so
668
+ this returns ``(None, None)`` and does not interfere with the header path.
669
+ """
670
+ origin = websocket.headers.get("origin")
671
+ if not origin:
672
+ return None, None # native client (no Origin) — the header path handles it
673
+ if not _origin_matches(websocket.app.state, origin, websocket.headers.get("host")):
674
+ return None, None # cross-origin browser handshake (CSWSH) — reject
675
+ token = session_token(websocket)
676
+ if not token:
677
+ return None, None
678
+ auth = getattr(websocket.app.state, "auth", None)
679
+ if auth is None or not auth.enabled:
680
+ return None, None
681
+ identity = await auth.identity_for_token(token)
682
+ if identity is None or identity.must_change_password:
683
+ return None, None
684
+ # ASVS 6.3.3, mirroring authorize_ws on the header path: an MFA-pending session does not stream.
685
+ # No exempt set — every /ui socket is a data feed, none is part of the enroll/verify escape path.
686
+ if not await auth.mfa_satisfied(token):
687
+ return None, None
688
+ for permission in permissions:
689
+ if not identity.has(permission):
690
+ return None, None
691
+ return identity, token
692
+
693
+
694
+ def set_session_cookie(response: Response, token: str, *, request: Request) -> None:
695
+ """Set the confined session cookie: HttpOnly + SameSite=Strict, Path=/, and — in an effective-https
696
+ context (and unless the org opt-out is set) — the ``__Host-`` prefixed name (ADR 0065 §hardening /
697
+ #192, ASVS 3.4.3). Secure is ALWAYS set when the effective scheme is https, even under the opt-out
698
+ (transport security is never downgraded). Over cleartext loopback this is byte-identical to the
699
+ pre-#192 cookie (``mf_session``, no Secure). Path=/ (not /ui) so a future same-origin WebSocket
700
+ handshake at the root can carry it (M2); the cookie is only ever *read* by ``require_ui`` on /ui
701
+ routes, never by the JSON API deps.
702
+ """
703
+ secure = effective_https(request.app.state, request.url.scheme)
704
+ name = HOST_COOKIE_NAME if (secure and browser_hardening_enabled()) else COOKIE_NAME
705
+ response.set_cookie(
706
+ name,
707
+ token,
708
+ httponly=True,
709
+ samesite="strict",
710
+ secure=secure,
711
+ path="/",
712
+ )
713
+
714
+
715
+ def clear_session_cookie(response: Response, request: Request) -> None:
716
+ """End this browser's session: delete the session cookie AND stamp
717
+ :data:`CLEAR_SITE_DATA_HEADER` (ASVS 14.3.1). Pairs with a server-side revoke.
718
+
719
+ Deletes whichever name this scheme uses (:func:`session_cookie_name`). Over cleartext loopback the
720
+ DELETION stays byte-identical to the pre-#192 clear (``delete_cookie(COOKIE_NAME, path="/")``); the
721
+ ``__Host-`` deletion additionally carries Secure so the browser accepts the expiry (a ``__Host-``
722
+ cookie is only writable — expiry included — over a Secure connection).
723
+
724
+ The header is set HERE rather than beside each call site because deleting the session cookie IS
725
+ the browser-visible end of a session: fusing them makes the 14.3.1 control structurally
726
+ un-forgettable. It was previously two lines a route had to remember to write together, and
727
+ ``POST /ui/account/password`` — which revokes EVERY session for the user, the termination an
728
+ operator reaches for after a suspected compromise — wrote only one of them, so Back/bfcache could
729
+ resurrect the rendered PHI page across a full revoke. Both current callers (sign-out, password
730
+ change) are terminations; a future caller that deletes the cookie without ending the session would
731
+ be the anomaly and must justify itself, not the other way round.
732
+ """
733
+ name = session_cookie_name(request)
734
+ if name == COOKIE_NAME:
735
+ response.delete_cookie(COOKIE_NAME, path="/")
736
+ else:
737
+ response.delete_cookie(name, path="/", secure=True, httponly=True, samesite="strict")
738
+ response.headers[CLEAR_SITE_DATA_HEADER] = CLEAR_SITE_DATA_VALUE
739
+
740
+
741
+ # --- ADR 0142: the OIDC flow cookie (browser binding for the federated login) ------
742
+
743
+ #: The federated-flow cookie. Carries only an opaque flow id — never a token, never the PKCE verifier
744
+ #: (those stay server-side in the service's ``FlowCache``, keyed on ``sha256(flow_id)``).
745
+ FLOW_COOKIE_NAME = "mf_oidc_flow"
746
+
747
+ #: The ``__Host-`` twin, used on the same effective-https terms as :data:`HOST_COOKIE_NAME`.
748
+ HOST_FLOW_COOKIE_NAME = "__Host-mf_oidc_flow"
749
+
750
+
751
+ def oidc_flow_cookie_name(conn: Request | WebSocket) -> str:
752
+ """The flow-cookie name for this connection — the ONE resolver the set/read/clear sites share, so
753
+ the name the start leg writes and the name the callback reads can never disagree.
754
+
755
+ Mirrors :func:`session_cookie_name`: a browser silently DROPS a ``__Host-`` cookie that is not
756
+ Secure, and ``[api].public_origin`` legitimately permits an http:// origin, so on cleartext
757
+ loopback the plain name is used. Without that split, the callback would find no cookie and audit
758
+ ``flow_binding_missing`` forever on every dev deployment, with nothing pointing at the cause.
759
+ """
760
+ if effective_https(conn.app.state, conn.url.scheme) and browser_hardening_enabled():
761
+ return HOST_FLOW_COOKIE_NAME
762
+ return FLOW_COOKIE_NAME
763
+
764
+
765
+ def set_oidc_flow_cookie(
766
+ response: Response, flow_id: str, *, request: Request, max_age: int
767
+ ) -> None:
768
+ """Stage the federated flow id in the browser (ADR 0142 browser-binding, AC-7).
769
+
770
+ ``SameSite=Lax``, deliberately NOT the session cookie's ``Strict``: Lax IS sent on a top-level
771
+ cross-site GET, which is exactly what the IdP's redirect back to ``/ui/oidc/callback`` is. A Strict
772
+ flow cookie would be withheld on that return and every federated login would fail
773
+ ``flow_binding_missing``. The session cookie's Strict is untouched — this is a separate, short-lived
774
+ cookie carrying no authority of its own.
775
+
776
+ ``max_age`` bounds it to the server-side flow TTL so an abandoned login does not leave a cookie
777
+ behind indefinitely.
778
+ """
779
+ secure = effective_https(request.app.state, request.url.scheme)
780
+ name = HOST_FLOW_COOKIE_NAME if (secure and browser_hardening_enabled()) else FLOW_COOKIE_NAME
781
+ response.set_cookie(
782
+ name,
783
+ flow_id,
784
+ max_age=max_age,
785
+ httponly=True,
786
+ samesite="lax",
787
+ secure=secure,
788
+ path="/",
789
+ )
790
+
791
+
792
+ def clear_oidc_flow_cookie(response: Response, request: Request) -> None:
793
+ """Delete the flow cookie. Called on EVERY terminal callback response, success or failure: the
794
+ server-side flow is single-use, so a surviving cookie would make the next callback present a flow
795
+ id that no longer resolves — a confusing ``flow_binding_missing`` on an otherwise clean retry."""
796
+ name = oidc_flow_cookie_name(request)
797
+ if name == FLOW_COOKIE_NAME:
798
+ response.delete_cookie(FLOW_COOKIE_NAME, path="/")
799
+ else:
800
+ response.delete_cookie(name, path="/", secure=True, httponly=True, samesite="lax")
801
+
802
+
803
+ # --- L5a: WebAuthn RP identity (ADR 0068 §7) --------------------------------------
804
+
805
+ #: Legible fail-closed copy, shared by every affected surface (account page, enroll flow, reauth
806
+ #: page) so the operator sees ONE consistent message + recovery path — never a redirect loop.
807
+ WEBAUTHN_RP_MISSING_NOTICE = (
808
+ "Passkeys are unavailable: [api].public_origin is not set — contact your administrator."
809
+ )
810
+ WEBAUTHN_EXTRA_MISSING_NOTICE = (
811
+ "Passkeys are unavailable on this install (the [webauthn] extra is not installed) — "
812
+ "contact your administrator."
813
+ )
814
+ WEBAUTHN_RP_CHANGED_NOTICE = (
815
+ "Your passkeys were enrolled under a different origin and are unusable here — contact your "
816
+ "administrator to reset your MFA so you can re-enroll."
817
+ )
818
+
819
+
820
+ def webauthn_rp(request: Request) -> tuple[str, str] | None:
821
+ """The WebAuthn RP identity for this deployment: ``(rp_id, expected_origin)`` or ``None``.
822
+
823
+ ``[api].public_origin`` is AUTHORITATIVE when set (it is already the validated, normalized
824
+ origin the /ui CSRF + CSWSH checks match against — never a second origin knob). Unset, the
825
+ request URL is used ONLY when ``create_app`` marked request-derivation safe (loopback bind
826
+ with no reverse proxy declared — the browser connected directly, so the request Host is what
827
+ it actually used, not proxy-rewritable). Anywhere else this returns ``None`` and ceremonies
828
+ FAIL CLOSED: behind a declared proxy the Host header is client-forwardable, and anchoring the
829
+ rp_id to it would defeat exactly the phishing resistance WebAuthn exists to add (the red-team
830
+ CRITICAL repair — keyed on the proxy declaration, never the bind host alone).
831
+ """
832
+ public_origin = getattr(request.app.state, "public_origin", None)
833
+ if public_origin:
834
+ host = urlsplit(public_origin).hostname
835
+ return (host, public_origin) if host else None
836
+ if getattr(request.app.state, "webauthn_rp_from_request", False):
837
+ host = request.url.hostname
838
+ if not host:
839
+ return None
840
+ return (host, f"{request.url.scheme}://{request.url.netloc}")
841
+ return None