From 4c82487e0eabd45ed0f4f606b2748023bda316d3 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Thu, 20 Aug 2026 10:27:30 -0600 Subject: [PATCH] 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. --- src/env.d.ts | 10 +++++ src/middleware.ts | 65 +++++++++++++++++++++++++++++ src/pages/acls.astro | 3 ++ src/pages/api/acls.ts | 3 ++ src/pages/api/dns/magic.ts | 3 ++ src/pages/api/dns/tailnet.ts | 3 ++ src/pages/api/settings/auth-keys.ts | 3 ++ src/pages/api/users.ts | 3 ++ src/pages/dns.astro | 3 ++ src/pages/index.astro | 3 ++ src/pages/machines.astro | 3 ++ src/pages/users.astro | 3 ++ 12 files changed, 105 insertions(+) create mode 100644 src/middleware.ts diff --git a/src/env.d.ts b/src/env.d.ts index e16c13c..2b7470a 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1 +1,11 @@ /// + +import type { SessionUser } from './lib/auth/session-manager.js'; + +declare global { + namespace App { + interface Locals { + user?: SessionUser; + } + } +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..6306719 --- /dev/null +++ b/src/middleware.ts @@ -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([ + '/login', + '/login/', +]); +const PUBLIC_ASSET_PREFIXES = ['/assets/', '/_astro/']; +const PUBLIC_ASSET_FILES = new Set([ + '/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); +}); diff --git a/src/pages/acls.astro b/src/pages/acls.astro index 9ae10f1..a2340ff 100644 --- a/src/pages/acls.astro +++ b/src/pages/acls.astro @@ -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 = { diff --git a/src/pages/api/acls.ts b/src/pages/api/acls.ts index 45407dc..daef450 100644 --- a/src/pages/api/acls.ts +++ b/src/pages/api/acls.ts @@ -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 diff --git a/src/pages/api/dns/magic.ts b/src/pages/api/dns/magic.ts index eb0e8b6..5325369 100644 --- a/src/pages/api/dns/magic.ts +++ b/src/pages/api/dns/magic.ts @@ -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(); diff --git a/src/pages/api/dns/tailnet.ts b/src/pages/api/dns/tailnet.ts index 38247a4..05058ec 100644 --- a/src/pages/api/dns/tailnet.ts +++ b/src/pages/api/dns/tailnet.ts @@ -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(); diff --git a/src/pages/api/settings/auth-keys.ts b/src/pages/api/settings/auth-keys.ts index f752a49..a0c53dd 100644 --- a/src/pages/api/settings/auth-keys.ts +++ b/src/pages/api/settings/auth-keys.ts @@ -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 = [ { diff --git a/src/pages/api/users.ts b/src/pages/api/users.ts index 93bd2ca..a208799 100644 --- a/src/pages/api/users.ts +++ b/src/pages/api/users.ts @@ -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 = [ { diff --git a/src/pages/dns.astro b/src/pages/dns.astro index 6122f18..5fa82d2 100644 --- a/src/pages/dns.astro +++ b/src/pages/dns.astro @@ -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'], diff --git a/src/pages/index.astro b/src/pages/index.astro index 400172c..b2c6bfc 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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', diff --git a/src/pages/machines.astro b/src/pages/machines.astro index 106d97b..ab7b186 100644 --- a/src/pages/machines.astro +++ b/src/pages/machines.astro @@ -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, diff --git a/src/pages/users.astro b/src/pages/users.astro index c32bad3..de743f1 100644 --- a/src/pages/users.astro +++ b/src/pages/users.astro @@ -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,