auth: middleware gates all pages + APIs behind session check

Ship an Astro middleware (src/middleware.ts) that runs on every request
and requires a valid Heady session for anything other than the login
flow itself. Previously any anonymous visitor could load /machines,
/users, /acls, /dns, /settings and the mock /api/users endpoint over
plaintext -- the SSR templates rendered as public shells and only the
front-end auth guard blocked interaction. That's a defense-in-depth
gap now that data endpoints are being wired up.

- src/middleware.ts: onRequest handler validates the heady_session
  cookie via HeadySessionManager.validateSession(). Public allowlist:
  /api/auth/*, /login, static assets. Unauthenticated /api/* returns
  401 JSON; unauthenticated pages redirect to /api/auth/login with a
  return_to query param so the OIDC round-trip lands back on the
  original URL. Authenticated requests get context.locals.user set so
  downstream pages can read Astro.locals.user without a second lookup.
- src/env.d.ts: type App.Locals.user as SessionUser so pages / APIs get
  IDE feedback on typos.
- src/pages/{index,machines,acls,dns,users}.astro: 'export const
  prerender = false' -- these pages render per-user data, they must
  hit middleware on every request rather than being served as a
  static file baked at build time. Without this, middleware runs
  once during prerender (with no cookie) and writes a redirect as
  the static HTML for the route.
- src/pages/api/{acls,users,dns/magic,dns/tailnet,settings/auth-keys}.ts:
  same reason. API endpoints must be SSR so the middleware can gate
  them at request time.
This commit is contained in:
Ryan Malloy 2026-08-20 10:27:30 -06:00
parent 09b1bae157
commit 4c82487e0e
12 changed files with 105 additions and 0 deletions

10
src/env.d.ts vendored
View File

@ -1 +1,11 @@
/// <reference path="../.astro/types.d.ts" />
import type { SessionUser } from './lib/auth/session-manager.js';
declare global {
namespace App {
interface Locals {
user?: SessionUser;
}
}
}

65
src/middleware.ts Normal file
View File

@ -0,0 +1,65 @@
import { defineMiddleware } from 'astro:middleware';
import { getSessionManager } from './lib/auth/session-manager.js';
// Public routes — must be reachable WITHOUT a valid session so the auth
// flow itself can happen. Everything else requires authentication.
// The dashboard at `/` is intentionally NOT public: it renders real
// machine + session data. Add explicit landing/marketing paths here if
// one is added later.
const PUBLIC_API_PREFIX = '/api/auth/';
const PUBLIC_PAGE_PATHS = new Set<string>([
'/login',
'/login/',
]);
const PUBLIC_ASSET_PREFIXES = ['/assets/', '/_astro/'];
const PUBLIC_ASSET_FILES = new Set<string>([
'/favicon.svg',
'/favicon.ico',
'/robots.txt',
]);
function isPublicPath(pathname: string): boolean {
if (pathname.startsWith(PUBLIC_API_PREFIX)) return true;
if (PUBLIC_PAGE_PATHS.has(pathname)) return true;
if (PUBLIC_ASSET_FILES.has(pathname)) return true;
for (const prefix of PUBLIC_ASSET_PREFIXES) {
if (pathname.startsWith(prefix)) return true;
}
return false;
}
export const onRequest = defineMiddleware(async (context, next) => {
const { pathname } = context.url;
if (isPublicPath(pathname)) {
return next();
}
const sessionMgr = getSessionManager();
const result = await sessionMgr.validateSession(context);
if (result.valid && result.user) {
context.locals.user = result.user;
return next();
}
// Unauthenticated: JSON for /api/*, redirect for pages.
if (pathname.startsWith('/api/')) {
return new Response(
JSON.stringify({
authenticated: false,
error: 'authentication_required',
reason: result.reason ?? 'missing_cookie',
login_url: '/api/auth/login',
}),
{
status: 401,
headers: { 'Content-Type': 'application/json' },
},
);
}
const loginUrl = new URL('/api/auth/login', context.url);
loginUrl.searchParams.set('return_to', pathname + context.url.search);
return context.redirect(loginUrl.pathname + loginUrl.search, 302);
});

View File

@ -10,6 +10,9 @@ import ACLEditor from '../components/acls/ACLEditor';
import AuthenticatedLayout from '../components/auth/AuthenticatedLayout.astro';
import Layout from '../layouts/Layout.astro';
// SSR: gated by role (network_admin), must run auth middleware every request.
export const prerender = false;
// In a real deployment this would come from Headscale's /v1/policy endpoint.
// For now, ship a sensible example that demonstrates every modeled key.
const samplePolicy = {

View File

@ -1,6 +1,9 @@
// Heady ACL API - Alpine.js/Astro ACL Management Endpoint 🤠
import type { APIRoute } from 'astro';
// Force SSR — auth middleware must run on every request.
export const prerender = false;
// Mock ACL data storage (in production, this would interface with Headscale API)
let mockAclPolicy = `{
// ACL Policy for Heady Network

View File

@ -1,6 +1,9 @@
// Heady DNS Magic API - Alpine.js/Astro Magic DNS Toggle 🤠
import type { APIRoute } from 'astro';
// Force SSR — auth middleware must run on every request.
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const { enabled } = await request.json();

View File

@ -1,6 +1,9 @@
// Heady DNS Tailnet API - Alpine.js/Astro Tailnet Name Management 🤠
import type { APIRoute } from 'astro';
// Force SSR — auth middleware must run on every request.
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const { baseDomain } = await request.json();

View File

@ -1,6 +1,9 @@
// Heady Settings Auth Keys API - Alpine.js/Astro Auth Key Management 🤠
import type { APIRoute } from 'astro';
// Force SSR — auth middleware must run on every request.
export const prerender = false;
// Mock auth keys storage (in production, this would interface with Headscale API)
const mockAuthKeys = [
{

View File

@ -1,6 +1,9 @@
// Heady Users API - Alpine.js/Astro User Management Endpoint 🤠
import type { APIRoute } from 'astro';
// Force SSR — auth middleware must run on every request.
export const prerender = false;
// Mock users storage (in production, this would interface with Headscale API and OIDC)
const mockUsers = [
{

View File

@ -4,6 +4,9 @@
import { DnsPage } from '@/components/dns/DnsPage';
import Layout from '@/layouts/Layout.astro';
// SSR: gated by role, must run auth middleware every request.
export const prerender = false;
// Sample DNS config until /api/dns is wired to Headscale.
const initial = {
prefixes: ['100.64.0.0/10', 'fd7a:115c:a1e0::/48'],

View File

@ -8,6 +8,9 @@ import { getCollection } from 'astro:content';
import { DashboardPage } from '@/components/dashboard/Dashboard';
import Layout from '@/layouts/Layout.astro';
// SSR: renders per-user dashboard, must run auth middleware on every request.
export const prerender = false;
const allMachines = await getCollection('machines');
const allSessions = await getCollection(
'sessions',

View File

@ -5,6 +5,9 @@ import { getCollection } from 'astro:content';
import Layout from '@/layouts/Layout.astro';
import { MachinesPage } from '@/components/machines/MachinesPage';
// SSR: renders per-user machine list, must run auth middleware on every request.
export const prerender = false;
const allMachines = await getCollection('machines');
const machines = allMachines.map((m) => ({
id: m.data.id,

View File

@ -5,6 +5,9 @@ import { getCollection } from 'astro:content';
import Layout from '@/layouts/Layout.astro';
import { UsersPage } from '@/components/users/UsersPage';
// SSR: renders per-user list with role visibility, must run auth middleware.
export const prerender = false;
const allUsers = await getCollection('users');
const users = allUsers.map((u) => ({
id: u.data.id,