host: browser auth redirects to login (no more raw JSON 401), with return-to

Clicking "edit" while logged out returned a raw JSON 401
{"ok":false,"error":"unauthorized"} — a dead end in the browser. HTML routes
now redirect to a usable login page and return you afterwards.

- host/require-login: browser-shaped guard. Same session-or-bearer check as
  host/require-user, but on failure REDIRECTS to /login?next=<path> instead of
  JSON 401. (host/require-user stays for JSON/API routes.)
- host/-principal-of: shared session-then-bearer resolution.
- login honours ?next=: GET /login renders a hidden next field; POST /login
  redirects there on success and re-renders the form (with next) on failure.
- host/-safe-next: only same-site absolute paths are honoured — //evil.com and
  http://… fall back to "/", closing the open-redirect.
- blog: host/blog--protect-html (require-login) guards the browser routes —
  POST /new, GET/POST /:slug/edit; the JSON /posts routes keep host/require-user.

Do we need login? Yes — it's the write/edit auth boundary; without it anyone
could edit or delete posts. The bug was the dead-end 401, not the gate. Now
logged-out edit -> login -> back to edit is a clean flow.

Tests: blog no-auth write routes assert 303 + Location /login(+next); session
suite gains next round-trip + open-redirect-guard cases. 218/218.
Verified live: /welcome/edit logged out -> 303 /login?next=/welcome/edit;
login -> 303 back to /welcome/edit -> 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 22:26:34 +00:00
parent 1eec131101
commit 5d5ff9948e
4 changed files with 109 additions and 41 deletions

View File

@@ -70,10 +70,14 @@
(host/blog-use-store! (persist/open))
;; -- editor form ingest (form-urlencoded, the editor's submit shape) --
(host-bl-test "form ingest no auth -> 401"
(host-bl-test "form ingest no auth -> redirect to login"
(dream-status (host-bl-wapp (host-bl-send "POST" "/new" nil
"application/x-www-form-urlencoded" "title=X")))
401)
303)
(host-bl-test "form ingest no auth Location is /login"
(contains? (dream-resp-header (host-bl-wapp (host-bl-send "POST" "/new" nil
"application/x-www-form-urlencoded" "title=X")) "location") "/login")
true)
(host-bl-test "form ingest authed -> 303 redirect"
(dream-status (host-bl-wapp (host-bl-send "POST" "/new" "Bearer good"
"application/x-www-form-urlencoded"
@@ -164,17 +168,22 @@
(dream-status (host-bl-wapp (host-bl-req "/my-first-post/"))) 200)
;; -- edit source (guarded GET form + guarded POST save) --
(host-bl-test "edit form no auth -> 401"
(dream-status (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" nil "" ""))) 401)
(host-bl-test "edit form no auth -> redirect to login"
(dream-status (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" nil "" ""))) 303)
(host-bl-test "edit form no auth Location carries next=/…/edit"
(contains?
(dream-resp-header (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" nil "" "")) "location")
"/login?next=/my-first-post/edit")
true)
(host-bl-test "edit form authed -> 200"
(dream-status (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" "Bearer good" "" ""))) 200)
(host-bl-test "edit form shows current source"
(contains? (dream-resp-body (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" "Bearer good" "" "")))
"(article")
true)
(host-bl-test "edit submit no auth -> 401"
(host-bl-test "edit submit no auth -> redirect to login"
(dream-status (host-bl-wapp (host-bl-send "POST" "/my-first-post/edit" nil
"application/x-www-form-urlencoded" "sx_content=(p+%22x%22)"))) 401)
"application/x-www-form-urlencoded" "sx_content=(p+%22x%22)"))) 303)
(host-bl-test "edit submit authed -> 303"
(dream-status (host-bl-wapp (host-bl-send "POST" "/my-first-post/edit" "Bearer good"
"application/x-www-form-urlencoded"

View File

@@ -69,6 +69,23 @@
(host-se-test "login bad creds -> 401"
(dream-status (host-se-login "admin" "wrong")) 401)
;; ── return-to (?next=) after login ──────────────────────────────────
(host-se-test "login page carries ?next in a hidden field"
(contains?
(dream-resp-body (host-se-app (dream-request "GET" "/login?next=/secure" {} "")))
"value=\"/secure\"")
true)
(host-se-test "login redirects to next on success"
(dream-resp-header
(host-se-app (dream-request "POST" "/login" {} "username=admin&password=secret&next=/secure"))
"location")
"/secure")
(host-se-test "login rejects open-redirect next (//evil) -> /"
(dream-resp-header
(host-se-app (dream-request "POST" "/login" {} "username=admin&password=secret&next=//evil.com"))
"location")
"/")
;; ── session-authed write ────────────────────────────────────────────
(host-se-test "logged-in session passes the guarded write -> 201"
(dream-status (host-se-secure (host-se-cookie-of (host-se-login "admin" "secret"))))