Authentication Endpoints

docsfy supports two authentication mechanisms:

  1. Bearer API key (recommended for API clients)
  2. Session cookie (docsfy_session, used by browser login flow)

All routes are protected by middleware except /login, /login/, and /health.

```108:115:src/docsfy/main.py

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)

## Endpoint Reference

| Endpoint | Method | Auth Required | Purpose | Success Behavior |
|---|---|---|---|---|
| `/login` | `GET` | No | Render login page | `200` HTML |
| `/login` | `POST` | No | Authenticate username + API key, create session | `302` redirect to `/`, sets `docsfy_session` cookie |
| `/logout` | `GET` | Yes | Invalidate session and clear cookie | `302` redirect to `/login`, deletes `docsfy_session` cookie |
| `/health` | `GET` | No | Liveness endpoint | `200` JSON |

> **Tip:** For programmatic clients, use `/api/*` routes. Unauthenticated API calls return JSON `401`, while non-API paths redirect to `/login`.

```151:155:src/docsfy/main.py
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)

POST /login Details

POST /login reads form fields (not JSON): username and api_key.

```157:167:src/docsfy/templates/login.html

Authentication logic:

- Admin login requires **both**:
  - `username == "admin"`
  - `api_key == ADMIN_KEY`
- User login requires:
  - `api_key` matches a stored user key
  - that key belongs to the submitted `username`

```283:305:src/docsfy/main.py
# 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,
    )
Failed login returns `401` with login HTML and `"Invalid username or password"`. ## `GET /logout` Details `GET /logout`: 1. Reads `docsfy_session` cookie 2. Deletes the server-side session record 3. Deletes the cookie 4. Redirects to `/login` ```317:331:src/docsfy/main.py @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, )
## Cookie and Session Behavior

- Cookie name: `docsfy_session`
- Cookie attributes on login:
  - `HttpOnly`
  - `SameSite=Strict`
  - `Secure` controlled by `secure_cookies`
  - `Max-Age=28800` (8 hours)
- Session token is **opaque** and generated with `secrets.token_urlsafe(32)`
- Database stores a **SHA-256 hash** of session token, not raw token
- Session lookup enforces expiration (`expires_at > datetime('now')`)

```21:23:src/docsfy/storage.py
SESSION_TTL_SECONDS = 28800  # 8 hours
SESSION_TTL_HOURS = SESSION_TTL_SECONDS // 3600
```681:710:src/docsfy/storage.py 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), ) async def get_session(token: str) -> dict[str, str | int | None] | None: """Look up a session. Returns None if expired or not found.""" token_hash = _hash_session_token(token) async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT * FROM sessions WHERE token = ? AND expires_at > datetime('now')",
> **Note:** Middleware checks `Authorization: Bearer ...` **before** checking `docsfy_session`. If both are present, Bearer token path is evaluated first.

```122:136:src/docsfy/main.py
# 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")
## API Client Auth Requirements For API clients, send: - `Authorization: Bearer ` Accepted tokens: - `ADMIN_KEY` (full admin access) - User API key (role-based access) Role gates: - `admin`, `user` => write endpoints allowed - `viewer` => read-only - Admin endpoints require admin privileges ```185:191:src/docsfy/main.py 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.",
```1203:1207:src/docsfy/main.py
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")
## Configuration for Authentication `ADMIN_KEY` is mandatory and must be at least 16 characters. `SECURE_COOKIES` defaults to secure behavior. ```1:2:.env.example # REQUIRED - Admin key for user management (minimum 16 characters) ADMIN_KEY=your-secure-admin-key-here-min-16-chars
```27:29:.env.example
# Set to false for local HTTP development
# SECURE_COOKIES=false
```83:89:src/docsfy/main.py 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)
> **Warning:** `secure_cookies` defaults to `True`; on plain HTTP local development, browser session cookies may not be set/sent unless `SECURE_COOKIES=false` is configured.

## Code-Backed Client Examples

Login via form and receive session cookie:

```101:111:tests/test_auth.py
async def test_login_with_admin_key(unauthed_client: AsyncClient) -> None:
    """POST /login with the admin key should set a session cookie and redirect."""
    response = await unauthed_client.post(
        "/login",
        data={"username": "admin", "api_key": TEST_ADMIN_KEY},
        follow_redirects=False,
    )
    assert response.status_code == 302
    assert response.headers["location"] == "/"
    assert "docsfy_session" in response.cookies
Bearer auth for API access: ```157:179:tests/test_auth.py async def test_api_bearer_auth(admin_client: AsyncClient) -> None: """Requests with a valid Bearer token should succeed.""" response = await admin_client.get("/api/status") assert response.status_code == 200 assert "projects" in response.json() async def test_api_bearer_auth_user_key(_init_db: None) -> None: """Requests with a valid user Bearer token should succeed.""" from docsfy.main import _generating, app from docsfy.storage import create_user _generating.clear() _username, raw_key = await create_user("bob")
Unauthenticated API request behavior:

```87:93:tests/test_auth.py
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"
Auth contract is continuously validated by the test suite executed via `tox`: ```1:7:tox.toml skipsdist = true envlist = ["unittests"] [env.unittests] deps = ["uv"] commands = [["uv", "run", "--extra", "dev", "pytest", "-n", "auto", "tests"]] ```