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
+9 -2
View File
@@ -4,6 +4,7 @@ import {
HeadContent,
Outlet,
Scripts,
useRouterState,
} from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import { AppShell } from '@/components/app-shell'
@@ -27,15 +28,21 @@ export const Route = createRootRouteWithContext<RouterContext>()({
})
function RootComponent() {
// The login screen renders bare (no sidebar); everything else gets the app shell.
const isLogin = useRouterState({ select: (s) => s.location.pathname === '/login' })
return (
<html lang="en" className="dark">
<head>
<HeadContent />
</head>
<body className="min-h-screen">
<AppShell>
{isLogin ? (
<Outlet />
</AppShell>
) : (
<AppShell>
<Outlet />
</AppShell>
)}
<Scripts />
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
import { useState } from 'react'
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { Radio } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { m } from '@/paraglide/messages'
import { useLocale } from '@/lib/i18n'
export const Route = createFileRoute('/login')({
validateSearch: (s: Record<string, unknown>): { next?: string } => ({
next: typeof s.next === 'string' ? s.next : undefined,
}),
component: LoginPage,
})
function LoginPage() {
useLocale()
const router = useRouter()
const { next } = Route.useSearch()
const [password, setPassword] = useState('')
const [error, setError] = useState(false)
const [busy, setBusy] = useState(false)
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setBusy(true)
setError(false)
try {
const res = await fetch('/_auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
if (!res.ok) {
setError(true)
setBusy(false)
return
}
// Full reload to the target so SSR re-runs WITH the new session cookie.
window.location.href = next && next.startsWith('/') ? next : '/'
} catch {
setError(true)
setBusy(false)
}
void router
}
return (
<div className="flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-sm">
<CardHeader className="items-center text-center">
<div className="mb-2 flex items-center gap-2">
<Radio className="size-5 text-[var(--success)]" />
<span className="font-semibold">{m.app_name()}</span>
</div>
<CardTitle>{m.login_title()}</CardTitle>
<p className="text-sm text-muted-foreground">{m.login_subtitle()}</p>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="pw">{m.login_password()}</Label>
<Input
id="pw"
type="password"
autoFocus
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{m.login_error()}</p>}
<Button type="submit" className="w-full" disabled={busy || !password}>
{busy ? m.login_signing_in() : m.login_submit()}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
+17 -44
View File
@@ -1,63 +1,24 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { Check } from 'lucide-react'
import { LogOut } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { getApiToken, setApiToken } from '@/api/fetcher'
import { m } from '@/paraglide/messages'
import { useLocale, changeLocale, locales, type Locale } from '@/lib/i18n'
import { cn } from '@/lib/utils'
export const Route = createFileRoute('/settings')({ component: SettingsPage })
function SettingsPage() {
const current = useLocale()
const qc = useQueryClient()
const [token, setToken] = useState(getApiToken())
const [saved, setSaved] = useState(false)
const onSave = (e: React.FormEvent) => {
e.preventDefault()
setApiToken(token.trim())
// Re-fetch everything with the new credential.
qc.invalidateQueries()
setSaved(true)
setTimeout(() => setSaved(false), 2_000)
const onLogout = async () => {
await fetch('/_auth/logout', { method: 'POST' })
window.location.href = '/login'
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">{m.settings_title()}</h1>
<Card className="max-w-lg">
<CardHeader>
<CardTitle>{m.settings_token_label()}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={onSave} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="token">{m.settings_token_label()}</Label>
<Input
id="token"
type="password"
autoComplete="off"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="••••••••"
/>
<p className="text-xs text-muted-foreground">{m.settings_token_help()}</p>
</div>
<Button type="submit">
{saved ? <Check className="size-4" /> : null}
{saved ? m.settings_saved() : m.settings_save()}
</Button>
</form>
</CardContent>
</Card>
<Card className="max-w-lg">
<CardHeader>
<CardTitle>{m.settings_language()}</CardTitle>
@@ -68,7 +29,7 @@ function SettingsPage() {
key={l}
variant={l === current ? 'default' : 'outline'}
size="sm"
className={cn('uppercase')}
className="uppercase"
onClick={() => changeLocale(l)}
>
{l}
@@ -76,6 +37,18 @@ function SettingsPage() {
))}
</CardContent>
</Card>
<Card className="max-w-lg">
<CardHeader>
<CardTitle>{m.nav_settings()}</CardTitle>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={onLogout}>
<LogOut className="size-4" />
{m.action_logout()}
</Button>
</CardContent>
</Card>
</div>
)
}