# Aura Auth — Development Plan

## Project Overview

**aura-auth** is a batteries-included, pluggable authentication framework for FastAPI.
The default `pip install aura-auth` gives you a fully working email/password auth system.
Optional extras unlock social login, OTP, magic links, 2FA, passkeys, and more.

---

## Tooling

### Python Version

- **Minimum**: Python 3.11
- **Development**: Python 3.12+

### Dev Dependencies

```bash
uv add fastapi "sqlalchemy[asyncio]" aiosqlite pydantic pyjwt bcrypt python-multipart
uv add --dev ruff ty pytest pytest-asyncio pytest-cov httpx
```

### pyproject.toml — Tooling Config

```toml
[project]
name = "aura-auth"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "fastapi>=0.100",
    "sqlalchemy[asyncio]>=2.0",
    "aiosqlite>=0.17",
    "pydantic>=2.0",
    "pyjwt[crypto]>=2.0",
    "bcrypt>=4.0",
    "python-multipart>=0.0.5",
]

[project.optional-dependencies]
google = ["httpx>=0.24"]
github = ["httpx>=0.24"]
facebook = ["httpx>=0.24"]
oauth = ["aura-auth[google,github,facebook]"]
magic-link = ["httpx>=0.24"]
otp = []
2fa = ["pyotp>=2.9"]
passkey = ["webauthn>=2.0"]
sqlmodel = ["sqlmodel>=0.0.14"]
admin = ["jinja2>=3.0"]
postgres = ["asyncpg>=0.27"]
mysql = ["aiomysql>=0.2"]
all = ["aura-auth[oauth,magic-link,otp,2fa,passkey,sqlmodel,admin,postgres,mysql]"]

# ── Ruff ──────────────────────────────────────────────
[tool.ruff]
line-length = 120
target-version = "py311"

[tool.ruff.lint]
select = [
    "E",     # pycodestyle errors
    "F",     # pyflakes
    "I",     # isort
    "UP",    # pyupgrade
    "B",     # flake8-bugbear
    "SIM",   # flake8-simplify
    "ASYNC", # flake8-async
    "T20",   # flake8-print (catch leftover prints)
    "RUF",   # ruff-specific rules
]

[tool.ruff.lint.isort]
known-first-party = ["aura_auth"]

# ── ty ────────────────────────────────────────────────
[tool.ty.environment]
python-version = "3.11"

[tool.ty.rules]
# Strictness: catch real bugs, don't fight the type checker on edge cases
possibly-unresolved-reference = "warn"
# Uncomment when the codebase stabilizes:
# possibly-unresolved-reference = "error"

[tool.ty.src]
include = ["src", "tests"]

[tool.ty.terminal]
error-on-warning = false
output-format = "full"

# ── Pytest ────────────────────────────────────────────
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
filterwarnings = ["error"]

[tool.coverage.run]
source = ["src/aura_auth"]
branch = true

[tool.coverage.report]
show_missing = true
skip_empty = true
```

### Daily Workflow

```bash
uv run ruff check .              # lint
uv run ruff format .             # format
uv run ty check                  # type check
uv run pytest                    # test
uv run pytest --cov              # test + coverage
uv run pytest -x -q              # fast: stop on first failure
```

---

## Folder Structure (Final Target)

