feat: add PRIVATE_MODE site-wide auth gate (0.48.0)
When `PRIVATE_MODE=true`, every API path except a small allowlist
(`/health`, `/auth/{config,login,logout,register}`) requires a valid
session cookie or bearer token — anonymous reads are rejected with
401. Self-registration is force-disabled in private mode regardless
of `ALLOW_SELF_REGISTER`, so a locked-down instance flips with a
single switch (admins still mint accounts via `POST /admin/users`).
The backend gate is a tower middleware that reuses the existing
`CurrentUser` extractor, so the cookie + bearer paths cannot drift
from per-handler auth. `/auth/config` now exposes the flag plus the
effective `self_register_enabled` value so the frontend can render
the navbar correctly on the first paint.
On the frontend, a new universal root `+layout.ts` fetches the
config and redirects anonymous visitors to `/login?next=<path>`
before page-specific loads fire. The redirect is UX only — the
backend middleware is the source of truth, so crafted requests
still 401.
Defaults stay public (`PRIVATE_MODE=false`); existing deployments
need no env change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
41
frontend/src/routes/+layout.ts
Normal file
41
frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Universal root load. Surfaces /auth/config to every page so the
|
||||
// navbar + layout can render without an extra round-trip, and — when
|
||||
// the backend reports PRIVATE_MODE=true — bounces anonymous visitors
|
||||
// to /login before any page-specific load fires. The backend
|
||||
// middleware is still the source of truth for the gate; this just
|
||||
// matches the UX so users don't see a page full of failed fetches.
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getAuthConfig, me, type AuthConfig } from '$lib/api/auth';
|
||||
|
||||
// Paths reachable anonymously even when private_mode is on. /login is
|
||||
// the entry point of the auth flow; everything else (including
|
||||
// /register, which is force-blocked in private mode) bounces.
|
||||
const PRIVATE_MODE_BYPASS = new Set(['/login']);
|
||||
|
||||
const PUBLIC_DEFAULTS: AuthConfig = {
|
||||
self_register_enabled: true,
|
||||
private_mode: false
|
||||
};
|
||||
|
||||
export const load: LayoutLoad = async ({ url }) => {
|
||||
let authConfig: AuthConfig = PUBLIC_DEFAULTS;
|
||||
try {
|
||||
authConfig = await getAuthConfig();
|
||||
} catch {
|
||||
// Fail-soft: keep the optimistic public-mode defaults so a
|
||||
// backend hiccup doesn't lock anyone out of the login page.
|
||||
// No private data can leak through here — the backend
|
||||
// middleware is still authoritative for the gate.
|
||||
}
|
||||
|
||||
if (authConfig.private_mode && !PRIVATE_MODE_BYPASS.has(url.pathname)) {
|
||||
const user = await me().catch(() => null);
|
||||
if (!user) {
|
||||
const next = url.pathname + url.search;
|
||||
redirect(302, `/login?next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { authConfig };
|
||||
};
|
||||
Reference in New Issue
Block a user