From effa9ad0260d1f3b0243cc00395cbd0913fcae7e Mon Sep 17 00:00:00 2001 From: sudipnext Date: Tue, 21 Apr 2026 17:41:37 +0545 Subject: [PATCH] feat: implement single-user authentication with environment variable support and update FastAPI middleware for session management --- README.md | 47 +++ docker-compose.yml | 20 +- nginx.conf | 51 ++- servers/fastapi/api/lifespan.py | 68 +++- servers/fastapi/api/main.py | 5 +- servers/fastapi/api/middlewares.py | 52 +++ servers/fastapi/api/v1/auth/router.py | 85 +++++ servers/fastapi/utils/simple_auth.py | 292 +++++++++++++++++ .../Components/DashboardSidebar.tsx | 10 - .../dashboard/components/Header.tsx | 9 +- .../(dashboard)/settings/SettingPage.tsx | 73 +++-- .../(dashboard)/settings/SettingSideBar.tsx | 35 +- .../app/(presentation-generator)/layout.tsx | 19 +- servers/nextjs/app/api/user-config/route.ts | 32 +- servers/nextjs/app/page.tsx | 10 +- servers/nextjs/components/Auth/AuthGate.tsx | 304 ++++++++++++++++++ .../nextjs/components/Auth/LogoutButton.tsx | 56 ++++ .../components/Auth/ProtectedRouteGuard.tsx | 77 +++++ .../OnBoarding/OnBoardingHeader.tsx | 69 ++-- servers/nextjs/middleware.ts | 147 +++++++++ servers/nextjs/utils/authErrors.ts | 22 ++ start.js | 9 + 22 files changed, 1375 insertions(+), 117 deletions(-) create mode 100644 servers/fastapi/api/v1/auth/router.py create mode 100644 servers/fastapi/utils/simple_auth.py create mode 100644 servers/nextjs/components/Auth/AuthGate.tsx create mode 100644 servers/nextjs/components/Auth/LogoutButton.tsx create mode 100644 servers/nextjs/components/Auth/ProtectedRouteGuard.tsx create mode 100644 servers/nextjs/middleware.ts create mode 100644 servers/nextjs/utils/authErrors.ts diff --git a/README.md b/README.md index 78534c81..348521ca 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,53 @@ You can disable anonymous telemetry using the following environment variable: - DISABLE_ANONYMOUS_TRACKING=[true/false]: Set this to **true** to disable anonymous telemetry. +### Web login (Docker / self-hosted) + +The web image can require a single admin username and password before the app and API are usable. Credentials are stored under your `app_data` volume (hashed in `userConfig.json`). Optional environment variables (also wired in `docker-compose.yml` for `production`, `production-gpu`, `development`, and `development-gpu`): + +- **AUTH_USERNAME** / **AUTH_PASSWORD** — Preseed the admin login on first boot (password at least 6 characters). If credentials already exist, these are ignored unless **AUTH_OVERRIDE_FROM_ENV** is set. +- **AUTH_OVERRIDE_FROM_ENV**=[true/false] — If **true**, overwrite stored credentials from **AUTH_USERNAME** / **AUTH_PASSWORD** on every container start and rotate the session signing secret (all existing sessions end). Remove after a one-off rotation. +- **RESET_AUTH**=[true/false] — If **true**, clear stored login data on startup (recovery). Use for one boot only, then unset so credentials are not wiped again. + +**Examples** + +Default (first visit opens the setup UI on `/`): + +```bash +docker compose up -d production +# open http://localhost:5000 +``` + +Preseed credentials via `.env` then start: + +```bash +# .env +AUTH_USERNAME=admin +AUTH_PASSWORD=your-secure-password + +docker compose up -d production +``` + +Rotate password from the environment (then remove `AUTH_OVERRIDE_FROM_ENV` from `.env` and redeploy): + +```bash +AUTH_USERNAME=admin +AUTH_PASSWORD=new-password +AUTH_OVERRIDE_FROM_ENV=true +docker compose up -d production +``` + +Locked out — reset and set up again: + +```bash +RESET_AUTH=true docker compose up -d production +# after one successful start, remove RESET_AUTH from .env and run compose again +``` + +**Manual reset:** stop the container, edit `./app_data/userConfig.json`, delete `AUTH_USERNAME`, `AUTH_PASSWORD_HASH`, and `AUTH_SECRET_KEY`, save, and start again. + +Sign out from the app: **Settings → Other → Sign out**. + > Note: You can freely choose both the LLM (text generation) and the image provider. Supported image providers: **dall-e-3**, **gpt-image-1.5** (OpenAI), **gemini_flash**, **nanobanana_pro** (Google), **pexels**, **pixabay**, and **comfyui** (self-hosted).
diff --git a/docker-compose.yml b/docker-compose.yml index 70b64627..348bd22a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,12 @@ services: - LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1} - OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL} - OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY} + # Single-user login (see AUTHENTICATION.md). + # Leave all three unset to let the UI collect credentials on first visit. + - AUTH_USERNAME=${AUTH_USERNAME:-} + - AUTH_PASSWORD=${AUTH_PASSWORD:-} + - AUTH_OVERRIDE_FROM_ENV=${AUTH_OVERRIDE_FROM_ENV:-} + - RESET_AUTH=${RESET_AUTH:-} production-gpu: # image: ghcr.io/presenton/presenton:latest @@ -97,9 +103,13 @@ services: - MEM0_EMBEDDING_DIMS=${MEM0_EMBEDDING_DIMS:-384} - LITEPARSE_DPI=${LITEPARSE_DPI:-120} - LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1} - + - OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL} - OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY} + - AUTH_USERNAME=${AUTH_USERNAME:-} + - AUTH_PASSWORD=${AUTH_PASSWORD:-} + - AUTH_OVERRIDE_FROM_ENV=${AUTH_OVERRIDE_FROM_ENV:-} + - RESET_AUTH=${RESET_AUTH:-} development: build: @@ -150,6 +160,10 @@ services: - LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1} - OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL} - OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY} + - AUTH_USERNAME=${AUTH_USERNAME:-} + - AUTH_PASSWORD=${AUTH_PASSWORD:-} + - AUTH_OVERRIDE_FROM_ENV=${AUTH_OVERRIDE_FROM_ENV:-} + - RESET_AUTH=${RESET_AUTH:-} development-gpu: build: @@ -206,6 +220,10 @@ services: - LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1} - OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL} - OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY} + - AUTH_USERNAME=${AUTH_USERNAME:-} + - AUTH_PASSWORD=${AUTH_PASSWORD:-} + - AUTH_OVERRIDE_FROM_ENV=${AUTH_OVERRIDE_FROM_ENV:-} + - RESET_AUTH=${RESET_AUTH:-} volumes: presenton_root_node_modules: diff --git a/nginx.conf b/nginx.conf index a61a9c9b..402814ef 100644 --- a/nginx.conf +++ b/nginx.conf @@ -21,7 +21,13 @@ http { proxy_http_version 1.1; # Required for WebSocket proxy_set_header Upgrade $http_upgrade; # WebSocket header proxy_set_header Connection "upgrade"; # WebSocket header - proxy_set_header Host $host; + # Preserve browser host:port (e.g. localhost:5000). $host strips the port + # and breaks Next.js redirects / absolute URLs. + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; proxy_read_timeout 30m; proxy_connect_timeout 30m; } @@ -30,6 +36,10 @@ http { proxy_pass http://localhost:8000; proxy_read_timeout 30m; proxy_connect_timeout 30m; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } # MCP @@ -58,15 +68,37 @@ http { proxy_pass http://localhost:8000/docs; proxy_read_timeout 30m; proxy_connect_timeout 30m; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } location /openapi.json { proxy_pass http://localhost:8000/openapi.json; proxy_read_timeout 30m; proxy_connect_timeout 30m; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } - # Static + # Internal auth subrequest used to gate /app_data/* static access behind the + # FastAPI session cookie. Nginx serves these files directly via `alias` for + # performance, so auth_request is what keeps them from being public. + location = /_auth_check { + internal; + proxy_pass http://localhost:8000/api/v1/auth/verify; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + } + + # Bundled UI static assets (logos, fonts packaged with the app) are safe to + # serve unauthenticated; they contain no user data. location /static { alias /app/servers/fastapi/static/; expires 1y; @@ -74,33 +106,38 @@ http { } location /app_data/images/ { + auth_request /_auth_check; alias /app_data/images/; expires 1y; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "private, max-age=31536000"; } location /app_data/exports/ { + auth_request /_auth_check; alias /app_data/exports/; expires 1y; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "private, max-age=31536000"; } location /app_data/uploads/ { + auth_request /_auth_check; alias /app_data/uploads/; expires 1y; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "private, max-age=31536000"; } location /app_data/fonts/ { + auth_request /_auth_check; alias /app_data/fonts/; expires 1y; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "private, max-age=31536000"; } location /app_data/pptx-to-html/ { + auth_request /_auth_check; alias /app_data/pptx-to-html/; expires 1y; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "private, max-age=31536000"; } } } \ No newline at end of file diff --git a/servers/fastapi/api/lifespan.py b/servers/fastapi/api/lifespan.py index 1ce3e26f..2f6b624d 100644 --- a/servers/fastapi/api/lifespan.py +++ b/servers/fastapi/api/lifespan.py @@ -1,4 +1,5 @@ from contextlib import asynccontextmanager +import logging import os from fastapi import FastAPI @@ -9,6 +10,67 @@ from utils.get_env import get_app_data_directory_env from utils.model_availability import ( check_llm_and_image_provider_api_or_model_availability, ) +from utils.simple_auth import ( + clear_stored_credentials, + force_set_credentials, + is_auth_configured, + setup_initial_credentials, +) + +logger = logging.getLogger(__name__) + + +def _is_truthy(value: str | None) -> bool: + if value is None: + return False + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _bootstrap_auth_from_env() -> None: + """ + Bootstrap the single-user login from environment variables. + + Behaviour: + - RESET_AUTH=true -> wipe stored credentials (recovery path). + - AUTH_USERNAME + AUTH_PASSWORD set: + * if no credentials configured -> create them (first-run preseed). + * if AUTH_OVERRIDE_FROM_ENV=true -> overwrite existing credentials. + - Otherwise do nothing; the login UI will run in setup-mode on first + visit and in sign-in-mode afterwards. + + Any errors here are logged and swallowed so a bad env value can never + brick the app — the operator can always fall back to the UI/reset flow. + """ + try: + if _is_truthy(os.getenv("RESET_AUTH")): + clear_stored_credentials() + logger.warning( + "RESET_AUTH is set; cleared stored login credentials. " + "The next visit will prompt for setup." + ) + + env_username = os.getenv("AUTH_USERNAME") + env_password = os.getenv("AUTH_PASSWORD") + if not env_username or not env_password: + return + + override = _is_truthy(os.getenv("AUTH_OVERRIDE_FROM_ENV")) + if is_auth_configured() and not override: + return + + if is_auth_configured() and override: + force_set_credentials(env_username, env_password) + logger.warning( + "AUTH_OVERRIDE_FROM_ENV is set; replaced stored credentials " + "with values from AUTH_USERNAME/AUTH_PASSWORD." + ) + else: + setup_initial_credentials(env_username, env_password) + logger.info( + "Initialized login credentials from AUTH_USERNAME/AUTH_PASSWORD." + ) + except Exception as exc: # pragma: no cover - defensive, never fatal. + logger.exception("Failed to bootstrap auth from environment: %s", exc) @asynccontextmanager @@ -16,12 +78,14 @@ async def app_lifespan(_: FastAPI): """ Lifespan context manager for FastAPI application. Initializes the application data directory, runs Alembic migrations when - MIGRATE_DATABASE_ON_STARTUP=true, creates any missing tables, and checks - LLM model availability. + MIGRATE_DATABASE_ON_STARTUP=true, creates any missing tables, bootstraps + the single-user login from env vars (if provided), and checks LLM model + availability. """ os.makedirs(get_app_data_directory_env(), exist_ok=True) await migrate_database_on_startup() await create_db_and_tables() + _bootstrap_auth_from_env() await check_llm_and_image_provider_api_or_model_availability() yield # Shutdown: release all database connections to prevent stale/leaked pools. diff --git a/servers/fastapi/api/main.py b/servers/fastapi/api/main.py index d0f9ae80..159a0f4e 100644 --- a/servers/fastapi/api/main.py +++ b/servers/fastapi/api/main.py @@ -5,7 +5,8 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from api.lifespan import app_lifespan -from api.middlewares import UserConfigEnvUpdateMiddleware +from api.middlewares import SessionAuthMiddleware, UserConfigEnvUpdateMiddleware +from api.v1.auth.router import API_V1_AUTH_ROUTER from api.v1.mock.router import API_V1_MOCK_ROUTER from api.v1.ppt.router import API_V1_PPT_ROUTER from api.v1.webhook.router import API_V1_WEBHOOK_ROUTER @@ -18,6 +19,7 @@ app = FastAPI(lifespan=app_lifespan) app.include_router(API_V1_PPT_ROUTER) app.include_router(API_V1_WEBHOOK_ROUTER) app.include_router(API_V1_MOCK_ROUTER) +app.include_router(API_V1_AUTH_ROUTER) # Mount app_data and static assets (direct FastAPI access; nginx also serves /static in Docker). app_data_dir = get_app_data_directory_env() @@ -40,3 +42,4 @@ app.add_middleware( ) app.add_middleware(UserConfigEnvUpdateMiddleware) +app.add_middleware(SessionAuthMiddleware) diff --git a/servers/fastapi/api/middlewares.py b/servers/fastapi/api/middlewares.py index f5c0c3f0..fb45e7f1 100644 --- a/servers/fastapi/api/middlewares.py +++ b/servers/fastapi/api/middlewares.py @@ -1,7 +1,9 @@ from fastapi import Request +from starlette.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from utils.get_env import get_can_change_keys_env +from utils.simple_auth import get_auth_status, get_session_token_from_request from utils.user_config import update_env_with_user_config @@ -10,3 +12,53 @@ class UserConfigEnvUpdateMiddleware(BaseHTTPMiddleware): if get_can_change_keys_env() != "false": update_env_with_user_config() return await call_next(request) + + +class SessionAuthMiddleware(BaseHTTPMiddleware): + _EXEMPT_PREFIXES = ( + "/api/v1/auth/", + ) + _PROTECTED_NON_API_PATHS = { + "/docs", + "/openapi.json", + "/redoc", + } + + def _is_exempt(self, path: str) -> bool: + return any(path.startswith(prefix) for prefix in self._EXEMPT_PREFIXES) + + def _requires_auth(self, path: str) -> bool: + if path.startswith("/api/"): + return True + if path.startswith("/app_data/"): + return True + return path in self._PROTECTED_NON_API_PATHS + + async def dispatch(self, request: Request, call_next): + path = request.url.path + + if ( + request.method == "OPTIONS" + or not self._requires_auth(path) + or self._is_exempt(path) + ): + return await call_next(request) + + auth_status = get_auth_status(get_session_token_from_request(request)) + if not auth_status["configured"]: + return JSONResponse( + status_code=428, + content={ + "detail": "Login setup is required", + "setup_required": True, + }, + ) + + if not auth_status["authenticated"]: + return JSONResponse( + status_code=401, + content={"detail": "Unauthorized"}, + ) + + request.state.auth_username = auth_status.get("username") + return await call_next(request) diff --git a/servers/fastapi/api/v1/auth/router.py b/servers/fastapi/api/v1/auth/router.py new file mode 100644 index 00000000..17b75dba --- /dev/null +++ b/servers/fastapi/api/v1/auth/router.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field +from starlette.responses import JSONResponse + +from utils.simple_auth import ( + clear_session_cookie, + create_session_token, + get_auth_status, + get_session_token_from_request, + is_auth_configured, + set_session_cookie, + setup_initial_credentials, + verify_credentials, +) + +API_V1_AUTH_ROUTER = APIRouter(prefix="/api/v1/auth", tags=["Auth"]) + + +class AuthCredentialsRequest(BaseModel): + username: str = Field(min_length=3, max_length=128) + password: str = Field(min_length=6, max_length=256) + + +@API_V1_AUTH_ROUTER.get("/status") +async def get_status(request: Request): + token = get_session_token_from_request(request) + return get_auth_status(token) + + +@API_V1_AUTH_ROUTER.get("/verify") +async def verify_session(request: Request): + auth_status = get_auth_status(get_session_token_from_request(request)) + if not auth_status["configured"] or not auth_status["authenticated"]: + raise HTTPException(status_code=401, detail="Unauthorized") + + return { + "authenticated": True, + "username": auth_status.get("username"), + } + + +@API_V1_AUTH_ROUTER.post("/setup") +async def setup_credentials(body: AuthCredentialsRequest, request: Request): + if is_auth_configured(): + raise HTTPException(status_code=409, detail="Credentials already configured") + + try: + setup_initial_credentials(body.username, body.password) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + token = create_session_token(body.username.strip()) + response = JSONResponse( + { + "configured": True, + "authenticated": True, + "username": body.username.strip(), + } + ) + set_session_cookie(response, token, request) + return response + + +@API_V1_AUTH_ROUTER.post("/login") +async def login(body: AuthCredentialsRequest, request: Request): + if not is_auth_configured(): + raise HTTPException(status_code=428, detail="Login setup is required") + + if not verify_credentials(body.username, body.password): + raise HTTPException(status_code=401, detail="Unauthorized") + + username = body.username.strip() + token = create_session_token(username) + response = JSONResponse( + {"configured": True, "authenticated": True, "username": username} + ) + set_session_cookie(response, token, request) + return response + + +@API_V1_AUTH_ROUTER.post("/logout") +async def logout(request: Request): + response = JSONResponse({"success": True}) + clear_session_cookie(response, request) + return response diff --git a/servers/fastapi/utils/simple_auth.py b/servers/fastapi/utils/simple_auth.py new file mode 100644 index 00000000..5eb23f50 --- /dev/null +++ b/servers/fastapi/utils/simple_auth.py @@ -0,0 +1,292 @@ +import base64 +import hashlib +import hmac +import json +import os +import secrets +import time +from typing import Optional + +from fastapi import Request +from starlette.responses import Response + +from utils.get_env import get_user_config_path_env + +SESSION_COOKIE_NAME = "presenton_session" +PBKDF2_ITERATIONS = 200_000 +SESSION_TTL_SECONDS = 60 * 60 * 24 * 30 + + +def _base64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") + + +def _base64url_decode(value: str) -> bytes: + padded = value + "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(padded.encode("utf-8")) + + +def _load_user_config() -> dict: + user_config_path = get_user_config_path_env() + if not user_config_path or not os.path.exists(user_config_path): + return {} + + try: + with open(user_config_path, "r", encoding="utf-8") as config_file: + data = json.load(config_file) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _save_user_config(config: dict) -> None: + user_config_path = get_user_config_path_env() + if not user_config_path: + raise ValueError("USER_CONFIG_PATH is not set") + + os.makedirs(os.path.dirname(user_config_path), exist_ok=True) + with open(user_config_path, "w", encoding="utf-8") as config_file: + json.dump(config, config_file) + + +def _hash_password(password: str, salt: bytes) -> bytes: + return hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS + ) + + +def _encode_password_hash(password: str) -> str: + salt = secrets.token_bytes(16) + digest = _hash_password(password, salt) + salt_encoded = _base64url_encode(salt) + digest_encoded = _base64url_encode(digest) + return ( + f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt_encoded}${digest_encoded}" + ) + + +def _verify_password_hash(password: str, encoded_hash: str) -> bool: + try: + algorithm, iterations_str, salt_encoded, digest_encoded = encoded_hash.split("$") + if algorithm != "pbkdf2_sha256": + return False + + iterations = int(iterations_str) + salt = _base64url_decode(salt_encoded) + expected_digest = _base64url_decode(digest_encoded) + actual_digest = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt, iterations + ) + return hmac.compare_digest(actual_digest, expected_digest) + except Exception: + return False + + +def _get_or_create_auth_secret(config: dict) -> str: + secret = config.get("AUTH_SECRET_KEY") + if secret: + return secret + + secret = _base64url_encode(secrets.token_bytes(32)) + config["AUTH_SECRET_KEY"] = secret + _save_user_config(config) + return secret + + +def is_auth_configured() -> bool: + config = _load_user_config() + return bool(config.get("AUTH_USERNAME") and config.get("AUTH_PASSWORD_HASH")) + + +def setup_initial_credentials(username: str, password: str) -> None: + cleaned_username = (username or "").strip() + if len(cleaned_username) < 3: + raise ValueError("Username must be at least 3 characters") + + if len(password or "") < 6: + raise ValueError("Password must be at least 6 characters") + + config = _load_user_config() + if config.get("AUTH_USERNAME") and config.get("AUTH_PASSWORD_HASH"): + raise ValueError("Credentials already configured") + + config["AUTH_USERNAME"] = cleaned_username + config["AUTH_PASSWORD_HASH"] = _encode_password_hash(password) + _get_or_create_auth_secret(config) + _save_user_config(config) + + +def force_set_credentials(username: str, password: str) -> None: + """Overwrite stored credentials; used by env-based preseed/override.""" + cleaned_username = (username or "").strip() + if len(cleaned_username) < 3: + raise ValueError("Username must be at least 3 characters") + + if len(password or "") < 6: + raise ValueError("Password must be at least 6 characters") + + config = _load_user_config() + config["AUTH_USERNAME"] = cleaned_username + config["AUTH_PASSWORD_HASH"] = _encode_password_hash(password) + # Rotate the signing secret so any previously-issued tokens stop validating. + config["AUTH_SECRET_KEY"] = _base64url_encode(secrets.token_bytes(32)) + _save_user_config(config) + + +def clear_stored_credentials() -> None: + """Remove stored credentials; next boot will request setup again.""" + config = _load_user_config() + removed = False + for key in ("AUTH_USERNAME", "AUTH_PASSWORD_HASH", "AUTH_SECRET_KEY"): + if key in config: + config.pop(key, None) + removed = True + if removed: + _save_user_config(config) + + +def verify_credentials(username: str, password: str) -> bool: + config = _load_user_config() + stored_username = config.get("AUTH_USERNAME") + stored_hash = config.get("AUTH_PASSWORD_HASH") + + if not stored_username or not stored_hash: + return False + + cleaned_username = (username or "").strip() + if not hmac.compare_digest(cleaned_username, stored_username): + return False + + return _verify_password_hash(password or "", stored_hash) + + +def _sign_payload(payload_encoded: str, secret: str) -> str: + signature = hmac.new( + secret.encode("utf-8"), payload_encoded.encode("utf-8"), hashlib.sha256 + ).digest() + return _base64url_encode(signature) + + +def create_session_token(username: str) -> str: + config = _load_user_config() + secret = _get_or_create_auth_secret(config) + + issued_at = int(time.time()) + payload = { + "v": 1, + "u": username, + "iat": issued_at, + "exp": issued_at + SESSION_TTL_SECONDS, + } + + payload_encoded = _base64url_encode( + json.dumps(payload, separators=(",", ":")).encode("utf-8") + ) + signature_encoded = _sign_payload(payload_encoded, secret) + return f"{payload_encoded}.{signature_encoded}" + + +def validate_session_token(token: Optional[str]) -> Optional[str]: + if not token: + return None + + config = _load_user_config() + stored_username = config.get("AUTH_USERNAME") + if not stored_username: + return None + + secret = config.get("AUTH_SECRET_KEY") + if not secret: + return None + + try: + payload_encoded, signature_encoded = token.split(".", 1) + except ValueError: + return None + + expected_signature = _sign_payload(payload_encoded, secret) + if not hmac.compare_digest(signature_encoded, expected_signature): + return None + + try: + payload_raw = _base64url_decode(payload_encoded) + payload = json.loads(payload_raw) + except Exception: + return None + + username = payload.get("u") + version = payload.get("v") + expires_at = payload.get("exp") + if not isinstance(username, str) or not isinstance(expires_at, int): + return None + + if version != 1: + return None + + if not hmac.compare_digest(username, stored_username): + return None + + if expires_at < int(time.time()): + return None + + return username + + +def get_session_token_from_request(request: Request) -> Optional[str]: + cookie_token = request.cookies.get(SESSION_COOKIE_NAME) + if cookie_token: + return cookie_token + + auth_header = request.headers.get("Authorization", "") + if auth_header.lower().startswith("bearer "): + return auth_header[7:].strip() or None + + return None + + +def get_auth_status(session_token: Optional[str] = None) -> dict: + config = _load_user_config() + configured = bool(config.get("AUTH_USERNAME") and config.get("AUTH_PASSWORD_HASH")) + + if not configured: + return { + "configured": False, + "authenticated": False, + "username": None, + } + + username = validate_session_token(session_token) + return { + "configured": True, + "authenticated": bool(username), + "username": username, + } + + +def _is_secure_request(request: Request) -> bool: + forwarded_proto = request.headers.get("x-forwarded-proto", "") + if forwarded_proto.lower() == "https": + return True + return request.url.scheme == "https" + + +def set_session_cookie(response: Response, token: str, request: Request) -> None: + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + max_age=SESSION_TTL_SECONDS, + httponly=True, + secure=_is_secure_request(request), + samesite="lax", + path="/", + ) + + +def clear_session_cookie(response: Response, request: Request) -> None: + response.delete_cookie( + key=SESSION_COOKIE_NAME, + httponly=True, + secure=_is_secure_request(request), + samesite="lax", + path="/", + ) diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/DashboardSidebar.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/DashboardSidebar.tsx index 25535f3c..7ba516b0 100644 --- a/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/DashboardSidebar.tsx +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/DashboardSidebar.tsx @@ -4,11 +4,6 @@ import React from "react"; import { LayoutDashboard, Star, Brain, Settings, Palette, HelpCircle } from "lucide-react"; import { usePathname } from "next/navigation"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; -import { useSelector } from "react-redux"; -import { RootState } from "@/store/store"; -import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants"; @@ -29,9 +24,6 @@ const DashboardSidebar = () => { const pathname = usePathname(); const activeTab = pathname.split("?")[0].split("/").pop(); - const router = useRouter(); - - const { llm_config } = useSelector((state: RootState) => state.userConfig) @@ -136,8 +128,6 @@ const DashboardSidebar = () => { ); })} - - diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/Header.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/Header.tsx index b971755e..88770fc8 100644 --- a/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/Header.tsx +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/Header.tsx @@ -51,8 +51,8 @@ const Header = () => { /> - {showHeaderBack ? ( -
+
+ {showHeaderBack ? ( { {backLabel} -
- ) : null} - + ) : null} +
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingPage.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingPage.tsx index b0689271..1c027d7a 100644 --- a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingPage.tsx @@ -24,6 +24,7 @@ import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants"; import { ImagesApi } from "@/app/(presentation-generator)/services/api/images"; import { getApiUrl } from "@/utils/api"; import { toast } from "sonner"; +import LogoutButton from "@/components/Auth/LogoutButton"; const STOCK_IMAGE_PROVIDERS = new Set(["pexels", "pixabay"]); @@ -41,7 +42,9 @@ const SettingsPage = () => { const router = useRouter(); const pathname = usePathname(); const [mode, setMode] = useState<'nanobanana' | 'presenton'>('presenton') - const [selectedProvider, setSelectedProvider] = useState<'text-provider' | 'image-provider' | 'privacy'>('text-provider') + const [selectedProvider, setSelectedProvider] = useState< + "text-provider" | "image-provider" | "privacy" | "session" + >("text-provider"); const userConfigState = useSelector((state: RootState) => state.userConfig); const [llmConfig, setLlmConfig] = useState( userConfigState.llm_config @@ -414,36 +417,52 @@ const SettingsPage = () => { />} {mode === 'presenton' && selectedProvider === 'image-provider' && } {selectedProvider === 'privacy' && } + {selectedProvider === "session" && ( +
+
+