```
aura-auth/
├── ARCHITECTURE.md
├── CONVENTIONS.md
├── README.md
├── pyproject.toml
├── tests/
│   ├── conftest.py
│   ├── test_core/
│   │   ├── test_types.py
│   │   ├── test_exceptions.py
│   │   ├── test_schemas.py
│   │   └── test_security.py
│   ├── test_backend/
│   │   └── test_sqlalchemy.py
│   ├── test_strategies/
│   │   └── test_password.py
│   ├── test_transport/
│   │   ├── test_bearer.py
│   │   └── test_cookie.py
│   ├── test_manager.py
│   └── test_fastapi/
│       ├── test_dependencies.py
│       └── test_router.py
│
└── src/
    └── aura_auth/
        ├── __init__.py
        ├── py.typed
        │
        ├── _core/
        │   ├── __init__.py
        │   ├── protocols.py
        │   ├── schemas.py
        │   ├── config.py
        │   ├── manager.py
        │   ├── security.py
        │   ├── exceptions.py
        │   └── types.py
        │
        ├── transport/
        │   ├── __init__.py
        │   ├── base.py
        │   ├── bearer.py
        │   └── cookie.py
        │
        ├── backend/
        │   ├── __init__.py
        │   ├── base.py
        │   ├── sqlalchemy.py
        │   └── sqlmodel.py          # optional: aura-auth[sqlmodel]
        │
        ├── strategies/
        │   ├── __init__.py
        │   ├── base.py
        │   ├── password.py
        │   ├── otp.py               # optional: aura-auth[otp]
        │   ├── magic_link.py        # optional: aura-auth[magic-link]
        │   ├── totp.py              # optional: aura-auth[2fa]
        │   └── passkey.py           # optional: aura-auth[passkey]
        │
        ├── oauth/                   # optional: aura-auth[google], etc.
        │   ├── __init__.py
        │   ├── base.py
        │   ├── google.py
        │   ├── github.py
        │   └── facebook.py
        │
        ├── router/
        │   ├── __init__.py
        │   ├── auth.py
        │   ├── oauth.py
        │   ├── otp.py
        │   ├── magic_link.py
        │   └── admin.py             # optional: aura-auth[admin]
        │
        ├── models/
        │   ├── __init__.py
        │   ├── base.py
        │   ├── sqlalchemy.py
        │   └── sqlmodel.py          # optional: aura-auth[sqlmodel]
        │
        ├── dependencies.py
        ├── middleware.py
        │
        └── admin/                   # optional: aura-auth[admin]
            ├── __init__.py
            ├── router.py
            ├── permissions.py
            └── schemas.py
```

---

## Phase Breakdown

---

### Phase 1 — Core Contracts

**Goal**: Define every interface the rest of the codebase depends on. Zero business logic, just shapes.

**Files to create**:

```
src/aura_auth/_core/__init__.py
src/aura_auth/_core/types.py
src/aura_auth/_core/exceptions.py
src/aura_auth/_core/protocols.py
src/aura_auth/_core/schemas.py
```

**Tests to create**:

```
tests/test_core/test_types.py
tests/test_core/test_exceptions.py
tests/test_core/test_schemas.py
tests/conftest.py
```

#### 1.1 — `_core/types.py`

Define type aliases used everywhere:

- `ID` — generic type var for user ID (`int`, `uuid.UUID`, `str`)
- `UserID` — `TypeVar` bound to `int | str | uuid.UUID`

```python
from typing import TypeVar
import uuid

# The user ID can be int, str, or UUID depending on the ORM model
ID = TypeVar("ID", int, str, uuid.UUID)
```

**Tests**: Verify type aliases are importable, verify TypeVar constraints work with dummy classes.

#### 1.2 — `_core/exceptions.py`

Define all custom exceptions. Every exception inherits from a base `AuraAuthError`:

```python
class AuraAuthError(Exception):
    """Base exception for aura-auth."""

class UserNotFoundError(AuraAuthError): ...
class UserAlreadyExistsError(AuraAuthError): ...
class UserInactiveError(AuraAuthError): ...
class UserNotVerifiedError(AuraAuthError): ...
class InvalidCredentialsError(AuraAuthError): ...
class InvalidTokenError(AuraAuthError): ...
class TokenExpiredError(InvalidTokenError): ...
class InvalidPasswordError(AuraAuthError): ...
class MissingDependencyError(AuraAuthError):
    """Raised when an optional dependency is not installed."""
    def __init__(self, package: str, extra: str) -> None:
        super().__init__(
            f"{package} is required. Install it with: pip install \"aura-auth[{extra}]\""
        )
```

**Tests**:
- Every exception is a subclass of `AuraAuthError`
- `TokenExpiredError` is a subclass of `InvalidTokenError`
- `MissingDependencyError` formats the message correctly
- All exceptions can be raised and caught

#### 1.3 — `_core/protocols.py`

Define the runtime-checkable protocols that backends, strategies, and transports must satisfy:

