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,451 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """L4b/L5a/L6b self-service account surface: change-password, TOTP MFA lifecycle, WebAuthn passkeys, and session management."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+
9
+ from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
10
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
11
+ from pydantic import ValidationError
12
+
13
+ from messagefoundry.api._ui_seam import UiDeps
14
+ from messagefoundry.api.auth_models import (
15
+ PasswordChangeRequest,
16
+ )
17
+ from messagefoundry.auth import Identity
18
+ from messagefoundry.auth import webauthn as webauthn_mod
19
+ from messagefoundry.auth.service import (
20
+ STEP_UP_ACTION_MFA_CONFIRM,
21
+ STEP_UP_ACTION_MFA_DISABLE,
22
+ STEP_UP_ACTION_MFA_ENROLL,
23
+ STEP_UP_ACTION_WEBAUTHN_DELETE,
24
+ STEP_UP_ACTION_WEBAUTHN_ENROLL,
25
+ AuthService,
26
+ )
27
+ from messagefoundry.auth.tokens import hash_token
28
+
29
+ from .. import pages
30
+ from .._auth import (
31
+ WEBAUTHN_EXTRA_MISSING_NOTICE,
32
+ WEBAUTHN_RP_MISSING_NOTICE,
33
+ allow_reauth_attempt,
34
+ assert_same_origin,
35
+ clear_session_cookie,
36
+ register_ui_action,
37
+ require_ui,
38
+ require_ui_reauth_only,
39
+ require_ui_reauth_only_action,
40
+ require_ui_step_up_action,
41
+ session_token,
42
+ webauthn_rp,
43
+ )
44
+ from .._service import _service
45
+ from ._common import _client, _form_pairs, _rate_limited
46
+
47
+ # --- L4b: self-service account (change password + TOTP MFA lifecycle) -------
48
+ # Self-scoped actions: any authenticated session acts on ITS OWN credential, so the registry
49
+ # entries carry permission=None (enforcement is the require_ui* dependency on each route).
50
+ # MFA enroll/disable are body-less POSTs → auto_retry; the confirm-code POST is body-carrying,
51
+ # so its standalone GET form registers as the unlock re-entry point. The change-password POST
52
+ # is in NEITHER registry: it has no step-up gate (the current password in the body IS the
53
+ # proof), so no re-auth continuation ever needs to reach it.
54
+ # enroll + confirm are password-only (require_ui_reauth_only_action) — step_up=False so the /ui/reauth
55
+ # flow knows a required-but-unenrolled session CAN complete them (it is the enrollment path);
56
+ # disable needs the full step-up (require_ui_step_up_action), so it keeps step_up=True (default).
57
+ # 7.5.1 (ADR 0077): each factor lane now carries an action= tag, so /ui/reauth mints the matching
58
+ # single-use grant and each bind consumes exactly one fresh proof (closing the /ui window residual).
59
+ register_ui_action(
60
+ r"^/ui/account/mfa/enroll$", None, step_up=False, action=STEP_UP_ACTION_MFA_ENROLL
61
+ )
62
+ register_ui_action(r"^/ui/account/mfa/disable$", None, action=STEP_UP_ACTION_MFA_DISABLE)
63
+ register_ui_action(
64
+ r"^/ui/account/mfa/confirm$",
65
+ None,
66
+ step_up=False,
67
+ auto_retry=False,
68
+ unlock=True,
69
+ action=STEP_UP_ACTION_MFA_CONFIRM,
70
+ )
71
+ # --- L5a: WebAuthn passkeys (ADR 0068, WP-14b) -------------------------------
72
+ # Self-scoped actions (permission=None — enforcement is each route's require_ui* dep).
73
+ # enroll is the TOTP-enroll twin: a body-less POST returning HTML, gated on the password-
74
+ # only re-proof (require_ui_reauth_only — WP-14: a stolen pre-MFA cookie must never bind
75
+ # an attacker's passkey) and registered step_up=False so the enroll_first anti-loop knows
76
+ # a required-but-unenrolled session CAN complete it. delete is the body-less path-param
77
+ # auto-retry shape behind the FULL step-up. The verify route is a body-carrying JSON POST
78
+ # — NEVER registered as a continuation (hard invariant); its require_ui_reauth_only maps a
79
+ # stale-window 303 to the REGISTERED enroll action (the #745 ui_mfa_verify precedent).
80
+ register_ui_action(
81
+ r"^/ui/account/webauthn/enroll$", None, step_up=False, action=STEP_UP_ACTION_WEBAUTHN_ENROLL
82
+ )
83
+ register_ui_action(
84
+ r"^/ui/account/webauthn/[^/?#]+/delete$", None, action=STEP_UP_ACTION_WEBAUTHN_DELETE
85
+ )
86
+ # --- L6b: self-service session TERMINATE (ASVS 7.5.2) — password re-proof before revoke ---
87
+ # Both terminate POSTs are body-less (all params in the PATH), so they register as auto_retry
88
+ # continuations /ui/reauth may re-POST after a stale-window re-auth. step_up=False (password-
89
+ # only, mirrors require_ui_reauth_only): a required-but-unenrolled MFA session must still be
90
+ # able to revoke — require_ui_step_up would deadlock it (WP-14). permission=None (self-scoped).
91
+ # The two patterns do not overlap: the per-session pattern requires two more segments after
92
+ # sessions/ (<id>/revoke), so /ui/account/sessions/revoke-others (one segment) matches only itself.
93
+ register_ui_action(r"^/ui/account/sessions/[^/?#]+/revoke$", None, step_up=False)
94
+ register_ui_action(r"^/ui/account/sessions/revoke-others$", None, step_up=False)
95
+
96
+
97
+ def register(app: FastAPI, deps: UiDeps) -> None:
98
+ """L4b/L5a/L6b self-service account surface: change-password, TOTP MFA lifecycle, WebAuthn passkeys, and session management."""
99
+ admin = deps.admin
100
+
101
+ # Post-redirect notices (allow-listed codes only — never reflected text).
102
+ _ACCOUNT_NOTICES = {
103
+ "mfa_off": "MFA disabled.",
104
+ "enroll_first": (
105
+ "That action requires MFA — enroll an authenticator (TOTP app or passkey) to continue."
106
+ ),
107
+ "passkey_added": "Passkey added.",
108
+ "passkey_removed": "Passkey removed.",
109
+ }
110
+
111
+ async def _account_response(
112
+ service: AuthService,
113
+ identity: Identity,
114
+ request: Request,
115
+ *,
116
+ notice: str | None = None,
117
+ error: str | None = None,
118
+ status_code: int = 200,
119
+ ) -> HTMLResponse:
120
+ mfa = await admin.my_mfa(service=service, identity=identity)
121
+ # L5a passkey rows (plain mappings — the pages module never touches the store); the
122
+ # "usable" flag compares each credential's mint-time rp_id to the CURRENT RP identity
123
+ # (an origin migration renders old credentials visibly unusable, ADR 0068 §7).
124
+ rp = webauthn_rp(request)
125
+ creds = await service.store.list_webauthn_credentials(identity.user_id)
126
+ passkeys = [
127
+ {
128
+ "label": c.label,
129
+ "created_at": c.created_at,
130
+ "last_used_at": c.last_used_at,
131
+ "backed_up": c.backed_up,
132
+ "usable": rp is not None and c.rp_id == rp[0],
133
+ "credential_id_hash": c.credential_id_hash,
134
+ }
135
+ for c in creds
136
+ ]
137
+ wa_notice: str | None = None
138
+ if not service.webauthn_available():
139
+ wa_notice = WEBAUTHN_EXTRA_MISSING_NOTICE
140
+ elif rp is None:
141
+ wa_notice = WEBAUTHN_RP_MISSING_NOTICE
142
+ return HTMLResponse(
143
+ pages.account_page(
144
+ admin.current_user(identity),
145
+ mfa,
146
+ notice=notice,
147
+ error=error,
148
+ passkeys=passkeys,
149
+ webauthn_notice=wa_notice,
150
+ ),
151
+ status_code=status_code,
152
+ )
153
+
154
+ @app.get("/ui/account", response_class=HTMLResponse)
155
+ async def ui_account(
156
+ request: Request,
157
+ service: AuthService = Depends(_service),
158
+ identity: Identity = Depends(require_ui(allow_mfa_pending=True)),
159
+ m: str | None = Query(None, max_length=32),
160
+ ) -> HTMLResponse:
161
+ return await _account_response(
162
+ service, identity, request, notice=_ACCOUNT_NOTICES.get(m or "")
163
+ )
164
+
165
+ @app.get("/ui/account/password", response_class=HTMLResponse)
166
+ async def ui_account_password_form(
167
+ identity: Identity = Depends(require_ui(allow_must_change=True, allow_mfa_pending=True)),
168
+ ) -> HTMLResponse:
169
+ # `forced` comes from the SERVER-side flag, never a query param (unspoofable).
170
+ return HTMLResponse(pages.password_page(forced=identity.must_change_password))
171
+
172
+ @app.post("/ui/account/password")
173
+ async def ui_account_password(
174
+ request: Request,
175
+ service: AuthService = Depends(_service),
176
+ identity: Identity = Depends(require_ui(allow_must_change=True, allow_mfa_pending=True)),
177
+ ) -> Response:
178
+ assert_same_origin(request)
179
+ # No throttle call here on purpose: this route DELEGATES to the JSON handler
180
+ # (admin.change_password) below, which applies the per-actor ceremony budget and whose 429 is
181
+ # re-raised intact. Charging here too would spend two tokens per submission.
182
+ form = dict(await _form_pairs(request))
183
+ forced = identity.must_change_password
184
+
185
+ def _retry(message: str, code: int = 400) -> HTMLResponse:
186
+ # Passwords are NEVER echoed back — the re-rendered form is always empty.
187
+ return HTMLResponse(pages.password_page(forced=forced, error=message), status_code=code)
188
+
189
+ if form.get("new_password", "") != form.get("new_password2", ""):
190
+ return _retry("the new passwords do not match")
191
+ try:
192
+ body = PasswordChangeRequest(
193
+ current_password=form.get("current_password", ""),
194
+ new_password=form.get("new_password", ""),
195
+ )
196
+ await admin.change_password(
197
+ body=body, request=request, service=service, identity=identity
198
+ )
199
+ except ValidationError:
200
+ return _retry("invalid input")
201
+ except HTTPException as exc:
202
+ if exc.status_code == status.HTTP_429_TOO_MANY_REQUESTS:
203
+ raise # rate-limited — keep the Retry-After semantics
204
+ return _retry(str(exc.detail), exc.status_code)
205
+ # Changed: the service revoked every session (incl. this cookie) — sign in again. This is the
206
+ # WIDEST termination the console offers (every session for this user, the one an operator
207
+ # reaches for after a suspected compromise), so it must drop the browser's cached PHI pages
208
+ # like any other: ``clear_session_cookie`` emits Clear-Site-Data with the deletion (ASVS
209
+ # 14.3.1), and ``pwchanged`` is an allow-listed Clear-Site-Data login code for the landing.
210
+ resp = RedirectResponse("/ui/login?e=pwchanged", status_code=303)
211
+ clear_session_cookie(resp, request)
212
+ return resp
213
+
214
+ @app.post("/ui/account/mfa/enroll")
215
+ async def ui_mfa_enroll(
216
+ request: Request,
217
+ service: AuthService = Depends(_service),
218
+ identity: Identity = Depends(require_ui_reauth_only_action(STEP_UP_ACTION_MFA_ENROLL)),
219
+ ) -> Response:
220
+ assert_same_origin(request)
221
+ try:
222
+ enroll = await admin.enroll_mfa(service=service, identity=identity)
223
+ except HTTPException as exc:
224
+ # AD account / already enrolled — surface on the account page.
225
+ return await _account_response(
226
+ service, identity, request, error=str(exc.detail), status_code=400
227
+ )
228
+ # The staged secret renders ONCE (with the confirm form); it is inert until confirmed.
229
+ return HTMLResponse(pages.mfa_enroll_page(enroll.secret, enroll.otpauth_uri))
230
+
231
+ @app.get("/ui/account/mfa/confirm", response_class=HTMLResponse)
232
+ async def ui_mfa_confirm_form(
233
+ _identity: Identity = Depends(require_ui_reauth_only()),
234
+ ) -> HTMLResponse:
235
+ # The unlock re-entry point: the secret is already staged server-side (and in the user's
236
+ # authenticator), so this form only collects the code — the secret is never re-shown.
237
+ return HTMLResponse(pages.mfa_confirm_page())
238
+
239
+ @app.post("/ui/account/mfa/verify")
240
+ async def ui_mfa_verify(
241
+ request: Request,
242
+ service: AuthService = Depends(_service),
243
+ identity: Identity = Depends(
244
+ require_ui_reauth_only_action(
245
+ STEP_UP_ACTION_MFA_CONFIRM, reauth_next=lambda _r: "/ui/account/mfa/confirm"
246
+ )
247
+ ),
248
+ ) -> Response:
249
+ assert_same_origin(request)
250
+ # The JSON confirm_mfa handler reads the HEADER bearer token (absent on a cookie request),
251
+ # so this route drives the SERVICE directly with the cookie session token — the same
252
+ # cookie-vs-header split ui_login/ui_reauth already handle. Semantics match the JSON
253
+ # handler: rate-limited like login; wrong code changes nothing.
254
+ client = _client(request)
255
+ if not allow_reauth_attempt(service, identity, client): # per-ACTOR, not the sign-in budget
256
+ raise _rate_limited(request, "mfa-confirm")
257
+ token = session_token(request)
258
+ if not token: # pragma: no cover - require_ui already authenticated this cookie
259
+ return RedirectResponse("/ui/login", status_code=303)
260
+ form = dict(await _form_pairs(request))
261
+ code = form.get("code", "").strip()
262
+ try:
263
+ codes = await service.confirm_mfa_enrollment(identity, code, token=token, client=client)
264
+ except ValueError as exc:
265
+ # No enrollment staged / not a local account — back to the account page.
266
+ return await _account_response(
267
+ service, identity, request, error=str(exc), status_code=400
268
+ )
269
+ if codes is None:
270
+ return HTMLResponse(pages.mfa_confirm_page(error="Invalid code."), status_code=400)
271
+ # Activated: the recovery codes render ONCE — never re-fetchable.
272
+ return HTMLResponse(pages.mfa_recovery_page(codes))
273
+
274
+ @app.post("/ui/account/mfa/disable")
275
+ async def ui_mfa_disable(
276
+ request: Request,
277
+ service: AuthService = Depends(_service),
278
+ identity: Identity = Depends(require_ui_step_up_action(STEP_UP_ACTION_MFA_DISABLE)),
279
+ ) -> Response:
280
+ assert_same_origin(request)
281
+ await admin.disable_my_mfa(request=request, service=service, identity=identity)
282
+ return RedirectResponse("/ui/account?m=mfa_off", status_code=303)
283
+
284
+ # --- L6b: self-service session management (#75 parity — desktop sessions.py twin) ---
285
+ # LISTING one's OWN sessions is plain cookie-authenticated self-service (require_ui). TERMINATING
286
+ # one (revoke one / revoke-others) additionally requires a fresh PASSWORD re-proof
287
+ # (require_ui_reauth_only — ASVS 7.5.2), NOT the full MFA step-up: a no-factor user must still be
288
+ # able to revoke, and require_ui_step_up would deadlock them (WP-14). A fresh login seeds the
289
+ # step-up window, so an immediate post-login revoke is unaffected; a stale window 303s to
290
+ # /ui/reauth?next=<this action> and auto-retries after re-verification (both terminate POSTs are
291
+ # registered auto_retry, step_up=False, above). The handlers read the HEADER/cookie session token
292
+ # to identify the current session and drive the SERVICE directly (the ui_mfa_verify pattern).
293
+ _SESSION_NOTICES = {
294
+ "revoked": "Session revoked.",
295
+ "signed_out_others": "Signed out of your other sessions.",
296
+ }
297
+
298
+ @app.get("/ui/account/sessions", response_class=HTMLResponse)
299
+ async def ui_account_sessions(
300
+ request: Request,
301
+ service: AuthService = Depends(_service),
302
+ identity: Identity = Depends(require_ui()),
303
+ m: str | None = Query(None, max_length=32),
304
+ ) -> HTMLResponse:
305
+ current = hash_token(session_token(request) or "")
306
+ sessions = await service.list_sessions(identity.user_id)
307
+ rows = [
308
+ {
309
+ "id": s.token_hash,
310
+ "created_at": s.created_at,
311
+ "last_used_at": s.last_used_at,
312
+ "expires_at": s.expires_at,
313
+ "client": s.client,
314
+ "current": s.token_hash == current,
315
+ }
316
+ for s in sessions
317
+ ]
318
+ return HTMLResponse(pages.sessions_page(rows, notice=_SESSION_NOTICES.get(m or "")))
319
+
320
+ @app.post("/ui/account/sessions/{session_id}/revoke")
321
+ async def ui_revoke_session(
322
+ session_id: str,
323
+ request: Request,
324
+ service: AuthService = Depends(_service),
325
+ identity: Identity = Depends(require_ui_reauth_only()),
326
+ ) -> Response:
327
+ assert_same_origin(request)
328
+ # Ownership-checked in the service; an unknown/foreign id is a silent no-op (never
329
+ # confirms another user's session id). Revoking the CURRENT session logs the caller
330
+ # out — the next request finds no session and 303s to login.
331
+ await service.revoke_own_session(identity, session_id, actor=identity.username)
332
+ return RedirectResponse("/ui/account/sessions?m=revoked", status_code=303)
333
+
334
+ @app.post("/ui/account/sessions/revoke-others")
335
+ async def ui_revoke_other_sessions(
336
+ request: Request,
337
+ service: AuthService = Depends(_service),
338
+ identity: Identity = Depends(require_ui_reauth_only()),
339
+ ) -> Response:
340
+ assert_same_origin(request)
341
+ current = hash_token(session_token(request) or "")
342
+ await service.revoke_other_sessions(identity, current, actor=identity.username)
343
+ return RedirectResponse("/ui/account/sessions?m=signed_out_others", status_code=303)
344
+
345
+ _RP_NAME = "MessageFoundry"
346
+
347
+ @app.post("/ui/account/webauthn/enroll")
348
+ async def ui_webauthn_enroll(
349
+ request: Request,
350
+ service: AuthService = Depends(_service),
351
+ identity: Identity = Depends(require_ui_reauth_only_action(STEP_UP_ACTION_WEBAUTHN_ENROLL)),
352
+ ) -> Response:
353
+ assert_same_origin(request)
354
+ if not service.webauthn_available():
355
+ return await _account_response(
356
+ service,
357
+ identity,
358
+ request,
359
+ error=WEBAUTHN_EXTRA_MISSING_NOTICE,
360
+ status_code=400,
361
+ )
362
+ rp = webauthn_rp(request)
363
+ if rp is None:
364
+ # Fail closed, legibly (ADR 0068 §7): 409 + the shared notice on the account page.
365
+ return await _account_response(
366
+ service,
367
+ identity,
368
+ request,
369
+ error=WEBAUTHN_RP_MISSING_NOTICE,
370
+ status_code=409,
371
+ )
372
+ token = session_token(request)
373
+ if not token: # pragma: no cover - require_ui_reauth_only authenticated this cookie
374
+ return RedirectResponse("/ui/login", status_code=303)
375
+ try:
376
+ options = await service.begin_webauthn_registration(
377
+ identity, token=token, rp_id=rp[0], rp_name=_RP_NAME
378
+ )
379
+ except ValueError as exc: # AD account
380
+ return await _account_response(
381
+ service, identity, request, error=str(exc), status_code=400
382
+ )
383
+ except webauthn_mod.ChallengeCacheFullError as exc:
384
+ return await _account_response(
385
+ service, identity, request, error=str(exc), status_code=503
386
+ )
387
+ # The creation options render ONCE in a data-* hook (CSP: no inline script).
388
+ return HTMLResponse(pages.webauthn_enroll_page(options))
389
+
390
+ @app.post("/ui/account/webauthn/verify")
391
+ async def ui_webauthn_verify(
392
+ request: Request,
393
+ service: AuthService = Depends(_service),
394
+ identity: Identity = Depends(
395
+ require_ui_reauth_only(reauth_next=lambda _r: "/ui/account/webauthn/enroll")
396
+ ),
397
+ ) -> Response:
398
+ assert_same_origin(request)
399
+ rp = webauthn_rp(request)
400
+ if rp is None:
401
+ return JSONResponse({"ok": False, "error": "rp_unavailable"}, status_code=409)
402
+ token = session_token(request)
403
+ if not token: # pragma: no cover - require_ui_reauth_only authenticated this cookie
404
+ return JSONResponse({"ok": False, "error": "session expired"}, status_code=401)
405
+ try:
406
+ body = await request.json()
407
+ response_json = json.dumps(body["response"])
408
+ label = str(body.get("label", ""))
409
+ except (ValueError, KeyError, TypeError):
410
+ return JSONResponse({"ok": False, "error": "malformed request"}, status_code=400)
411
+ try:
412
+ ok = await service.finish_webauthn_registration(
413
+ identity,
414
+ response_json,
415
+ label=label,
416
+ token=token,
417
+ client=_client(request),
418
+ rp_id=rp[0],
419
+ origin=rp[1],
420
+ )
421
+ except ValueError as exc:
422
+ # Service-authored, safe messages only (expired ceremony / duplicate label or
423
+ # credential / bad label / AD) — never reflected input.
424
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
425
+ if not ok:
426
+ return JSONResponse(
427
+ {"ok": False, "error": "passkey verification failed"}, status_code=400
428
+ )
429
+ return JSONResponse({"ok": True, "redirect": "/ui/account?m=passkey_added"})
430
+
431
+ @app.post("/ui/account/webauthn/{credential_id_hash}/delete")
432
+ async def ui_webauthn_delete(
433
+ credential_id_hash: str,
434
+ request: Request,
435
+ service: AuthService = Depends(_service),
436
+ identity: Identity = Depends(require_ui_step_up_action(STEP_UP_ACTION_WEBAUTHN_DELETE)),
437
+ ) -> Response:
438
+ assert_same_origin(request)
439
+ try:
440
+ removed = await service.delete_webauthn_credential(
441
+ identity, credential_id_hash, client=_client(request)
442
+ )
443
+ except ValueError as exc: # last-required-factor refusal
444
+ return await _account_response(
445
+ service, identity, request, error=str(exc), status_code=400
446
+ )
447
+ if not removed:
448
+ return await _account_response(
449
+ service, identity, request, error="No such passkey.", status_code=404
450
+ )
451
+ return RedirectResponse("/ui/account?m=passkey_removed", status_code=303)