feat: add AUTH_DISABLED no-auth fallback mode

- config.py: AUTH_DISABLED: bool = False setting
- middleware/auth.py: bypass all checks when AUTH_DISABLED=True
- security.py: require_admin_auth returns synthetic context when disabled
- endpoints/auth.py: /me returns synthetic admin; /sign-out → / when disabled
- main.py: startup WARNING when disabled; pass auth_disabled to login.html
- templates/login.html: info banner with Go to dashboard link when disabled
- templates/setup.html: document AUTH_DISABLED option with security warning
- tests/test_auth.py: 4 new AUTH_DISABLED tests (445 total, all pass)

Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/18f41bf2-0b68-4b7d-afb5-d2894c212a8f

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-30 11:38:52 +00:00
parent 531dc968a8
commit 04931172dd
8 changed files with 163 additions and 10 deletions
+24 -3
View File
@@ -166,9 +166,14 @@ async def sign_out(request: Request) -> RedirectResponse:
"""
Sign the user out.
Clears the app session cookie and redirects to Logto's end-session
When ``AUTH_DISABLED=true`` there is nothing to sign out of; redirects to ``/``.
Otherwise clears the app session cookie and redirects to Logto's end-session
endpoint (if available) so that the Logto session is terminated too.
"""
if settings.AUTH_DISABLED:
return RedirectResponse(url="/", status_code=302)
post_logout_url = str(request.base_url).rstrip("/")
# Best-effort: obtain Logto's end-session URL from OIDC metadata.
@@ -200,9 +205,25 @@ async def get_current_user(
"""
Return the profile of the currently authenticated user.
Reads the ``dmarq_session`` cookie (issued at callback time) and looks up
the corresponding local ``User`` record.
When ``AUTH_DISABLED=true`` a synthetic anonymous-admin profile is returned
so that UI components (e.g. the navbar user menu) work without a real session.
Otherwise reads the ``dmarq_session`` cookie (issued at callback time) and
looks up the corresponding local ``User`` record.
"""
# Auth-disabled: return a synthetic profile so the UI renders correctly.
if settings.AUTH_DISABLED:
return {
"id": 0,
"email": "admin@localhost",
"full_name": "Local Admin",
"username": "admin",
"picture": None,
"is_superuser": True,
"logto_id": None,
"auth_disabled": True,
}
token = request.cookies.get(SESSION_COOKIE)
if not token:
raise HTTPException(
+9
View File
@@ -54,6 +54,15 @@ class Settings(BaseSettings):
# Use: openssl rand -hex 32
ADMIN_API_KEY: Optional[str] = None
# ── Authentication mode ───────────────────────────────────────────────────
# Set AUTH_DISABLED=true to run without any authentication.
# Every request is treated as an anonymous admin.
#
# ⚠️ Only use this for local development or deployments that are protected
# by an external auth proxy (e.g. Authelia, OAuth2 Proxy, Traefik Forward Auth).
# Never expose an AUTH_DISABLED instance directly to the internet.
AUTH_DISABLED: bool = False
# ── Logto OIDC ────────────────────────────────────────────────────────────
# Set these to enable Logto-based authentication.
# LOGTO_ENDPOINT: the base URL of your Logto instance,
+8 -3
View File
@@ -170,13 +170,18 @@ async def require_admin_auth(
Dependency to require authentication for admin/API endpoints.
Accepts (in priority order):
1. ``dmarq_session`` cookie set after a successful Logto login.
2. ``X-API-Key`` header static admin key for programmatic access.
3. ``Authorization: Bearer <token>`` header app-issued JWT.
1. ``AUTH_DISABLED=true`` env var passes through with a synthetic context.
2. ``dmarq_session`` cookie set after a successful Logto login.
3. ``X-API-Key`` header static admin key for programmatic access.
4. ``Authorization: Bearer <token>`` header app-issued JWT.
Returns an authentication context dict describing how the request was
authenticated. Raises ``HTTP 401`` when no valid credential is present.
"""
# 0. Auth globally disabled
if settings.AUTH_DISABLED:
return {"auth_type": "disabled"}
# 1. Session cookie (Logto-backed app session)
from app.core.logto import SESSION_COOKIE, decode_session_token # local import
+13
View File
@@ -300,6 +300,18 @@ def create_app() -> FastAPI:
# Ensure all tables exist (no-op if already present)
Base.metadata.create_all(bind=engine)
# Warn loudly when authentication is completely disabled
if settings.AUTH_DISABLED:
logger.warning(
"%s\n"
"⚠️ AUTH_DISABLED=true — authentication is turned OFF.\n"
"All requests have unrestricted admin access.\n"
"Do NOT expose this instance directly to the internet.\n"
"%s",
"=" * 80,
"=" * 80,
)
# Load or generate the admin API key
if settings.ADMIN_API_KEY:
api_key = settings.ADMIN_API_KEY
@@ -373,6 +385,7 @@ async def login(request: Request, next: str = "/"):
{
"app_name": settings.PROJECT_NAME,
"logto_configured": settings.logto_configured,
"auth_disabled": settings.AUTH_DISABLED,
"next": next,
},
)
+8 -3
View File
@@ -56,6 +56,13 @@ class AuthRedirectMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[override]
path = request.url.path
# ── 0. Auth disabled globally ─────────────────────────────────────────
from app.core.config import get_settings # local import avoids circular dep
cfg = get_settings()
if cfg.AUTH_DISABLED:
return await call_next(request)
# ── 1. Public paths & prefixes ────────────────────────────────────────
if path in _PUBLIC_PATHS:
return await call_next(request)
@@ -68,9 +75,7 @@ class AuthRedirectMiddleware(BaseHTTPMiddleware):
return await call_next(request)
# ── 3. Logto not configured ───────────────────────────────────────────
from app.core.config import get_settings # local import avoids circular dep
if not get_settings().logto_configured:
if not cfg.logto_configured:
return RedirectResponse(url="/setup", status_code=302)
# ── 4. Redirect to login ──────────────────────────────────────────────
+16 -1
View File
@@ -26,7 +26,22 @@
<div class="card-body gap-6">
<h1 class="card-title text-xl justify-center">Sign in to your account</h1>
{% if not logto_configured %}
{% if auth_disabled %}
<!-- Auth disabled mode -->
<div role="alert" class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
</svg>
<div>
<p class="font-semibold">Authentication is disabled</p>
<p class="text-sm">
<code class="font-mono bg-base-200 px-1 rounded">AUTH_DISABLED=true</code>
is set. All requests have full access — no sign-in required.
<a href="/" class="link link-info font-medium">Go to dashboard →</a>
</p>
</div>
</div>
{% elif not logto_configured %}
<!-- Logto not yet configured -->
<div role="alert" class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+29
View File
@@ -158,6 +158,35 @@ LOGTO_APP_SECRET=&lt;your-app-secret&gt;
</div>
</div>
<!-- Alternative: disable auth entirely -->
<div class="card bg-base-100 shadow border border-warning/40">
<div class="card-body">
<h2 class="card-title text-lg text-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
Alternative: disable authentication entirely
</h2>
<p class="text-sm text-base-content/70">
If you're running {{ app_name }} locally or behind a trusted reverse proxy that
already handles authentication, you can skip Logto and grant everyone full access
by setting:
</p>
<div class="mockup-code text-xs mt-2">
<pre><code>AUTH_DISABLED=true</code></pre>
</div>
<div role="alert" class="alert alert-warning mt-3 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
<p>
<strong>Never</strong> set <code class="font-mono">AUTH_DISABLED=true</code> on a
publicly reachable instance. Anyone with network access will have full admin access.
</p>
</div>
</div>
</div>
{% if logto_configured %}
<div class="text-center mt-4">
<a href="/login" class="btn btn-primary btn-lg">Go to Sign-in</a>
+56
View File
@@ -237,3 +237,59 @@ class TestSignOutEndpoint:
set_cookie = res.headers.get("set-cookie", "")
assert SESSION_COOKIE in set_cookie
assert "Max-Age=0" in set_cookie or "max-age=0" in set_cookie
# ── AUTH_DISABLED mode ────────────────────────────────────────────────────────
class TestAuthDisabled:
"""Verify the AUTH_DISABLED=true no-auth fallback mode."""
def test_me_returns_synthetic_admin_when_auth_disabled(self, client: TestClient):
"""With AUTH_DISABLED, /me must return the synthetic admin profile."""
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
mock_settings.AUTH_DISABLED = True
res = client.get("/api/v1/auth/me")
assert res.status_code == 200
data = res.json()
assert data["is_superuser"] is True
assert data["auth_disabled"] is True
assert data["email"] == "admin@localhost"
def test_sign_out_redirects_to_root_when_auth_disabled(self, client: TestClient):
"""With AUTH_DISABLED, sign-out should redirect to / (no Logto session to clear)."""
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
mock_settings.AUTH_DISABLED = True
res = client.get("/api/v1/auth/sign-out", follow_redirects=False)
assert res.status_code == 302
assert res.headers["location"] == "/"
def test_require_admin_auth_passes_when_disabled(self):
"""require_admin_auth must return a synthetic context when AUTH_DISABLED=True."""
import asyncio
from unittest.mock import MagicMock
from app.core.security import require_admin_auth
with patch("app.core.security.settings") as mock_settings:
mock_settings.AUTH_DISABLED = True
mock_req = MagicMock()
mock_req.cookies = {}
result = asyncio.get_event_loop().run_until_complete(
require_admin_auth(request=mock_req, api_key=None, bearer=None)
)
assert result["auth_type"] == "disabled"
def test_middleware_passes_all_requests_when_auth_disabled(self, client: TestClient):
"""The auth middleware must let every request through when AUTH_DISABLED=True."""
# The middleware does `from app.core.config import get_settings` inside dispatch,
# so we patch the canonical location used at call time.
with patch("app.core.config.get_settings") as mock_get_settings:
mock_cfg = MagicMock()
mock_cfg.AUTH_DISABLED = True
mock_get_settings.return_value = mock_cfg
# Even without a session cookie, the middleware lets the request through.
# The endpoint itself then handles auth (API key or 401), but it must
# never be a 302 redirect from the middleware.
res = client.get("/settings", follow_redirects=False)
assert res.status_code != 302