```python
class UserProtocol(Protocol[ID]):
    """Any user model must satisfy this shape."""
    id: ID
    email: str
    hashed_password: str | None
    is_active: bool
    is_verified: bool

class BackendProtocol(Protocol[ID]):
    """Database backend for user CRUD."""
    async def get_by_id(self, user_id: ID) -> UserProtocol[ID] | None: ...
    async def get_by_email(self, email: str) -> UserProtocol[ID] | None: ...
    async def create(self, data: dict[str, Any]) -> UserProtocol[ID]: ...
    async def update(self, user: UserProtocol[ID], data: dict[str, Any]) -> UserProtocol[ID]: ...
    async def delete(self, user: UserProtocol[ID]) -> None: ...

class StrategyProtocol(Protocol):
    """An authentication strategy (password, OTP, OAuth, etc.)."""
    async def authenticate(self, credentials: dict[str, Any]) -> str | None: ...
    async def create_token(self, user_id: str, **kwargs: Any) -> str: ...
    async def validate_token(self, token: str) -> str | None: ...

class TransportProtocol(Protocol):
    """How auth tokens travel (bearer header, cookie, etc.)."""
    async def get_token(self, request: Any) -> str | None: ...
    async def set_token(self, response: Any, token: str) -> None: ...
    async def remove_token(self, response: Any) -> None: ...
```

**Tests**:
- Create dummy classes that implement each protocol
- Verify `isinstance()` checks pass with `runtime_checkable`
- Create dummy classes that are MISSING methods and verify they fail `isinstance()`
- Ensure protocols are generic over `ID` correctly

#### 1.4 — `_core/schemas.py`

Pydantic v2 models for request/response shapes:

```python
class UserCreate(BaseModel):
    email: EmailStr
    password: str

class UserRead(BaseModel, Generic[ID]):
    id: ID
    email: str
    is_active: bool
    is_verified: bool
    model_config = ConfigDict(from_attributes=True)

class UserUpdate(BaseModel):
    email: str | None = None
    password: str | None = None
    is_active: bool | None = None
    is_verified: bool | None = None

class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"

class LoginRequest(BaseModel):
    email: str
    password: str
```

**Tests**:
- Valid data creates models successfully
- Invalid email rejects in `UserCreate`
- `UserRead` can be created from ORM-like objects (`from_attributes=True`)
- `UserUpdate` with all `None` is valid (partial update)
- `TokenResponse` has correct default `token_type`
- Test serialization round-trips (`.model_dump()` → construct)

#### Phase 1 — Definition of Done

- [ ] All files created
- [ ] `uv run ruff check .` passes
- [ ] `uv run ruff format --check .` passes
- [ ] `uv run ty check` passes
- [ ] `uv run pytest tests/test_core/` — all pass
- [ ] Every public class/function has a docstring

---

### Phase 2 — Security Utilities

**Goal**: Password hashing and JWT token management. Used by strategies and manager.

**Files to create**:

```
src/aura_auth/_core/security.py
```

**Tests to create**:

```
tests/test_core/test_security.py
```

#### 2.1 — Password Hashing

```python
class PasswordHelper:
    """Handles password hashing and verification using bcrypt."""

    def hash(self, password: str) -> str: ...
    def verify(self, password: str, hashed: str) -> bool: ...
    def validate_strength(self, password: str) -> None: ...
```

- Uses `bcrypt` (in base install)
- `validate_strength` enforces minimum length (8 chars default, configurable)
- Raises `InvalidPasswordError` on weak passwords

**Tests**:
- Hash produces a bcrypt hash (starts with `$2b$`)
- Verify returns `True` for correct password
- Verify returns `False` for wrong password
- Different calls to `hash()` with same password produce different hashes (salt)
- `validate_strength` raises for short passwords
- `validate_strength` passes for valid passwords
- Empty string password is rejected

#### 2.2 — JWT Token Management

```python
class TokenHelper:
    """Creates and validates JWT tokens."""

    def __init__(self, secret: str, algorithm: str = "HS256", lifetime_seconds: int = 3600): ...
    def create_token(self, user_id: str, extra_claims: dict[str, Any] | None = None) -> str: ...
    def decode_token(self, token: str) -> dict[str, Any]: ...
```

- Uses `PyJWT`
- `create_token` sets `sub`, `exp`, `iat`, `jti` (unique token ID)
- `decode_token` returns the decoded payload
- Raises `InvalidTokenError` on bad tokens
- Raises `TokenExpiredError` on expired tokens

**Tests**:
- Create → decode round-trip returns correct `sub`
- Expired token raises `TokenExpiredError`
- Tampered token raises `InvalidTokenError`
- Token with wrong secret raises `InvalidTokenError`
- Extra claims are included in payload
- `jti` is unique across multiple tokens
- Missing `sub` claim raises `InvalidTokenError`

#### 2.3 — Token Generation Utilities

```python
def generate_random_token(length: int = 32) -> str:
    """Generate a cryptographically secure random token (URL-safe base64)."""
    ...
```

