From f6f83cce634a5c5d6e2ea128f2a6fea9cc1294b2 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 5 Feb 2026 23:18:07 -0700 Subject: [PATCH 01/26] initial tweaks to Dockerfile & dev compose --- .dockerignore | 1 + deployments/api/Dockerfile | 63 +++++++++++++++++++++------------- deployments/api/Dockerfile.api | 56 ++++++++++++++++++++++++++++++ docker-compose.yml | 6 +++- 4 files changed, 102 insertions(+), 24 deletions(-) create mode 100644 deployments/api/Dockerfile.api diff --git a/.dockerignore b/.dockerignore index 3307546..c2ce185 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,5 +7,6 @@ **/.pytest_cache **/.mypy_cache **/.ruff_cache +**/tests build diff --git a/deployments/api/Dockerfile b/deployments/api/Dockerfile index 2526754..dcb4895 100644 --- a/deployments/api/Dockerfile +++ b/deployments/api/Dockerfile @@ -1,44 +1,61 @@ -FROM python:3.12.12-slim-trixie AS builder +# ── shared base: Python + uv ────────────────────────────────────────── +FROM python:3.12.12-slim-trixie AS base-uv +COPY --from=ghcr.io/astral-sh/uv:0.9.25 /uv /uvx /bin/ -# better console streaming for docker logs ENV PYTHONUNBUFFERED=1 \ UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy WORKDIR /app -# Install uv -# Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-uv -COPY --from=ghcr.io/astral-sh/uv:0.9.25 /uv /uvx /bin/ +COPY deployments/api/pyproject.toml deployments/api/pyproject.toml -# --- Copy workspace metadata -COPY pyproject.toml uv.lock ./ +# -- base installs 3rd part deps only +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-install-workspace --package stitch-api -# Copy the member pyprojects (and/or whole packages) that are needed to resolve deps -COPY deployments/api/pyproject.toml deployments/api/pyproject.toml -COPY deployments/api/src /app/src +# ── dev target: sync editable, we'll use `develop` in docker compose to watch/rebuild +FROM base-uv AS dev -COPY packages/stitch-core/pyproject.toml packages/stitch-core/pyproject.toml -COPY packages/stitch-core/src packages/stitch-core/src +COPY ./deployments/api /app/deployments/api +COPY ./packages /app/packages -# Install deps for the api project (important: target the subproject) -# Create venv and sync using the lock for the API project RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --project deployments/api --no-install-workspace + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --package stitch-api + + +ENV PATH="/app/.venv/bin:$PATH" -# ------------------------------------------------------- +CMD ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000"] + +# ── builder: full source → wheel + sdist ────────────────────────────── +FROM base-uv AS builder -FROM python:3.12.12-slim-trixie AS runtime +COPY pyproject.toml uv.lock ./ +COPY packages/stitch-core packages/stitch-core +COPY deployments/api deployments/api + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv build --package stitch-api --out-dir /app/dist + +# ── prod target: clean image with only the installed wheel ──────────── +FROM python:3.12.12-slim-trixie AS prod ENV PYTHONUNBUFFERED=1 WORKDIR /app -# Copy the ready-to-run virtualenv -COPY --from=builder /app/.venv /app/.venv -ENV PATH="/app/.venv/bin:$PATH" +RUN useradd -m -u 1000 appuser -# Copy only the API source (runtime code) -COPY deployments/api/src /app/src -ENV PYTHONPATH=/app/src +COPY --from=builder /app/dist /tmp/dist +RUN python -m venv /app/.venv && \ + /app/.venv/bin/pip install --no-cache-dir --find-links /tmp/dist stitch-api && \ + rm -rf /tmp/dist + +ENV PATH="/app/.venv/bin:$PATH" +USER appuser CMD ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deployments/api/Dockerfile.api b/deployments/api/Dockerfile.api new file mode 100644 index 0000000..e8d6236 --- /dev/null +++ b/deployments/api/Dockerfile.api @@ -0,0 +1,56 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder + +ENV PYTHONUNBUFFERED=1 + +ENV UV_COMPILE_BYTECODE=1 + +ENV UV_LINK_MODE=copy + +# Omit development dependencies +ENV UV_NO_DEV=1 + +# Disable Python downloads, because we want to use the system interpreter +# across both images. If using a managed Python version, it needs to be +# copied from the build image into the final image; see `standalone.Dockerfile` +# for an example. +ENV UV_PYTHON_DOWNLOADS=0 + +WORKDIR /app +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-install-workspace --package stitch-api + +COPY ./deployments/api /app/deployments/api +COPY ./packages /app/packages + +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --package stitch-api + + +# Then, use a final image without uv +FROM python:3.12-slim-bookworm +# It is important to use the image that matches the builder, as the path to the +# Python executable must be the same, e.g., using `python:3.11-slim-bookworm` +# will fail. + +# Setup a non-root user +RUN groupadd --system --gid 999 nonroot \ + && useradd --system --gid 999 --uid 999 --create-home nonroot + +# Copy the application from the builder +COPY --from=builder --chown=nonroot:nonroot /app /app + +# Place executables in the environment at the front of the path +ENV PATH="/app/.venv/bin:$PATH" + +# Use the non-root user to run our application +USER nonroot + +# Use `/app` as the working directory +WORKDIR /app + +# Run the FastAPI application by default +CMD ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.yml b/docker-compose.yml index 4adf7d2..fc0fe6e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,9 @@ services: build: context: . dockerfile: deployments/api/Dockerfile + target: dev volumes: - - ./deployments/api/src:/app/src + - .:/app env_file: - .env environment: @@ -58,6 +59,9 @@ services: build: context: . dockerfile: deployments/api/Dockerfile + target: dev + volumes: + - .:/app env_file: - .env environment: From 412596f16ceac9aa550dc173ddafde17ce43f5a3 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Fri, 6 Feb 2026 11:29:43 -0700 Subject: [PATCH 02/26] feat(docker): reconcile Dockerfile & compose for dev/prod targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite Dockerfile: 3 stages (base→dev→prod), drop builder stage - Switch from trixie to bookworm, add UV_NO_DEV and UV_PYTHON_DOWNLOADS=0 - Prod copies venv from dev instead of building/installing wheels - Replace volume bind mounts with develop.watch in compose - Add --reload via compose command override - Delete Dockerfile.api prototype --- deployments/api/Dockerfile | 43 ++++++++++---------------- deployments/api/Dockerfile.api | 56 ---------------------------------- docker-compose.yml | 36 +++++++++++++++++----- 3 files changed, 44 insertions(+), 91 deletions(-) delete mode 100644 deployments/api/Dockerfile.api diff --git a/deployments/api/Dockerfile b/deployments/api/Dockerfile index dcb4895..872593b 100644 --- a/deployments/api/Dockerfile +++ b/deployments/api/Dockerfile @@ -1,23 +1,25 @@ -# ── shared base: Python + uv ────────────────────────────────────────── -FROM python:3.12.12-slim-trixie AS base-uv +# ── base: Python + uv + third-party deps ───────────────────────────── +FROM python:3.12-slim-bookworm AS base + COPY --from=ghcr.io/astral-sh/uv:0.9.25 /uv /uvx /bin/ ENV PYTHONUNBUFFERED=1 \ UV_COMPILE_BYTECODE=1 \ - UV_LINK_MODE=copy + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=0 \ + UV_NO_DEV=1 WORKDIR /app -COPY deployments/api/pyproject.toml deployments/api/pyproject.toml - -# -- base installs 3rd part deps only +# Third-party deps only — cached until uv.lock changes. +# All metadata via bind mounts (not copied into the layer). RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --frozen --no-install-workspace --package stitch-api -# ── dev target: sync editable, we'll use `develop` in docker compose to watch/rebuild -FROM base-uv AS dev +# ── dev target ──────────────────────────────────────────────────────── +FROM base AS dev COPY ./deployments/api /app/deployments/api COPY ./packages /app/packages @@ -27,35 +29,22 @@ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --locked --package stitch-api - ENV PATH="/app/.venv/bin:$PATH" CMD ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000"] -# ── builder: full source → wheel + sdist ────────────────────────────── -FROM base-uv AS builder - -COPY pyproject.toml uv.lock ./ -COPY packages/stitch-core packages/stitch-core -COPY deployments/api deployments/api - -RUN --mount=type=cache,target=/root/.cache/uv \ - uv build --package stitch-api --out-dir /app/dist - -# ── prod target: clean image with only the installed wheel ──────────── -FROM python:3.12.12-slim-trixie AS prod +# ── prod target ─────────────────────────────────────────────────────── +FROM python:3.12-slim-bookworm AS prod ENV PYTHONUNBUFFERED=1 WORKDIR /app -RUN useradd -m -u 1000 appuser +RUN groupadd --system --gid 999 nonroot \ + && useradd --system --gid 999 --uid 999 --create-home nonroot -COPY --from=builder /app/dist /tmp/dist -RUN python -m venv /app/.venv && \ - /app/.venv/bin/pip install --no-cache-dir --find-links /tmp/dist stitch-api && \ - rm -rf /tmp/dist +COPY --from=dev --chown=nonroot:nonroot /app /app ENV PATH="/app/.venv/bin:$PATH" -USER appuser +USER nonroot CMD ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deployments/api/Dockerfile.api b/deployments/api/Dockerfile.api deleted file mode 100644 index e8d6236..0000000 --- a/deployments/api/Dockerfile.api +++ /dev/null @@ -1,56 +0,0 @@ -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder - -ENV PYTHONUNBUFFERED=1 - -ENV UV_COMPILE_BYTECODE=1 - -ENV UV_LINK_MODE=copy - -# Omit development dependencies -ENV UV_NO_DEV=1 - -# Disable Python downloads, because we want to use the system interpreter -# across both images. If using a managed Python version, it needs to be -# copied from the build image into the final image; see `standalone.Dockerfile` -# for an example. -ENV UV_PYTHON_DOWNLOADS=0 - -WORKDIR /app -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-install-workspace --package stitch-api - -COPY ./deployments/api /app/deployments/api -COPY ./packages /app/packages - -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --locked --package stitch-api - - -# Then, use a final image without uv -FROM python:3.12-slim-bookworm -# It is important to use the image that matches the builder, as the path to the -# Python executable must be the same, e.g., using `python:3.11-slim-bookworm` -# will fail. - -# Setup a non-root user -RUN groupadd --system --gid 999 nonroot \ - && useradd --system --gid 999 --uid 999 --create-home nonroot - -# Copy the application from the builder -COPY --from=builder --chown=nonroot:nonroot /app /app - -# Place executables in the environment at the front of the path -ENV PATH="/app/.venv/bin:$PATH" - -# Use the non-root user to run our application -USER nonroot - -# Use `/app` as the working directory -WORKDIR /app - -# Run the FastAPI application by default -CMD ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.yml b/docker-compose.yml index fc0fe6e..24a9a52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,31 @@ services: - api: build: context: . dockerfile: deployments/api/Dockerfile target: dev - volumes: - - .:/app + command: + - uvicorn + - stitch.api.main:app + - --host + - "0.0.0.0" + - --port + - "8000" + - --reload + develop: + watch: + - path: ./deployments/api/src + action: sync + target: /app/deployments/api/src + - path: ./packages/stitch-core/src + action: sync + target: /app/packages/stitch-core/src + - path: ./deployments/api/pyproject.toml + action: rebuild + - path: ./packages/stitch-core/pyproject.toml + action: rebuild + - path: ./uv.lock + action: rebuild env_file: - .env environment: @@ -19,7 +38,7 @@ services: STITCH_DB_PASSWORD: ${STITCH_APP_PASSWORD} ports: - - "8000:8000" + - "8000:8000" depends_on: db: condition: service_healthy @@ -29,7 +48,11 @@ services: db: image: postgres:17-alpine healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-stitch} || exit 1"] + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-stitch} || exit 1", + ] interval: 10s timeout: 5s retries: 10 @@ -54,14 +77,11 @@ services: db: condition: service_healthy - db-init: build: context: . dockerfile: deployments/api/Dockerfile target: dev - volumes: - - .:/app env_file: - .env environment: From d61aaeaa0b190a9229c6772eaca3447077a1a78b Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Mon, 9 Feb 2026 15:53:22 -0700 Subject: [PATCH 03/26] fix(docker): tighten Dockerfile and compose config --- deployments/api/Dockerfile | 20 +++++++++++--------- docker-compose.yml | 10 +--------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/deployments/api/Dockerfile b/deployments/api/Dockerfile index 872593b..a85e90d 100644 --- a/deployments/api/Dockerfile +++ b/deployments/api/Dockerfile @@ -1,7 +1,5 @@ # ── base: Python + uv + third-party deps ───────────────────────────── -FROM python:3.12-slim-bookworm AS base - -COPY --from=ghcr.io/astral-sh/uv:0.9.25 /uv /uvx /bin/ +FROM python:3.12-slim-trixie AS base ENV PYTHONUNBUFFERED=1 \ UV_COMPILE_BYTECODE=1 \ @@ -9,6 +7,8 @@ ENV PYTHONUNBUFFERED=1 \ UV_PYTHON_DOWNLOADS=0 \ UV_NO_DEV=1 +COPY --from=ghcr.io/astral-sh/uv:0.9.25 /uv /uvx /bin/ + WORKDIR /app # Third-party deps only — cached until uv.lock changes. @@ -31,20 +31,22 @@ RUN --mount=type=cache,target=/root/.cache/uv \ ENV PATH="/app/.venv/bin:$PATH" -CMD ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] # ── prod target ─────────────────────────────────────────────────────── -FROM python:3.12-slim-bookworm AS prod +FROM python:3.12-slim-trixie AS prod ENV PYTHONUNBUFFERED=1 WORKDIR /app -RUN groupadd --system --gid 999 nonroot \ - && useradd --system --gid 999 --uid 999 --create-home nonroot +RUN groupadd --system --gid 999 stitch-app \ + && useradd --system --gid 999 --uid 999 --create-home stitch-app -COPY --from=dev --chown=nonroot:nonroot /app /app +COPY --from=dev --chown=stitch-app:stitch-app /app/.venv /app/.venv +COPY --from=dev --chown=stitch-app:stitch-app /app/deployments/api /app/deployments/api +COPY --from=dev --chown=stitch-app:stitch-app /app/packages/stitch-core /app/packages/stitch-core ENV PATH="/app/.venv/bin:$PATH" -USER nonroot +USER stitch-app CMD ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.yml b/docker-compose.yml index 24a9a52..6b1186a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,14 +4,6 @@ services: context: . dockerfile: deployments/api/Dockerfile target: dev - command: - - uvicorn - - stitch.api.main:app - - --host - - "0.0.0.0" - - --port - - "8000" - - --reload develop: watch: - path: ./deployments/api/src @@ -35,7 +27,7 @@ services: POSTGRES_PORT: 5432 # API connects as the app role (no DDL) STITCH_DB_USER: stitch_app - STITCH_DB_PASSWORD: ${STITCH_APP_PASSWORD} + STITCH_DB_PASSWORD: ${STITCH_APP_PASSWORD:?STITCH_APP_PASSWORD must be set in .env} ports: - "8000:8000" From 8d5b19ac5255fffe1d4ce2d83a33f3d2a3b0d6df Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Mon, 9 Feb 2026 16:04:44 -0700 Subject: [PATCH 04/26] refactor(docker): split compose into base and local dev override --- Makefile | 8 ++++++-- README.md | 13 +++++++++---- docker-compose.local.yml | 23 +++++++++++++++++++++++ docker-compose.yml | 15 --------------- 4 files changed, 38 insertions(+), 21 deletions(-) create mode 100644 docker-compose.local.yml diff --git a/Makefile b/Makefile index 30404db..06f1035 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ UV ?= uv -DOCKER_COMPOSE := docker compose +DOCKER_COMPOSE := docker compose -f docker-compose.yml +DOCKER_COMPOSE_DEV := $(DOCKER_COMPOSE) -f docker-compose.local.yml PYTEST := $(UV) run pytest RUFF := $(UV) run ruff @@ -127,9 +128,12 @@ frontend-clean: # docker clean-docker: - $(DOCKER_COMPOSE) down --volumes --remove-orphans + $(DOCKER_COMPOSE_DEV) down --volumes --remove-orphans dev-docker: + $(DOCKER_COMPOSE_DEV) up + +prod-docker: $(DOCKER_COMPOSE) up .PHONY: all build clean \ diff --git a/README.md b/README.md index 007c0b9..83f124e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ Stitch is a platform that integrates diverse oil & gas asset datasets, applies A Local development is run via Docker Compose (DB + API + Frontend) with optional DB initialization/seeding. +The stack uses two compose files: + +- **`docker-compose.yml`** — base services (API, DB, frontend, etc.) +- **`docker-compose.local.yml`** — local dev overrides (dev build target, debug logging, file-watch sync) + ### Prerequisites - Docker Desktop (includes Docker Engine + Docker Compose) @@ -29,7 +34,7 @@ Edit `.env` as needed (passwords, seed settings, etc.). Start (and build) the stack: ```bash -docker compose up --build +docker compose -f docker-compose.yml -f docker-compose.local.yml up --build # or, if you have make installed: make dev-docker @@ -37,7 +42,7 @@ make dev-docker or, if already built: ```bash -docker compose up db api frontend +docker compose -f docker-compose.yml -f docker-compose.local.yml up db api frontend ``` Useful URLs: @@ -54,7 +59,7 @@ Note: The `db-init` service runs automatically (via `depends_on`) to apply schem Stop containers and delete the Postgres volume (this removes all local DB data): ```bash -docker compose down -v +docker compose -f docker-compose.yml -f docker-compose.local.yml down -v # or, if you have make installed: make clean-docker @@ -62,5 +67,5 @@ make clean-docker Then start fresh: ```bash -docker compose up db api frontend +docker compose -f docker-compose.yml -f docker-compose.local.yml up db api frontend ``` diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..c25bfad --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,23 @@ +services: + api: + build: + target: dev + environment: + LOG_LEVEL: debug + develop: + watch: + # Source directories — synced into the running container. + # NOTE: these must stay in sync with workspace deps of stitch-api. + - path: ./deployments/api/src + action: sync + target: /app/deployments/api/src + - path: ./packages/stitch-core/src + action: sync + target: /app/packages/stitch-core/src + # Dependency changes — full rebuild. + - path: ./deployments/api/pyproject.toml + action: rebuild + - path: ./packages/stitch-core/pyproject.toml + action: rebuild + - path: ./uv.lock + action: rebuild diff --git a/docker-compose.yml b/docker-compose.yml index 6b1186a..38bc365 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,21 +3,6 @@ services: build: context: . dockerfile: deployments/api/Dockerfile - target: dev - develop: - watch: - - path: ./deployments/api/src - action: sync - target: /app/deployments/api/src - - path: ./packages/stitch-core/src - action: sync - target: /app/packages/stitch-core/src - - path: ./deployments/api/pyproject.toml - action: rebuild - - path: ./packages/stitch-core/pyproject.toml - action: rebuild - - path: ./uv.lock - action: rebuild env_file: - .env environment: From d985079c04432478eba98c7864afbe53c0ab4028 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Mon, 9 Feb 2026 16:31:11 -0700 Subject: [PATCH 05/26] docs(readme): add Make Targets section --- README.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 83f124e..8d9c356 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,11 @@ Edit `.env` as needed (passwords, seed settings, etc.). Start (and build) the stack: ```bash docker compose -f docker-compose.yml -f docker-compose.local.yml up --build - -# or, if you have make installed: -make dev-docker ``` -or, if already built: +Or use `make dev-docker` (see [Make Targets](#make-targets)). + +Or, if already built: ```bash docker compose -f docker-compose.yml -f docker-compose.local.yml up db api frontend ``` @@ -60,12 +59,60 @@ Note: The `db-init` service runs automatically (via `depends_on`) to apply schem Stop containers and delete the Postgres volume (this removes all local DB data): ```bash docker compose -f docker-compose.yml -f docker-compose.local.yml down -v - -# or, if you have make installed: -make clean-docker ``` Then start fresh: ```bash docker compose -f docker-compose.yml -f docker-compose.local.yml up db api frontend ``` + +## Make Targets + +Most common operations have `make` shortcuts. Run `make ` from the repo root. + +### Build + +| Target | Description | +|---|---| +| `make all` | Build all Python packages and the frontend | +| `make build-python` | Build all discovered Python packages (under `packages/`) | +| `make build-python PKG=stitch-core` | Build a single package by name | +| `make frontend` | Build the frontend | + +Python package discovery is automatic — any subdirectory of `packages/` with a `pyproject.toml` is included. Builds are incremental via stamp files under `build/`. + +### Check / Lint / Test + +| Target | Description | +|---|---| +| `make check` | Run all checks (lint, test, format-check, lock-check) | +| `make lint` | Run Python and frontend linters | +| `make test` | Run Python and frontend tests | +| `make format` | Auto-format Python and frontend code | +| `make format-check` | Check formatting without modifying files | +| `make lock-check` | Verify `uv.lock` is up to date | + +### Docker + +| Target | Description | +|---|---| +| `make dev-docker` | Start the full local-dev stack | +| `make prod-docker` | Start without local-dev overrides | +| `make docker-exec SVC=api` | Open a shell in a running container | +| `make docker-run SVC=api` | Spin up a one-off container with a shell | +| `make docker-logs SVC=api` | Tail logs for a service | +| `make docker-ps` | List running containers | +| `make stop-docker` | Stop containers (keep volumes) | +| `make clean-docker` | Stop containers and delete volumes | + +`SVC` defaults to `api` if omitted. + +### Clean + +| Target | Description | +|---|---| +| `make clean` | Run all clean targets | +| `make clean-build` | Remove `build/` and `dist/` | +| `make clean-cache` | Remove `.ruff_cache` and `.pytest_cache` | +| `make clean-docker` | Stop containers and delete volumes | +| `make frontend-clean` | Remove frontend `dist/`, `node_modules`, and stamps | From 4bb97ee4cac4fe52b398121b6444bbf21da8f85d Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Mon, 9 Feb 2026 16:58:04 -0700 Subject: [PATCH 06/26] refactor(docker): use volume mounts instead of develop.watch --- deployments/api/Dockerfile | 2 +- docker-compose.local.yml | 20 +++----------------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/deployments/api/Dockerfile b/deployments/api/Dockerfile index a85e90d..d587cfd 100644 --- a/deployments/api/Dockerfile +++ b/deployments/api/Dockerfile @@ -44,7 +44,7 @@ RUN groupadd --system --gid 999 stitch-app \ COPY --from=dev --chown=stitch-app:stitch-app /app/.venv /app/.venv COPY --from=dev --chown=stitch-app:stitch-app /app/deployments/api /app/deployments/api -COPY --from=dev --chown=stitch-app:stitch-app /app/packages/stitch-core /app/packages/stitch-core +COPY --from=dev --chown=stitch-app:stitch-app /app/packages /app/packages ENV PATH="/app/.venv/bin:$PATH" USER stitch-app diff --git a/docker-compose.local.yml b/docker-compose.local.yml index c25bfad..787d01f 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -4,20 +4,6 @@ services: target: dev environment: LOG_LEVEL: debug - develop: - watch: - # Source directories — synced into the running container. - # NOTE: these must stay in sync with workspace deps of stitch-api. - - path: ./deployments/api/src - action: sync - target: /app/deployments/api/src - - path: ./packages/stitch-core/src - action: sync - target: /app/packages/stitch-core/src - # Dependency changes — full rebuild. - - path: ./deployments/api/pyproject.toml - action: rebuild - - path: ./packages/stitch-core/pyproject.toml - action: rebuild - - path: ./uv.lock - action: rebuild + volumes: + - ./deployments/api/src:/app/deployments/api/src + - ./packages:/app/packages From 689b916f2e22ac4ea732a9ecf8f29163a807f5ef Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Mon, 9 Feb 2026 17:26:32 -0700 Subject: [PATCH 07/26] refactor(docker): collapse to 2-stage build (base + runtime) --- deployments/api/Dockerfile | 24 +++++------------------- docker-compose.local.yml | 13 ++++++++++--- docker-compose.yml | 1 - 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/deployments/api/Dockerfile b/deployments/api/Dockerfile index d587cfd..ef14924 100644 --- a/deployments/api/Dockerfile +++ b/deployments/api/Dockerfile @@ -18,8 +18,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --frozen --no-install-workspace --package stitch-api -# ── dev target ──────────────────────────────────────────────────────── -FROM base AS dev +# ── runtime target ──────────────────────────────────────────────────── +FROM base AS runtime + +RUN groupadd --system --gid 999 stitch-app \ + && useradd --system --gid 999 --uid 999 --create-home stitch-app COPY ./deployments/api /app/deployments/api COPY ./packages /app/packages @@ -29,23 +32,6 @@ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --locked --package stitch-api -ENV PATH="/app/.venv/bin:$PATH" - -CMD ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] - -# ── prod target ─────────────────────────────────────────────────────── -FROM python:3.12-slim-trixie AS prod - -ENV PYTHONUNBUFFERED=1 -WORKDIR /app - -RUN groupadd --system --gid 999 stitch-app \ - && useradd --system --gid 999 --uid 999 --create-home stitch-app - -COPY --from=dev --chown=stitch-app:stitch-app /app/.venv /app/.venv -COPY --from=dev --chown=stitch-app:stitch-app /app/deployments/api /app/deployments/api -COPY --from=dev --chown=stitch-app:stitch-app /app/packages /app/packages - ENV PATH="/app/.venv/bin:$PATH" USER stitch-app diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 787d01f..d5aad61 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,9 +1,16 @@ services: api: - build: - target: dev environment: LOG_LEVEL: debug volumes: - ./deployments/api/src:/app/deployments/api/src - - ./packages:/app/packages + - ./packages/stitch-core/src:/app/packages/stitch-core/src + develop: + watch: + - path: ./uv.lock + action: rebuild + - path: ./deployments/api/pyproject.toml + action: rebuild + + command: + ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/docker-compose.yml b/docker-compose.yml index 38bc365..99622a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,7 +58,6 @@ services: build: context: . dockerfile: deployments/api/Dockerfile - target: dev env_file: - .env environment: From 25d3a62dbe622d17d73118ed1760e7faf6102364 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Mon, 9 Feb 2026 20:50:21 -0700 Subject: [PATCH 08/26] refactor(compose): widen volume mounts with scoped reload dirs --- docker-compose.local.yml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index d5aad61..1808182 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -4,13 +4,18 @@ services: LOG_LEVEL: debug volumes: - ./deployments/api/src:/app/deployments/api/src - - ./packages/stitch-core/src:/app/packages/stitch-core/src - develop: - watch: - - path: ./uv.lock - action: rebuild - - path: ./deployments/api/pyproject.toml - action: rebuild - + - ./packages:/app/packages command: - ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + - uvicorn + - stitch.api.main:app + - --host + - "0.0.0.0" + - --port + - "8000" + - --reload + - --reload-dir + - /app/deployments/api/src + - --reload-dir + - /app/packages + - --reload-exclude + - "*/tests/*" From d5de21bb380489d741c0aafa615a0030759b91eb Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 5 Feb 2026 15:20:02 +0100 Subject: [PATCH 09/26] dev(db): Adds a dev user to the seeding script for API --- deployments/api/src/stitch/api/db/init_job.py | 23 +++++++++++++++++++ deployments/api/src/stitch/api/deps.py | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/deployments/api/src/stitch/api/db/init_job.py b/deployments/api/src/stitch/api/db/init_job.py index 72b9d93..c36bcd1 100644 --- a/deployments/api/src/stitch/api/db/init_job.py +++ b/deployments/api/src/stitch/api/db/init_job.py @@ -27,6 +27,7 @@ User as UserEntity, WMData, ) +from stitch.api.deps import get_current_user """ DB init/seed job. @@ -263,6 +264,17 @@ def create_seed_user() -> UserModel: email="seed@example.com", ) +def create_dev_user() -> UserModel: + dev_user = get_current_user() + print("[db-init] getting info for Dev User...", flush=True) + print(f"[db-init] User: '{dev_user}'...", flush=True) + return UserModel( + id=dev_user.id, + first_name=dev_user.name, + last_name="Deverson", + email=dev_user.email, + ) + def create_seed_sources(): gem_sources = [ @@ -356,16 +368,27 @@ def seed_dev(engine) -> None: session.add(user_model) session.flush() + dev_model = create_dev_user() + session.add(dev_model) + session.flush() + user_entity = UserEntity( id=user_model.id, email=user_model.email, name=f"{user_model.first_name} {user_model.last_name}", ) + dev_entity = UserEntity( + id=dev_model.id, + email=dev_model.email, + name=f"{dev_model.first_name} {dev_model.last_name}", + ) + gem_sources, wm_sources, rmi_sources, cc_sources = create_seed_sources() session.add_all(gem_sources + wm_sources + rmi_sources + cc_sources) resources = create_seed_resources(user_entity) + resources = create_seed_resources(dev_entity) session.add_all(resources) memberships = create_seed_memberships( diff --git a/deployments/api/src/stitch/api/deps.py b/deployments/api/src/stitch/api/deps.py index 8074f3f..49932e4 100644 --- a/deployments/api/src/stitch/api/deps.py +++ b/deployments/api/src/stitch/api/deps.py @@ -7,7 +7,7 @@ def get_current_user() -> User: """Placeholder user dependency. Replace with real auth in production.""" - return User(id=111, role="admin", email="admin@stitch.com", name="Stitch Admin") + return User(id=2, role="admin", email="dev@example.com", name="Dev") CurrentUser = Annotated[User, Depends(get_current_user)] From 0f9d57ad7bda30f66783b53005005225c37fe1ca Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 5 Feb 2026 15:32:06 +0100 Subject: [PATCH 10/26] style: ruff --- deployments/api/src/stitch/api/db/init_job.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deployments/api/src/stitch/api/db/init_job.py b/deployments/api/src/stitch/api/db/init_job.py index c36bcd1..d188ec5 100644 --- a/deployments/api/src/stitch/api/db/init_job.py +++ b/deployments/api/src/stitch/api/db/init_job.py @@ -264,6 +264,7 @@ def create_seed_user() -> UserModel: email="seed@example.com", ) + def create_dev_user() -> UserModel: dev_user = get_current_user() print("[db-init] getting info for Dev User...", flush=True) From 3aa0d05a8ca7f11c705d172b5b024e1cc5f29f5e Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:07:09 -0700 Subject: [PATCH 11/26] feat(auth): add stitch-auth package for OIDC JWT validation --- packages/stitch-auth/.python-version | 1 + packages/stitch-auth/pyproject.toml | 31 +++ .../stitch-auth/src/stitch/auth/__init__.py | 14 ++ .../stitch-auth/src/stitch/auth/claims.py | 8 + .../stitch-auth/src/stitch/auth/errors.py | 11 ++ packages/stitch-auth/src/stitch/auth/py.typed | 0 .../stitch-auth/src/stitch/auth/settings.py | 31 +++ .../stitch-auth/src/stitch/auth/validator.py | 54 ++++++ packages/stitch-auth/tests/conftest.py | 115 ++++++++++++ .../stitch-auth/tests/test_claims_unit.py | 57 ++++++ packages/stitch-auth/tests/test_settings.py | 107 +++++++++++ .../stitch-auth/tests/test_validator_unit.py | 177 ++++++++++++++++++ 12 files changed, 606 insertions(+) create mode 100644 packages/stitch-auth/.python-version create mode 100644 packages/stitch-auth/pyproject.toml create mode 100644 packages/stitch-auth/src/stitch/auth/__init__.py create mode 100644 packages/stitch-auth/src/stitch/auth/claims.py create mode 100644 packages/stitch-auth/src/stitch/auth/errors.py create mode 100644 packages/stitch-auth/src/stitch/auth/py.typed create mode 100644 packages/stitch-auth/src/stitch/auth/settings.py create mode 100644 packages/stitch-auth/src/stitch/auth/validator.py create mode 100644 packages/stitch-auth/tests/conftest.py create mode 100644 packages/stitch-auth/tests/test_claims_unit.py create mode 100644 packages/stitch-auth/tests/test_settings.py create mode 100644 packages/stitch-auth/tests/test_validator_unit.py diff --git a/packages/stitch-auth/.python-version b/packages/stitch-auth/.python-version new file mode 100644 index 0000000..763b626 --- /dev/null +++ b/packages/stitch-auth/.python-version @@ -0,0 +1 @@ +3.12.12 diff --git a/packages/stitch-auth/pyproject.toml b/packages/stitch-auth/pyproject.toml new file mode 100644 index 0000000..b8db2ae --- /dev/null +++ b/packages/stitch-auth/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "stitch-auth" +version = "0.1.0" +description = "Provider-agnostic OIDC JWT validation for Stitch" +authors = [{ name = "Michael Barlow", email = "mbarlow@rmi.org" }] +requires-python = ">=3.12.12" +dependencies = [ + "pyjwt[crypto]>=2.9.0", + "pydantic>=2.0", + "pydantic-settings>=2.11.0", +] + +[build-system] +requires = ["uv_build>=0.9.5,<0.10.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "stitch.auth" + +[dependency-groups] +dev = [ + "pytest>=8.0", + "cryptography>=44.0.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = ["-v", "--strict-markers", "--tb=short"] diff --git a/packages/stitch-auth/src/stitch/auth/__init__.py b/packages/stitch-auth/src/stitch/auth/__init__.py new file mode 100644 index 0000000..47c0459 --- /dev/null +++ b/packages/stitch-auth/src/stitch/auth/__init__.py @@ -0,0 +1,14 @@ +from .claims import TokenClaims +from .errors import AuthError, JWKSFetchError, TokenExpiredError, TokenValidationError +from .settings import OIDCSettings +from .validator import JWTValidator + +__all__ = [ + "AuthError", + "JWKSFetchError", + "JWTValidator", + "OIDCSettings", + "TokenClaims", + "TokenExpiredError", + "TokenValidationError", +] diff --git a/packages/stitch-auth/src/stitch/auth/claims.py b/packages/stitch-auth/src/stitch/auth/claims.py new file mode 100644 index 0000000..632d056 --- /dev/null +++ b/packages/stitch-auth/src/stitch/auth/claims.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, Field + + +class TokenClaims(BaseModel): + sub: str + email: str | None = None + name: str | None = None + raw: dict = Field(default_factory=dict) diff --git a/packages/stitch-auth/src/stitch/auth/errors.py b/packages/stitch-auth/src/stitch/auth/errors.py new file mode 100644 index 0000000..4892306 --- /dev/null +++ b/packages/stitch-auth/src/stitch/auth/errors.py @@ -0,0 +1,11 @@ +class AuthError(Exception): + """Base for all auth errors. Consumers can catch broadly or narrowly.""" + + +class TokenExpiredError(AuthError): ... + + +class TokenValidationError(AuthError): ... + + +class JWKSFetchError(AuthError): ... diff --git a/packages/stitch-auth/src/stitch/auth/py.typed b/packages/stitch-auth/src/stitch/auth/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/stitch-auth/src/stitch/auth/settings.py b/packages/stitch-auth/src/stitch/auth/settings.py new file mode 100644 index 0000000..e364ccd --- /dev/null +++ b/packages/stitch-auth/src/stitch/auth/settings.py @@ -0,0 +1,31 @@ +from typing import Self + +from pydantic import model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class OIDCSettings(BaseSettings): + issuer: str = "" + audience: str = "" + jwks_uri: str = "" + algorithms: tuple[str, ...] = ("RS256",) + jwks_cache_ttl: int = 600 + clock_skew_seconds: int = 30 + disabled: bool = False + + model_config = SettingsConfigDict( + env_prefix="AUTH_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + @model_validator(mode="after") + def _require_fields_when_enabled(self) -> Self: + if not self.disabled: + missing = [ + f for f in ("issuer", "audience", "jwks_uri") if not getattr(self, f) + ] + if missing: + raise ValueError(f"Required when AUTH_DISABLED is not true: {missing}") + return self diff --git a/packages/stitch-auth/src/stitch/auth/validator.py b/packages/stitch-auth/src/stitch/auth/validator.py new file mode 100644 index 0000000..947b900 --- /dev/null +++ b/packages/stitch-auth/src/stitch/auth/validator.py @@ -0,0 +1,54 @@ +from datetime import timedelta + +import jwt +from jwt import PyJWKClient + +from .claims import TokenClaims +from .errors import JWKSFetchError, TokenExpiredError, TokenValidationError +from .settings import OIDCSettings + + +class JWTValidator: + def __init__(self, settings: OIDCSettings) -> None: + self._settings = settings + self._jwks_client = PyJWKClient( + uri=settings.jwks_uri, + cache_jwk_set=True, + lifespan=settings.jwks_cache_ttl, + ) + + def validate(self, token: str) -> TokenClaims: + try: + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + except (jwt.PyJWKClientError, jwt.PyJWKClientConnectionError) as e: + raise JWKSFetchError(str(e)) from e + + try: + payload = jwt.decode( + token, + signing_key.key, + algorithms=list(self._settings.algorithms), + audience=self._settings.audience, + issuer=self._settings.issuer, + leeway=timedelta(seconds=self._settings.clock_skew_seconds), + options={ + "require": ["exp", "iss", "aud", "sub", "nbf"], + "verify_exp": True, + "verify_iss": True, + "verify_aud": True, + }, + ) + except jwt.ExpiredSignatureError as e: + raise TokenExpiredError(str(e)) from e + except jwt.InvalidTokenError as e: + raise TokenValidationError(str(e)) from e + + email = payload.get("email") or payload.get("preferred_username") + name = payload.get("name") + + return TokenClaims( + sub=payload["sub"], + email=email, + name=name, + raw=payload, + ) diff --git a/packages/stitch-auth/tests/conftest.py b/packages/stitch-auth/tests/conftest.py new file mode 100644 index 0000000..c8b2c15 --- /dev/null +++ b/packages/stitch-auth/tests/conftest.py @@ -0,0 +1,115 @@ +"""Pytest fixtures for stitch-auth tests. + +Provides an RSA keypair, JWKS endpoint mock, and a token factory +for testing JWTValidator without hitting real OIDC providers. +""" + +import time +from typing import Any +from unittest.mock import MagicMock, patch + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from jwt import PyJWK +from jwt.algorithms import RSAAlgorithm + +from stitch.auth.settings import OIDCSettings + + +@pytest.fixture +def rsa_private_key() -> rsa.RSAPrivateKey: + """Generate a fresh RSA private key for signing test tokens.""" + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture +def rsa_private_key_pem(rsa_private_key: rsa.RSAPrivateKey) -> bytes: + """PEM-encoded private key for PyJWT signing.""" + return rsa_private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + +@pytest.fixture +def rsa_public_jwk(rsa_private_key: rsa.RSAPrivateKey) -> dict[str, Any]: + """JWK dict for the public key (as returned by a JWKS endpoint).""" + public_key = rsa_private_key.public_key() + jwk_dict = RSAAlgorithm.to_jwk(public_key, as_dict=True) + jwk_dict["kid"] = "test-key-1" + jwk_dict["use"] = "sig" + jwk_dict["alg"] = "RS256" + return jwk_dict + + +@pytest.fixture +def oidc_settings() -> OIDCSettings: + """OIDC settings for tests — auth enabled with test values.""" + return OIDCSettings( + issuer="https://test.auth0.com/", + audience="https://api.test.example.com", + jwks_uri="https://test.auth0.com/.well-known/jwks.json", + algorithms=("RS256",), + clock_skew_seconds=30, + disabled=False, + ) + + +@pytest.fixture +def token_factory(rsa_private_key_pem: bytes): + """Factory that creates signed JWTs with configurable claims.""" + + def _make_token( + sub: str = "auth0|user123", + email: str | None = "user@example.com", + name: str | None = "Test User", + iss: str = "https://test.auth0.com/", + aud: str = "https://api.test.example.com", + exp: int | None = None, + nbf: int | None = None, + iat: int | None = None, + kid: str = "test-key-1", + extra_claims: dict[str, Any] | None = None, + ) -> str: + now = int(time.time()) + payload: dict[str, Any] = { + "sub": sub, + "iss": iss, + "aud": aud, + "exp": exp if exp is not None else now + 3600, + "nbf": nbf if nbf is not None else now - 10, + "iat": iat if iat is not None else now, + } + if email is not None: + payload["email"] = email + if name is not None: + payload["name"] = name + if extra_claims: + payload.update(extra_claims) + + return jwt.encode( + payload, + rsa_private_key_pem, + algorithm="RS256", + headers={"kid": kid}, + ) + + return _make_token + + +@pytest.fixture +def mock_jwks_client(rsa_public_jwk: dict[str, Any]): + """Patches PyJWKClient.get_signing_key_from_jwt to return our test key.""" + signing_key = PyJWK.from_dict(rsa_public_jwk) + + mock_client = MagicMock() + mock_client.get_signing_key_from_jwt.return_value = signing_key + + with patch( + "stitch.auth.validator.PyJWKClient", return_value=mock_client + ) as mock_cls: + mock_cls._instance = mock_client + yield mock_client diff --git a/packages/stitch-auth/tests/test_claims_unit.py b/packages/stitch-auth/tests/test_claims_unit.py new file mode 100644 index 0000000..5b7a5db --- /dev/null +++ b/packages/stitch-auth/tests/test_claims_unit.py @@ -0,0 +1,57 @@ +"""Tests for TokenClaims construction edge cases.""" + +import pytest + +from pydantic import ValidationError +from stitch.auth.claims import TokenClaims + + +class TestTokenClaimsConstruction: + """TokenClaims model validation.""" + + def test_minimal_claims(self): + """Only sub is required.""" + claims = TokenClaims(sub="auth0|abc123") + + assert claims.sub == "auth0|abc123" + assert claims.email is None + assert claims.name is None + assert claims.raw == {} + + def test_full_claims(self): + """All fields populated.""" + claims = TokenClaims( + sub="auth0|abc123", + email="user@example.com", + name="Jane Doe", + raw={"custom": "value"}, + ) + + assert claims.sub == "auth0|abc123" + assert claims.email == "user@example.com" + assert claims.name == "Jane Doe" + assert claims.raw["custom"] == "value" + + def test_sub_is_required(self): + """Missing sub raises ValidationError.""" + with pytest.raises(ValidationError): + TokenClaims() # pyright: ignore[reportCallIssue] + + def test_raw_defaults_to_empty_dict(self): + """raw field defaults to empty dict, not shared reference.""" + claims1 = TokenClaims(sub="user1") + claims2 = TokenClaims(sub="user2") + + claims1.raw["key"] = "value" + + assert "key" not in claims2.raw + + def test_uuid_sub(self): + """Entra ID-style UUID sub.""" + claims = TokenClaims(sub="550e8400-e29b-41d4-a716-446655440000") + assert claims.sub == "550e8400-e29b-41d4-a716-446655440000" + + def test_pipe_sub(self): + """Auth0-style sub with pipe separator.""" + claims = TokenClaims(sub="auth0|abc123") + assert claims.sub == "auth0|abc123" diff --git a/packages/stitch-auth/tests/test_settings.py b/packages/stitch-auth/tests/test_settings.py new file mode 100644 index 0000000..e8f0a90 --- /dev/null +++ b/packages/stitch-auth/tests/test_settings.py @@ -0,0 +1,107 @@ +"""Tests for OIDCSettings parsing and validation.""" + +import pytest +from pydantic import ValidationError + +from stitch.auth.settings import OIDCSettings + + +class TestOIDCSettingsValidation: + """Validation rules for OIDCSettings.""" + + def test_disabled_requires_no_other_fields(self): + """AUTH_DISABLED=true should work without issuer/audience/jwks_uri.""" + settings = OIDCSettings(disabled=True) + + assert settings.disabled is True + assert settings.issuer == "" + assert settings.audience == "" + assert settings.jwks_uri == "" + + def test_enabled_requires_issuer_audience_jwks_uri(self): + """Missing required fields when auth is enabled raises ValidationError.""" + with pytest.raises( + ValidationError, match="Required when AUTH_DISABLED is not true" + ): + OIDCSettings(disabled=False) + + def test_enabled_partial_fields_raises(self): + """Providing only some required fields still raises.""" + with pytest.raises( + ValidationError, match="Required when AUTH_DISABLED is not true" + ): + OIDCSettings( + issuer="https://test.auth0.com/", + audience="", + jwks_uri="", + ) + + def test_enabled_all_fields_succeeds(self): + """All required fields provided when enabled.""" + settings = OIDCSettings( + issuer="https://test.auth0.com/", + audience="https://api.example.com", + jwks_uri="https://test.auth0.com/.well-known/jwks.json", + ) + + assert settings.issuer == "https://test.auth0.com/" + assert settings.audience == "https://api.example.com" + assert settings.disabled is False + + +class TestOIDCSettingsDefaults: + """Default values for OIDCSettings.""" + + def test_default_algorithms(self): + settings = OIDCSettings( + issuer="https://x.com/", + audience="aud", + jwks_uri="https://x.com/jwks", + ) + assert settings.algorithms == ("RS256",) + + def test_default_cache_ttl(self): + settings = OIDCSettings( + issuer="https://x.com/", + audience="aud", + jwks_uri="https://x.com/jwks", + ) + assert settings.jwks_cache_ttl == 600 + + def test_default_clock_skew(self): + settings = OIDCSettings( + issuer="https://x.com/", + audience="aud", + jwks_uri="https://x.com/jwks", + ) + assert settings.clock_skew_seconds == 30 + + def test_default_disabled_is_false(self): + """disabled defaults to False (auth is on by default).""" + with pytest.raises(ValidationError): + OIDCSettings() + + +class TestOIDCSettingsFromEnv: + """Settings can be loaded from environment variables.""" + + def test_from_env_vars(self, monkeypatch): + monkeypatch.setenv("AUTH_ISSUER", "https://env.auth0.com/") + monkeypatch.setenv("AUTH_AUDIENCE", "https://api.env.example.com") + monkeypatch.setenv( + "AUTH_JWKS_URI", "https://env.auth0.com/.well-known/jwks.json" + ) + monkeypatch.setenv("AUTH_CLOCK_SKEW_SECONDS", "60") + + settings = OIDCSettings() + + assert settings.issuer == "https://env.auth0.com/" + assert settings.audience == "https://api.env.example.com" + assert settings.clock_skew_seconds == 60 + + def test_disabled_from_env(self, monkeypatch): + monkeypatch.setenv("AUTH_DISABLED", "true") + + settings = OIDCSettings() + + assert settings.disabled is True diff --git a/packages/stitch-auth/tests/test_validator_unit.py b/packages/stitch-auth/tests/test_validator_unit.py new file mode 100644 index 0000000..f4eae71 --- /dev/null +++ b/packages/stitch-auth/tests/test_validator_unit.py @@ -0,0 +1,177 @@ +"""Unit tests for JWTValidator with mocked JWKS.""" + +import time + +import jwt as pyjwt +import pytest + +from stitch.auth.errors import JWKSFetchError, TokenExpiredError, TokenValidationError +from stitch.auth.validator import JWTValidator + + +class TestJWTValidatorHappyPath: + """Successful token validation scenarios.""" + + def test_validates_token_returns_claims( + self, oidc_settings, mock_jwks_client, token_factory + ): + """Valid token produces TokenClaims with correct fields.""" + token = token_factory( + sub="auth0|abc123", + email="user@example.com", + name="Jane Doe", + ) + validator = JWTValidator(oidc_settings) + + claims = validator.validate(token) + + assert claims.sub == "auth0|abc123" + assert claims.email == "user@example.com" + assert claims.name == "Jane Doe" + assert claims.raw["sub"] == "auth0|abc123" + + def test_email_fallback_to_preferred_username( + self, oidc_settings, mock_jwks_client, token_factory + ): + """When email claim is absent, falls back to preferred_username.""" + token = token_factory( + email=None, + extra_claims={"preferred_username": "user@tenant.onmicrosoft.com"}, + ) + validator = JWTValidator(oidc_settings) + + claims = validator.validate(token) + + assert claims.email == "user@tenant.onmicrosoft.com" + + def test_optional_claims_can_be_absent( + self, oidc_settings, mock_jwks_client, token_factory + ): + """email and name are optional.""" + token = token_factory(email=None, name=None) + validator = JWTValidator(oidc_settings) + + claims = validator.validate(token) + + assert claims.email is None + assert claims.name is None + assert claims.sub == "auth0|user123" + + def test_raw_contains_full_payload( + self, oidc_settings, mock_jwks_client, token_factory + ): + """raw dict contains the complete JWT payload.""" + token = token_factory( + extra_claims={"given_name": "Jane", "family_name": "Doe"}, + ) + validator = JWTValidator(oidc_settings) + + claims = validator.validate(token) + + assert claims.raw["given_name"] == "Jane" + assert claims.raw["family_name"] == "Doe" + + def test_uuid_sub_format(self, oidc_settings, mock_jwks_client, token_factory): + """Entra ID-style UUID sub is treated as opaque string.""" + token = token_factory(sub="550e8400-e29b-41d4-a716-446655440000") + validator = JWTValidator(oidc_settings) + + claims = validator.validate(token) + + assert claims.sub == "550e8400-e29b-41d4-a716-446655440000" + + +class TestJWTValidatorErrors: + """Error handling and exception mapping.""" + + def test_expired_token_raises_token_expired_error( + self, oidc_settings, mock_jwks_client, token_factory + ): + """Expired token raises TokenExpiredError.""" + token = token_factory(exp=int(time.time()) - 3600) + validator = JWTValidator(oidc_settings) + + with pytest.raises(TokenExpiredError): + validator.validate(token) + + def test_wrong_audience_raises_token_validation_error( + self, oidc_settings, mock_jwks_client, token_factory + ): + """Mismatched audience raises TokenValidationError.""" + token = token_factory(aud="https://wrong-audience.example.com") + validator = JWTValidator(oidc_settings) + + with pytest.raises(TokenValidationError): + validator.validate(token) + + def test_wrong_issuer_raises_token_validation_error( + self, oidc_settings, mock_jwks_client, token_factory + ): + """Mismatched issuer raises TokenValidationError.""" + token = token_factory(iss="https://wrong-issuer.example.com/") + validator = JWTValidator(oidc_settings) + + with pytest.raises(TokenValidationError): + validator.validate(token) + + def test_jwks_fetch_error(self, oidc_settings, mock_jwks_client): + """JWKS client error raises JWKSFetchError.""" + mock_jwks_client.get_signing_key_from_jwt.side_effect = pyjwt.PyJWKClientError( + "Connection refused" + ) + validator = JWTValidator(oidc_settings) + + with pytest.raises(JWKSFetchError, match="Connection refused"): + validator.validate("some.invalid.token") + + def test_jwks_connection_error(self, oidc_settings, mock_jwks_client): + """JWKS connection error raises JWKSFetchError.""" + mock_jwks_client.get_signing_key_from_jwt.side_effect = ( + pyjwt.PyJWKClientConnectionError("Timeout") + ) + validator = JWTValidator(oidc_settings) + + with pytest.raises(JWKSFetchError, match="Timeout"): + validator.validate("some.invalid.token") + + def test_nbf_in_future_raises_token_validation_error( + self, oidc_settings, mock_jwks_client, token_factory + ): + """Token not yet valid (nbf in future) raises TokenValidationError.""" + token = token_factory(nbf=int(time.time()) + 3600) + validator = JWTValidator(oidc_settings) + + with pytest.raises(TokenValidationError): + validator.validate(token) + + def test_tampered_token_raises_token_validation_error( + self, oidc_settings, mock_jwks_client, token_factory, rsa_private_key_pem + ): + """Token signed with wrong key raises TokenValidationError.""" + from cryptography.hazmat.primitives.asymmetric import rsa as rsa_mod + from cryptography.hazmat.primitives import serialization + + other_key = rsa_mod.generate_private_key(public_exponent=65537, key_size=2048) + other_pem = other_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + token = pyjwt.encode( + { + "sub": "auth0|user123", + "iss": "https://test.auth0.com/", + "aud": "https://api.test.example.com", + "exp": int(time.time()) + 3600, + "nbf": int(time.time()) - 10, + }, + other_pem, + algorithm="RS256", + headers={"kid": "test-key-1"}, + ) + + validator = JWTValidator(oidc_settings) + + with pytest.raises(TokenValidationError): + validator.validate(token) From a62e20d0010c999c8c25164892d40e0a6f149ea4 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:47:34 -0700 Subject: [PATCH 12/26] build: add stitch-auth to workspace members --- pyproject.toml | 3 +- uv.lock | 165 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a4d8390..87d0322 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,11 @@ requires-python = ">=3.12" dependencies = ["stitch-core"] [tool.uv.workspace] -members = ["deployments/api", "packages/stitch-core"] +members = ["deployments/api", "packages/stitch-core", "packages/stitch-auth"] [tool.uv.sources] stitch-core = { workspace = true } +stitch-auth = { workspace = true } [dependency-groups] dev = ["pytest>=8.4.2", "ruff>=0.14.2"] diff --git a/uv.lock b/uv.lock index 9c97c2b..ed186f8 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,7 @@ requires-python = ">=3.12.12" members = [ "stitch", "stitch-api", + "stitch-auth", "stitch-core", ] @@ -58,6 +59,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -79,6 +137,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, + { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, + { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, + { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, + { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, + { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, + { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, + { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, + { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, + { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, + { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -530,6 +641,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -657,6 +777,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -973,6 +1107,7 @@ dependencies = [ { name = "greenlet" }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, + { name = "stitch-auth" }, { name = "stitch-core" }, ] @@ -990,6 +1125,7 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.3.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, + { name = "stitch-auth", editable = "packages/stitch-auth" }, { name = "stitch-core", editable = "packages/stitch-core" }, ] @@ -1001,6 +1137,35 @@ dev = [ { name = "pytest-anyio", specifier = ">=0.0.0" }, ] +[[package]] +name = "stitch-auth" +version = "0.1.0" +source = { editable = "packages/stitch-auth" } +dependencies = [ + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "cryptography" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic-settings", specifier = ">=2.11.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "cryptography", specifier = ">=44.0.0" }, + { name = "pytest", specifier = ">=8.0" }, +] + [[package]] name = "stitch-core" version = "0.1.0" From 4301aba03c180459c687db878fd6ac0fb706f03b Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:52:20 -0700 Subject: [PATCH 13/26] fix(lock): regenerate uv.lock without API stitch-auth dep --- uv.lock | 2 -- 1 file changed, 2 deletions(-) diff --git a/uv.lock b/uv.lock index ed186f8..a806a75 100644 --- a/uv.lock +++ b/uv.lock @@ -1107,7 +1107,6 @@ dependencies = [ { name = "greenlet" }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, - { name = "stitch-auth" }, { name = "stitch-core" }, ] @@ -1125,7 +1124,6 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.3.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, - { name = "stitch-auth", editable = "packages/stitch-auth" }, { name = "stitch-core", editable = "packages/stitch-core" }, ] From 967d86257ded77795743a2ac5cedd3aaefb3dae5 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:47:58 -0700 Subject: [PATCH 14/26] build(api): add stitch-auth dependency --- deployments/api/pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deployments/api/pyproject.toml b/deployments/api/pyproject.toml index bbf2243..4eff8e4 100644 --- a/deployments/api/pyproject.toml +++ b/deployments/api/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "greenlet>=3.3.0", "pydantic-settings>=2.12.0", "sqlalchemy>=2.0.44", + "stitch-auth", "stitch-core", ] @@ -39,4 +40,5 @@ python_functions = ["test_*"] addopts = ["-v", "--strict-markers", "--tb=short"] [tool.uv.sources] +stitch-auth = { workspace = true } stitch-core = { workspace = true } From 296271e55a5a61dcc1f55cc355403177ff357908 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:07:26 -0700 Subject: [PATCH 15/26] feat(api): add sub column to User, consolidate first_name/last_name to name --- deployments/api/src/stitch/api/db/init_job.py | 22 +++++++++---------- .../api/src/stitch/api/db/model/user.py | 7 ++++-- deployments/api/src/stitch/api/entities.py | 1 + 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/deployments/api/src/stitch/api/db/init_job.py b/deployments/api/src/stitch/api/db/init_job.py index d188ec5..5a256c2 100644 --- a/deployments/api/src/stitch/api/db/init_job.py +++ b/deployments/api/src/stitch/api/db/init_job.py @@ -27,7 +27,6 @@ User as UserEntity, WMData, ) -from stitch.api.deps import get_current_user """ DB init/seed job. @@ -259,21 +258,18 @@ def fail_partial(existing_tables: set[str], expected: set[str]) -> None: def create_seed_user() -> UserModel: return UserModel( id=1, - first_name="Seed", - last_name="User", + sub="seed|system", + name="Seed User", email="seed@example.com", ) def create_dev_user() -> UserModel: - dev_user = get_current_user() - print("[db-init] getting info for Dev User...", flush=True) - print(f"[db-init] User: '{dev_user}'...", flush=True) return UserModel( - id=dev_user.id, - first_name=dev_user.name, - last_name="Deverson", - email=dev_user.email, + id=2, + sub="dev|local-placeholder", + name="Dev Deverson", + email="dev@example.com", ) @@ -375,14 +371,16 @@ def seed_dev(engine) -> None: user_entity = UserEntity( id=user_model.id, + sub=user_model.sub, email=user_model.email, - name=f"{user_model.first_name} {user_model.last_name}", + name=user_model.name, ) dev_entity = UserEntity( id=dev_model.id, + sub=dev_model.sub, email=dev_model.email, - name=f"{dev_model.first_name} {dev_model.last_name}", + name=dev_model.name, ) gem_sources, wm_sources, rmi_sources, cc_sources = create_seed_sources() diff --git a/deployments/api/src/stitch/api/db/model/user.py b/deployments/api/src/stitch/api/db/model/user.py index 9f8bbb7..9c2047e 100644 --- a/deployments/api/src/stitch/api/db/model/user.py +++ b/deployments/api/src/stitch/api/db/model/user.py @@ -1,10 +1,13 @@ +from sqlalchemy import String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from .common import Base class User(Base): __tablename__ = "users" + __table_args__ = (UniqueConstraint("sub", name="uq_users_sub"),) + id: Mapped[int] = mapped_column(primary_key=True) - first_name: Mapped[str] - last_name: Mapped[str] + sub: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + name: Mapped[str] email: Mapped[str] diff --git a/deployments/api/src/stitch/api/entities.py b/deployments/api/src/stitch/api/entities.py index 4401800..337a5ee 100644 --- a/deployments/api/src/stitch/api/entities.py +++ b/deployments/api/src/stitch/api/entities.py @@ -164,6 +164,7 @@ class CreateResource(ResourceBase): class User(BaseModel): id: int = Field(...) + sub: str = Field(...) role: str | None = None email: EmailStr name: str From bbebbb8d3862862e9952b8e09d326ba4d235150a Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:08:12 -0700 Subject: [PATCH 16/26] feat(api): integrate JWT auth with JIT user provisioning --- deployments/api/src/stitch/api/deps.py | 133 +++++++++++++++++- deployments/api/src/stitch/api/main.py | 2 + .../api/src/stitch/api/routers/resources.py | 6 +- deployments/api/tests/conftest.py | 14 +- 4 files changed, 145 insertions(+), 10 deletions(-) diff --git a/deployments/api/src/stitch/api/deps.py b/deployments/api/src/stitch/api/deps.py index 49932e4..11282a2 100644 --- a/deployments/api/src/stitch/api/deps.py +++ b/deployments/api/src/stitch/api/deps.py @@ -1,13 +1,138 @@ +import asyncio +import logging +from functools import lru_cache from typing import Annotated -from fastapi import Depends +from fastapi import Depends, HTTPException, Request +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from starlette.status import HTTP_401_UNAUTHORIZED +from stitch.auth import JWTValidator, OIDCSettings, TokenClaims +from stitch.auth.errors import AuthError, JWKSFetchError + +from stitch.api.db.config import UnitOfWorkDep +from stitch.api.db.model.user import User as UserModel from stitch.api.entities import User +from stitch.api.settings import Environment, get_settings + +logger = logging.getLogger(__name__) + + +@lru_cache +def get_oidc_settings() -> OIDCSettings: + return OIDCSettings() + + +@lru_cache +def get_jwt_validator() -> JWTValidator: + return JWTValidator(get_oidc_settings()) + + +_DEV_CLAIMS = TokenClaims( + sub="dev|local-placeholder", + email="dev@example.com", + name="Dev User", + raw={}, +) + + +def validate_auth_config_at_startup() -> None: + """Called from FastAPI lifespan. Fail fast if misconfigured.""" + settings = get_oidc_settings() + if settings.disabled and get_settings().environment == Environment.PROD: + raise RuntimeError( + "AUTH_DISABLED=true is forbidden when ENVIRONMENT=prod. " + "Remove AUTH_DISABLED or set it to false." + ) + + +async def get_token_claims(request: Request) -> TokenClaims: + """Extract and validate JWT from Authorization header.""" + settings = get_oidc_settings() + + if settings.disabled: + return _DEV_CLAIMS + + auth_header = request.headers.get("Authorization") + if not auth_header: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Missing Authorization header", + headers={"WWW-Authenticate": "Bearer"}, + ) + + scheme, _, token = auth_header.partition(" ") + if scheme.lower() != "bearer" or not token: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Invalid Authorization header format", + headers={"WWW-Authenticate": "Bearer"}, + ) + + validator = get_jwt_validator() + try: + return await asyncio.to_thread(validator.validate, token) + except JWKSFetchError: + logger.error( + "JWKS endpoint unreachable or returned invalid data", exc_info=True + ) + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + except AuthError: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +Claims = Annotated[TokenClaims, Depends(get_token_claims)] + + +async def get_current_user(claims: Claims, uow: UnitOfWorkDep) -> User: + """Resolve TokenClaims to a User entity. JIT provision on first login. + + Race-safe: uses a savepoint so concurrent first-login requests + don't corrupt the outer transaction on IntegrityError. + """ + session = uow.session + + user_model = ( + await session.execute(select(UserModel).where(UserModel.sub == claims.sub)) + ).scalar_one_or_none() + + if user_model is not None: + user_model.name = claims.name or user_model.name + user_model.email = claims.email or user_model.email + return _to_entity(user_model) + + try: + async with session.begin_nested(): + user_model = UserModel( + sub=claims.sub, + name=claims.name or "", + email=claims.email or "", + ) + session.add(user_model) + except IntegrityError: + user_model = ( + await session.execute(select(UserModel).where(UserModel.sub == claims.sub)) + ).scalar_one() + + return _to_entity(user_model) -def get_current_user() -> User: - """Placeholder user dependency. Replace with real auth in production.""" - return User(id=2, role="admin", email="dev@example.com", name="Dev") +def _to_entity(model: UserModel) -> User: + return User( + id=model.id, + sub=model.sub, + email=model.email, + name=model.name, + ) CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/deployments/api/src/stitch/api/main.py b/deployments/api/src/stitch/api/main.py index b8cd779..9d192f6 100644 --- a/deployments/api/src/stitch/api/main.py +++ b/deployments/api/src/stitch/api/main.py @@ -5,6 +5,7 @@ from starlette.status import HTTP_503_SERVICE_UNAVAILABLE from .middleware import register_middlewares from .db.config import dispose_engine +from .deps import validate_auth_config_at_startup from .settings import get_settings from .routers.resources import router as resource_router @@ -17,6 +18,7 @@ @asynccontextmanager async def lifespan(app: FastAPI): + validate_auth_config_at_startup() yield await dispose_engine() diff --git a/deployments/api/src/stitch/api/routers/resources.py b/deployments/api/src/stitch/api/routers/resources.py index 8f769cd..6458952 100644 --- a/deployments/api/src/stitch/api/routers/resources.py +++ b/deployments/api/src/stitch/api/routers/resources.py @@ -15,12 +15,14 @@ @router.get("/") -async def get_all_resources(*, uow: UnitOfWorkDep) -> Sequence[Resource]: +async def get_all_resources( + *, uow: UnitOfWorkDep, user: CurrentUser +) -> Sequence[Resource]: return await resource_actions.get_all(session=uow.session) @router.get("/{id}", response_model=Resource) -async def get_resource(*, uow: UnitOfWorkDep, id: int) -> Resource: +async def get_resource(*, uow: UnitOfWorkDep, user: CurrentUser, id: int) -> Resource: return await resource_actions.get(session=uow.session, id=id) diff --git a/deployments/api/tests/conftest.py b/deployments/api/tests/conftest.py index 2551a91..c85cf60 100644 --- a/deployments/api/tests/conftest.py +++ b/deployments/api/tests/conftest.py @@ -37,7 +37,9 @@ def anyio_backend() -> str: @pytest.fixture def test_user() -> User: """Test user entity for dependency injection.""" - return User(id=1, email="test@test.com", name="Test User", role="admin") + return User( + id=1, sub="test|user-1", email="test@test.com", name="Test User", role="admin" + ) @pytest.fixture @@ -45,8 +47,8 @@ def test_user_model() -> UserModel: """Test user ORM model for database seeding.""" return UserModel( id=1, - first_name="Test", - last_name="User", + sub="test|user-1", + name="Test User", email="test@test.com", ) @@ -84,9 +86,13 @@ def mock_uow(mock_session: MagicMock) -> MagicMock: @pytest.fixture(autouse=True) def reset_dependency_overrides(): - """Reset FastAPI dependency overrides after each test.""" + """Reset FastAPI dependency overrides and auth caches after each test.""" yield app.dependency_overrides = {} + from stitch.api.deps import get_oidc_settings, get_jwt_validator + + get_oidc_settings.cache_clear() + get_jwt_validator.cache_clear() @pytest.fixture From 387ccd4fde7241743532cc27d730eccc8a7067f0 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 13:58:46 -0700 Subject: [PATCH 17/26] infra: add stitch-auth to Docker build, AUTH_DISABLED for local dev --- env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/env.example b/env.example index b222c8f..a51f749 100644 --- a/env.example +++ b/env.example @@ -12,3 +12,6 @@ STITCH_DB_SEED_MODE="if-needed" STITCH_DB_SEED_PROFILE="dev" FRONTEND_ORIGIN_URL=http://localhost:3000 + +# Auth (AUTH_DISABLED=true bypasses JWT validation for local dev) +AUTH_DISABLED=true From cc17a255b8626e59d267af0132314587599bd392 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:24:12 -0700 Subject: [PATCH 18/26] fix(stitch.api.db): improve type hints & LBYL in uow --- deployments/api/src/stitch/api/db/config.py | 5 +++-- deployments/api/src/stitch/api/db/model/sources.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/deployments/api/src/stitch/api/db/config.py b/deployments/api/src/stitch/api/db/config.py index 8394abd..be7c8f0 100644 --- a/deployments/api/src/stitch/api/db/config.py +++ b/deployments/api/src/stitch/api/db/config.py @@ -33,8 +33,9 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: await self.rollback() else: await self.commit() - await self._session.close() - self._session = None + if self._session is not None: + await self._session.close() + self._session = None async def commit(self) -> None: await self.session.commit() diff --git a/deployments/api/src/stitch/api/db/model/sources.py b/deployments/api/src/stitch/api/db/model/sources.py index f05fc2d..a315b16 100644 --- a/deployments/api/src/stitch/api/db/model/sources.py +++ b/deployments/api/src/stitch/api/db/model/sources.py @@ -1,4 +1,5 @@ # pyright: reportAssignmentType=false +from typing_extensions import Self from collections.abc import Mapping, MutableMapping from typing import Final, Generic, TypeVar, TypedDict, get_args, get_origin @@ -67,7 +68,7 @@ def as_entity(self): return self.__entity_class_out__.model_validate(self) @classmethod - def from_entity(cls, entity: TModelIn) -> "SourceBase": + def from_entity(cls, entity: TModelIn) -> Self: mapper = inspect(cls) column_keys = {col.key for col in mapper.columns} filtered = {k: v for k, v in entity.model_dump().items() if k in column_keys} From 3eeaa1ec54063a641d16b33c93815b8ef6a222a5 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:43:42 -0700 Subject: [PATCH 19/26] refactor(api): move auth code from deps.py to stitch.api.auth module --- deployments/api/src/stitch/api/{deps.py => auth.py} | 0 deployments/api/src/stitch/api/db/resource_actions.py | 2 +- deployments/api/src/stitch/api/main.py | 2 +- deployments/api/src/stitch/api/routers/resources.py | 2 +- deployments/api/tests/conftest.py | 4 ++-- 5 files changed, 5 insertions(+), 5 deletions(-) rename deployments/api/src/stitch/api/{deps.py => auth.py} (100%) diff --git a/deployments/api/src/stitch/api/deps.py b/deployments/api/src/stitch/api/auth.py similarity index 100% rename from deployments/api/src/stitch/api/deps.py rename to deployments/api/src/stitch/api/auth.py diff --git a/deployments/api/src/stitch/api/db/resource_actions.py b/deployments/api/src/stitch/api/db/resource_actions.py index 4e407ce..303da4d 100644 --- a/deployments/api/src/stitch/api/db/resource_actions.py +++ b/deployments/api/src/stitch/api/db/resource_actions.py @@ -9,7 +9,7 @@ from starlette.status import HTTP_404_NOT_FOUND from stitch.api.db.model.sources import SOURCE_TABLES, SourceModel -from stitch.api.deps import CurrentUser +from stitch.api.auth import CurrentUser from stitch.api.entities import ( CreateResource, CreateResourceSourceData, diff --git a/deployments/api/src/stitch/api/main.py b/deployments/api/src/stitch/api/main.py index 9d192f6..0cbf561 100644 --- a/deployments/api/src/stitch/api/main.py +++ b/deployments/api/src/stitch/api/main.py @@ -5,7 +5,7 @@ from starlette.status import HTTP_503_SERVICE_UNAVAILABLE from .middleware import register_middlewares from .db.config import dispose_engine -from .deps import validate_auth_config_at_startup +from .auth import validate_auth_config_at_startup from .settings import get_settings from .routers.resources import router as resource_router diff --git a/deployments/api/src/stitch/api/routers/resources.py b/deployments/api/src/stitch/api/routers/resources.py index 6458952..8d63c15 100644 --- a/deployments/api/src/stitch/api/routers/resources.py +++ b/deployments/api/src/stitch/api/routers/resources.py @@ -4,7 +4,7 @@ from stitch.api.db import resource_actions from stitch.api.db.config import UnitOfWorkDep -from stitch.api.deps import CurrentUser +from stitch.api.auth import CurrentUser from stitch.api.entities import CreateResource, Resource diff --git a/deployments/api/tests/conftest.py b/deployments/api/tests/conftest.py index c85cf60..54edf0c 100644 --- a/deployments/api/tests/conftest.py +++ b/deployments/api/tests/conftest.py @@ -16,7 +16,7 @@ UserModel, WMSourceModel, ) -from stitch.api.deps import get_current_user +from stitch.api.auth import get_current_user from stitch.api.entities import User from stitch.api.main import app @@ -89,7 +89,7 @@ def reset_dependency_overrides(): """Reset FastAPI dependency overrides and auth caches after each test.""" yield app.dependency_overrides = {} - from stitch.api.deps import get_oidc_settings, get_jwt_validator + from stitch.api.auth import get_oidc_settings, get_jwt_validator get_oidc_settings.cache_clear() get_jwt_validator.cache_clear() From 7c39caa069f54799af28a5cb4c68c4519385492e Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:54:02 -0700 Subject: [PATCH 20/26] build(lock): update uv.lock for stitch-auth API dependency --- uv.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uv.lock b/uv.lock index a806a75..ed186f8 100644 --- a/uv.lock +++ b/uv.lock @@ -1107,6 +1107,7 @@ dependencies = [ { name = "greenlet" }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, + { name = "stitch-auth" }, { name = "stitch-core" }, ] @@ -1124,6 +1125,7 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.3.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, + { name = "stitch-auth", editable = "packages/stitch-auth" }, { name = "stitch-core", editable = "packages/stitch-core" }, ] From 5bd9155b6930f403b972d64a04a52c3cff25641f Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:58:31 -0700 Subject: [PATCH 21/26] ci: trigger workflow run after base branch change From 5ed8066c09195e1f565052a0247f042b0ee047b5 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 13:43:18 -0700 Subject: [PATCH 22/26] feat(auth): add local auth testing with localauth0 mock OIDC server --- README.md | 39 +++++++ deployments/api/src/stitch/api/auth.py | 18 ++- dev/localauth0.toml | 18 +++ docker-compose.yml | 8 ++ docs/auth-testing.md | 145 +++++++++++++++++++++++++ env.example | 14 ++- 6 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 dev/localauth0.toml create mode 100644 docs/auth-testing.md diff --git a/README.md b/README.md index 8d9c356..6201b28 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,45 @@ Note: The `db-init` service runs automatically (via `depends_on`) to apply schem - `STITCH_DB_SEED_MODE` - `STITCH_DB_SEED_PROFILE` +### Auth Testing (optional) + +By default, auth is disabled (`AUTH_DISABLED=true`) — all requests get a hardcoded dev user with no token required. To test real JWT auth flows locally with a mock OIDC server: + +1. Update `.env`: + ``` + AUTH_DISABLED=false + AUTH_ISSUER=http://localauth0:3000/ + AUTH_AUDIENCE=stitch-api-local + AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json + ``` + +2. Start with the `auth-test` profile: + ```bash + docker compose --profile auth-test up --build + ``` + +3. Get a token and make requests: + ```bash + # Health check (always open) + curl localhost:8000/api/v1/health + + # No token → 401 + curl localhost:8000/api/v1/resources/ + + # Get a valid token + TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"test","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + | jq -r '.access_token') + + # Authenticated request → 200 + curl -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/ + ``` + +Swagger UI (`/docs`) also supports the "Authorize" button for token entry. + +See [docs/auth-testing.md](docs/auth-testing.md) for the full scenario guide. + ## Reset (wipe DB volumes safely) Stop containers and delete the Postgres volume (this removes all local DB data): diff --git a/deployments/api/src/stitch/api/auth.py b/deployments/api/src/stitch/api/auth.py index 11282a2..80d05b2 100644 --- a/deployments/api/src/stitch/api/auth.py +++ b/deployments/api/src/stitch/api/auth.py @@ -4,6 +4,7 @@ from typing import Annotated from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from sqlalchemy.exc import IntegrityError from starlette.status import HTTP_401_UNAUTHORIZED @@ -36,6 +37,10 @@ def get_jwt_validator() -> JWTValidator: raw={}, ) +# auto_error=False so that when AUTH_DISABLED=true the missing header +# doesn't trigger a 403 before our custom handler runs. +_bearer_scheme = HTTPBearer(auto_error=False) + def validate_auth_config_at_startup() -> None: """Called from FastAPI lifespan. Fail fast if misconfigured.""" @@ -47,8 +52,17 @@ def validate_auth_config_at_startup() -> None: ) -async def get_token_claims(request: Request) -> TokenClaims: - """Extract and validate JWT from Authorization header.""" +async def get_token_claims( + request: Request, + _credential: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), +) -> TokenClaims: + """Extract and validate JWT from Authorization header. + + The ``_credential`` parameter exists solely so FastAPI registers the + HTTPBearer security scheme in the OpenAPI spec (Swagger "Authorize" + button). Actual token parsing still uses the raw header so we can + return precise 401 messages for missing/malformed values. + """ settings = get_oidc_settings() if settings.disabled: diff --git a/dev/localauth0.toml b/dev/localauth0.toml new file mode 100644 index 0000000..84777c8 --- /dev/null +++ b/dev/localauth0.toml @@ -0,0 +1,18 @@ +issuer = "http://localauth0:3000/" + +[user_info] +subject = "mock|dev-user-1" +name = "Dev User" +email = "dev@example.com" +email_verified = true + +[[audience]] +name = "stitch-api-local" +permissions = [] + +[[audience]] +name = "wrong-audience" +permissions = [] + +[http] +port = 3000 diff --git a/docker-compose.yml b/docker-compose.yml index 99622a0..a4900a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,5 +81,13 @@ services: ports: - "3000:80" + localauth0: + profiles: [auth-test] + image: public.ecr.aws/primaassicurazioni/localauth0:0.8.3 + ports: + - "3100:3000" + volumes: + - ./dev/localauth0.toml:/localauth0.toml:ro + volumes: db_data: diff --git a/docs/auth-testing.md b/docs/auth-testing.md new file mode 100644 index 0000000..0efc83a --- /dev/null +++ b/docs/auth-testing.md @@ -0,0 +1,145 @@ +# Local Auth Testing Guide + +This guide covers how to test JWT authentication locally using [localauth0](https://github.com/primait/localauth0), a lightweight mock OIDC server. + +## Default Mode (auth disabled) + +By default, `AUTH_DISABLED=true` in `.env`. All API requests are accepted without tokens, and a hardcoded dev user (`sub="dev|local-placeholder"`) is injected. This is the normal local development experience. + +## Enabling Auth Testing + +### 1. Configure environment + +Update `.env` with the auth-test settings: + +``` +AUTH_DISABLED=false +AUTH_ISSUER=http://localauth0:3000/ +AUTH_AUDIENCE=stitch-api-local +AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json +``` + +### 2. Start the stack + +```bash +docker compose --profile auth-test up --build +``` + +This starts the normal stack (db, api, frontend) plus `localauth0` on port 3100 (host) / 3000 (Docker network). + +### 3. Verify localauth0 is running + +```bash +curl -s localhost:3100/.well-known/openid-configuration | jq . +``` + +## Getting Tokens + +### Valid token (correct audience) + +```bash +TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"test","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + | jq -r '.access_token') + +echo $TOKEN +``` + +### Token with wrong audience + +```bash +WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"test","audience":"wrong-audience","grant_type":"client_credentials"}' \ + | jq -r '.access_token') +``` + +## Test Scenarios + +| # | Scenario | Command | Expected | +|---|----------|---------|----------| +| 1 | No Authorization header | `curl localhost:8000/api/v1/resources/` | 401 | +| 2 | Malformed header | `curl -H "Authorization: Basic xyz" localhost:8000/api/v1/resources/` | 401 | +| 3 | Garbage token (wrong signing key) | `curl -H "Authorization: Bearer not.a.real.jwt" localhost:8000/api/v1/resources/` | 401 | +| 4 | Wrong audience | `curl -H "Authorization: Bearer $WRONG_TOKEN" localhost:8000/api/v1/resources/` | 401 | +| 5 | Valid token, first request | `curl -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/` | 200, user JIT-created | +| 6 | Valid token, repeat request | Same as #5 | 200, user info updated | +| 7 | Health endpoint (no auth) | `curl localhost:8000/api/v1/health` | 200 always | + +### Running the scenarios + +```bash +# 1. No token +curl -s -o /dev/null -w "%{http_code}" localhost:8000/api/v1/resources/ +# → 401 + +# 2. Malformed header +curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Basic xyz" localhost:8000/api/v1/resources/ +# → 401 + +# 3. Garbage token +curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer not.a.real.jwt" localhost:8000/api/v1/resources/ +# → 401 + +# 4. Wrong audience +WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"test","audience":"wrong-audience","grant_type":"client_credentials"}' \ + | jq -r '.access_token') +curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $WRONG_TOKEN" localhost:8000/api/v1/resources/ +# → 401 + +# 5. Valid token (first request — JIT user creation) +TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"test","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + | jq -r '.access_token') +curl -s -w "\n%{http_code}" -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/ +# → 200 + +# 6. Same token again (user already exists) +curl -s -w "\n%{http_code}" -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/ +# → 200 + +# 7. Health endpoint (always open) +curl -s -o /dev/null -w "%{http_code}" localhost:8000/api/v1/health +# → 200 +``` + +## Verifying JIT User Provisioning + +After a successful authenticated request, verify the user was created in the database via Adminer: + +1. Open http://localhost:8081 +2. Connect to `stitch` database (user: `postgres`, password: `postgres`) +3. Browse the `users` table +4. You should see a row with `sub = "mock|dev-user-1"` + +## Using Swagger UI + +1. Open http://localhost:8000/docs +2. Click the "Authorize" button (lock icon) +3. Enter a Bearer token obtained from localauth0 +4. Click "Authorize" +5. All subsequent "Try it out" requests will include the token + +## localauth0 Configuration + +The mock server is configured via `dev/localauth0.toml`: + +- **Issuer**: `http://localauth0:3000/` (matches `AUTH_ISSUER`) +- **User**: `sub=mock|dev-user-1`, name "Dev User", email `dev@example.com` +- **Audiences**: `stitch-api-local` (valid) and `wrong-audience` (for testing rejection) +- **Port**: 3000 inside Docker, mapped to 3100 on the host + +## Configuring Real Auth0 + +For staging or production, replace the environment variables with your Auth0 tenant values: + +``` +AUTH_DISABLED=false +AUTH_ISSUER=https://your-tenant.auth0.com/ +AUTH_AUDIENCE=your-api-audience +AUTH_JWKS_URI=https://your-tenant.auth0.com/.well-known/jwks.json +``` diff --git a/env.example b/env.example index a51f749..c9f8389 100644 --- a/env.example +++ b/env.example @@ -13,5 +13,17 @@ STITCH_DB_SEED_PROFILE="dev" FRONTEND_ORIGIN_URL=http://localhost:3000 -# Auth (AUTH_DISABLED=true bypasses JWT validation for local dev) +# --- Auth --- +# AUTH_DISABLED=true bypasses JWT validation for local dev (default). AUTH_DISABLED=true + +# For local auth testing with mock OIDC (docker compose --profile auth-test): +# AUTH_DISABLED=false +# AUTH_ISSUER=http://localauth0:3000/ +# AUTH_AUDIENCE=stitch-api-local +# AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json +# +# For real Auth0: +# AUTH_ISSUER=https://your-tenant.auth0.com/ +# AUTH_AUDIENCE=your-api-audience +# AUTH_JWKS_URI=https://your-tenant.auth0.com/.well-known/jwks.json From dd2099c0ec123634d8de611bbe74908f0bd451f0 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 13:54:34 -0700 Subject: [PATCH 23/26] md,yml formatting and localauth0 config --- docker-compose.yml | 6 +++++- docs/auth-testing.md | 18 +++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a4900a3..1112a33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,10 +84,14 @@ services: localauth0: profiles: [auth-test] image: public.ecr.aws/primaassicurazioni/localauth0:0.8.3 + healthcheck: + test: ["CMD", "/localauth0", "healthcheck"] + environment: + LOCALAUTH0_CONFIG_PATH: /etc/localauth0.toml ports: - "3100:3000" volumes: - - ./dev/localauth0.toml:/localauth0.toml:ro + - ./dev/localauth0.toml:/etc/localauth0.toml:ro volumes: db_data: diff --git a/docs/auth-testing.md b/docs/auth-testing.md index 0efc83a..c92c67c 100644 --- a/docs/auth-testing.md +++ b/docs/auth-testing.md @@ -57,15 +57,15 @@ WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ ## Test Scenarios -| # | Scenario | Command | Expected | -|---|----------|---------|----------| -| 1 | No Authorization header | `curl localhost:8000/api/v1/resources/` | 401 | -| 2 | Malformed header | `curl -H "Authorization: Basic xyz" localhost:8000/api/v1/resources/` | 401 | -| 3 | Garbage token (wrong signing key) | `curl -H "Authorization: Bearer not.a.real.jwt" localhost:8000/api/v1/resources/` | 401 | -| 4 | Wrong audience | `curl -H "Authorization: Bearer $WRONG_TOKEN" localhost:8000/api/v1/resources/` | 401 | -| 5 | Valid token, first request | `curl -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/` | 200, user JIT-created | -| 6 | Valid token, repeat request | Same as #5 | 200, user info updated | -| 7 | Health endpoint (no auth) | `curl localhost:8000/api/v1/health` | 200 always | +| # | Scenario | Command | Expected | +| --- | --------------------------------- | --------------------------------------------------------------------------------- | ---------------------- | +| 1 | No Authorization header | `curl localhost:8000/api/v1/resources/` | 401 | +| 2 | Malformed header | `curl -H "Authorization: Basic xyz" localhost:8000/api/v1/resources/` | 401 | +| 3 | Garbage token (wrong signing key) | `curl -H "Authorization: Bearer not.a.real.jwt" localhost:8000/api/v1/resources/` | 401 | +| 4 | Wrong audience | `curl -H "Authorization: Bearer $WRONG_TOKEN" localhost:8000/api/v1/resources/` | 401 | +| 5 | Valid token, first request | `curl -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/` | 200, user JIT-created | +| 6 | Valid token, repeat request | Same as #5 | 200, user info updated | +| 7 | Health endpoint (no auth) | `curl localhost:8000/api/v1/health` | 200 always | ### Running the scenarios From 267bc09b838d1d42f78dfcb875505d3497363867 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 19:26:52 -0700 Subject: [PATCH 24/26] fix(auth): add access_token custom claims, auth-demo script, and docs improvements --- README.md | 4 +- dev/auth-demo.sh | 207 +++++++++++++++++++++++++++++++++++++++ dev/localauth0.toml | 9 ++ docker-compose.local.yml | 11 +++ docker-compose.yml | 12 --- docs/auth-testing.md | 49 +++++++-- 6 files changed, 272 insertions(+), 20 deletions(-) create mode 100755 dev/auth-demo.sh diff --git a/README.md b/README.md index 6201b28..1b04572 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ By default, auth is disabled (`AUTH_DISABLED=true`) — all requests get a hardc 2. Start with the `auth-test` profile: ```bash - docker compose --profile auth-test up --build + docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up --build ``` 3. Get a token and make requests: @@ -82,7 +82,7 @@ By default, auth is disabled (`AUTH_DISABLED=true`) — all requests get a hardc # Get a valid token TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ -H "Content-Type: application/json" \ - -d '{"client_id":"test","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ | jq -r '.access_token') # Authenticated request → 200 diff --git a/dev/auth-demo.sh b/dev/auth-demo.sh new file mode 100755 index 0000000..04fc069 --- /dev/null +++ b/dev/auth-demo.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# +# Interactive auth testing demo. +# Walks through each auth scenario against the local API + localauth0. +# +# Prerequisites: +# 1. .env configured with AUTH_DISABLED=false and localauth0 settings +# 2. Stack running: docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up +# +# Usage: +# bash dev/auth-demo.sh + +set -euo pipefail + +API=localhost:8000/api/v1 +OIDC=localhost:3100 + +BOLD='\033[1m' +DIM='\033[2m' +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +RESET='\033[0m' + +# ── helpers ────────────────────────────────────────────────────────── + +step_number=0 + +wait_for_enter() { + echo "" + read -rp "$(echo -e "${DIM}Press Enter to run...${RESET}")" +} + +show_step() { + step_number=$((step_number + 1)) + local title=$1 + local description=$2 + local expect=$3 + + echo "" + echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" + echo -e "${BOLD} Scenario ${step_number}: ${title}${RESET}" + echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" + echo -e " ${description}" + echo -e " ${CYAN}Expected: ${expect}${RESET}" +} + +show_cmd() { + echo -e "\n ${DIM}\$${RESET} $1" +} + +run_curl() { + local label=$1 + shift + echo "" + # Run curl, capture status code on last line + local response + response=$(curl -s -w "\n%{http_code}" "$@") + local http_code + http_code=$(echo "$response" | tail -1) + local body + body=$(echo "$response" | sed '$d') + + if [ -n "$body" ]; then + echo -e " ${DIM}Body:${RESET}" + # Pretty-print JSON if jq is available, otherwise raw + if command -v jq &>/dev/null; then + echo "$body" | jq . 2>/dev/null | sed 's/^/ /' || echo " $body" + else + echo " $body" + fi + fi + echo -e " ${BOLD}HTTP ${http_code}${RESET}" +} + +# ── preflight checks ──────────────────────────────────────────────── + +echo -e "${BOLD}Auth Testing Demo${RESET}" +echo -e "${DIM}Testing API at ${API}, OIDC at ${OIDC}${RESET}" +echo "" + +echo -n "Checking API... " +if curl -sf -o /dev/null "${API}/health" 2>/dev/null; then + echo -e "${GREEN}OK${RESET}" +else + echo -e "${RED}FAILED${RESET}" + echo " API is not reachable at ${API}. Is the stack running?" + exit 1 +fi + +echo -n "Checking localauth0... " +if curl -sf -o /dev/null "${OIDC}/.well-known/openid-configuration" 2>/dev/null; then + echo -e "${GREEN}OK${RESET}" +else + echo -e "${RED}FAILED${RESET}" + echo " localauth0 is not reachable at ${OIDC}." + echo " Start with: docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up" + exit 1 +fi + +# ── scenario 1: health endpoint (no auth required) ────────────────── + +show_step \ + "Health endpoint (no auth)" \ + "The /health endpoint is always open — no token required." \ + "200" +show_cmd "curl ${API}/health" +wait_for_enter +run_curl "health" "${API}/health" + +# ── scenario 2: no authorization header ───────────────────────────── + +show_step \ + "No Authorization header" \ + "A request with no token at all. The API checks for the Authorization\n header and rejects the request before any JWT parsing happens." \ + "401" +show_cmd "curl ${API}/resources/" +wait_for_enter +run_curl "no-auth" "${API}/resources/" + +# ── scenario 3: malformed header (wrong scheme) ───────────────────── + +show_step \ + "Malformed Authorization header" \ + "Using 'Basic' instead of 'Bearer'. The API parses the scheme and\n rejects anything that isn't 'Bearer '." \ + "401" +show_cmd "curl -H 'Authorization: Basic xyz' ${API}/resources/" +wait_for_enter +run_curl "basic-auth" "${API}/resources/" -H "Authorization: Basic xyz" + +# ── scenario 4: garbage token (wrong signing key) ─────────────────── + +show_step \ + "Garbage token (invalid JWT)" \ + "A string that isn't a valid JWT. The JWKS client can't find a matching\n key ID, so signature verification fails." \ + "401" +show_cmd "curl -H 'Authorization: Bearer not.a.real.jwt' ${API}/resources/" +wait_for_enter +run_curl "garbage" "${API}/resources/" -H "Authorization: Bearer not.a.real.jwt" + +# ── scenario 5: wrong audience ────────────────────────────────────── + +show_step \ + "Valid JWT, wrong audience" \ + "A properly signed token from localauth0, but issued for 'wrong-audience'\n instead of 'stitch-api-local'. The API validates the 'aud' claim and rejects it." \ + "401" + +echo -e "\n ${DIM}Fetching token with audience='wrong-audience'...${RESET}" +WRONG_TOKEN=$(curl -s -X POST "${OIDC}/oauth/token" \ + -H "Content-Type: application/json" \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"wrong-audience","grant_type":"client_credentials"}' \ + | jq -r '.access_token' 2>/dev/null) || true + +if [ -z "$WRONG_TOKEN" ] || [ "$WRONG_TOKEN" = "null" ]; then + echo -e " ${RED}Failed to get token from localauth0${RESET}" + exit 1 +fi +echo -e " ${GREEN}Got token${RESET} ${DIM}(${#WRONG_TOKEN} chars)${RESET}" + +show_cmd "curl -H 'Authorization: Bearer \$WRONG_TOKEN' ${API}/resources/" +wait_for_enter +run_curl "wrong-aud" "${API}/resources/" -H "Authorization: Bearer ${WRONG_TOKEN}" + +# ── scenario 6: valid token, first request (JIT provisioning) ─────── + +show_step \ + "Valid token — first request (JIT user creation)" \ + "A properly signed token with the correct audience. On the first\n authenticated request, the API creates a new user row in the database\n from the token's sub/name/email claims." \ + "200 + user JIT-created in DB" + +echo -e "\n ${DIM}Fetching token with audience='stitch-api-local'...${RESET}" +TOKEN=$(curl -s -X POST "${OIDC}/oauth/token" \ + -H "Content-Type: application/json" \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + | jq -r '.access_token' 2>/dev/null) || true + +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo -e " ${RED}Failed to get token from localauth0${RESET}" + exit 1 +fi +echo -e " ${GREEN}Got token${RESET} ${DIM}(${#TOKEN} chars)${RESET}" + +show_cmd "curl -H 'Authorization: Bearer \$TOKEN' ${API}/resources/" +wait_for_enter +run_curl "valid-first" "${API}/resources/" -H "Authorization: Bearer ${TOKEN}" + +# ── scenario 7: valid token, repeat request ───────────────────────── + +show_step \ + "Valid token — repeat request (user already exists)" \ + "Same token again. The API finds the existing user by 'sub' and updates\n name/email from the token claims. No new row is created." \ + "200 + user info updated" +show_cmd "curl -H 'Authorization: Bearer \$TOKEN' ${API}/resources/" +wait_for_enter +run_curl "valid-repeat" "${API}/resources/" -H "Authorization: Bearer ${TOKEN}" + +# ── done ───────────────────────────────────────────────────────────── + +echo "" +echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" +echo -e "${GREEN}${BOLD} All scenarios complete.${RESET}" +echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" +echo "" +echo -e " Verify JIT user in Adminer: ${CYAN}http://localhost:8081${RESET}" +echo -e " Try Swagger UI: ${CYAN}http://localhost:8000/docs${RESET}" +echo "" diff --git a/dev/localauth0.toml b/dev/localauth0.toml index 84777c8..c98906a 100644 --- a/dev/localauth0.toml +++ b/dev/localauth0.toml @@ -14,5 +14,14 @@ permissions = [] name = "wrong-audience" permissions = [] +# Inject user-profile claims into the access token. +# This mirrors a real Auth0 "Login / Post Login" Action that copies +# email and name into the access_token for API consumption. +[access_token] +custom_claims = [ + { name = "email", value = { String = "dev@example.com" } }, + { name = "name", value = { String = "Dev User" } }, +] + [http] port = 3000 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 1808182..eca7ee2 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -19,3 +19,14 @@ services: - /app/packages - --reload-exclude - "*/tests/*" + localauth0: + profiles: [auth-test] + image: public.ecr.aws/primaassicurazioni/localauth0:0.8.3 + healthcheck: + test: ["CMD", "/localauth0", "healthcheck"] + environment: + LOCALAUTH0_CONFIG_PATH: /etc/localauth0.toml + ports: + - "3100:3000" + volumes: + - ./dev/localauth0.toml:/etc/localauth0.toml:ro diff --git a/docker-compose.yml b/docker-compose.yml index 1112a33..99622a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,17 +81,5 @@ services: ports: - "3000:80" - localauth0: - profiles: [auth-test] - image: public.ecr.aws/primaassicurazioni/localauth0:0.8.3 - healthcheck: - test: ["CMD", "/localauth0", "healthcheck"] - environment: - LOCALAUTH0_CONFIG_PATH: /etc/localauth0.toml - ports: - - "3100:3000" - volumes: - - ./dev/localauth0.toml:/etc/localauth0.toml:ro - volumes: db_data: diff --git a/docs/auth-testing.md b/docs/auth-testing.md index c92c67c..e926381 100644 --- a/docs/auth-testing.md +++ b/docs/auth-testing.md @@ -2,6 +2,27 @@ This guide covers how to test JWT authentication locally using [localauth0](https://github.com/primait/localauth0), a lightweight mock OIDC server. +## How Auth Works in Production + +When `AUTH_DISABLED=false`, every request (except `/health`) goes through a JWT validation pipeline that mirrors a real Auth0 deployment: + +1. **Header parsing** — extract the `Bearer ` from the `Authorization` header +2. **JWKS fetch** — retrieve the signing key from the OIDC provider's `/.well-known/jwks.json` endpoint (cached for 600s) +3. **Signature verification** — verify the token was signed with the provider's private key (RS256) +4. **Claims validation** — the following claims are required and checked: + | Claim | Check | + |-------|-------| + | `exp` | Token has not expired (with 30s clock skew tolerance) | + | `nbf` | Token is not used before its "not before" time | + | `iss` | Issuer matches `AUTH_ISSUER` | + | `aud` | Audience matches `AUTH_AUDIENCE` | + | `sub` | Subject is present (unique user identifier) | +5. **User provisioning** — the `sub` claim is looked up in the `users` table. On first login, a user row is JIT-created; on subsequent logins, `name` and `email` are updated from the token claims. + +Any failure at steps 1-4 returns a **401** with `WWW-Authenticate: Bearer`. + +**Production guardrail:** `AUTH_DISABLED=true` is blocked at startup when `ENVIRONMENT=prod`. + ## Default Mode (auth disabled) By default, `AUTH_DISABLED=true` in `.env`. All API requests are accepted without tokens, and a hardcoded dev user (`sub="dev|local-placeholder"`) is injected. This is the normal local development experience. @@ -22,7 +43,7 @@ AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json ### 2. Start the stack ```bash -docker compose --profile auth-test up --build +docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up --build ``` This starts the normal stack (db, api, frontend) plus `localauth0` on port 3100 (host) / 3000 (Docker network). @@ -35,12 +56,14 @@ curl -s localhost:3100/.well-known/openid-configuration | jq . ## Getting Tokens +Tokens from localauth0 are valid for **24 hours** (`expires_in: 86400`). Expired-token validation is covered by unit tests in `packages/stitch-auth/tests/test_validator_unit.py`. + ### Valid token (correct audience) ```bash TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ -H "Content-Type: application/json" \ - -d '{"client_id":"test","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ | jq -r '.access_token') echo $TOKEN @@ -51,7 +74,7 @@ echo $TOKEN ```bash WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ -H "Content-Type: application/json" \ - -d '{"client_id":"test","audience":"wrong-audience","grant_type":"client_credentials"}' \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"wrong-audience","grant_type":"client_credentials"}' \ | jq -r '.access_token') ``` @@ -67,7 +90,17 @@ WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ | 6 | Valid token, repeat request | Same as #5 | 200, user info updated | | 7 | Health endpoint (no auth) | `curl localhost:8000/api/v1/health` | 200 always | -### Running the scenarios +**Not testable with localauth0:** wrong-issuer rejection (localauth0's issuer is fixed). This is validated in production and covered by unit tests (`test_validator_unit.py::test_wrong_issuer_raises`). + +### Interactive demo script + +Run the scenarios interactively with step-by-step confirmation: + +```bash +bash dev/auth-demo.sh +``` + +### Running the scenarios manually ```bash # 1. No token @@ -85,7 +118,7 @@ curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer not.a.real.jwt" # 4. Wrong audience WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ -H "Content-Type: application/json" \ - -d '{"client_id":"test","audience":"wrong-audience","grant_type":"client_credentials"}' \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"wrong-audience","grant_type":"client_credentials"}' \ | jq -r '.access_token') curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $WRONG_TOKEN" localhost:8000/api/v1/resources/ # → 401 @@ -93,7 +126,7 @@ curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $WRONG_TOKEN" l # 5. Valid token (first request — JIT user creation) TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ -H "Content-Type: application/json" \ - -d '{"client_id":"test","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ | jq -r '.access_token') curl -s -w "\n%{http_code}" -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/ # → 200 @@ -124,6 +157,10 @@ After a successful authenticated request, verify the user was created in the dat 4. Click "Authorize" 5. All subsequent "Try it out" requests will include the token +## CORS and Browser Requests + +The API's CORS middleware explicitly allows the `Authorization` header from the configured `FRONTEND_ORIGIN_URL`. Browser-based requests from the frontend will include the JWT in the `Authorization` header and pass CORS preflight checks. To test this flow, use the frontend at http://localhost:3000 after authenticating via Swagger or configure the frontend to send tokens. + ## localauth0 Configuration The mock server is configured via `dev/localauth0.toml`: From 407bba4591cdc684ed74626a43cb4612e39a4d6c Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 19:42:49 -0700 Subject: [PATCH 25/26] fix(auth): handle garbage tokens in validator, add demo script confirmations --- dev/auth-demo.sh | 10 ++++++++-- packages/stitch-auth/src/stitch/auth/validator.py | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/dev/auth-demo.sh b/dev/auth-demo.sh index 04fc069..fdd0e5e 100755 --- a/dev/auth-demo.sh +++ b/dev/auth-demo.sh @@ -146,7 +146,10 @@ show_step \ "A properly signed token from localauth0, but issued for 'wrong-audience'\n instead of 'stitch-api-local'. The API validates the 'aud' claim and rejects it." \ "401" -echo -e "\n ${DIM}Fetching token with audience='wrong-audience'...${RESET}" +show_cmd "curl -s -X POST ${OIDC}/oauth/token -H 'Content-Type: application/json' \\" +echo -e " ${DIM}-d '{\"audience\":\"wrong-audience\", ...}'${RESET}" +wait_for_enter + WRONG_TOKEN=$(curl -s -X POST "${OIDC}/oauth/token" \ -H "Content-Type: application/json" \ -d '{"client_id":"client_id","client_secret":"client_secret","audience":"wrong-audience","grant_type":"client_credentials"}' \ @@ -169,7 +172,10 @@ show_step \ "A properly signed token with the correct audience. On the first\n authenticated request, the API creates a new user row in the database\n from the token's sub/name/email claims." \ "200 + user JIT-created in DB" -echo -e "\n ${DIM}Fetching token with audience='stitch-api-local'...${RESET}" +show_cmd "curl -s -X POST ${OIDC}/oauth/token -H 'Content-Type: application/json' \\" +echo -e " ${DIM}-d '{\"audience\":\"stitch-api-local\", ...}'${RESET}" +wait_for_enter + TOKEN=$(curl -s -X POST "${OIDC}/oauth/token" \ -H "Content-Type: application/json" \ -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ diff --git a/packages/stitch-auth/src/stitch/auth/validator.py b/packages/stitch-auth/src/stitch/auth/validator.py index 947b900..0ac2532 100644 --- a/packages/stitch-auth/src/stitch/auth/validator.py +++ b/packages/stitch-auth/src/stitch/auth/validator.py @@ -22,6 +22,8 @@ def validate(self, token: str) -> TokenClaims: signing_key = self._jwks_client.get_signing_key_from_jwt(token) except (jwt.PyJWKClientError, jwt.PyJWKClientConnectionError) as e: raise JWKSFetchError(str(e)) from e + except jwt.InvalidTokenError as e: + raise TokenValidationError(str(e)) from e try: payload = jwt.decode( From 5a2dd606e04e4e6bf7c32b2da82f527bd7814d67 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 19:55:10 -0700 Subject: [PATCH 26/26] ci: trigger workflow run after base branch change