Sign out

+

+ End your session on this deployment. You will need to sign in again to use the app and access the API. +

+
+ +
+ )} - {/* Fixed Bottom Button */} -
- -
+ {/* Fixed Bottom Button — hidden on Sign out; nothing to save there */} + {selectedProvider !== "session" ? ( +
+ +
+ ) : null} {/* Download Progress Modal */} {showDownloadModal && downloadingModel && ( diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingSideBar.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingSideBar.tsx index 6a33af0a..2aa8343d 100644 --- a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingSideBar.tsx +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingSideBar.tsx @@ -1,10 +1,12 @@ import React from 'react' -import { Shield } from 'lucide-react' +import { LogOut, Shield } from 'lucide-react' import { IMAGE_PROVIDERS, LLM_PROVIDERS } from '@/utils/providerConstants' import { useSelector } from 'react-redux' import { RootState } from '@/store/store' -const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }: { mode: 'nanobanana' | 'presenton', setMode: (mode: 'nanobanana' | 'presenton') => void, selectedProvider: 'text-provider' | 'image-provider' | 'privacy', setSelectedProvider: (provider: 'text-provider' | 'image-provider' | 'privacy') => void }) => { +type SettingsSection = 'text-provider' | 'image-provider' | 'privacy' | 'session' + +const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }: { mode: 'nanobanana' | 'presenton', setMode: (mode: 'nanobanana' | 'presenton') => void, selectedProvider: SettingsSection, setSelectedProvider: (provider: SettingsSection) => void }) => { const { llm_config } = useSelector((state: RootState) => state.userConfig) const textProviderIcon = LLM_PROVIDERS[llm_config.LLM as keyof typeof LLM_PROVIDERS]?.icon const imageProviderIcon = IMAGE_PROVIDERS[llm_config.IMAGE_PROVIDER as keyof typeof IMAGE_PROVIDERS]?.icon || '/providers/pexel.png' @@ -73,15 +75,26 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }

Other

- +
+ + +
) diff --git a/servers/nextjs/app/(presentation-generator)/layout.tsx b/servers/nextjs/app/(presentation-generator)/layout.tsx index ea8aaf2a..d05021cc 100644 --- a/servers/nextjs/app/(presentation-generator)/layout.tsx +++ b/servers/nextjs/app/(presentation-generator)/layout.tsx @@ -1,13 +1,16 @@ -import React from 'react' -import { ConfigurationInitializer } from '../ConfigurationInitializer' +import React from "react"; + +import ProtectedRouteGuard from "@/components/Auth/ProtectedRouteGuard"; +import { ConfigurationInitializer } from "../ConfigurationInitializer"; + const layout = ({ children }: { children: React.ReactNode }) => { return (
- - {children} - + + {children} +
- ) -} + ); +}; -export default layout +export default layout; diff --git a/servers/nextjs/app/api/user-config/route.ts b/servers/nextjs/app/api/user-config/route.ts index 77975a58..0db4b289 100644 --- a/servers/nextjs/app/api/user-config/route.ts +++ b/servers/nextjs/app/api/user-config/route.ts @@ -4,6 +4,25 @@ import { LLMConfig } from "@/types/llm_config"; const userConfigPath = process.env.USER_CONFIG_PATH!; const canChangeKeys = process.env.CAN_CHANGE_KEYS !== "false"; +const AUTH_FIELDS = new Set([ + "AUTH_USERNAME", + "AUTH_PASSWORD_HASH", + "AUTH_SECRET_KEY", +]); + +function stripAuthFields(config: Record) { + const sanitized = { ...config }; + for (const key of AUTH_FIELDS) { + delete sanitized[key]; + } + return sanitized; +} + +function stripAuthFieldsFromIncoming(config: Record) { + return Object.fromEntries( + Object.entries(config).filter(([key]) => !AUTH_FIELDS.has(key)) + ); +} export async function GET() { if (!canChangeKeys) { @@ -23,7 +42,8 @@ export async function GET() { return NextResponse.json({}); } const configData = fs.readFileSync(userConfigPath, "utf-8"); - return NextResponse.json(JSON.parse(configData)); + const parsedConfig = JSON.parse(configData) as Record; + return NextResponse.json(stripAuthFields(parsedConfig)); } export async function POST(request: Request) { @@ -33,9 +53,9 @@ export async function POST(request: Request) { }); } - const userConfig = await request.json(); - - console.log('userConfig', userConfig); + const userConfig = stripAuthFieldsFromIncoming( + (await request.json()) as Record + ) as LLMConfig; let existingConfig: LLMConfig = {}; if (fs.existsSync(userConfigPath)) { const configData = fs.readFileSync(userConfigPath, "utf-8"); @@ -78,5 +98,7 @@ export async function POST(request: Request) { : existingConfig.DISABLE_ANONYMOUS_TRACKING, }; fs.writeFileSync(userConfigPath, JSON.stringify(mergedConfig)); - return NextResponse.json(mergedConfig); + return NextResponse.json( + stripAuthFields(mergedConfig as Record) + ); } diff --git a/servers/nextjs/app/page.tsx b/servers/nextjs/app/page.tsx index fff5d4b2..bbd1c942 100644 --- a/servers/nextjs/app/page.tsx +++ b/servers/nextjs/app/page.tsx @@ -1,9 +1,7 @@ -import Home from "@/components/Home" +import AuthGate from "@/components/Auth/AuthGate"; const page = () => { - return ( - - ) -} + return ; +}; -export default page \ No newline at end of file +export default page; \ No newline at end of file diff --git a/servers/nextjs/components/Auth/AuthGate.tsx b/servers/nextjs/components/Auth/AuthGate.tsx new file mode 100644 index 00000000..bc9be868 --- /dev/null +++ b/servers/nextjs/components/Auth/AuthGate.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { ConfigurationInitializer } from "@/app/ConfigurationInitializer"; +import Home from "@/components/Home"; +import { getApiUrl } from "@/utils/api"; +import { formatFastApiDetail, UNAUTHORIZED_DETAIL } from "@/utils/authErrors"; + +type AuthStatus = { + configured: boolean; + authenticated: boolean; + username: string | null; +}; + +const initialStatus: AuthStatus = { + configured: false, + authenticated: false, + username: null, +}; + +export default function AuthGate() { + const [status, setStatus] = useState(initialStatus); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [blockedAccessMessage, setBlockedAccessMessage] = useState(null); + + const isSetupMode = useMemo(() => !status.configured, [status.configured]); + + useEffect(() => { + void refreshStatus(); + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + const params = new URLSearchParams(window.location.search); + if (params.get("reason") === "unauthorized") { + setBlockedAccessMessage(UNAUTHORIZED_DETAIL); + const clean = `${window.location.pathname}`; + window.history.replaceState({}, "", clean); + } + }, []); + + const refreshStatus = async () => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(getApiUrl("/api/v1/auth/status"), { + method: "GET", + cache: "no-store", + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Could not load login state"); + } + + const data = (await response.json()) as AuthStatus; + setStatus({ + configured: Boolean(data.configured), + authenticated: Boolean(data.authenticated), + username: data.username ?? null, + }); + } catch (fetchError) { + console.error(fetchError); + setError("Could not connect to the login service. Please refresh and try again."); + } finally { + setIsLoading(false); + } + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setBlockedAccessMessage(null); + + const cleanedUsername = username.trim(); + if (cleanedUsername.length < 3) { + setError("Username must be at least 3 characters."); + return; + } + + if (password.length < 6) { + setError("Password must be at least 6 characters."); + return; + } + + if (isSetupMode && password !== confirmPassword) { + setError("Password confirmation does not match."); + return; + } + + setIsSubmitting(true); + + try { + const response = await fetch( + getApiUrl(isSetupMode ? "/api/v1/auth/setup" : "/api/v1/auth/login"), + { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: cleanedUsername, + password, + }), + } + ); + + const payload = await response.json(); + if (!response.ok) { + const detail = formatFastApiDetail(payload?.detail); + if (response.status === 401) { + setError(detail === UNAUTHORIZED_DETAIL ? UNAUTHORIZED_DETAIL : detail); + } else { + setError(detail || "Login failed. Please try again."); + } + return; + } + + setStatus({ + configured: Boolean(payload.configured), + authenticated: Boolean(payload.authenticated), + username: payload.username ?? cleanedUsername, + }); + setPassword(""); + setConfirmPassword(""); + } catch (submitError) { + console.error(submitError); + setError("Login service is unavailable. Please try again in a moment."); + } finally { + setIsSubmitting(false); + } + }; + + if (isLoading) { + return ( +
+
+
+
+ Presenton +
+

Presenton

+

Preparing your workspace…

+
+ + + +
+
+
+
+ ); + } + + if (status.authenticated) { + return ( + + + + ); + } + + return ( +
+
+
+
+ +
+
+
+
+ +
+
+

+ Secure instance +

+

+ {isSetupMode ? "Create your admin login" : "Sign in to continue"} +

+
+
+
+ +

+ {isSetupMode + ? "One-time setup for this deployment. You will use the same username and password on future visits." + : "This deployment is protected. Enter your credentials to open the app."} +

+ + {blockedAccessMessage ? ( +
+ {blockedAccessMessage}. All other routes require a valid session. +
+ ) : null} + +
+
+ + setUsername(event.target.value)} + placeholder="your-admin-user" + className="w-full rounded-[11px] border border-[#EDEEEF] bg-white px-4 py-3 font-syne text-sm text-black outline-none transition placeholder:text-[#999999] focus:border-[#a49cfc] focus:ring-2 focus:ring-[#5146E5]/20" + disabled={isSubmitting} + /> +
+ +
+ + setPassword(event.target.value)} + placeholder="At least 6 characters" + className="w-full rounded-[11px] border border-[#EDEEEF] bg-white px-4 py-3 font-syne text-sm text-black outline-none transition placeholder:text-[#999999] focus:border-[#a49cfc] focus:ring-2 focus:ring-[#5146E5]/20" + disabled={isSubmitting} + /> +
+ + {isSetupMode ? ( +
+ + setConfirmPassword(event.target.value)} + placeholder="Re-enter your password" + className="w-full rounded-[11px] border border-[#EDEEEF] bg-white px-4 py-3 font-syne text-sm text-black outline-none transition placeholder:text-[#999999] focus:border-[#a49cfc] focus:ring-2 focus:ring-[#5146E5]/20" + disabled={isSubmitting} + /> +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {!isSetupMode && status.configured ? ( +

+ Setup is complete for this instance. Use the username and password you configured. +

+ ) : null} + + +
+
+
+ ); +} diff --git a/servers/nextjs/components/Auth/LogoutButton.tsx b/servers/nextjs/components/Auth/LogoutButton.tsx new file mode 100644 index 00000000..c984c256 --- /dev/null +++ b/servers/nextjs/components/Auth/LogoutButton.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { LogOut } from "lucide-react"; + +import { getApiUrl } from "@/utils/api"; + +type LogoutButtonProps = { + label?: string; + className?: string; + iconOnly?: boolean; +}; + +export default function LogoutButton({ + label = "Logout", + className = "", + iconOnly = false, +}: LogoutButtonProps) { + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleLogout = async () => { + if (isSubmitting) { + return; + } + + setIsSubmitting(true); + try { + await fetch(getApiUrl("/api/v1/auth/logout"), { + method: "POST", + credentials: "include", + }); + } catch { + // Always route back to auth gate even if backend logout fails. + } finally { + router.push("/"); + router.refresh(); + setIsSubmitting(false); + } + }; + + return ( + + ); +} diff --git a/servers/nextjs/components/Auth/ProtectedRouteGuard.tsx b/servers/nextjs/components/Auth/ProtectedRouteGuard.tsx new file mode 100644 index 00000000..103d874b --- /dev/null +++ b/servers/nextjs/components/Auth/ProtectedRouteGuard.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; + +import { getApiUrl } from "@/utils/api"; + +/** + * Defense in depth: if a protected page ever renders without a valid session + * (stale tab, manual history navigation, etc.), send the user back to the + * login screen. Edge middleware is the primary gate; this catches client-only + * edge cases. + */ +export default function ProtectedRouteGuard({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const pathname = usePathname(); + const [allowed, setAllowed] = useState(false); + + useEffect(() => { + let cancelled = false; + + const verify = async () => { + try { + const response = await fetch(getApiUrl("/api/v1/auth/status"), { + method: "GET", + credentials: "include", + cache: "no-store", + }); + + if (!response.ok || cancelled) { + if (!cancelled) { + router.replace("/?reason=unauthorized"); + } + return; + } + + const data = (await response.json()) as { + authenticated?: boolean; + }; + + if (cancelled) { + return; + } + + if (!data.authenticated) { + router.replace("/?reason=unauthorized"); + return; + } + + setAllowed(true); + } catch { + if (!cancelled) { + router.replace("/?reason=unauthorized"); + } + } + }; + + void verify(); + return () => { + cancelled = true; + }; + }, [pathname, router]); + + if (!allowed) { + return ( +
+ Verifying session… +
+ ); + } + + return <>{children}; +} diff --git a/servers/nextjs/components/OnBoarding/OnBoardingHeader.tsx b/servers/nextjs/components/OnBoarding/OnBoardingHeader.tsx index 92fd7af5..bca980b6 100644 --- a/servers/nextjs/components/OnBoarding/OnBoardingHeader.tsx +++ b/servers/nextjs/components/OnBoarding/OnBoardingHeader.tsx @@ -1,45 +1,46 @@ import React from 'react' - const OnBoardingHeader = ({ currentStep, setStep }: { currentStep: number, setStep: (step: number) => void }) => { return (
-
{ - if (currentStep > 1) { - setStep(1); - } - }} - > -
- 1 -
-

Select Mode

-
- - - -
{ - if (currentStep > 2) { - setStep(2); - } - }} - > -
- 2 -
-