Used later for email verification tokens, password reset tokens, OTP codes.

**Tests**:
- Output is URL-safe (no `+`, `/`, or `=`)
- Specified length is respected
- Two calls produce different tokens

#### Phase 2 — Definition of Done

- [ ] `security.py` created with `PasswordHelper`, `TokenHelper`, `generate_random_token`
- [ ] All lint/format/type checks pass
- [ ] `uv run pytest tests/test_core/test_security.py` — all pass
- [ ] Every public method has a docstring

---

### Phase 3 — SQLAlchemy Backend

**Goal**: First real database integration. Implements `BackendProtocol` for SQLAlchemy.

**Files to create**:

```
src/aura_auth/models/__init__.py
src/aura_auth/models/base.py
src/aura_auth/models/sqlalchemy.py
src/aura_auth/backend/__init__.py
src/aura_auth/backend/base.py
src/aura_auth/backend/sqlalchemy.py
```

**Tests to create**:

```
tests/test_backend/test_sqlalchemy.py
```

#### 3.1 — `models/base.py` — User Mixin

Define a mixin that provides the standard user columns:

```python
class UserMixin:
    """Mixin that adds standard auth columns to any user model."""
    # Subclass defines: id, email, hashed_password, is_active, is_verified
    # Plus timestamps: created_at, updated_at
```

The mixin does NOT define `id` — that's left to the concrete model so the user controls the primary key type (int, UUID, etc.).

#### 3.2 — `models/sqlalchemy.py` — Concrete Model

```python
class SQLAlchemyBaseUser(UserMixin, DeclarativeBase):
    """Base SQLAlchemy user model. Users can subclass to add custom fields."""
    __tablename__ = "users"

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False)
    hashed_password: Mapped[str | None] = mapped_column(String(1024), nullable=True)
    is_active: Mapped[bool] = mapped_column(default=True)
    is_verified: Mapped[bool] = mapped_column(default=False)
    created_at: Mapped[datetime] = mapped_column(default=func.now())
    updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
```

#### 3.3 — `backend/base.py` — Abstract Backend

```python
class BaseBackend(ABC, Generic[ID]):
    """Abstract base for all database backends."""
    @abstractmethod
    async def get_by_id(self, user_id: ID) -> UserProtocol[ID] | None: ...

    @abstractmethod
    async def get_by_email(self, email: str) -> UserProtocol[ID] | None: ...

    @abstractmethod
    async def create(self, data: dict[str, Any]) -> UserProtocol[ID]: ...

    @abstractmethod
    async def update(self, user: UserProtocol[ID], data: dict[str, Any]) -> UserProtocol[ID]: ...

    @abstractmethod
    async def delete(self, user: UserProtocol[ID]) -> None: ...
```

#### 3.4 — `backend/sqlalchemy.py` — SQLAlchemy Implementation

```python
class SQLAlchemyBackend(BaseBackend[ID]):
    def __init__(self, session_factory: async_sessionmaker[AsyncSession], user_model: type): ...
```

Implements all CRUD methods using SQLAlchemy async sessions.

**Tests** (all async, using SQLite in-memory):
- **Setup**: Create engine + tables in a pytest fixture
- `create` — inserts user, returns user with ID
- `create` — duplicate email raises `UserAlreadyExistsError`
- `get_by_id` — returns user when exists
- `get_by_id` — returns `None` when not exists
- `get_by_email` — returns user when exists
- `get_by_email` — returns `None` when not exists
- `update` — changes fields and persists
- `update` — partial update (only provided fields change)
- `delete` — removes user, subsequent get returns `None`
- Backend satisfies `BackendProtocol` (isinstance check)

#### Phase 3 — Definition of Done

- [ ] Models and backend created
- [ ] All lint/format/type checks pass
- [ ] `uv run pytest tests/test_backend/` — all pass
- [ ] Backend passes `isinstance(backend, BackendProtocol)` check in tests

---

### Phase 4 — Password Strategy

**Goal**: First authentication strategy. Takes credentials, returns a token.

**Files to create**:

```
src/aura_auth/strategies/__init__.py
src/aura_auth/strategies/base.py
src/aura_auth/strategies/password.py
```

**Tests to create**:

```
tests/test_strategies/test_password.py
```

#### 4.1 — `strategies/base.py`

