Authentication Model
docsfy uses a single middleware gate for all requests and supports two authentication mechanisms:
- Bearer token auth for API/automation clients
- Session-cookie auth for browser/UI flows
Authentication Gate and Evaluation Order
Every request passes through AuthMiddleware. Only three paths bypass auth.
class AuthMiddleware(BaseHTTPMiddleware):
"""Authenticate every request via Bearer token or session cookie."""
# Paths that do not require authentication
_PUBLIC_PATHS = frozenset({"/login", "/login/", "/health"})
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if request.url.path in self._PUBLIC_PATHS:
return await call_next(request)
settings = get_settings()
user = None
is_admin = False
username = ""
# 1. Check Authorization header (API clients)
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if token == settings.admin_key:
is_admin = True
username = "admin"
else:
user = await get_user_by_key(token)
# 2. Check session cookie (browser) -- opaque session token
if not user and not is_admin:
session_token = request.cookies.get("docsfy_session")
if session_token:
session = await get_session(session_token)
if session:
is_admin = bool(session["is_admin"])
username = str(session["username"])
# Fix 8: For DB users (not ADMIN_KEY admin), verify user still exists
if username != "admin":
user = await get_user_by_username(username)
if not user:
# User was deleted since session was created
if request.url.path.startswith("/api/"):
return JSONResponse(
status_code=401, content={"detail": "Unauthorized"}
)
return RedirectResponse(url="/login", status_code=302)
if not user and not is_admin:
# Not authenticated
if request.url.path.startswith("/api/"):
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
return RedirectResponse(url="/login", status_code=302)
Note: Bearer auth is checked first. If Bearer fails (or is absent), middleware falls back to
docsfy_session.
Bearer Token Flow
Bearer tokens are accepted from the Authorization header (Bearer <token>):
- If token equals
ADMIN_KEY, request is authenticated as built-in admin user (admin). - Otherwise, token is treated as a user API key and looked up in the
userstable. - User API keys are not stored raw; they are HMAC-hashed using
ADMIN_KEYas secret.
def hash_api_key(key: str, hmac_secret: str = "") -> str:
"""Hash an API key with HMAC-SHA256 for storage.
Uses ADMIN_KEY as the HMAC secret so that even if the source is read,
keys cannot be cracked without the environment secret.
"""
# NOTE: ADMIN_KEY is used as the HMAC secret. Rotating ADMIN_KEY will
# invalidate all existing api_key_hash values, requiring all users to
# regenerate their API keys.
secret = hmac_secret or os.getenv("ADMIN_KEY", "")
if not secret:
msg = "ADMIN_KEY environment variable is required for key hashing"
raise RuntimeError(msg)
return hmac.new(secret.encode(), key.encode(), hashlib.sha256).hexdigest()
async def get_user_by_key(api_key: str) -> dict[str, str | int | None] | None:
"""Look up a user by their raw API key."""
key_hash = hash_api_key(api_key)
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM users WHERE api_key_hash = ?", (key_hash,)
)
row = await cursor.fetchone()
return dict(row) if row else None
Tip: For scripts and CI jobs, prefer Bearer auth over login/cookies to keep requests stateless.
Warning: Rotating
ADMIN_KEYinvalidates existing user API key hashes by design.
Session-Cookie Flow
Browser login uses form fields username + api_key and creates an opaque session cookie.
@app.post("/login", response_model=None)
async def login(request: Request) -> RedirectResponse | HTMLResponse:
"""Authenticate with username + API key and set a session cookie."""
form = await request.form()
username = str(form.get("username", ""))
api_key = str(form.get("api_key", ""))
settings = get_settings()
is_admin = False
authenticated = False
# Check admin -- username must be "admin" and key must match
if username == "admin" and api_key == settings.admin_key:
is_admin = True
authenticated = True
else:
# Check user key -- verify username matches the key's owner
user = await get_user_by_key(api_key)
if user and user["username"] == username:
authenticated = True
is_admin = user.get("role") == "admin"
if authenticated:
session_token = await create_session(username, is_admin=is_admin)
response = RedirectResponse(url="/", status_code=302)
response.set_cookie(
"docsfy_session",
session_token,
httponly=True,
samesite="strict",
secure=settings.secure_cookies,
max_age=SESSION_TTL_SECONDS,
)
return response
The login UI labels this field as password, but backend field name is still api_key:
<!-- Field name="api_key" matches the POST handler in main.py (form.get("api_key")).
Label says "Password" for UX, but the backend field name is api_key. -->
<label for="api_key">Password</label>
<input type="password" id="api_key" name="api_key" placeholder="Enter your password" required>
Session tokens are opaque and stored hashed, with an 8-hour TTL:
SESSION_TTL_SECONDS = 28800 # 8 hours
SESSION_TTL_HOURS = SESSION_TTL_SECONDS // 3600
def _hash_session_token(token: str) -> str:
"""Hash a session token for storage."""
return hashlib.sha256(token.encode()).hexdigest()
async def create_session(
username: str, is_admin: bool = False, ttl_hours: int = SESSION_TTL_HOURS
) -> str:
"""Create an opaque session token."""
token = secrets.token_urlsafe(32)
token_hash = _hash_session_token(token)
expires_at = datetime.now(timezone.utc) + timedelta(hours=ttl_hours)
expires_str = expires_at.strftime("%Y-%m-%d %H:%M:%S")
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"INSERT INTO sessions (token, username, is_admin, expires_at) VALUES (?, ?, ?, ?)",
(token_hash, username, 1 if is_admin else 0, expires_str),
)
await db.commit()
return token
Logout clears both DB session state and browser cookie:
@app.get("/logout")
async def logout(request: Request) -> RedirectResponse:
"""Clear the session cookie, delete session from DB, and redirect to login."""
session_token = request.cookies.get("docsfy_session")
if session_token:
await delete_session(session_token)
settings = get_settings()
response = RedirectResponse(url="/login", status_code=302)
response.delete_cookie(
"docsfy_session",
httponly=True,
samesite="strict",
secure=settings.secure_cookies,
)
return response
Warning:
secure_cookiesdefaults toTrue; browser session cookies will not be sent over plain HTTP.
Public Paths
Only these paths are unauthenticated:
/login/login//health
/health is also used by runtime health checks:
services:
docsfy:
env_file: .env
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
Protected Endpoint Behavior
Unauthenticated requests
Behavior is path-class dependent:
- Any protected non-API route (for example
/,/status/...,/docs/...,/admin) ->302redirect to/login - Any protected API route under
/api/*->401JSON{ "detail": "Unauthorized" }
Verified in tests:
async def test_login_redirect_when_unauthenticated(
unauthed_client: AsyncClient,
) -> None:
"""Browser requests to protected pages should redirect to /login."""
response = await unauthed_client.get("/", follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == "/login"
async def test_api_returns_401_when_unauthenticated(
unauthed_client: AsyncClient,
) -> None:
"""API requests without auth should return 401."""
response = await unauthed_client.get("/api/status")
assert response.status_code == 401
assert response.json()["detail"] == "Unauthorized"
Role-based authorization
docsfy enforces role checks after authentication:
def _require_write_access(request: Request) -> None:
"""Raise 403 if user is a viewer (read-only)."""
if request.state.role not in ("admin", "user"):
raise HTTPException(
status_code=403,
detail="Write access required.",
)
def _require_admin(request: Request) -> None:
"""Raise 403 if the user is not an admin."""
if not request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
viewerusers are read-only for write endpoints (/api/generate, delete/abort endpoints).adminrole is required for/adminand/api/admin/*.viewercan still change their own password via/api/me/rotate-key(by explicit design).
# Don't call _require_write_access -- viewers should be able to change their password
if request.state.is_admin and not request.state.user:
raise HTTPException(
status_code=400,
detail="ADMIN_KEY users cannot rotate keys. Change the ADMIN_KEY env var instead.",
)
Ownership and resource visibility
Project-scoped access checks intentionally return 404 (not 403) when a user lacks access, to avoid leaking resource existence:
async def _check_ownership(
request: Request, project_name: str, project: dict[str, Any]
) -> None:
"""Raise 404 if the requesting user does not own the project (unless admin)."""
if request.state.is_admin:
return
project_owner = str(project.get("owner", ""))
if project_owner == request.state.username:
return
# Check if user has been granted access (scoped by project_owner)
access = await get_project_access(project_name, project_owner=project_owner)
if request.state.username in access:
return
raise HTTPException(status_code=404, detail="Not found")
# GET /api/projects/{name} - returns 404 to avoid leaking existence
response = await ac.get("/api/projects/secret-proj")
assert response.status_code == 404
Additional protected behavior
- Non-admin use of
repo_pathin generation is denied (403). - Admin variant resolution can return
409if multiple owners exist for same project/provider/model without disambiguation. - If a session belongs to a deleted DB user, middleware invalidates access and returns
401(API) or302(UI redirect).
Warning: In-app login rate limiting is marked TODO; enforce rate limiting at reverse proxy/load balancer level.
Configuration
Environment-level auth settings:
# REQUIRED - Admin key for user management (minimum 16 characters)
ADMIN_KEY=your-secure-admin-key-here-min-16-chars
# Set to false for local HTTP development
# SECURE_COOKIES=false
Application defaults:
class Settings(BaseSettings):
admin_key: str = "" # Required — validated at startup
ai_provider: str = "claude"
ai_model: str = "claude-opus-4-6[1m]"
ai_cli_timeout: int = Field(default=60, gt=0)
log_level: str = "INFO"
data_dir: str = "/data"
secure_cookies: bool = True # Set to False for local HTTP dev
Startup hard-fails if ADMIN_KEY is missing or too short:
if not settings.admin_key:
logger.error("ADMIN_KEY environment variable is required")
raise SystemExit(1)
if len(settings.admin_key) < 16:
logger.error("ADMIN_KEY must be at least 16 characters long")
raise SystemExit(1)
Tip: For local non-TLS development, set
SECURE_COOKIES=falsein.envso browser sessions work overhttp://.
Test and Automation Coverage
Auth behavior is regression-tested in tests/test_auth.py, and the repo test command is defined in tox.toml:
[env.unittests]
deps = ["uv"]
commands = [["uv", "run", "--extra", "dev", "pytest", "-n", "auto", "tests"]]
Note: No dedicated GitHub/GitLab/Jenkins workflow files are present in this repository; automated auth verification currently depends on the tox/pytest path above.