feat(auth): harden sessions + HA Ingress support
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled

- backend: settings SESSION_COOKIE_SECURE + TRUSTED_NETWORK_AUTO_AUTH,
  require_session uses secrets.compare_digest and respects trusted-network
  opt-in, main.py adds require_family_session middleware gating all /api/
  routes except auth/admin/email-vote-token paths
- docker-compose: pass SESSION_COOKIE_SECURE + TRUSTED_NETWORK_AUTO_AUTH
  through to backend + scheduler (fixes env-file changes not reaching runtime)
- frontend: Ingress path-prefix support (APP_BASE_PATH, BrowserRouter basename,
  vite base './'), Login redirect honors APP_BASE_PATH
- nginx: no-cache headers on root + /assets/
- docs: Home Assistant Ingress install/troubleshooting + plan file
- tests: test_auth expects 401 on no-session GET

Defaults: SESSION_COOKIE_SECURE=false, TRUSTED_NETWORK_AUTO_AUTH=true
(HA is the auth boundary; MealPlanner must not be port-forwarded directly).
This commit is contained in:
2026-06-30 16:11:33 -07:00
parent 7f5757094e
commit 7838c49721
14 changed files with 148 additions and 38 deletions
+2
View File
@@ -33,3 +33,5 @@ SECRET_KEY=change-me-to-a-random-secret-key
# Auth
ADMIN_TOKEN=change-me-to-a-random-admin-token
SESSION_PASSWORD=change-me-to-the-family-shared-password
SESSION_COOKIE_SECURE=false
TRUSTED_NETWORK_AUTO_AUTH=true
+1 -1
View File
@@ -53,7 +53,7 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)):
value=cookie_value,
max_age=SESSION_MAX_AGE,
httponly=True,
secure=True,
secure=settings.SESSION_COOKIE_SECURE,
samesite="lax",
path="/",
)
+2
View File
@@ -29,6 +29,8 @@ class Settings(BaseSettings):
# Auth (R1-B+D)
ADMIN_TOKEN: str = ""
SESSION_PASSWORD: str = ""
SESSION_COOKIE_SECURE: bool = True
TRUSTED_NETWORK_AUTO_AUTH: bool = False
ADMIN_EMAIL: str = ""
APP_BASE_URL: str = "http://localhost"
+28 -1
View File
@@ -1,9 +1,11 @@
from fastapi import FastAPI, Depends
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.database import get_db
from app.config import settings
from app.security import require_session
import logging
logging.basicConfig(level=settings.LOG_LEVEL)
@@ -19,6 +21,31 @@ app = FastAPI(
app.mount("/static", StaticFiles(directory="static"), name="static")
def _requires_session(path: str, method: str) -> bool:
if method == "OPTIONS" or not path.startswith("/api/"):
return False
if path.startswith("/api/auth/") or path.startswith("/api/admin/"):
return False
# Email approval links carry their own signed, single-use token.
if path.startswith("/api/meals/vote/"):
return False
return True
@app.middleware("http")
async def require_family_session(request: Request, call_next):
if _requires_session(request.url.path, request.method):
try:
require_session(request)
except HTTPException as exc:
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=getattr(exc, "headers", None),
)
return await call_next(request)
@app.get("/health")
def health_check(db: Session = Depends(get_db)):
return {"status": "ok"}
+22 -28
View File
@@ -1,17 +1,6 @@
"""
Auth dependencies for the MealPlanner backend.
"""Auth dependencies for the MealPlanner backend."""
Two flavors:
- ``require_admin`` — bearer token for ``/api/admin/*`` routes; token compared
to ``settings.ADMIN_TOKEN`` (must be set in env).
- ``require_session`` — auto-returns the first family_profile_id (no login
required). This app runs on a private home network so auth is disabled
for family-facing routes. Kept as a dependency so admin/token endpoints
can be re-enabled later by restoring cookie logic.
The per-voter approval-token flow on meal items is intentionally NOT covered
here — it has its own short-lived single-use tokens elsewhere.
"""
import secrets
from fastapi import HTTPException, Request, status
from itsdangerous import TimestampSigner
@@ -35,7 +24,7 @@ def require_admin(request: Request) -> None:
detail="Admin auth not configured",
)
auth = request.headers.get(bearer_header, "")
if not auth.startswith("Bearer ") or auth[7:] != expected:
if not auth.startswith("Bearer ") or not secrets.compare_digest(auth[7:], expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid admin token",
@@ -52,13 +41,7 @@ def issue_session(family_profile_id: str) -> str:
def require_session(request: Request) -> str:
"""Auto-authenticate: return the first family_profile_id from the DB.
No cookie or password needed — this app runs on a private home network.
If no FamilyProfile exists yet, return \"bootstrap\" so the app can
initialise itself on first run.
"""
# 1. Try to read the signed cookie (backward-compat with existing sessions)
"""Require a signed session cookie, with explicit LAN auto-auth opt-in."""
raw = request.cookies.get(SESSION_COOKIE)
if raw:
try:
@@ -66,13 +49,24 @@ def require_session(request: Request) -> str:
_signer().unsign(raw.encode(), max_age=SESSION_MAX_AGE).decode()
)
except Exception:
pass # fall through to auto-auth
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session",
)
# 2. Auto-auth: grab the first family profile from the DB
db = next(get_db())
profile = db.query(FamilyProfile).first()
if profile:
return str(profile.id)
if not settings.TRUSTED_NETWORK_AUTO_AUTH:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session required",
)
db_gen = get_db()
db = next(db_gen)
try:
profile = db.query(FamilyProfile).first()
if profile:
return str(profile.id)
finally:
db_gen.close()
# 3. Bootstrap hatch — no profile yet, return a sentinel value
return "bootstrap"
+5 -4
View File
@@ -19,6 +19,7 @@ import pytest
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
os.environ.setdefault("SESSION_PASSWORD", "test-family-password")
os.environ.setdefault("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
os.environ.setdefault("SESSION_COOKIE_SECURE", "false")
@pytest.fixture(autouse=True)
@@ -27,6 +28,7 @@ def _reload_settings(monkeypatch):
monkeypatch.setenv("ADMIN_TOKEN", "test-admin-token")
monkeypatch.setenv("SESSION_PASSWORD", "test-family-password")
monkeypatch.setenv("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
monkeypatch.setenv("SESSION_COOKIE_SECURE", "false")
# Re-instantiate the singleton so dependents pick up env.
from app import config as app_config
@@ -76,11 +78,10 @@ def test_session_required_for_mutation(client):
@pytest.mark.requires_postgres
def test_session_open_for_reads(client):
"""GET /api/profile is NOT auth-gated (reads stay open)."""
def test_session_required_for_reads(client):
"""GET /api/profile requires a session for non-LAN exposure."""
r = client.get("/api/profile")
# Either 200 (profile exists) or 404 (no profile yet) — never 401.
assert r.status_code in (200, 404), r.text
assert r.status_code == 401, r.text
# ---------------------------------------------------------------------------
+4
View File
@@ -25,6 +25,8 @@ services:
- SECRET_KEY=${SECRET_KEY}
- ADMIN_TOKEN=${ADMIN_TOKEN}
- SESSION_PASSWORD=${SESSION_PASSWORD}
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-false}
- TRUSTED_NETWORK_AUTO_AUTH=${TRUSTED_NETWORK_AUTO_AUTH:-true}
- EMAIL_BACKEND=${EMAIL_BACKEND:-console}
- ADMIN_EMAIL=${ADMIN_EMAIL:-}
- APP_BASE_URL=${APP_BASE_URL:-http://localhost}
@@ -63,6 +65,8 @@ services:
- SECRET_KEY=${SECRET_KEY}
- ADMIN_TOKEN=${ADMIN_TOKEN}
- SESSION_PASSWORD=${SESSION_PASSWORD}
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-false}
- TRUSTED_NETWORK_AUTO_AUTH=${TRUSTED_NETWORK_AUTO_AUTH:-true}
- EMAIL_BACKEND=${EMAIL_BACKEND:-console}
- ADMIN_EMAIL=${ADMIN_EMAIL:-}
- APP_BASE_URL=${APP_BASE_URL:-http://localhost}
+37
View File
@@ -0,0 +1,37 @@
# Home Assistant Ingress
MealPlanner can be exposed through Home Assistant by installing the `mealplanner-ingress` add-on. The add-on is an authenticated Ingress proxy to the existing MealPlanner Docker deployment; it does not run the database, backend, or frontend itself.
## Install
- In Home Assistant, go to Settings -> Add-ons -> Add-on Store -> Repositories.
- Add this repository URL: `https://git.research.bike/admin/Meal-Planner.git`.
- Install `MealPlanner Ingress` from the add-on store.
- Set `upstream_url` to the LAN URL for the existing MealPlanner nginx service, for example `http://192.168.1.54:8082`.
- Start the add-on and open the `MealPlanner` sidebar item.
## Required MealPlanner Env
Set these in MealPlanner's `.env` before exposing it through Home Assistant:
```env
ADMIN_TOKEN=<random-admin-token>
SESSION_PASSWORD=<family-shared-password>
SECRET_KEY=<random-secret>
SESSION_COOKIE_SECURE=false
TRUSTED_NETWORK_AUTO_AUTH=true
```
Home Assistant is the authentication boundary in this setup. `TRUSTED_NETWORK_AUTO_AUTH=true` removes the extra MealPlanner password prompt, so do not port-forward MealPlanner directly.
## Network Model
- Public Internet -> Home Assistant auth/MFA -> Ingress -> MealPlanner LAN URL.
- Do not port-forward MealPlanner directly.
- Keep the existing MealPlanner compose stack bound to the LAN only.
## Troubleshooting
- `could not read Username`: the Git repository is not anonymously cloneable from Home Assistant. Make the repository public, or use a separate public add-on repository.
- `not a valid app repository`: Home Assistant cloned the repository, but did not find valid add-on metadata. Confirm `repository.yaml` exists at the repository root and `mealplanner-ingress/config.yaml` exists on the default branch.
- Short/clipped display: do not use an embedded WebURL card for this app. Use the `MealPlanner Ingress` add-on sidebar item so Home Assistant proxies the full UI.
+2 -1
View File
@@ -6,6 +6,7 @@ import { OnboardingTour, useOnboarding } from './components/OnboardingTour'
import { showApiError } from './lib/toast'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { requestFocusSearch } from './hooks/useFocusSearch'
import { APP_BASE_PATH } from './api'
import Dashboard from './pages/Dashboard'
import MealDetail from './pages/MealDetail'
import Pantry from './pages/Pantry'
@@ -81,7 +82,7 @@ function App() {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<BrowserRouter basename={APP_BASE_PATH || undefined}>
<GlobalShortcuts />
<div className="min-h-screen bg-surface-50">
<Navigation />
+10 -1
View File
@@ -1,6 +1,15 @@
import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_URL || '/api'
export function getIngressBasePath() {
const parts = window.location.pathname.split('/').filter(Boolean)
if (parts[0] === 'api' && parts[1] === 'hassio_ingress' && parts[2]) {
return `/${parts.slice(0, 3).join('/')}`
}
return ''
}
export const APP_BASE_PATH = getIngressBasePath()
const API_BASE = import.meta.env.VITE_API_URL || `${APP_BASE_PATH}/api`
const api = axios.create({
baseURL: API_BASE,
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Lock, ArrowRight } from 'lucide-react'
import { mealPlannerApi } from '../api'
import { APP_BASE_PATH, mealPlannerApi } from '../api'
import { Button } from '../components/ui/Button'
import { Card, CardBody } from '../components/ui/Card'
import { Input } from '../components/ui/Input'
@@ -16,7 +16,7 @@ export default function Login() {
setLoading(true)
try {
await mealPlannerApi.auth.login(password)
window.location.href = '/'
window.location.href = `${APP_BASE_PATH}/`
} catch {
setError('Incorrect password. Try again.')
} finally {
+1
View File
@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
server: {
port: 3000,
proxy: {
+18
View File
@@ -0,0 +1,18 @@
# Home Assistant Ingress Add-on
## Goal
Expose MealPlanner through Home Assistant Ingress while keeping MealPlanner off the public Internet.
## Tasks
- [x] Add HA add-on metadata and nginx proxy wrapper -> Verify: `mealplanner-ingress/config.yaml`, `Dockerfile`, `run.sh` exist.
- [x] Make frontend path-prefix aware for Ingress -> Verify: Vite base, router basename, and API base derive from `/api/hassio_ingress/...`.
- [x] Re-enable session enforcement for family API routes -> Verify: missing session returns 401 unless `TRUSTED_NETWORK_AUTO_AUTH=true`.
- [x] Document install and env settings -> Verify: `docs/home-assistant-ingress.md` exists.
- [x] Run backend/frontend focused checks.
- [x] Push add-on repository metadata -> Verify: anonymous shallow clone contains `repository.yaml` and `mealplanner-ingress/config.yaml`.
## Done When
- [x] Add-on config validates enough to build in Home Assistant.
- [x] Frontend builds.
- [x] Backend auth tests/import checks pass.
- [x] Home Assistant accepts the repository.
+14
View File
@@ -34,6 +34,20 @@ http {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
location /assets/ {
set $frontend_upstream http://frontend:80;
proxy_pass $frontend_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
}
}