```python
class BaseStrategy(ABC):
    """Base class for all auth strategies."""

    @abstractmethod
    async def authenticate(self, credentials: dict[str, Any], backend: BackendProtocol) -> UserProtocol | None:
        """Validate credentials and return the user, or None."""
        ...

    @abstractmethod
    async def on_after_register(self, user: UserProtocol) -> None:
        """Hook called after successful registration. Override for custom logic."""
        ...

    @abstractmethod
    async def on_after_login(self, user: UserProtocol) -> None:
        """Hook called after successful login. Override for custom logic."""
        ...
```

#### 4.2 — `strategies/password.py`

```python
class PasswordStrategy(BaseStrategy):
    def __init__(self, password_helper: PasswordHelper, token_helper: TokenHelper): ...

    async def authenticate(self, credentials: dict[str, Any], backend: BackendProtocol) -> UserProtocol | None:
        """Authenticate with email + password. Returns user or None."""
        ...

    async def register(self, data: UserCreate, backend: BackendProtocol) -> UserProtocol:
        """Create a new user with hashed password."""
        ...
```

**Tests** (using real `PasswordHelper`, `TokenHelper`, and SQLAlchemy backend from Phase 3):
- `register` — creates user with hashed password (not plaintext)
- `register` — duplicate email raises `UserAlreadyExistsError`
- `authenticate` — correct credentials returns user
- `authenticate` — wrong password returns `None`
- `authenticate` — non-existent email returns `None`
- `authenticate` — inactive user raises `UserInactiveError`
- `on_after_register` hook is called (use a subclass to verify)
- `on_after_login` hook is called
- Strategy satisfies `StrategyProtocol` (isinstance check, if applicable)

#### Phase 4 — Definition of Done

- [ ] Strategy files created
- [ ] All lint/format/type checks pass
- [ ] `uv run pytest tests/test_strategies/` — all pass
- [ ] End-to-end: register → authenticate with real backend works

---

### Phase 5 — Transport Layer

**Goal**: Define how tokens travel between client and server.

**Files to create**:

```
src/aura_auth/transport/__init__.py
src/aura_auth/transport/base.py
src/aura_auth/transport/bearer.py
src/aura_auth/transport/cookie.py
```

**Tests to create**:

```
tests/test_transport/test_bearer.py
tests/test_transport/test_cookie.py
```

#### 5.1 — `transport/base.py`

```python
class BaseTransport(ABC):
    @abstractmethod
    async def get_token(self, request: Request) -> str | None: ...

    @abstractmethod
    def set_token(self, response: Response, token: str) -> None: ...

    @abstractmethod
    def remove_token(self, response: Response) -> None: ...
```

#### 5.2 — `transport/bearer.py`

Extracts token from `Authorization: Bearer <token>` header.

```python
class BearerTransport(BaseTransport):
    async def get_token(self, request: Request) -> str | None:
        auth = request.headers.get("Authorization")
        if auth and auth.startswith("Bearer "):
            return auth[7:]
        return None

    def set_token(self, response: Response, token: str) -> None:
        # Bearer tokens are returned in the response body, not set on response
        pass

    def remove_token(self, response: Response) -> None:
        pass
```

#### 5.3 — `transport/cookie.py`

Sets/reads token from an HTTP-only cookie.

```python
class CookieTransport(BaseTransport):
    def __init__(
        self,
        cookie_name: str = "aura_token",
        cookie_secure: bool = True,
        cookie_httponly: bool = True,
        cookie_samesite: str = "lax",
        cookie_max_age: int = 3600,
    ): ...
```

**Tests** (using Starlette `Request`/`Response` test helpers):
- **Bearer**: Extracts token from valid `Authorization` header
- **Bearer**: Returns `None` when header is missing
- **Bearer**: Returns `None` when header is not `Bearer` scheme
- **Bearer**: Handles extra whitespace gracefully
- **Cookie**: `set_token` adds cookie with correct attributes
- **Cookie**: `get_token` reads correct cookie
- **Cookie**: `remove_token` deletes the cookie
- **Cookie**: Respects custom cookie name
- **Cookie**: Secure/httponly/samesite flags are set correctly
- Both transports satisfy `TransportProtocol`

#### Phase 5 — Definition of Done

- [ ] Transport files created
- [ ] All lint/format/type checks pass
- [ ] `uv run pytest tests/test_transport/` — all pass

---

### Phase 6 — User Manager

**Goal**: The orchestrator. Wires backend + strategy + transport together.

**Files to create**:

```
src/aura_auth/_core/manager.py
```

