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/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..1b04572 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,15 +34,14 @@ Edit `.env` as needed (passwords, seed settings, etc.). Start (and build) the stack: ```bash -docker compose up --build - -# or, if you have make installed: -make dev-docker +docker compose -f docker-compose.yml -f docker-compose.local.yml up --build ``` -or, if already built: +Or use `make dev-docker` (see [Make Targets](#make-targets)). + +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: @@ -50,17 +54,104 @@ 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 -f docker-compose.yml -f docker-compose.local.yml --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":"client_id","client_secret":"client_secret","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): ```bash -docker compose down -v - -# or, if you have make installed: -make clean-docker +docker compose -f docker-compose.yml -f docker-compose.local.yml down -v ``` 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 ``` + +## 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 | diff --git a/deployments/api/Dockerfile b/deployments/api/Dockerfile index 2526754..ef14924 100644 --- a/deployments/api/Dockerfile +++ b/deployments/api/Dockerfile @@ -1,44 +1,38 @@ -FROM python:3.12.12-slim-trixie AS builder +# ── base: Python + uv + third-party deps ───────────────────────────── +FROM python:3.12-slim-trixie AS base -# better console streaming for docker logs 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 - -# 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 workspace metadata -COPY pyproject.toml uv.lock ./ - -# 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 - -COPY packages/stitch-core/pyproject.toml packages/stitch-core/pyproject.toml -COPY packages/stitch-core/src packages/stitch-core/src +WORKDIR /app -# Install deps for the api project (important: target the subproject) -# Create venv and sync using the lock for the API project +# 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 \ - 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 --frozen --no-install-workspace --package stitch-api -# ------------------------------------------------------- +# ── runtime target ──────────────────────────────────────────────────── +FROM base AS runtime -FROM python:3.12.12-slim-trixie AS runtime +RUN groupadd --system --gid 999 stitch-app \ + && useradd --system --gid 999 --uid 999 --create-home stitch-app -ENV PYTHONUNBUFFERED=1 -WORKDIR /app +COPY ./deployments/api /app/deployments/api +COPY ./packages /app/packages -# Copy the ready-to-run virtualenv -COPY --from=builder /app/.venv /app/.venv -ENV PATH="/app/.venv/bin:$PATH" +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 -# Copy only the API source (runtime code) -COPY deployments/api/src /app/src -ENV PYTHONPATH=/app/src +ENV PATH="/app/.venv/bin:$PATH" +USER stitch-app CMD ["uvicorn", "stitch.api.main:app", "--host", "0.0.0.0", "--port", "8000"] 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 } diff --git a/deployments/api/src/stitch/api/auth.py b/deployments/api/src/stitch/api/auth.py new file mode 100644 index 0000000..80d05b2 --- /dev/null +++ b/deployments/api/src/stitch/api/auth.py @@ -0,0 +1,152 @@ +import asyncio +import logging +from functools import lru_cache +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 + +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={}, +) + +# 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.""" + 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, + _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: + 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 _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/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/init_job.py b/deployments/api/src/stitch/api/db/init_job.py index 72b9d93..5a256c2 100644 --- a/deployments/api/src/stitch/api/db/init_job.py +++ b/deployments/api/src/stitch/api/db/init_job.py @@ -258,12 +258,21 @@ 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: + return UserModel( + id=2, + sub="dev|local-placeholder", + name="Dev Deverson", + email="dev@example.com", + ) + + def create_seed_sources(): gem_sources = [ GemSourceModel.from_entity( @@ -356,16 +365,29 @@ 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, + 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=dev_model.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/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} 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/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/deps.py b/deployments/api/src/stitch/api/deps.py deleted file mode 100644 index 8074f3f..0000000 --- a/deployments/api/src/stitch/api/deps.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Annotated - -from fastapi import Depends - -from stitch.api.entities import User - - -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") - - -CurrentUser = Annotated[User, Depends(get_current_user)] 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 diff --git a/deployments/api/src/stitch/api/main.py b/deployments/api/src/stitch/api/main.py index b8cd779..0cbf561 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 .auth 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..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 @@ -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..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 @@ -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.auth import get_oidc_settings, get_jwt_validator + + get_oidc_settings.cache_clear() + get_jwt_validator.cache_clear() @pytest.fixture diff --git a/dev/auth-demo.sh b/dev/auth-demo.sh new file mode 100755 index 0000000..fdd0e5e --- /dev/null +++ b/dev/auth-demo.sh @@ -0,0 +1,213 @@ +#!/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" + +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"}' \ + | 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" + +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"}' \ + | 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 new file mode 100644 index 0000000..c98906a --- /dev/null +++ b/dev/localauth0.toml @@ -0,0 +1,27 @@ +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 = [] + +# 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 new file mode 100644 index 0000000..eca7ee2 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,32 @@ +services: + api: + environment: + LOG_LEVEL: debug + volumes: + - ./deployments/api/src:/app/deployments/api/src + - ./packages:/app/packages + command: + - 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/*" + 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 4adf7d2..99622a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,8 @@ services: - api: build: context: . dockerfile: deployments/api/Dockerfile - volumes: - - ./deployments/api/src:/app/src env_file: - .env environment: @@ -15,10 +12,10 @@ 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" + - "8000:8000" depends_on: db: condition: service_healthy @@ -28,7 +25,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 @@ -53,7 +54,6 @@ services: db: condition: service_healthy - db-init: build: context: . diff --git a/docs/auth-testing.md b/docs/auth-testing.md new file mode 100644 index 0000000..e926381 --- /dev/null +++ b/docs/auth-testing.md @@ -0,0 +1,182 @@ +# 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. + +## 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. + +## 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 -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). + +### 3. Verify localauth0 is running + +```bash +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":"client_id","client_secret":"client_secret","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":"client_id","client_secret":"client_secret","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 | + +**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 +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":"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 + +# 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":"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 + +# 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 + +## 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`: + +- **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 b222c8f..c9f8389 100644 --- a/env.example +++ b/env.example @@ -12,3 +12,18 @@ 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 (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 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..0ac2532 --- /dev/null +++ b/packages/stitch-auth/src/stitch/auth/validator.py @@ -0,0 +1,56 @@ +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 + except jwt.InvalidTokenError as e: + raise TokenValidationError(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) 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"