porchlight/docs/plans/2026-02-11-scaffolding-plan.md

22 KiB

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

[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

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

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

# 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

# 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

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

# 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

# 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

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

# 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

# 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

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

# 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

# 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

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

# 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
# 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

# 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

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

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

#!/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

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.