**Tests to create**:

```
tests/test_manager.py
```

#### 6.1 — `_core/manager.py`

```python
class UserManager(Generic[ID]):
    """Orchestrates authentication: connects backend, strategy, and transport."""

    def __init__(
        self,
        backend: BaseBackend[ID],
        password_strategy: PasswordStrategy,
        token_helper: TokenHelper,
    ): ...

    async def register(self, data: UserCreate) -> UserProtocol[ID]:
        """Register a new user."""
        ...

    async def login(self, credentials: LoginRequest) -> str:
        """Authenticate and return a token string."""
        ...

    async def get_user(self, user_id: ID) -> UserProtocol[ID] | None:
        """Get user by ID."""
        ...

    async def verify_token(self, token: str) -> UserProtocol[ID] | None:
        """Decode token, fetch and return user."""
        ...

    async def request_verify(self, user: UserProtocol[ID]) -> str:
        """Generate a verification token for the user."""
        ...

    async def verify(self, token: str) -> UserProtocol[ID]:
        """Verify the user's email with a verification token."""
        ...
```

**Tests** (full integration with real backend + strategy from previous phases):
- `register` → creates user, returns user object
- `register` → duplicate email raises error
- `login` → valid creds returns JWT token
- `login` → invalid creds raises `InvalidCredentialsError`
- `login` → inactive user raises `UserInactiveError`
- `verify_token` → valid token returns user
- `verify_token` → expired token returns `None`
- `verify_token` → invalid token returns `None`
- `get_user` → existing ID returns user
- `get_user` → non-existing ID returns `None`
- Full flow: `register` → `login` → `verify_token` → get user back

#### Phase 6 — Definition of Done

- [ ] Manager created
- [ ] All lint/format/type checks pass
- [ ] `uv run pytest tests/test_manager.py` — all pass
- [ ] Full register → login → verify token flow works end-to-end

---

### Phase 7 — FastAPI Integration

**Goal**: The public API. After this phase, `pip install aura-auth` is usable.

**Files to create**:

```
src/aura_auth/dependencies.py
src/aura_auth/router/auth.py
src/aura_auth/router/__init__.py
src/aura_auth/__init__.py         # AuraAuth main class
src/aura_auth/_core/config.py
```

**Tests to create**:

```
tests/test_fastapi/test_dependencies.py
tests/test_fastapi/test_router.py
```

#### 7.1 — `_core/config.py`

```python
@dataclass
class AuraAuthConfig:
    database_url: str
    secret: str
    token_lifetime_seconds: int = 3600
    cookie_transport: bool = False
    password_min_length: int = 8
    user_model: type | None = None   # custom user model, defaults to built-in
```

#### 7.2 — `dependencies.py`

FastAPI dependency injection:

```python
async def current_user(request: Request) -> UserProtocol:
    """Get the current authenticated user. Raises 401 if not authenticated."""
    ...

async def current_active_user(user: UserProtocol = Depends(current_user)) -> UserProtocol:
    """Get the current user, must be active. Raises 403 if inactive."""
    ...

async def current_verified_user(user: UserProtocol = Depends(current_active_user)) -> UserProtocol:
    """Get the current user, must be active and verified. Raises 403 if unverified."""
    ...
```

#### 7.3 — `router/auth.py`

Pre-built FastAPI router:

```
POST   /auth/register    → register a new user
POST   /auth/login        → login, returns token
POST   /auth/logout       → logout (clears cookie if cookie transport)
GET    /auth/me           → get current user info
```

#### 7.4 — `__init__.py` — The `AuraAuth` Class

The main entry point users interact with:

```python
class AuraAuth:
    """Main entry point. Wires everything together."""

    def __init__(self, database_url: str, secret: str, **kwargs): ...

    @property
    def router(self) -> APIRouter:
        """Pre-built auth router. Use: app.include_router(auth.router)"""
        ...

    def current_user(self) -> Callable:
        """Dependency for getting the current user."""
        ...

    async def create_tables(self) -> None:
        """Create database tables. Call on startup."""
        ...
```

**Target user experience**:

```python
from fastapi import FastAPI, Depends
from aura_auth import AuraAuth

app = FastAPI()
auth = AuraAuth(database_url="sqlite+aiosqlite:///./app.db", secret="change-me")

app.include_router(auth.router)

@app.get("/protected")
async def protected(user=Depends(auth.current_user)):
    return {"message": f"Hello {user.email}"}

@app.on_event("startup")
async def startup():
    await auth.create_tables()
```

