# Project Scaffolding Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Set up the project foundation: tooling (ruff, ty), package structure, configuration, Pydantic models, repository protocols, app factory, and initial test infrastructure. **Architecture:** FastAPI app factory pattern with src layout. Pydantic models for data, Protocol-based repository interfaces, env-based configuration via pydantic-settings. All code checked by ruff (broad ruleset) and ty (strict mode). **Tech Stack:** Python 3.13, FastAPI, pydantic-settings, ruff, ty, pytest, pytest-asyncio **Design Document:** `docs/plans/2026-02-11-oidc-op-design.md` --- ### Task 1: Configure pyproject.toml with dependencies and tooling **Files:** - Modify: `pyproject.toml` **Step 1: Update pyproject.toml with all sections** ```toml [project] name = "fastapi-oidc-op" version = "0.1.0" description = "OIDC OpenID Provider with user management" readme = "README.md" requires-python = ">=3.13" dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.34", "idpyoidc>=5.0", "pydantic-settings>=2.7", "jinja2>=3.1", "fido2>=2.1", "argon2-cffi>=25.1", "motor>=3.7", "aiosqlite>=0.21", "proquint>=0.2", "python-multipart>=0.0.20", "httpx>=0.28", ] [project.scripts] fastapi-oidc-op = "fastapi_oidc_op.cli:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/fastapi_oidc_op"] [dependency-groups] dev = [ "pytest>=8.0", "pytest-asyncio>=0.25", "ruff>=0.15", "ty>=0.0.16", ] [tool.ruff] line-length = 120 target-version = "py311" src = ["src", "tests"] [tool.ruff.lint] select = ["E", "F", "UP", "B", "SIM", "I", "C4", "RUF"] ignore = ["E501"] [tool.ruff.lint.per-file-ignores] "tests/*" = ["S101"] [tool.ruff.format] quote-style = "double" indent-style = "space" docstring-code-format = true [tool.ty] python-version = "3.13" src = ["src"] [tool.ty.rules] possibly-unresolved-reference = "error" [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] ``` **Step 2: Install dependencies** Run: `uv sync` Expected: All dependencies resolve and install successfully. **Step 3: Verify tools work** Run: `uv run ruff check --version && uv run ty --version` Expected: Both print version numbers. **Step 4: Commit** ```bash git add pyproject.toml uv.lock git commit -m "chore: configure project dependencies and tooling (ruff, ty, pytest)" ``` --- ### Task 2: Create package structure with __init__.py files **Files:** - Create: `src/fastapi_oidc_op/__init__.py` - Create: `src/fastapi_oidc_op/oidc/__init__.py` - Create: `src/fastapi_oidc_op/authn/__init__.py` - Create: `src/fastapi_oidc_op/manage/__init__.py` - Create: `src/fastapi_oidc_op/store/__init__.py` - Create: `src/fastapi_oidc_op/store/mongodb/__init__.py` - Create: `src/fastapi_oidc_op/store/sqlite/__init__.py` - Create: `src/fastapi_oidc_op/invite/__init__.py` - Create: `tests/__init__.py` - Create: `tests/test_store/__init__.py` - Create: `tests/test_authn/__init__.py` - Create: `tests/test_oidc/__init__.py` - Create: `tests/test_manage/__init__.py` **Step 1: Create all directories and __init__.py files** All `__init__.py` files are empty. Create the directory tree: ``` src/ └── fastapi_oidc_op/ ├── __init__.py ├── oidc/ │ └── __init__.py ├── authn/ │ └── __init__.py ├── manage/ │ └── __init__.py ├── store/ │ ├── __init__.py │ ├── mongodb/ │ │ └── __init__.py │ └── sqlite/ │ └── __init__.py └── invite/ └── __init__.py tests/ ├── __init__.py ├── test_store/ │ └── __init__.py ├── test_authn/ │ └── __init__.py ├── test_oidc/ │ └── __init__.py └── test_manage/ └── __init__.py ``` **Step 2: Verify package is importable** Run: `uv run python -c "import fastapi_oidc_op; print('OK')"` Expected: Prints `OK` **Step 3: Commit** ```bash git add src/ tests/ git commit -m "chore: create package structure with src layout" ``` --- ### Task 3: Implement configuration module **Files:** - Create: `src/fastapi_oidc_op/config.py` - Create: `tests/test_config.py` **Step 1: Write the failing test** ```python # tests/test_config.py from fastapi_oidc_op.config import Settings, StorageBackend def test_default_settings() -> None: settings = Settings( issuer="http://localhost:8000", ) assert settings.issuer == "http://localhost:8000" assert settings.storage_backend == StorageBackend.SQLITE assert settings.sqlite_path == "data/oidc_op.db" assert settings.manage_client_id == "manage-app" assert settings.invite_ttl == 86400 assert settings.theme == "default" def test_mongodb_settings() -> None: settings = Settings( issuer="http://localhost:8000", storage_backend=StorageBackend.MONGODB, mongodb_uri="mongodb://mongo:27017", mongodb_database="test_db", ) assert settings.storage_backend == StorageBackend.MONGODB assert settings.mongodb_uri == "mongodb://mongo:27017" assert settings.mongodb_database == "test_db" def test_settings_from_env(monkeypatch: "pytest.MonkeyPatch") -> None: import pytest monkeypatch.setenv("OIDC_OP_ISSUER", "https://op.example.org") monkeypatch.setenv("OIDC_OP_STORAGE_BACKEND", "mongodb") monkeypatch.setenv("OIDC_OP_MONGODB_URI", "mongodb://remote:27017") settings = Settings() # type: ignore[call-arg] assert settings.issuer == "https://op.example.org" assert settings.storage_backend == StorageBackend.MONGODB ``` **Step 2: Run test to verify it fails** Run: `uv run pytest tests/test_config.py -v` Expected: FAIL - ImportError **Step 3: Write the implementation** ```python # src/fastapi_oidc_op/config.py from enum import StrEnum from pydantic_settings import BaseSettings class StorageBackend(StrEnum): SQLITE = "sqlite" MONGODB = "mongodb" class Settings(BaseSettings): model_config = {"env_prefix": "OIDC_OP_"} # Core issuer: str debug: bool = False # Storage storage_backend: StorageBackend = StorageBackend.SQLITE # SQLite sqlite_path: str = "data/oidc_op.db" # MongoDB mongodb_uri: str = "mongodb://localhost:27017" mongodb_database: str = "oidc_op" # Management RP manage_client_id: str = "manage-app" # Magic links invite_ttl: int = 86400 # seconds # Theme theme: str = "default" ``` **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/test_config.py -v` Expected: All 3 tests PASS **Step 5: Run ruff and ty** Run: `uv run ruff check src/fastapi_oidc_op/config.py tests/test_config.py && uv run ruff format --check src/fastapi_oidc_op/config.py tests/test_config.py && uv run ty check src/fastapi_oidc_op/config.py` Expected: No errors **Step 6: Commit** ```bash git add src/fastapi_oidc_op/config.py tests/test_config.py git commit -m "feat: add configuration module with env-based settings" ``` --- ### Task 4: Implement Pydantic models **Files:** - Create: `src/fastapi_oidc_op/models.py` - Create: `tests/test_models.py` **Step 1: Write the failing tests** ```python # tests/test_models.py from datetime import datetime, timezone from fastapi_oidc_op.models import ( CredentialType, MagicLink, PasswordCredential, User, WebAuthnCredential, ) def test_user_creation() -> None: user = User( userid="lusab-bansen", username="alice", ) assert user.userid == "lusab-bansen" assert user.username == "alice" assert user.preferred_username is None assert user.email is None assert user.active is True assert user.groups == [] assert user.created_at is not None assert user.updated_at is not None def test_user_with_all_fields() -> None: user = User( userid="lusab-bansen", username="alice", preferred_username="Alice W.", given_name="Alice", family_name="Wonderland", nickname="ally", email="alice@example.com", email_verified=True, phone_number="+1234567890", phone_number_verified=False, picture="https://example.com/alice.jpg", locale="en-US", active=True, groups=["admin", "users"], ) assert user.given_name == "Alice" assert user.groups == ["admin", "users"] def test_webauthn_credential() -> None: cred = WebAuthnCredential( user_id="lusab-bansen", credential_id=b"\x01\x02\x03", public_key=b"\x04\x05\x06", sign_count=0, device_name="YubiKey 5", ) assert cred.type == CredentialType.WEBAUTHN assert cred.credential_id == b"\x01\x02\x03" assert cred.device_name == "YubiKey 5" def test_password_credential() -> None: cred = PasswordCredential( user_id="lusab-bansen", password_hash="$argon2id$v=19$m=65536,t=3,p=4$hash", ) assert cred.type == CredentialType.PASSWORD assert cred.password_hash.startswith("$argon2") def test_magic_link() -> None: link = MagicLink( token="abc123def456", username="newuser", ) assert link.token == "abc123def456" assert link.username == "newuser" assert link.used is False assert link.created_by is None assert link.expires_at > datetime.now(timezone.utc) ``` **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_models.py -v` Expected: FAIL - ImportError **Step 3: Write the implementation** ```python # src/fastapi_oidc_op/models.py from datetime import datetime, timedelta, timezone from enum import StrEnum from pydantic import BaseModel, Field def _utcnow() -> datetime: return datetime.now(timezone.utc) def _default_expiry() -> datetime: return datetime.now(timezone.utc) + timedelta(hours=24) class CredentialType(StrEnum): WEBAUTHN = "webauthn" PASSWORD = "password" class User(BaseModel): userid: str username: str preferred_username: str | None = None given_name: str | None = None family_name: str | None = None nickname: str | None = None email: str | None = None email_verified: bool = False phone_number: str | None = None phone_number_verified: bool = False picture: str | None = None locale: str | None = None active: bool = True created_at: datetime = Field(default_factory=_utcnow) updated_at: datetime = Field(default_factory=_utcnow) groups: list[str] = Field(default_factory=list) class WebAuthnCredential(BaseModel): user_id: str type: CredentialType = CredentialType.WEBAUTHN credential_id: bytes public_key: bytes sign_count: int = 0 device_name: str = "" created_at: datetime = Field(default_factory=_utcnow) class PasswordCredential(BaseModel): user_id: str type: CredentialType = CredentialType.PASSWORD password_hash: str created_at: datetime = Field(default_factory=_utcnow) class MagicLink(BaseModel): token: str username: str expires_at: datetime = Field(default_factory=_default_expiry) used: bool = False created_by: str | None = None note: str | None = None ``` **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/test_models.py -v` Expected: All 5 tests PASS **Step 5: Run ruff and ty** Run: `uv run ruff check src/fastapi_oidc_op/models.py tests/test_models.py && uv run ruff format --check src/fastapi_oidc_op/models.py tests/test_models.py && uv run ty check src/fastapi_oidc_op/models.py` Expected: No errors **Step 6: Commit** ```bash git add src/fastapi_oidc_op/models.py tests/test_models.py git commit -m "feat: add Pydantic models for User, Credential, and MagicLink" ``` --- ### Task 5: Implement repository protocols **Files:** - Create: `src/fastapi_oidc_op/store/protocols.py` - Create: `tests/test_store/test_protocols.py` **Step 1: Write the failing test** ```python # tests/test_store/test_protocols.py from typing import runtime_checkable from fastapi_oidc_op.store.protocols import ( CredentialRepository, MagicLinkRepository, UserRepository, ) def test_protocols_are_runtime_checkable() -> None: assert runtime_checkable(UserRepository) # type: ignore[arg-type] assert runtime_checkable(CredentialRepository) # type: ignore[arg-type] assert runtime_checkable(MagicLinkRepository) # type: ignore[arg-type] ``` Note: This test just verifies the protocols are importable and runtime-checkable. The actual conformance tests come when we implement the SQLite/MongoDB repositories. **Step 2: Run test to verify it fails** Run: `uv run pytest tests/test_store/test_protocols.py -v` Expected: FAIL - ImportError **Step 3: Write the implementation** ```python # src/fastapi_oidc_op/store/protocols.py from typing import Protocol, runtime_checkable from fastapi_oidc_op.models import ( MagicLink, PasswordCredential, User, WebAuthnCredential, ) @runtime_checkable class UserRepository(Protocol): async def create(self, user: User) -> User: ... async def get_by_userid(self, userid: str) -> User | None: ... async def get_by_username(self, username: str) -> User | None: ... async def update(self, user: User) -> User: ... async def list_users(self, offset: int = 0, limit: int = 100) -> list[User]: ... async def delete(self, userid: str) -> bool: ... @runtime_checkable class CredentialRepository(Protocol): async def create_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential: ... async def create_password(self, credential: PasswordCredential) -> PasswordCredential: ... async def get_webauthn_by_user(self, user_id: str) -> list[WebAuthnCredential]: ... async def get_webauthn_by_credential_id(self, credential_id: bytes) -> WebAuthnCredential | None: ... async def get_password_by_user(self, user_id: str) -> PasswordCredential | None: ... async def update_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential: ... async def delete_webauthn(self, user_id: str, credential_id: bytes) -> bool: ... async def delete_password(self, user_id: str) -> bool: ... @runtime_checkable class MagicLinkRepository(Protocol): async def create(self, link: MagicLink) -> MagicLink: ... async def get_by_token(self, token: str) -> MagicLink | None: ... async def mark_used(self, token: str) -> bool: ... async def delete_expired(self) -> int: ... ``` **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/test_store/test_protocols.py -v` Expected: PASS **Step 5: Run ruff and ty** Run: `uv run ruff check src/fastapi_oidc_op/store/protocols.py && uv run ruff format --check src/fastapi_oidc_op/store/protocols.py && uv run ty check src/fastapi_oidc_op/store/protocols.py` Expected: No errors **Step 6: Commit** ```bash git add src/fastapi_oidc_op/store/protocols.py tests/test_store/test_protocols.py git commit -m "feat: add repository Protocol interfaces for User, Credential, MagicLink" ``` --- ### Task 6: Implement userid generation utility **Files:** - Create: `src/fastapi_oidc_op/userid.py` - Create: `tests/test_userid.py` **Step 1: Write the failing tests** ```python # tests/test_userid.py import re from fastapi_oidc_op.userid import generate_userid def test_generate_userid_format() -> None: userid = generate_userid() # 32-bit proquint format: xxxxx-xxxxx parts = userid.split("-") assert len(parts) == 2 for part in parts: assert len(part) == 5 def test_generate_userid_uniqueness() -> None: ids = {generate_userid() for _ in range(100)} assert len(ids) == 100 # All unique def test_generate_userid_is_lowercase() -> None: userid = generate_userid() assert userid == userid.lower() ``` **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_userid.py -v` Expected: FAIL - ImportError **Step 3: Write the implementation** ```python # src/fastapi_oidc_op/userid.py import secrets from proquint import uint2quint def generate_userid() -> str: """Generate a unique user identifier in proquint format. Returns a 32-bit proquint string like 'lusab-bansen'. """ return uint2quint(secrets.randbits(32)) ``` **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/test_userid.py -v` Expected: All 3 tests PASS **Step 5: Run ruff and ty** Run: `uv run ruff check src/fastapi_oidc_op/userid.py tests/test_userid.py && uv run ruff format --check src/fastapi_oidc_op/userid.py tests/test_userid.py && uv run ty check src/fastapi_oidc_op/userid.py` Expected: No errors (ty may warn about proquint missing type stubs - suppress with `# type: ignore[import-untyped]` if needed) **Step 6: Commit** ```bash git add src/fastapi_oidc_op/userid.py tests/test_userid.py git commit -m "feat: add proquint-based userid generation" ``` --- ### Task 7: Implement app factory and health endpoint **Files:** - Create: `src/fastapi_oidc_op/app.py` - Create: `tests/conftest.py` - Create: `tests/test_app.py` - Delete: `main.py` (replaced by app.py) **Step 1: Write the failing tests** ```python # tests/conftest.py from collections.abc import AsyncIterator import pytest from httpx import ASGITransport, AsyncClient from fastapi_oidc_op.app import create_app from fastapi_oidc_op.config import Settings @pytest.fixture def settings() -> Settings: return Settings(issuer="http://localhost:8000") @pytest.fixture async def client(settings: Settings) -> AsyncIterator[AsyncClient]: app = create_app(settings) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url=settings.issuer) as ac: yield ac ``` ```python # tests/test_app.py from httpx import AsyncClient async def test_health_endpoint(client: AsyncClient) -> None: response = await client.get("/health") assert response.status_code == 200 data = response.json() assert data["status"] == "ok" async def test_app_has_title(client: AsyncClient) -> None: response = await client.get("/openapi.json") assert response.status_code == 200 data = response.json() assert data["info"]["title"] == "FastAPI OIDC OP" ``` **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_app.py -v` Expected: FAIL - ImportError **Step 3: Write the implementation** ```python # src/fastapi_oidc_op/app.py from fastapi import FastAPI from fastapi_oidc_op.config import Settings def create_app(settings: Settings | None = None) -> FastAPI: if settings is None: settings = Settings() # type: ignore[call-arg] app = FastAPI( title="FastAPI OIDC OP", version="0.1.0", docs_url="/docs" if settings.debug else None, redoc_url=None, ) app.state.settings = settings @app.get("/health") async def health() -> dict[str, str]: return {"status": "ok"} return app ``` **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/test_app.py -v` Expected: All 2 tests PASS **Step 5: Delete the old main.py** Run: `rm main.py` **Step 6: Run ruff and ty on all source files** Run: `uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/ && uv run ty check src/` Expected: No errors **Step 7: Commit** ```bash git rm main.py git add src/fastapi_oidc_op/app.py tests/conftest.py tests/test_app.py git commit -m "feat: add app factory with health endpoint and test infrastructure" ``` --- ### Task 8: Run full quality checks and format **Files:** - All files in `src/` and `tests/` **Step 1: Format all code with ruff** Run: `uv run ruff format src/ tests/` Expected: Files formatted (or already formatted) **Step 2: Run ruff lint with auto-fix** Run: `uv run ruff check src/ tests/ --fix` Expected: No errors (or safe fixes applied) **Step 3: Run ty type check** Run: `uv run ty check src/` Expected: No errors **Step 4: Run full test suite** Run: `uv run pytest -v` Expected: All tests pass **Step 5: Commit any formatting changes** ```bash git add -A git diff --cached --quiet || git commit -m "style: apply ruff formatting" ``` --- ### Task 9: Add pre-commit quality gate script **Files:** - Create: `scripts/check.sh` **Step 1: Create the check script** ```bash #!/usr/bin/env bash # Run all quality checks set -euo pipefail echo "==> Formatting..." uv run ruff format src/ tests/ echo "==> Linting..." uv run ruff check src/ tests/ --fix echo "==> Type checking..." uv run ty check src/ echo "==> Testing..." uv run pytest -v echo "==> All checks passed!" ``` **Step 2: Make it executable** Run: `chmod +x scripts/check.sh` **Step 3: Run the script to verify it works** Run: `./scripts/check.sh` Expected: All checks pass **Step 4: Commit** ```bash git add scripts/check.sh git commit -m "chore: add quality check script (ruff, ty, pytest)" ``` --- ## Summary After completing these 9 tasks, the project will have: 1. **pyproject.toml** - Dependencies, ruff config (broad ruleset), ty config (strict), pytest config 2. **Package structure** - Full src layout with all subpackages 3. **Configuration** - Pydantic Settings with env vars, storage backend selection 4. **Models** - User, WebAuthnCredential, PasswordCredential, MagicLink 5. **Repository protocols** - Type-safe interfaces for all data access 6. **Userid generation** - Proquint-based unique identifiers 7. **App factory** - FastAPI app with health endpoint 8. **Test infrastructure** - pytest + httpx async client fixtures 9. **Quality tooling** - ruff (lint + format), ty (type check), check script The next plan will cover the SQLite repository implementation, followed by authentication, OIDC provider integration, and the management UI.