Merge pull request #560 from christianlouis/copilot/add-webhook-snippet-and-api-tokens

fix(security): resolve CodeQL clear-text logging and weak hashing alerts
This commit is contained in:
Christian Krakau-Louis
2026-03-08 21:42:28 +01:00
committed by GitHub
17 changed files with 1485 additions and 44 deletions
+2
View File
@@ -7,6 +7,7 @@ import logging
from fastapi import APIRouter
from app.api.admin_users import router as admin_users_router
from app.api.api_tokens import router as api_tokens_router
from app.api.azure import router as azure_router
from app.api.backup import router as backup_router
from app.api.billing import router as billing_router
@@ -45,6 +46,7 @@ router = APIRouter()
# Include all the routers
router.include_router(admin_users_router)
router.include_router(api_tokens_router)
router.include_router(user_router)
router.include_router(backup_router)
router.include_router(files_router)
+224
View File
@@ -0,0 +1,224 @@
"""API endpoints for managing personal API tokens.
Provides CRUD operations so users can create, list, and revoke tokens
that grant programmatic access to the DocuElevate API (e.g. webhook
uploads, scripted integrations).
Tokens use ``secrets.token_urlsafe`` from the Python standard library
(no extra dependencies) and are prefixed with ``de_`` for easy
identification. Only a PBKDF2-HMAC-SHA256 hash is persisted; the
plaintext is returned exactly once at creation time.
"""
import hashlib
import logging
import secrets
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import ApiToken
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api-tokens", tags=["api-tokens"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
#: Prefix prepended to every generated token for easy identification.
TOKEN_PREFIX = "de_"
#: Number of random bytes for the token body (32 → 43 URL-safe chars).
TOKEN_BYTES = 32
#: PBKDF2 iteration count for hashing API tokens.
TOKEN_HASH_ITERATIONS = 100_000
#: PBKDF2 salt for API token hashing (not secret, but fixed for determinism).
TOKEN_HASH_SALT = b"api-token-v1"
# ---------------------------------------------------------------------------
# Auth helper
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if not owner_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def generate_api_token() -> str:
"""Generate a new API token with the ``de_`` prefix.
Returns:
A URL-safe random token string, e.g. ``de_Ab3xY…``.
"""
return TOKEN_PREFIX + secrets.token_urlsafe(TOKEN_BYTES)
def hash_token(token: str) -> str:
"""Return a PBKDF2-HMAC-SHA256 hex digest of *token*.
Args:
token: The plaintext API token.
Returns:
64-character lowercase hex string.
"""
dk = hashlib.pbkdf2_hmac(
"sha256",
token.encode("utf-8"),
TOKEN_HASH_SALT,
TOKEN_HASH_ITERATIONS,
)
return dk.hex()
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class TokenCreate(BaseModel):
"""Schema for creating a new API token."""
name: str = Field(..., min_length=1, max_length=255, description="Human-readable label for the token")
class TokenResponse(BaseModel):
"""Schema returned when listing tokens (plaintext is never included)."""
id: int
name: str
token_prefix: str
is_active: bool
last_used_at: datetime | None
last_used_ip: str | None
created_at: datetime | None
revoked_at: datetime | None
model_config = {"from_attributes": True}
class TokenCreatedResponse(TokenResponse):
"""Schema returned once at creation time — includes the full plaintext token."""
token: str = Field(..., description="The full API token. Store it securely — it will not be shown again.")
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=TokenCreatedResponse)
async def create_token(
body: TokenCreate,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Create a new personal API token.
The full token is returned **only once** in the response. Subsequent
``GET`` requests will only show the prefix for identification.
"""
plaintext = generate_api_token()
token_hash_value = hash_token(plaintext)
prefix = plaintext[:12] # "de_" prefix + 9 random chars = 12 chars total
db_token = ApiToken(
owner_id=owner_id,
name=body.name,
token_hash=token_hash_value,
token_prefix=prefix,
)
try:
db.add(db_token)
db.commit()
db.refresh(db_token)
except Exception:
db.rollback()
raise
logger.info("API token created: id=%s owner=%s name=%r", db_token.id, owner_id, body.name)
return {
"id": db_token.id,
"name": db_token.name,
"token_prefix": db_token.token_prefix,
"is_active": db_token.is_active,
"last_used_at": db_token.last_used_at,
"last_used_ip": db_token.last_used_ip,
"created_at": db_token.created_at,
"revoked_at": db_token.revoked_at,
"token": plaintext,
}
@router.get("/", response_model=list[TokenResponse])
async def list_tokens(
owner_id: CurrentOwner,
db: DbSession,
) -> list[dict[str, Any]]:
"""List all API tokens for the authenticated user."""
tokens = db.query(ApiToken).filter(ApiToken.owner_id == owner_id).order_by(ApiToken.created_at.desc()).all()
return [
{
"id": t.id,
"name": t.name,
"token_prefix": t.token_prefix,
"is_active": t.is_active,
"last_used_at": t.last_used_at,
"last_used_ip": t.last_used_ip,
"created_at": t.created_at,
"revoked_at": t.revoked_at,
}
for t in tokens
]
@router.delete("/{token_id}", status_code=status.HTTP_200_OK)
async def revoke_token(
token_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Revoke (soft-delete) an API token.
The token row is kept for audit purposes but marked inactive with a
``revoked_at`` timestamp.
"""
db_token = db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.owner_id == owner_id).first()
if not db_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found")
if not db_token.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token is already revoked")
try:
db_token.is_active = False
db_token.revoked_at = datetime.now(timezone.utc)
db.commit()
except Exception:
db.rollback()
raise
logger.info("API token revoked: id=%s owner=%s", token_id, owner_id)
return {"detail": "Token revoked"}
+9 -9
View File
@@ -291,15 +291,15 @@ def create_pipeline(request: Request, db: DbSession, body: PipelineCreate) -> di
db.add(pipeline)
db.commit()
db.refresh(pipeline)
except Exception as exc:
except Exception:
db.rollback()
logger.exception(f"Failed to create pipeline user={user_id}: {exc}")
logger.exception("Failed to create pipeline user=%s", user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create pipeline",
)
logger.info(f"Pipeline created: id={pipeline.id}, owner={user_id}, name={name!r}")
logger.info("Pipeline created: id=%s, owner=%s, name=%r", pipeline.id, user_id, name)
return _serialize_pipeline(pipeline)
@@ -387,15 +387,15 @@ def update_pipeline(pipeline_id: int, request: Request, db: DbSession, body: Pip
try:
db.commit()
db.refresh(pipeline)
except Exception as exc:
except Exception:
db.rollback()
logger.exception(f"Failed to update pipeline id={pipeline_id}: {exc}")
logger.exception("Failed to update pipeline id=%s", pipeline_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update pipeline",
)
logger.info(f"Pipeline updated: id={pipeline_id}, user={user_id}")
logger.info("Pipeline updated: id=%s, user=%s", pipeline_id, user_id)
return _serialize_pipeline(pipeline, include_steps=True, db=db)
@@ -425,15 +425,15 @@ def delete_pipeline(pipeline_id: int, request: Request, db: DbSession) -> None:
db.query(PipelineStep).filter(PipelineStep.pipeline_id == pipeline_id).delete()
db.delete(pipeline)
db.commit()
except Exception as exc:
except Exception:
db.rollback()
logger.exception(f"Failed to delete pipeline id={pipeline_id}: {exc}")
logger.exception("Failed to delete pipeline id=%s", pipeline_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete pipeline",
)
logger.info(f"Pipeline deleted: id={pipeline_id}, user={user_id}")
logger.info("Pipeline deleted: id=%s, user=%s", pipeline_id, user_id)
# ---------------------------------------------------------------------------
+9 -9
View File
@@ -190,15 +190,15 @@ def create_saved_search(
db.add(saved_search)
db.commit()
db.refresh(saved_search)
except Exception as exc:
except Exception:
db.rollback()
logger.exception(f"Failed to create saved search for user={user_id}: {exc}")
logger.exception("Failed to create saved search for user=%s", user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to save search",
)
logger.info(f"Saved search created: user={user_id}, name={name!r}")
logger.info("Saved search created: user=%s, name=%r", user_id, name)
return _serialize_saved_search(saved_search)
@@ -263,15 +263,15 @@ def update_saved_search(
try:
db.commit()
db.refresh(saved_search)
except Exception as exc:
except Exception:
db.rollback()
logger.exception(f"Failed to update saved search id={search_id}, user={user_id}: {exc}")
logger.exception("Failed to update saved search id=%s, user=%s", search_id, user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update saved search",
)
logger.info(f"Saved search updated: id={search_id}, user={user_id}")
logger.info("Saved search updated: id=%s, user=%s", search_id, user_id)
return _serialize_saved_search(saved_search)
@@ -294,12 +294,12 @@ def delete_saved_search(search_id: int, request: Request, db: DbSession):
try:
db.delete(saved_search)
db.commit()
except Exception as exc:
except Exception:
db.rollback()
logger.exception(f"Failed to delete saved search id={search_id}, user={user_id}: {exc}")
logger.exception("Failed to delete saved search id=%s, user=%s", search_id, user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete saved search",
)
logger.info(f"Saved search deleted: id={search_id}, user={user_id}")
logger.info("Saved search deleted: id=%s, user=%s", search_id, user_id)
+91 -23
View File
@@ -2,6 +2,7 @@ import hashlib
import inspect
import logging
import pathlib
from datetime import datetime, timezone
from functools import wraps
from urllib.parse import urlparse
@@ -52,9 +53,62 @@ router = APIRouter()
def get_current_user(request: Request):
# Check for Bearer token auth first (API tokens)
api_user = getattr(request.state, "api_token_user", None)
if isinstance(api_user, dict):
return api_user
return request.session.get("user")
def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
"""Resolve a user from a Bearer API token in the Authorization header.
If the header is present and the token is valid, updates usage tracking
(last_used_at, last_used_ip) and returns a synthetic user dict compatible
with the session user format.
Returns:
A user dict or ``None`` if no valid Bearer token is present.
"""
auth_header = request.headers.get("authorization", "")
if not isinstance(auth_header, str) or not auth_header.startswith("Bearer "):
return None
raw_token = auth_header[7:]
if not raw_token or not isinstance(raw_token, str):
return None
from app.api.api_tokens import hash_token
from app.models import ApiToken
token_hash = hash_token(raw_token)
db_token = db.query(ApiToken).filter(ApiToken.token_hash == token_hash, ApiToken.is_active.is_(True)).first()
if db_token is None:
return None
# Update usage tracking
try:
db_token.last_used_at = datetime.now(timezone.utc)
# Extract client IP (respect X-Forwarded-For from reverse proxy)
client_ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if not client_ip and request.client:
client_ip = request.client.host
db_token.last_used_ip = client_ip or None
db.commit()
except Exception:
db.rollback()
logger.debug("Failed to update API token usage tracking for token_id=%s", db_token.id)
# Build a synthetic user dict that mimics the session user format
return {
"id": db_token.owner_id,
"email": db_token.owner_id,
"preferred_username": db_token.owner_id,
"is_admin": False,
"_api_token_id": db_token.id,
}
def get_current_user_id(request: Request) -> str:
"""Return a stable string identifier for the authenticated user.
@@ -82,28 +136,42 @@ def require_login(func):
@wraps(func)
async def wrapper(request: Request, *args, **kwargs):
if not request.session.get("user"):
# For API endpoints return 401 instead of storing the URL in the session
# and redirecting to /login. Without this guard, the /api/auth/whoami
# probe issued by common.js on every page load would overwrite
# redirect_after_login with the API URL, causing the post-login redirect
# to land on a JSON endpoint rather than the original page.
url_path = urlparse(str(request.url)).path
if url_path.startswith("/api/"):
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"error": "Not authenticated"},
)
request.session["redirect_after_login"] = str(request.url)
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
# Pass request as a keyword argument so that endpoints whose first
# parameter is a path variable (e.g. pipeline_id) are not accidentally
# bound to the request object when FastAPI supplies all arguments as
# keyword arguments.
if inspect.iscoroutinefunction(func):
return await func(*args, request=request, **kwargs)
else:
return func(*args, request=request, **kwargs)
# Check session auth first
if request.session.get("user"):
if inspect.iscoroutinefunction(func):
return await func(*args, request=request, **kwargs)
else:
return func(*args, request=request, **kwargs)
# Fall back to Bearer token auth for API endpoints
url_path = urlparse(str(request.url)).path
if url_path.startswith("/api/"):
try:
from app.database import SessionLocal
db = SessionLocal()
try:
api_user = _resolve_bearer_user(request, db)
finally:
db.close()
except Exception:
api_user = None
if api_user:
request.state.api_token_user = api_user
if inspect.iscoroutinefunction(func):
return await func(*args, request=request, **kwargs)
else:
return func(*args, request=request, **kwargs)
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"error": "Not authenticated"},
)
# Non-API endpoint with no session — redirect to login
request.session["redirect_after_login"] = str(request.url)
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
return wrapper
@@ -443,7 +511,7 @@ if AUTH_ENABLED:
@require_login
async def whoami(request: Request):
"""API endpoint to get current user information"""
user = request.session.get("user")
user = get_current_user(request)
return user or {"error": "Not authenticated"}
+7
View File
@@ -109,6 +109,13 @@ class CSRFMiddleware(BaseHTTPMiddleware):
# Validate for state-changing methods on non-exempt paths.
if request.method in CSRF_PROTECTED_METHODS and request.url.path not in CSRF_EXEMPT_PATHS:
# Bearer-authenticated requests (API tokens) are exempt from CSRF
# because the token itself acts as proof of intent — it cannot be
# injected by a cross-site request from a browser.
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
return await call_next(request)
submitted_token = await self._get_submitted_token(request)
if not submitted_token or not secrets.compare_digest(csrf_token, submitted_token):
logger.warning(f"[SECURITY] CSRF_VALIDATION_FAILED method={request.method} path={request.url.path}")
+37
View File
@@ -600,3 +600,40 @@ class UserIntegration(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class ApiToken(Base):
"""Personal API token for programmatic access.
Users can create multiple tokens, each with a human-readable name.
Only the SHA-256 hash of the token is stored; the plaintext is shown
exactly once at creation time. A short prefix (first 8 chars) is
persisted for easy identification in the UI.
Usage tracking records the timestamp and IP address of the most
recent request that used the token.
"""
__tablename__ = "api_tokens"
id = Column(Integer, primary_key=True, index=True)
# Stable owner identifier — matches FileRecord.owner_id / UserIntegration.owner_id
owner_id = Column(String, nullable=False, index=True)
# Human-readable label chosen by the user (e.g. "CI Pipeline", "Webhook Upload")
name = Column(String(255), nullable=False)
# SHA-256 hex digest of the full token value
token_hash = Column(String(64), nullable=False, unique=True, index=True)
# First 12 characters of the token for display (e.g. "de_Ab3xY7kL…")
token_prefix = Column(String(16), nullable=False)
# Usage tracking
last_used_at = Column(DateTime(timezone=True), nullable=True)
last_used_ip = Column(String(45), nullable=True) # IPv6 max length
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
revoked_at = Column(DateTime(timezone=True), nullable=True)
+2
View File
@@ -5,6 +5,7 @@ Aggregated view routers for the application.
from fastapi import APIRouter
from app.views.admin_users import router as admin_users_router
from app.views.api_tokens import router as api_tokens_router
from app.views.backup import router as backup_router
from app.views.db_wizard import router as db_wizard_router
from app.views.dropbox import router as dropbox_router
@@ -33,6 +34,7 @@ router = APIRouter()
router.include_router(wizard_router) # Wizard first (for /setup)
router.include_router(db_wizard_router) # Database wizard
router.include_router(admin_users_router) # Admin user management
router.include_router(api_tokens_router) # API token management
router.include_router(backup_router) # Backup dashboard
router.include_router(general_router)
router.include_router(status_router)
+24
View File
@@ -0,0 +1,24 @@
"""View route for the API Tokens management page.
Renders the ``api_tokens.html`` template where users can create, view,
and revoke their personal API tokens for programmatic access.
"""
import logging
from fastapi import APIRouter, Request
from app.views.base import require_login, templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/api-tokens")
@require_login
async def api_tokens_page(request: Request):
"""Render the API Tokens management page."""
return templates.TemplateResponse(
"api_tokens.html",
{"request": request, "page_title": "API Tokens"},
)
+132 -1
View File
@@ -86,7 +86,38 @@ def make_api_request(url, max_retries=3):
## Authentication
When authentication is enabled, you must include an authentication token in your requests:
When authentication is enabled, you must include an authentication token in your requests.
### API Tokens (Recommended)
DocuElevate supports personal API tokens for programmatic access. Tokens are the recommended
way to authenticate scripts, CI/CD pipelines, and webhook integrations.
**Creating a token:**
1. Log in to DocuElevate and navigate to **API Tokens** (available in your user menu or at `/api-tokens`).
2. Enter a descriptive name (e.g. "CI Pipeline", "Scanner Integration") and click **Create Token**.
3. Copy the token immediately — it is shown only once.
**Using a token:**
```bash
curl -X GET "http://<your-docuelevate-instance>/api/files" \
-H "Authorization: Bearer <your-api-token>"
```
**Managing tokens programmatically:**
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/api-tokens/` | Create a new token |
| `GET` | `/api/api-tokens/` | List all your tokens |
| `DELETE` | `/api/api-tokens/{id}` | Revoke a token |
### Session Authentication
Browser-based users authenticate via OAuth or local login. Session cookies are set
automatically and used for subsequent requests:
```bash
curl -X GET "http://<your-docuelevate-instance>/api/files" \
@@ -1900,6 +1931,106 @@ Pass no `pipeline_id` query parameter (or omit it) to clear the assignment.
```
## API Tokens
Personal API tokens allow programmatic access to the DocuElevate API without
session cookies. Tokens are ideal for CI/CD pipelines, webhook integrations,
and automation scripts.
Each token is prefixed with `de_` for easy identification. Only a SHA-256 hash
is stored server-side; the plaintext is returned exactly once at creation time.
Usage tracking records when each token was last used and from which IP address.
### POST /api/api-tokens/
Create a new API token.
**Request:**
```json
{
"name": "CI Pipeline"
}
```
**Response (201 Created):**
```json
{
"id": 1,
"name": "CI Pipeline",
"token_prefix": "de_Ab3xY7kL",
"token": "de_Ab3xY7kLmN9pQrStUvWxYz0123456789abcdef",
"is_active": true,
"last_used_at": null,
"last_used_ip": null,
"created_at": "2026-03-08T12:00:00Z",
"revoked_at": null
}
```
> **Important:** The `token` field is only included in the creation response.
> Copy it immediately — it will not be shown again.
### GET /api/api-tokens/
List all tokens for the authenticated user. The full token value is never included.
**Response (200):**
```json
[
{
"id": 1,
"name": "CI Pipeline",
"token_prefix": "de_Ab3xY7kL",
"is_active": true,
"last_used_at": "2026-03-08T15:30:00Z",
"last_used_ip": "203.0.113.42",
"created_at": "2026-03-08T12:00:00Z",
"revoked_at": null
}
]
```
### DELETE /api/api-tokens/{token_id}
Revoke a token. The token is soft-deleted (kept for audit purposes) and can no
longer be used for authentication.
**Response (200):**
```json
{
"detail": "Token revoked"
}
```
### Using API Tokens
Include the token in the `Authorization` header of any API request:
```bash
# Upload a document
curl -X POST "http://your-instance/api/files/ui-upload" \
-H "Authorization: Bearer de_your_token_here" \
-F "file=@/path/to/document.pdf"
# List files
curl -X GET "http://your-instance/api/files" \
-H "Authorization: Bearer de_your_token_here"
```
**Python example:**
```python
import requests
response = requests.post(
"http://your-instance/api/files/ui-upload",
headers={"Authorization": "Bearer de_your_token_here"},
files={"file": open("document.pdf", "rb")},
)
print(response.json())
```
## Further Assistance
For additional help with the API, please contact our support team or refer to the [Development Guide](../CONTRIBUTING.md).
+47
View File
@@ -165,6 +165,7 @@ The **Integrations** page (`/integrations`) provides a unified view of all your
- **Email Forward** — recipient email address
- **Watch Folder** — folder path
- **Paperless NGX** — URL and API token
- **Webhook** — no configuration needed; the form shows a quick-start guide with sample `curl` and Python snippets for uploading documents via the API
5. Click **Test Connection** to verify the settings before saving.
6. Click **Save** to persist the integration.
@@ -193,6 +194,52 @@ DocuElevate can poll an FTP or SFTP directory for new files. Enable this with `F
See [Configuration Guide — Watch Folder Ingestion](ConfigurationGuide.md#watch-folder-ingestion) for full setup instructions.
### Webhook Ingestion (API Upload)
Webhook integrations allow external systems to push documents directly into DocuElevate
via the REST API. Instead of DocuElevate polling for new files, **your application sends
files to DocuElevate** using an HTTP request with an API token for authentication.
This is ideal for:
- **CI/CD pipelines** — upload build artifacts or generated reports
- **Scanner integrations** — push scanned documents from network scanners
- **Automation scripts** — send documents from any system with HTTP support
To set up webhook ingestion:
1. Go to **Integrations** and add a new **Source → Webhook** integration.
The integration form shows copy-ready code snippets.
2. Go to **API Tokens** (in your user menu) and create a personal API token.
3. Use the token to upload documents:
```bash
curl -X POST "https://your-instance/api/files/ui-upload" \
-H "Authorization: Bearer de_your_token_here" \
-F "file=@/path/to/document.pdf"
```
See the [API Documentation](API.md#api-tokens) for detailed API token management.
### API Tokens
API tokens provide secure, programmatic access to the DocuElevate API. Each user
can create multiple tokens, each with a descriptive name.
**Features:**
- **Multiple tokens** — create separate tokens for different scripts or integrations
- **Usage tracking** — see when each token was last used and from which IP address
- **Revocation** — instantly disable a compromised token without affecting others
**Managing tokens:**
1. Click your avatar in the top navigation and select **API Tokens**, or go to `/api-tokens`.
2. Enter a descriptive name and click **Create Token**.
3. Copy the token immediately — it is shown only once.
4. To revoke a token, click **Revoke** next to it in the token list.
> **Security tip:** Create a dedicated token for each integration and revoke it
> immediately if compromised. Never share tokens or commit them to source control.
## Managing Documents
The **Files** page provides access to all processed documents:
+15
View File
@@ -209,6 +209,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
linksDiv.appendChild(
_makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', 'My Subscription', 'text-gray-700')
);
linksDiv.appendChild(
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', 'API Tokens', 'text-gray-700')
);
// Divider + Sign Out
const divider = document.createElement('div');
@@ -278,6 +281,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
subLink.appendChild(document.createTextNode('My Subscription'));
mobileAuthSection.appendChild(subLink);
// API Tokens link
const tokensLink = document.createElement('a');
tokensLink.href = '/api-tokens';
tokensLink.className =
'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50';
const tokensIcon = document.createElement('i');
tokensIcon.className = 'fas fa-key mr-2 text-yellow-500';
tokensIcon.setAttribute('aria-hidden', 'true');
tokensLink.appendChild(tokensIcon);
tokensLink.appendChild(document.createTextNode('API Tokens'));
mobileAuthSection.appendChild(tokensLink);
// Logout link
const logoutLink = document.createElement('a');
logoutLink.href = '/logout';
+311
View File
@@ -0,0 +1,311 @@
{% extends "base.html" %}
{% block title %}API Tokens DocuElevate{% endblock %}
{% block content %}
<div x-data="apiTokens()" x-init="loadTokens()" class="container mx-auto px-4 py-8 max-w-4xl">
<header class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-key text-yellow-500" aria-hidden="true"></i>
API Tokens
</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400 text-sm leading-relaxed max-w-2xl">
Create personal API tokens to interact with the DocuElevate API programmatically.
Use tokens for webhook uploads, CI/CD pipelines, or any script that needs to upload
or retrieve documents.
</p>
</header>
<!-- Create token section -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6" aria-labelledby="create-token-heading">
<h2 id="create-token-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Create New Token</h2>
<form @submit.prevent="createToken()" class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<label for="token-name" class="sr-only">Token name</label>
<input
id="token-name"
type="text"
x-model="newTokenName"
placeholder="e.g. CI Pipeline, Webhook Upload, My Script"
required
minlength="1"
maxlength="255"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm"
aria-required="true"
/>
</div>
<button
type="submit"
:disabled="creating || !newTokenName.trim()"
class="inline-flex items-center px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md
hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50
transition-colors"
style="min-height:40px; min-width:44px;"
>
<i class="fas fa-plus mr-2" aria-hidden="true"></i>
<span x-text="creating ? 'Creating…' : 'Create Token'"></span>
</button>
</form>
<!-- Newly created token display -->
<template x-if="newlyCreatedToken">
<div class="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-300 dark:border-green-700 rounded-lg p-4" role="alert">
<div class="flex items-start gap-3">
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mt-0.5 text-lg" aria-hidden="true"></i>
<div class="flex-1">
<p class="font-semibold text-green-800 dark:text-green-200 text-sm">Token created successfully!</p>
<p class="text-green-700 dark:text-green-300 text-xs mt-1">
Copy this token now — it will <strong>not be shown again</strong>.
</p>
<div class="mt-3 flex items-center gap-2">
<code
class="flex-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded px-3 py-2
text-sm font-mono text-gray-900 dark:text-gray-100 select-all break-all"
x-text="newlyCreatedToken"
></code>
<button
type="button"
@click="copyToken()"
class="inline-flex items-center px-3 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300
dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-200
hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors"
style="min-height:40px; min-width:44px;"
:aria-label="copied ? 'Copied!' : 'Copy token to clipboard'"
>
<i :class="copied ? 'fas fa-check text-green-600' : 'fas fa-copy'" aria-hidden="true"></i>
<span class="ml-1 hidden sm:inline" x-text="copied ? 'Copied!' : 'Copy'"></span>
</button>
</div>
</div>
</div>
</div>
</template>
</section>
<!-- Usage example section -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6" aria-labelledby="usage-heading">
<h2 id="usage-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-3">
<i class="fas fa-code text-blue-500 mr-2" aria-hidden="true"></i>
Usage Example
</h2>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-3">
Use your API token in the <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">Authorization</code>
header with any API request:
</p>
<div class="relative">
<pre class="bg-gray-900 text-green-400 rounded-lg p-4 text-sm overflow-x-auto font-mono leading-relaxed"><code>curl -X POST "<span x-text="baseUrl"></span>/api/files/ui-upload" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-F "file=@/path/to/document.pdf"</code></pre>
<button
type="button"
@click="copySnippet('upload')"
class="absolute top-2 right-2 px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600
focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors"
style="min-height:30px; min-width:30px;"
aria-label="Copy upload example to clipboard"
>
<i class="fas fa-copy" aria-hidden="true"></i>
</button>
</div>
</section>
<!-- Tokens list -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden" aria-labelledby="tokens-heading">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 id="tokens-heading" class="text-lg font-semibold text-gray-900 dark:text-white">Your Tokens</h2>
</div>
<!-- Loading state -->
<template x-if="loading">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-spinner fa-spin text-2xl mb-2" aria-hidden="true"></i>
<p class="text-sm">Loading tokens…</p>
</div>
</template>
<!-- Empty state -->
<template x-if="!loading && tokens.length === 0">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-key text-4xl mb-3 text-gray-300 dark:text-gray-600" aria-hidden="true"></i>
<p class="font-medium">No API tokens yet</p>
<p class="text-sm mt-1">Create your first token above to get started.</p>
</div>
</template>
<!-- Tokens table -->
<template x-if="!loading && tokens.length > 0">
<div class="overflow-x-auto">
<table class="w-full text-sm" aria-label="API Tokens">
<thead>
<tr class="bg-gray-50 dark:bg-gray-750 text-left">
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Name</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Token Prefix</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Created</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Last Used</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Last IP</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="token in tokens" :key="token.id">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<span class="font-medium text-gray-900 dark:text-white" x-text="token.name"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<code class="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-xs font-mono" x-text="token.token_prefix + '…'"></code>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="token.last_used_at ? formatDate(token.last_used_at) : 'Never'"></td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
<code x-show="token.last_used_ip" class="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-xs font-mono" x-text="token.last_used_ip"></code>
<span x-show="!token.last_used_ip" class="text-gray-400"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="token.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
x-text="token.is_active ? 'Active' : 'Revoked'"
></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<button
x-show="token.is_active"
type="button"
@click="revokeToken(token)"
:disabled="revoking === token.id"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
style="min-height:36px; min-width:44px;"
:aria-label="'Revoke token ' + token.name"
>
<i :class="revoking === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
Revoke
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<!-- Error display -->
<template x-if="error">
<div class="m-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 p-3 rounded text-sm" role="alert">
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
<span x-text="error"></span>
</div>
</template>
</section>
</div>
<script>
function apiTokens() {
const csrfToken = '{{ csrf_token | default("") }}';
return {
tokens: [],
loading: true,
creating: false,
revoking: null,
error: null,
newTokenName: '',
newlyCreatedToken: null,
copied: false,
baseUrl: window.location.origin,
async loadTokens() {
this.loading = true;
this.error = null;
try {
const res = await fetch('/api/api-tokens/', {
headers: { 'X-CSRF-Token': csrfToken }
});
if (!res.ok) throw new Error('Failed to load tokens');
this.tokens = await res.json();
} catch (e) {
this.error = e.message;
} finally {
this.loading = false;
}
},
async createToken() {
if (!this.newTokenName.trim()) return;
this.creating = true;
this.error = null;
this.newlyCreatedToken = null;
try {
const res = await fetch('/api/api-tokens/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ name: this.newTokenName.trim() }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to create token');
}
const data = await res.json();
this.newlyCreatedToken = data.token;
this.newTokenName = '';
await this.loadTokens();
} catch (e) {
this.error = e.message;
} finally {
this.creating = false;
}
},
async revokeToken(token) {
if (!confirm(`Revoke token "${token.name}"? This cannot be undone.`)) return;
this.revoking = token.id;
this.error = null;
try {
const res = await fetch(`/api/api-tokens/${token.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to revoke token');
}
await this.loadTokens();
} catch (e) {
this.error = e.message;
} finally {
this.revoking = null;
}
},
copyToken() {
if (this.newlyCreatedToken) {
navigator.clipboard.writeText(this.newlyCreatedToken);
this.copied = true;
setTimeout(() => { this.copied = false; }, 2000);
}
},
copySnippet(type) {
const snippets = {
upload: `curl -X POST "${this.baseUrl}/api/files/ui-upload" \\\n -H "Authorization: Bearer YOUR_API_TOKEN" \\\n -F "file=@/path/to/document.pdf"`,
};
navigator.clipboard.writeText(snippets[type] || '');
},
formatDate(d) {
if (!d) return '—';
const dt = new Date(d);
return dt.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
' ' + dt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
},
};
}
</script>
{% endblock %}
+91 -2
View File
@@ -628,8 +628,97 @@
</div>
</template>
<!-- Generic fallback for types without dedicated fields -->
<template x-if="form.integration_type && !hasFormFields(form.integration_type)">
<!-- Webhook explanation and sample snippets -->
<template x-if="form.integration_type === 'WEBHOOK'">
<div class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-purple-500 uppercase tracking-wider">
<i class="fas fa-bolt mr-1" aria-hidden="true"></i> Webhook Ingestion
</p>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 class="text-sm font-semibold text-blue-800 dark:text-blue-200 mb-2">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
How Webhook Ingestion Works
</h4>
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed">
A webhook integration allows external systems to push documents directly into DocuElevate
via the REST API. Instead of DocuElevate polling for new files (like IMAP), <strong>your
application sends files to DocuElevate</strong> using an HTTP request with an API token for
authentication.
</p>
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed mt-2">
This is ideal for <strong>CI/CD pipelines</strong>, <strong>automation scripts</strong>,
<strong>scanner integrations</strong>, or any system that generates documents and needs to
send them for processing.
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-750 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
<i class="fas fa-terminal mr-1" aria-hidden="true"></i>
Quick Start
</h4>
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-2 list-decimal list-inside">
<li>
Go to <a href="/api-tokens" class="text-indigo-600 hover:underline font-medium">API Tokens</a>
and create a personal token.
</li>
<li>Use the token to upload documents via the API:</li>
</ol>
<div class="mt-3 relative">
<pre class="bg-gray-900 text-green-400 rounded-lg p-4 text-xs overflow-x-auto font-mono leading-relaxed"><code>curl -X POST "<span x-text="window.location.origin"></span>/api/files/ui-upload" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-F "file=@/path/to/document.pdf"</code></pre>
<button
type="button"
@click="navigator.clipboard.writeText('curl -X POST \'' + window.location.origin + '/api/files/ui-upload\' \\\n -H \'Authorization: Bearer YOUR_API_TOKEN\' \\\n -F \'file=@/path/to/document.pdf\'')"
class="absolute top-2 right-2 px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
style="min-height:28px; min-width:28px;"
aria-label="Copy curl example to clipboard"
>
<i class="fas fa-copy" aria-hidden="true"></i>
</button>
</div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mt-4 mb-2">
<i class="fab fa-python mr-1" aria-hidden="true"></i>
Python Example
</h4>
<div class="relative">
<pre class="bg-gray-900 text-green-400 rounded-lg p-4 text-xs overflow-x-auto font-mono leading-relaxed"><code>import requests
response = requests.post(
"<span x-text="window.location.origin"></span>/api/files/ui-upload",
headers={"Authorization": "Bearer YOUR_API_TOKEN"},
files={"file": open("document.pdf", "rb")},
)
print(response.json())</code></pre>
<button
type="button"
@click="navigator.clipboard.writeText('import requests\n\nresponse = requests.post(\n \'' + window.location.origin + '/api/files/ui-upload\',\n headers={\'Authorization\': \'Bearer YOUR_API_TOKEN\'},\n files={\'file\': open(\'document.pdf\', \'rb\')},\n)\nprint(response.json())')"
class="absolute top-2 right-2 px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
style="min-height:28px; min-width:28px;"
aria-label="Copy Python example to clipboard"
>
<i class="fas fa-copy" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-shield-alt mr-1" aria-hidden="true"></i>
<strong>Security tip:</strong> Create a dedicated API token for each integration and
revoke it immediately if compromised. Tokens can be managed on the
<a href="/api-tokens" class="underline font-medium">API Tokens</a> page.
</p>
</div>
</div>
</template>
<!-- Generic fallback for types without dedicated fields (excludes WEBHOOK) -->
<template x-if="form.integration_type && !hasFormFields(form.integration_type) && form.integration_type !== 'WEBHOOK'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="form.integration_type + ' Settings'"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">
+45
View File
@@ -0,0 +1,45 @@
"""Add api_tokens table for personal API token authentication
Revision ID: 024_add_api_tokens
Revises: 023_add_user_integrations
Create Date: 2026-03-08
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "024_add_api_tokens"
down_revision: Union[str, None] = "023_add_user_integrations"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create api_tokens table."""
op.create_table(
"api_tokens",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("owner_id", sa.String(), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("token_prefix", sa.String(16), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_ip", sa.String(45), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("ix_api_tokens_id", "api_tokens", ["id"])
op.create_index("ix_api_tokens_owner_id", "api_tokens", ["owner_id"])
op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"])
def downgrade() -> None:
"""Drop api_tokens table."""
op.drop_index("ix_api_tokens_token_hash", "api_tokens")
op.drop_index("ix_api_tokens_owner_id", "api_tokens")
op.drop_index("ix_api_tokens_id", "api_tokens")
op.drop_table("api_tokens")
+1
View File
@@ -60,6 +60,7 @@ from app.main import app as fastapi_app # noqa: E402
# Import models to register them with SQLAlchemy Base
from app.models import ( # noqa: F401, E402
ApiToken,
DocumentMetadata,
FileRecord,
Pipeline,
+438
View File
@@ -0,0 +1,438 @@
"""Tests for the personal API tokens feature (app/api/api_tokens.py + auth integration)."""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import ApiToken
# ---------------------------------------------------------------------------
# Test data
# ---------------------------------------------------------------------------
_OWNER = "tokenuser@example.com"
_OTHER_OWNER = "other@example.com"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def tok_engine():
"""In-memory SQLite engine."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def tok_session(tok_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=tok_engine)
session = Session()
yield session
session.close()
def _make_client(tok_engine, owner_id: str = _OWNER) -> TestClient:
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from app.api.api_tokens import _get_owner_id
from app.main import app
Session = sessionmaker(bind=tok_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
def _override_owner():
return owner_id
app.dependency_overrides[get_db] = _override_get_db
app.dependency_overrides[_get_owner_id] = _override_owner
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
return client
def _cleanup(app):
"""Remove dependency overrides after test."""
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Tests Token CRUD
# ---------------------------------------------------------------------------
class TestTokenCreate:
"""Tests for POST /api/api-tokens/."""
@pytest.mark.unit
def test_create_token_returns_full_token(self, tok_engine):
"""Creating a token should return the full plaintext token exactly once."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "Test Token"})
assert resp.status_code == 201, f"Expected 201, got {resp.status_code}: {resp.text}"
data = resp.json()
assert "token" in data
assert data["token"].startswith("de_")
assert data["name"] == "Test Token"
assert data["is_active"] is True
assert data["token_prefix"] == data["token"][:12]
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_token_stored_as_hash(self, tok_engine, tok_session):
"""The database should only store a PBKDF2-HMAC-SHA256 hash, never the plaintext."""
from app.api.api_tokens import hash_token
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "Hash Check"})
token_plaintext = resp.json()["token"]
expected_hash = hash_token(token_plaintext)
db_token = tok_session.query(ApiToken).first()
assert db_token is not None
assert db_token.token_hash == expected_hash
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_token_empty_name_rejected(self, tok_engine):
"""An empty token name should be rejected with 422."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": ""})
assert resp.status_code == 422
finally:
_cleanup(app)
class TestTokenList:
"""Tests for GET /api/api-tokens/."""
@pytest.mark.unit
def test_list_tokens_empty(self, tok_engine):
"""Listing tokens when none exist should return an empty list."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.get("/api/api-tokens/")
assert resp.status_code == 200
assert resp.json() == []
finally:
_cleanup(app)
@pytest.mark.unit
def test_list_tokens_returns_multiple(self, tok_engine):
"""Listing tokens should return all tokens for the current user."""
from app.main import app
client = _make_client(tok_engine)
try:
client.post("/api/api-tokens/", json={"name": "Token A"})
client.post("/api/api-tokens/", json={"name": "Token B"})
resp = client.get("/api/api-tokens/")
assert resp.status_code == 200
tokens = resp.json()
assert len(tokens) == 2
# Full plaintext should NOT appear in list
for t in tokens:
assert "token" not in t
finally:
_cleanup(app)
@pytest.mark.unit
def test_list_tokens_isolation(self, tok_engine):
"""Users should only see their own tokens."""
from app.main import app
client_a = _make_client(tok_engine, _OWNER)
try:
client_a.post("/api/api-tokens/", json={"name": "Owner A Token"})
finally:
_cleanup(app)
client_b = _make_client(tok_engine, _OTHER_OWNER)
try:
resp = client_b.get("/api/api-tokens/")
assert resp.status_code == 200
assert resp.json() == []
finally:
_cleanup(app)
class TestTokenRevoke:
"""Tests for DELETE /api/api-tokens/{id}."""
@pytest.mark.unit
def test_revoke_token(self, tok_engine):
"""Revoking a token should set is_active=False."""
from app.main import app
client = _make_client(tok_engine)
try:
create_resp = client.post("/api/api-tokens/", json={"name": "To Revoke"})
token_id = create_resp.json()["id"]
resp = client.delete(f"/api/api-tokens/{token_id}")
assert resp.status_code == 200
list_resp = client.get("/api/api-tokens/")
revoked = [t for t in list_resp.json() if t["id"] == token_id][0]
assert revoked["is_active"] is False
assert revoked["revoked_at"] is not None
finally:
_cleanup(app)
@pytest.mark.unit
def test_revoke_already_revoked_token(self, tok_engine):
"""Revoking an already-revoked token should return 400."""
from app.main import app
client = _make_client(tok_engine)
try:
create_resp = client.post("/api/api-tokens/", json={"name": "Double Revoke"})
token_id = create_resp.json()["id"]
client.delete(f"/api/api-tokens/{token_id}")
resp = client.delete(f"/api/api-tokens/{token_id}")
assert resp.status_code == 400
finally:
_cleanup(app)
@pytest.mark.unit
def test_revoke_nonexistent_token(self, tok_engine):
"""Revoking a token that doesn't exist should return 404."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.delete("/api/api-tokens/99999")
assert resp.status_code == 404
finally:
_cleanup(app)
@pytest.mark.unit
def test_revoke_other_users_token(self, tok_engine):
"""A user should not be able to revoke another user's token."""
from app.main import app
# Owner A creates a token
client_a = _make_client(tok_engine, _OWNER)
try:
create_resp = client_a.post("/api/api-tokens/", json={"name": "A's Token"})
token_id = create_resp.json()["id"]
finally:
_cleanup(app)
# Owner B tries to revoke it
client_b = _make_client(tok_engine, _OTHER_OWNER)
try:
resp = client_b.delete(f"/api/api-tokens/{token_id}")
assert resp.status_code == 404
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests Bearer token authentication
# ---------------------------------------------------------------------------
class TestBearerAuth:
"""Tests for API token authentication via Authorization: Bearer header."""
@pytest.mark.unit
def test_bearer_resolve_user_with_valid_token(self, tok_engine, tok_session):
"""_resolve_bearer_user should return a user dict for a valid token."""
from unittest.mock import MagicMock
# Create a token directly in DB
from app.api.api_tokens import generate_api_token, hash_token
from app.auth import _resolve_bearer_user
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id=_OWNER,
name="Test Bearer",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
)
tok_session.add(db_token)
tok_session.commit()
# Build a mock request
mock_request = MagicMock()
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
mock_request.client.host = "127.0.0.1"
user = _resolve_bearer_user(mock_request, tok_session)
assert user is not None
assert user["preferred_username"] == _OWNER
assert user["_api_token_id"] == db_token.id
@pytest.mark.unit
def test_bearer_resolve_user_invalid_token(self, tok_engine, tok_session):
"""_resolve_bearer_user should return None for an invalid token."""
from unittest.mock import MagicMock
from app.auth import _resolve_bearer_user
mock_request = MagicMock()
mock_request.headers = {"authorization": "Bearer de_invalid_token"}
mock_request.client.host = "127.0.0.1"
user = _resolve_bearer_user(mock_request, tok_session)
assert user is None
@pytest.mark.unit
def test_bearer_resolve_no_header(self, tok_engine, tok_session):
"""_resolve_bearer_user should return None when no Auth header present."""
from unittest.mock import MagicMock
from app.auth import _resolve_bearer_user
mock_request = MagicMock()
mock_request.headers = {}
user = _resolve_bearer_user(mock_request, tok_session)
assert user is None
@pytest.mark.unit
def test_bearer_updates_usage_tracking(self, tok_engine, tok_session):
"""Using a Bearer token should update last_used_at and last_used_ip."""
from unittest.mock import MagicMock
from app.api.api_tokens import generate_api_token, hash_token
from app.auth import _resolve_bearer_user
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id=_OWNER,
name="Usage Track",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
)
tok_session.add(db_token)
tok_session.commit()
assert db_token.last_used_at is None
assert db_token.last_used_ip is None
mock_request = MagicMock()
mock_request.headers = {
"authorization": f"Bearer {plaintext}",
"x-forwarded-for": "203.0.113.42",
}
mock_request.client.host = "10.0.0.1"
_resolve_bearer_user(mock_request, tok_session)
tok_session.refresh(db_token)
assert db_token.last_used_at is not None
assert db_token.last_used_ip == "203.0.113.42"
@pytest.mark.unit
def test_revoked_token_not_resolved(self, tok_engine, tok_session):
"""A revoked token should not resolve to a user."""
from unittest.mock import MagicMock
from app.api.api_tokens import generate_api_token, hash_token
from app.auth import _resolve_bearer_user
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id=_OWNER,
name="Revoked Token",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=False, # Already revoked
)
tok_session.add(db_token)
tok_session.commit()
mock_request = MagicMock()
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
mock_request.client.host = "127.0.0.1"
user = _resolve_bearer_user(mock_request, tok_session)
assert user is None
# ---------------------------------------------------------------------------
# Tests Token generation utilities
# ---------------------------------------------------------------------------
class TestTokenUtils:
"""Tests for token generation and hashing utilities."""
@pytest.mark.unit
def test_generate_api_token_format(self):
"""Generated tokens should start with 'de_' prefix."""
from app.api.api_tokens import generate_api_token
token = generate_api_token()
assert token.startswith("de_")
assert len(token) > 20 # Should be reasonably long
@pytest.mark.unit
def test_generate_api_token_unique(self):
"""Each generated token should be unique."""
from app.api.api_tokens import generate_api_token
tokens = {generate_api_token() for _ in range(100)}
assert len(tokens) == 100
@pytest.mark.unit
def test_hash_token_deterministic(self):
"""Hashing the same token should always produce the same result."""
from app.api.api_tokens import hash_token
token = "de_test_token_value"
assert hash_token(token) == hash_token(token)
@pytest.mark.unit
def test_hash_token_output_properties(self):
"""Token hash should be a 64-character lowercase hex digest."""
from app.api.api_tokens import hash_token
token = "de_test_token_value"
h = hash_token(token)
assert isinstance(h, str)
assert len(h) == 64
# All characters should be valid lowercase hex digits.
int(h, 16)
assert h == h.lower()