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/andtests/
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:
- pyproject.toml - Dependencies, ruff config (broad ruleset), ty config (strict), pytest config
- Package structure - Full src layout with all subpackages
- Configuration - Pydantic Settings with env vars, storage backend selection
- Models - User, WebAuthnCredential, PasswordCredential, MagicLink
- Repository protocols - Type-safe interfaces for all data access
- Userid generation - Proquint-based unique identifiers
- App factory - FastAPI app with health endpoint
- Test infrastructure - pytest + httpx async client fixtures
- 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.