**Tests** (using `httpx.AsyncClient` with a real FastAPI app):
- `POST /auth/register` — 201 with valid data
- `POST /auth/register` — 422 with invalid email
- `POST /auth/register` — 409 with duplicate email
- `POST /auth/login` — 200 returns token
- `POST /auth/login` — 401 with wrong password
- `POST /auth/login` — 401 with non-existent email
- `GET /auth/me` — 200 with valid token
- `GET /auth/me` — 401 without token
- `GET /auth/me` — 401 with expired token
- `POST /auth/logout` — 200
- Protected endpoint — accessible with valid token
- Protected endpoint — 401 without token
- `current_active_user` — 403 for inactive user
- `current_verified_user` — 403 for unverified user
- Full flow: register → login → access protected route → logout

#### Phase 7 — Definition of Done

- [ ] All FastAPI integration files created
- [ ] All lint/format/type checks pass
- [ ] `uv run pytest` — ALL tests across ALL phases pass
- [ ] Full HTTP-level flow works end-to-end
- [ ] `ARCHITECTURE.md` written
- [ ] `CONVENTIONS.md` written
- [ ] `README.md` has quickstart example
- [ ] Package is installable with `uv pip install -e .`

---

## Testing Conventions

### File Naming

- Test files mirror source: `src/aura_auth/_core/security.py` → `tests/test_core/test_security.py`
- Each test file has `__init__.py` in its directory

### Fixture Strategy

- `conftest.py` at `tests/` root: shared fixtures (engine, session, backend, user factory)
- Per-directory `conftest.py` only when needed

### Naming Pattern

```python
async def test_<method>_<scenario>_<expected>():
    """Example: test_authenticate_wrong_password_returns_none"""
    ...
```

### Async

All database and strategy tests are `async`. `pytest-asyncio` with `asyncio_mode = "auto"` handles this — no need for `@pytest.mark.asyncio` on every test.

### Coverage Target

- Phase 1–6: 95%+ line coverage
- Phase 7 (FastAPI): 90%+ line coverage (some middleware edge cases are hard to test)

---

## Coding Conventions

### Imports

```python
# stdlib
from __future__ import annotations
import uuid
from typing import Any, Generic

# third-party
from pydantic import BaseModel
from sqlalchemy import String

# first-party
from aura_auth._core.types import ID
from aura_auth._core.exceptions import UserNotFoundError
```

### Dependency Boundaries

```
_core/     → imports NOTHING from other aura_auth packages
transport/ → imports from _core/ only
backend/   → imports from _core/ only
strategies/→ imports from _core/ only
router/    → imports from _core/, strategies/, transport/, backend/
__init__.py→ imports from everything (wires it all together)
```

**Rule**: If you're tempted to import from `backend/` inside `_core/`, you need a new protocol in `_core/` instead.

### Docstrings

Every public class, method, and function gets a docstring. Use Google style:

```python
def create_token(self, user_id: str, extra_claims: dict[str, Any] | None = None) -> str:
    """Create a signed JWT token.

    Args:
        user_id: The user's ID to encode as the subject claim.
        extra_claims: Additional claims to include in the token payload.

    Returns:
        The encoded JWT token string.

    Raises:
        ValueError: If user_id is empty.
    """
```

### Error Handling

- Never catch broad `Exception` in library code
- Raise specific `AuraAuthError` subclasses
- Let unexpected errors propagate naturally
- FastAPI exception handlers convert `AuraAuthError` → HTTP responses

### Optional Dependencies

Use lazy imports with clear error messages:

```python
def _require_httpx() -> None:
    try:
        import httpx  # noqa: F401
    except ImportError:
        raise MissingDependencyError("httpx", "google") from None
```

---

## Milestone Checkpoints

| Milestone   | What Works                                                  |
| ----------- | ----------------------------------------------------------- |
| Phase 1     | Contracts defined, schemas validate                         |
| Phase 2     | Passwords hash/verify, JWTs create/decode                   |
| Phase 3     | Users persist in SQLite via SQLAlchemy                       |
| Phase 4     | Email/password register + authenticate works                |
| Phase 5     | Tokens can be sent via bearer header or cookie              |
| Phase 6     | Full auth flow works in pure Python (no HTTP)               |
| **Phase 7** | **`pip install aura-auth` → working FastAPI auth system**   |