feat(web): login-gated BFF auth — sealed session cookie + server-side token injection
ci / rust (push) Has been cancelled

Single-user, LAN-reachable-but-gated. The web server is a backend-for-frontend:

- Login: POST /_auth/login {password} checks PUNKTFUNK_UI_PASSWORD (constant-time) and
  sets a SEALED session cookie (h3 useSession / AES-GCM). server/middleware/auth.ts gates
  every request — pages 302 → /login, /api → 401 — and FAILS CLOSED (503) when
  PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one.
- The management API stays loopback-only + token (never LAN-exposed). The proxy
  (server/routes/api/[...].ts) injects PUNKTFUNK_MGMT_TOKEN server-side and drops the
  browser's cookie before forwarding — the token never reaches the browser, which only
  holds the session cookie.

Nitro doesn't auto-scan a server/ dir, so the Nitro plugin gets an explicit scanDirs to
pick up middleware + routes. Client: removed the localStorage token (server injects it);
the fetcher bounces to /login on 401; new /login page (bare, no shell); Settings drops the
token field and gains a Sign-out button; en/de strings.

Validated live end to end: unauth /→302, /api→401; wrong pw→401; right pw→200+cookie;
authed /api/v1/status→200 (proxied, mgmt token injected — the host required it); logout→
session cleared→401. tsc + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 18:43:14 +00:00
parent 7e4ae05944
commit 9856c04b75
16 changed files with 340 additions and 81 deletions
+21
View File
@@ -0,0 +1,21 @@
// /api/** → the management API. By the time we get here the gate (middleware/auth.ts) has
// confirmed an authenticated session. We inject the management bearer token server-side
// (the browser never sees it) and drop the browser's own cookies/auth from the upstream
// request, then proxy. The management API itself binds loopback only — this proxy is the
// ONLY path to it from the LAN, and it's authenticated.
import { defineEventHandler, getRequestURL, proxyRequest } from 'h3'
import { mgmtToken, mgmtUrl } from '../../util/auth'
export default defineEventHandler((event) => {
const { pathname, search } = getRequestURL(event)
const target = `${mgmtUrl()}${pathname}${search}`
const token = mgmtToken()
return proxyRequest(event, target, {
headers: {
// Overwrite, not append: the host-held token replaces anything the browser sent.
authorization: token ? `Bearer ${token}` : '',
// Don't forward the session cookie to the management API.
cookie: '',
},
})
})