Choose Providers

-
- - -
-
- 3 +
{ + if (currentStep > 1) { + setStep(1); + } + }} + > +
+ 1 +
+

Select Mode

+
+ + + +
{ + if (currentStep > 2) { + setStep(2); + } + }} + > +
+ 2 +
+

Choose Providers

+
+ + + +
+
+ 3 +
+

Finish Setup

-

Finish Setup

) diff --git a/servers/nextjs/middleware.ts b/servers/nextjs/middleware.ts new file mode 100644 index 00000000..fb382b44 --- /dev/null +++ b/servers/nextjs/middleware.ts @@ -0,0 +1,147 @@ +import { NextRequest, NextResponse } from "next/server"; + +const PUBLIC_PATHS = new Set([ + "/", + "/favicon.ico", + "/apple-icon.png", + "/icon1.svg", + "/icon2.png", + "/api/telemetry-status", +]); + +const PUBLIC_PREFIXES = ["/_next/", "/api/v1/auth/"]; + +/** + * Build the URL the browser used to reach the app. When nginx proxies to + * Next on :3000, `request.nextUrl.origin` is often `http://localhost:3000` + * (wrong for redirects). Prefer reverse-proxy headers instead. + */ +function getExternalOrigin(request: NextRequest): string { + const xfHost = + request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ?? ""; + if (xfHost) { + const xfProto = + request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase() ?? + ""; + const proto = + xfProto === "https" || xfProto === "http" ? xfProto : "http"; + return `${proto}://${xfHost}`; + } + return request.nextUrl.origin; +} + +function isPublicRequest(pathname: string): boolean { + if (PUBLIC_PATHS.has(pathname)) { + return true; + } + + if (PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) { + return true; + } + + // Allow requests for static files in /public. + if (/\.[a-zA-Z0-9]+$/.test(pathname)) { + return true; + } + + return false; +} + +function getFastApiBaseUrl(request: NextRequest): string { + // Server-side-only override. Used by the Docker runtime so the Next.js + // middleware can reach FastAPI directly inside the container (nginx's + // port is not reachable from inside the Next.js process). + const internal = process.env.FAST_API_INTERNAL_URL?.trim(); + if (internal) { + return internal.replace(/\/+$/, ""); + } + + const configured = process.env.NEXT_PUBLIC_FAST_API?.trim(); + if (configured) { + return configured.replace(/\/+$/, ""); + } + + if (process.env.NODE_ENV === "development") { + return "http://127.0.0.1:8000"; + } + + // Fallback: reuse the incoming origin (works when Next.js and FastAPI + // are served from the same origin, e.g. behind nginx on the same host). + return request.nextUrl.origin; +} + +type AuthStatus = { + configured: boolean; + authenticated: boolean; +}; + +async function getAuthStatus(request: NextRequest): Promise { + const cookieHeader = request.headers.get("cookie"); + const authStatusUrl = `${getFastApiBaseUrl(request)}/api/v1/auth/status`; + + try { + const response = await fetch(authStatusUrl, { + method: "GET", + headers: cookieHeader ? { Cookie: cookieHeader } : undefined, + cache: "no-store", + }); + + if (!response.ok) { + return { + configured: true, + authenticated: false, + }; + } + + const payload = (await response.json()) as Partial; + return { + configured: Boolean(payload.configured), + authenticated: Boolean(payload.authenticated), + }; + } catch { + return { + configured: true, + authenticated: false, + }; + } +} + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (request.method === "OPTIONS" || isPublicRequest(pathname)) { + return NextResponse.next(); + } + + const authStatus = await getAuthStatus(request); + if (authStatus.authenticated) { + return NextResponse.next(); + } + + if (pathname.startsWith("/api/")) { + const statusCode = authStatus.configured ? 401 : 428; + const detail = authStatus.configured + ? "Unauthorized" + : "Login setup is required"; + + return NextResponse.json( + { detail }, + { + status: statusCode, + headers: { + "Cache-Control": "no-store, must-revalidate", + }, + } + ); + } + + const redirectUrl = new URL("/", getExternalOrigin(request)); + if (pathname !== "/") { + redirectUrl.searchParams.set("reason", "unauthorized"); + } + return NextResponse.redirect(redirectUrl); +} + +export const config = { + matcher: ["/:path*"], +}; diff --git a/servers/nextjs/utils/authErrors.ts b/servers/nextjs/utils/authErrors.ts new file mode 100644 index 00000000..72c88aa0 --- /dev/null +++ b/servers/nextjs/utils/authErrors.ts @@ -0,0 +1,22 @@ +/** Matches FastAPI `HTTPException(detail=...)` and JSON error bodies. */ +export const UNAUTHORIZED_DETAIL = "Unauthorized"; + +export function formatFastApiDetail(detail: unknown): string { + if (typeof detail === "string") { + return detail; + } + if (Array.isArray(detail)) { + return detail + .map((item) => { + if (item && typeof item === "object" && "msg" in item) { + return String((item as { msg?: string }).msg ?? item); + } + return String(item); + }) + .join(" "); + } + if (detail && typeof detail === "object" && "message" in detail) { + return String((detail as { message?: string }).message); + } + return UNAUTHORIZED_DETAIL; +} diff --git a/start.js b/start.js index 77e6a778..545de7c5 100644 --- a/start.js +++ b/start.js @@ -103,6 +103,12 @@ const ensurePresentationExportRuntime = async () => { }; process.env.USER_CONFIG_PATH = userConfigPath; +// Let Next.js middleware reach FastAPI over the loopback interface inside the +// container without having to bounce through nginx (the host-facing port is +// not reachable from inside the Next.js process). +if (!process.env.FAST_API_INTERNAL_URL) { + process.env.FAST_API_INTERNAL_URL = `http://127.0.0.1:${fastapiPort}`; +} //? UserConfig is only setup if API Keys can be changed const setupUserConfigFromEnv = () => { @@ -155,6 +161,9 @@ const setupUserConfigFromEnv = () => { CODEX_REFRESH_TOKEN: existingConfig.CODEX_REFRESH_TOKEN, CODEX_TOKEN_EXPIRES: existingConfig.CODEX_TOKEN_EXPIRES, CODEX_ACCOUNT_ID: existingConfig.CODEX_ACCOUNT_ID, + AUTH_USERNAME: existingConfig.AUTH_USERNAME, + AUTH_PASSWORD_HASH: existingConfig.AUTH_PASSWORD_HASH, + AUTH_SECRET_KEY: existingConfig.AUTH_SECRET_KEY, }; writeFileSync(userConfigPath, JSON.stringify(userConfig));