Files
datatools-dev/server/app/auth.py
Michael bab2c9468c feat(server): mint API + Postgres schema + manual adapter (PR 1)
Source-agnostic license issuance service. FastAPI app fronts a
Postgres `licenses` table; the only currently-wired source is
`manual` (operator mints via /internal/mint). Gumroad webhook
adapter lands in PR 2.

Key design points:

- Signing reuses src/license/crypto.py via a COPY into the image
  (single source of truth — blobs minted server-side verify against
  the same embedded pubkey on the buyer's machine).
- Source adapter Protocol (app/adapters/base.py) is the seam for
  Gumroad / Lemon Squeezy / Stripe in later PRs; Mint API speaks
  only SaleEvent / RefundEvent.
- (source, source_order_id) UNIQUE composite gives idempotent
  webhook retries without double-mint.
- JSONB type uses with_variant(JSON, 'sqlite') so the same models
  drive both Postgres prod and SQLite tests (no testcontainers dep).
- Bearer-token auth on /internal/*; the IP-loopback guard was
  removed after the docker bridge made it fight legitimate prod
  traffic (nginx defense + Bearer remain).
- Secrets resolved via *_FILE env vars pointing at
  /run/secrets/<name>, so passwords never appear in `docker inspect`.

21 unit tests (SQLite in-memory, StaticPool) plus a real-Postgres
docker-compose smoke test in server/scripts/smoke.sh that builds the
image, runs the alembic migration, mints a license, verifies the
signature against the host dev pubkey, and checks the DB row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:46:54 +00:00

66 lines
2.3 KiB
Python

"""Auth guards for ``/internal/*``.
Active layer: Bearer token, presented by the operator's CLI and
matched against the value in the secrets dir. Token rotation =
update the file, restart the container.
:func:`require_localhost` is preserved but unused by default — it
fights the Docker bridge network model (the container sees the
gateway IP, not 127.0.0.1, regardless of where traffic originated).
Re-enable it only if the API runs in ``network_mode: host``.
"""
from __future__ import annotations
import hmac
from typing import Optional
from fastapi import HTTPException, Request, status
from app.config import get_settings
def require_localhost(request: Request) -> None:
"""Reject the request unless the connecting peer is loopback.
``request.client.host`` reflects the actual TCP peer (the nginx
upstream connecting from 127.0.0.1) when ``proxy_set_header`` is
used appropriately. We deliberately do NOT trust
``X-Forwarded-For`` here — we want the raw peer.
"""
peer = request.client.host if request.client else None
if peer not in {"127.0.0.1", "::1"}:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Not found",
)
def require_bearer_token(request: Request) -> None:
"""Verify ``Authorization: Bearer <admin_token>``.
Uses constant-time comparison so timing leaks don't reveal token
prefixes. The 401 deliberately doesn't echo the supplied token or
leak whether a token is configured at all — clients should treat
"no token configured" the same as "wrong token".
"""
settings = get_settings()
expected: Optional[str] = settings.resolve_admin_token()
if not expected:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Server not configured for internal access.",
)
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Bearer token required.",
)
presented = auth.removeprefix("Bearer ").strip()
if not hmac.compare_digest(presented, expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token.",
)