From 04931172dd551351cc2a9699f2cd18db96e49eb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:38:52 +0000 Subject: [PATCH] feat: add AUTH_DISABLED no-auth fallback mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- backend/app/api/api_v1/endpoints/auth.py | 27 ++++++++++-- backend/app/core/config.py | 9 ++++ backend/app/core/security.py | 11 +++-- backend/app/main.py | 13 ++++++ backend/app/middleware/auth.py | 11 +++-- backend/app/templates/login.html | 17 ++++++- backend/app/templates/setup.html | 29 ++++++++++++ backend/app/tests/test_auth.py | 56 ++++++++++++++++++++++++ 8 files changed, 163 insertions(+), 10 deletions(-) diff --git a/backend/app/api/api_v1/endpoints/auth.py b/backend/app/api/api_v1/endpoints/auth.py index 2bc2a1b..91a1c0c 100644 --- a/backend/app/api/api_v1/endpoints/auth.py +++ b/backend/app/api/api_v1/endpoints/auth.py @@ -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( diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b6439ba..fda78de 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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, diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 0c55ad9..582edd0 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -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 `` 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 `` 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 diff --git a/backend/app/main.py b/backend/app/main.py index f52fc6e..5482690 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, }, ) diff --git a/backend/app/middleware/auth.py b/backend/app/middleware/auth.py index a87f608..d5fc338 100644 --- a/backend/app/middleware/auth.py +++ b/backend/app/middleware/auth.py @@ -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 ────────────────────────────────────────────── diff --git a/backend/app/templates/login.html b/backend/app/templates/login.html index 26e1afd..363bb2d 100644 --- a/backend/app/templates/login.html +++ b/backend/app/templates/login.html @@ -26,7 +26,22 @@

Sign in to your account

- {% if not logto_configured %} + {% if auth_disabled %} + + + {% elif not logto_configured %}
+ +
+
+

+ + + + Alternative: disable authentication entirely +

+

+ 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: +

+
+
AUTH_DISABLED=true
+
+ +
+
+ {% if logto_configured %}
Go to Sign-in diff --git a/backend/app/tests/test_auth.py b/backend/app/tests/test_auth.py index 7dbcade..6e123fc 100644 --- a/backend/app/tests/test_auth.py +++ b/backend/app/tests/test_auth.py @@ -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