# FastAPI FullAuth

> Production-grade, async-native authentication and authorization for FastAPI.

FastAPI FullAuth is a pluggable authentication and authorization library for FastAPI. It provides JWT access and refresh tokens with rotation and reuse detection, password hashing (Argon2id or bcrypt), email verification and password reset flows, OAuth2 social login (Google, GitHub), role-based access control with permissions, rate limiting, CSRF protection, and security headers middleware. The library uses a layered architecture: adapters handle database storage (SQLModel, SQLAlchemy, or in-memory), backends handle token extraction, flows implement auth logic, and composable routers expose API endpoints. Configuration is managed through Pydantic Settings with environment variable support. Generic type parameters allow custom user schemas with full IDE support.

- Documentation: https://mdfarhankc.github.io/fastapi-fullauth/
- Source: https://github.com/mdfarhankc/fastapi-fullauth
- PyPI: https://pypi.org/project/fastapi-fullauth/
- License: MIT
- Python: 3.10 -- 3.14

---

# README

FastAPI FullAuth


  Production-grade, async-native authentication and authorization for FastAPI.



  
  
  
  
  



  Documentation: https://mdfarhankc.github.io/fastapi-fullauth
  
  Source Code: https://github.com/mdfarhankc/fastapi-fullauth


---

Add a complete authentication and authorization system to your **FastAPI** project. FastAPI FullAuth is designed to be production-ready, async-native, and pluggable = handling JWT tokens, refresh rotation, password hashing, email verification, OAuth2 social login, and role-based access out of the box.

## Features

- **JWT access + refresh tokens** with configurable expiry
- **Refresh token rotation** with reuse detection = revokes entire session family on replay
- **Password hashing** via Argon2id (default) or bcrypt, with transparent rehashing
- **Email verification** and **password reset** flows with event hooks
- **OAuth2 social login** = Google and GitHub, with multi-redirect-URI support
- **Role-based access control** = `current_user`, `require_role()`, `require_permission()`
- **Rate limiting** = per-route auth limits + global middleware (memory or Redis)
- **CSRF protection** and **security headers** middleware, auto-wired
- **Pluggable adapters** = SQLModel or SQLAlchemy
- **Generic type parameters** = define your own schemas with full IDE support and type safety
- **Composable routers** = include only the route groups you need
- **Event hooks** = `after_register`, `after_login`, `send_verification_email`, etc.
- **Custom JWT claims** = embed app-specific data in tokens
- **Structured logging** = all auth events, security violations, and failures logged
- **Redis support** = token blacklist and rate limiter backends
- **Python 3.10 - 3.14** supported

## Installation

```bash
pip install fastapi-fullauth

# with an ORM adapter
pip install fastapi-fullauth[sqlmodel]
pip install fastapi-fullauth[sqlalchemy]

# with Redis for token blacklisting
pip install fastapi-fullauth[sqlmodel,redis]

# with OAuth2 social login
pip install fastapi-fullauth[sqlmodel,oauth]

# everything
pip install fastapi-fullauth[all]
```

## Quick start

```python
from fastapi import FastAPI
from fastapi_fullauth import FullAuth, FullAuthConfig
from fastapi_fullauth.adapters.sqlmodel import SQLModelAdapter

app = FastAPI()

fullauth = FullAuth(
    adapter=SQLModelAdapter(session_maker=session_maker, user_model=User),
    config=FullAuthConfig(SECRET_KEY="your-secret-key"),
)
fullauth.init_app(app)
```

That's it = all auth routes are registered under `/api/v1/auth/` automatically.

Omit `config` in dev and a random secret key is generated (tokens won't survive restarts).

### Composable routers

Exclude routers you don't need:

```python
fullauth.init_app(app, include_routers=["auth", "profile"])
```

Or wire routers manually for full control:

```python
app = FastAPI()
fullauth.bind(app)  # required for dependencies to work

app.include_router(fullauth.auth_router, prefix="/api/v1/auth")
app.include_router(fullauth.profile_router, prefix="/api/v1/auth")
# middleware is opt-in = import what you need from fastapi_fullauth.middleware
```

| Router | Routes |
|--------|--------|
| `auth_router` | register, login, logout, refresh |
| `profile_router` | me, verified-me, update profile, delete account, change password |
| `verify_router` | email verification, password reset |
| `admin_router` | assign/remove roles and permissions (superuser) |
| `oauth_router` | OAuth provider routes (only if configured) |
| `passkey_router` | Passkey register, authenticate, list, delete (only if enabled) |

`fullauth.init_app(app)` includes all of them. Pass `include_routers=["auth", "profile"]` (or use individual router properties) for granular control.

## Routes

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/auth/register` | Create a new user |
| `POST` | `/auth/login` | Authenticate, get tokens |
| `POST` | `/auth/logout` | Blacklist token |
| `POST` | `/auth/refresh` | Rotate token pair |
| `GET` | `/auth/me` | Get current user |
| `GET` | `/auth/me/verified` | Verified users only |
| `PATCH` | `/auth/me` | Update profile |
| `DELETE` | `/auth/me` | Delete account |
| `POST` | `/auth/change-password` | Change password |
| `POST` | `/auth/verify-email/request` | Request verification email |
| `POST` | `/auth/verify-email/confirm` | Confirm email |
| `POST` | `/auth/password-reset/request` | Request password reset |
| `POST` | `/auth/password-reset/confirm` | Reset password |
| `POST` | `/auth/admin/assign-role` | Assign role (superuser) |
| `POST` | `/auth/admin/remove-role` | Remove role (superuser) |
| `POST` | `/auth/admin/assign-permission` | Assign permission to role (superuser) |
| `POST` | `/auth/admin/remove-permission` | Remove permission from role (superuser) |
| `GET` | `/auth/admin/role-permissions/{role}` | List role's permissions (superuser) |

With OAuth enabled, additional routes are registered under `/auth/oauth/`. All routes are prefixed with `/api/v1` by default.

## Custom user schemas

Define your model and schemas = pass them explicitly to the adapter:

```python
from sqlmodel import Field, Relationship
from fastapi_fullauth import FullAuth, FullAuthConfig, UserSchema, CreateUserSchema
from fastapi_fullauth.adapters.sqlmodel import SQLModelAdapter
from fastapi_fullauth.models.sqlmodel import (
    RefreshTokenMixin, RoleMixin, UserMixin, UserRoleMixin,
)


class RefreshToken(RefreshTokenMixin, table=True): pass
class Role(RoleMixin, table=True): pass
class UserRole(UserRoleMixin, table=True): pass


class User(UserMixin, table=True):
    display_name: str = Field(default="", max_length=100)
    phone: str = Field(default="", max_length=20)
    roles: list[Role] = Relationship(link_model=UserRole)
    refresh_tokens: list[RefreshToken] = Relationship()


class MyUserSchema(UserSchema):
    display_name: str = ""
    phone: str = ""


class MyCreateSchema(CreateUserSchema):
    display_name: str = ""


fullauth = FullAuth(
    adapter=SQLModelAdapter(
        session_maker,
        user_model=User,
        refresh_token_model=RefreshToken,
        role_model=Role,
        user_role_model=UserRole,
        user_schema=MyUserSchema,
        create_user_schema=MyCreateSchema,
    ),
    config=FullAuthConfig(SECRET_KEY="..."),
)
```

Full IDE autocompletion and type checking on custom fields:

```python
from typing import Annotated
from fastapi import Depends
from fastapi_fullauth.dependencies import current_user, current_active_verified_user

CurrentUser = Annotated[MyUserSchema, Depends(current_user)]
VerifiedUser = Annotated[MyUserSchema, Depends(current_active_verified_user)]

@app.get("/profile")
async def profile(user: CurrentUser):
    return {"name": user.display_name}  # IDE knows this field exists
```

## Protected routes

```python
from typing import Annotated
from fastapi import Depends
from fastapi_fullauth.dependencies import current_user, current_active_verified_user, current_superuser, require_role

CurrentUser = Annotated[UserSchema, Depends(current_user)]
VerifiedUser = Annotated[UserSchema, Depends(current_active_verified_user)]
SuperUser = Annotated[UserSchema, Depends(current_superuser)]

@app.get("/profile")
async def profile(user: CurrentUser):
    return user

@app.get("/dashboard")
async def dashboard(user: VerifiedUser):
    return {"email": user.email}

@app.delete("/admin/users/{id}")
async def delete_user(user: SuperUser):
    ...

@app.get("/editor")
async def editor_panel(user=Depends(require_role("editor"))):
    ...
```

## OAuth2 social login

```python
from fastapi_fullauth import FullAuth, FullAuthConfig
from fastapi_fullauth.oauth.google import GoogleOAuthProvider
from fastapi_fullauth.oauth.github import GitHubOAuthProvider

fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(SECRET_KEY="..."),
    providers=[
        GoogleOAuthProvider(
            client_id="your-google-client-id",
            client_secret="your-google-secret",
            redirect_uris=[
                "http://localhost:3000/auth/callback",
                "https://myapp.com/auth/callback",
            ],
        ),
        GitHubOAuthProvider(
            client_id="your-github-client-id",
            client_secret="your-github-secret",
            redirect_uris=["http://localhost:3000/auth/callback"],
        ),
    ],
)
```

Requires `httpx`: `pip install fastapi-fullauth[oauth]`

## Event hooks

```python
async def welcome(user):
    await send_email(user.email, "Welcome!")

async def send_verify(email, token):
    await send_email(email, f"Verify: https://myapp.com/verify?token={token}")

fullauth.hooks.on("after_register", welcome)
fullauth.hooks.on("send_verification_email", send_verify)
```

Events: `after_register`, `after_login`, `after_logout`, `after_password_change`, `after_password_reset`, `after_email_verify`, `send_verification_email`, `send_password_reset_email`, `after_oauth_login`

## Configuration

Pass a `FullAuthConfig` object or set env vars with `FULLAUTH_` prefix.

```python
fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        SECRET_KEY="...",
        ACCESS_TOKEN_EXPIRE_MINUTES=60,
        API_PREFIX="/api/v2",
        LOGIN_FIELD="username",
        PASSWORD_HASH_ALGORITHM="bcrypt",
        BLACKLIST_BACKEND="redis",
        REDIS_URL="redis://localhost:6379/0",
        AUTH_TRUSTED_PROXY_HEADERS=["X-Forwarded-For"],
    ),
)
```

See [Configuration docs](https://mdfarhankc.github.io/fastapi-fullauth/configuration/) for all options.

## AI-friendly docs

Using an AI coding assistant? Point it at our LLM-optimized docs:

- **[llms.txt](https://mdfarhankc.github.io/fastapi-fullauth/llms.txt)** = concise overview with links to all doc pages
- **[llms-full.txt](https://mdfarhankc.github.io/fastapi-fullauth/llms-full.txt)** = full documentation in a single file

Works with Claude, Cursor, Copilot, and any tool that accepts a docs URL.

## Development

```bash
git clone https://github.com/mdfarhankc/fastapi-fullauth.git
cd fastapi-fullauth
uv sync --dev --extra sqlalchemy --extra sqlmodel
uv run pytest tests/ -v

# run examples
uv run uvicorn examples.sqlmodel_app.main:app --reload
```

## License

MIT

---

# Getting Started

# Getting Started

This guide walks through setting up fastapi-fullauth from scratch.

## Installation

```bash
pip install fastapi-fullauth[sqlmodel]
```

## 1. Define your tables

Each library table is a **mixin** you combine with `table=True` (SQLModel) or your own `DeclarativeBase` (SQLAlchemy). Only subclass the mixins for features you use.

```python
# models.py
from sqlmodel import Field, Relationship
from fastapi_fullauth.models.sqlmodel import (
    RefreshTokenMixin, RoleMixin, UserMixin, UserRoleMixin,
)


class RefreshToken(RefreshTokenMixin, table=True):
    pass


class Role(RoleMixin, table=True):
    pass


class UserRole(UserRoleMixin, table=True):
    pass


class User(UserMixin, table=True):
    display_name: str = Field(default="", max_length=100)
    phone: str = Field(default="", max_length=20)
    roles: list[Role] = Relationship(link_model=UserRole)
    refresh_tokens: list[RefreshToken] = Relationship()
```

`UserMixin` provides `id`, `email`, `hashed_password` (nullable = `NULL` for OAuth-only users), `is_active`, `is_verified`, `is_superuser`, and `created_at`. Add any extra fields you need.

!!! note
    Define your own schemas extending `UserSchema` and `CreateUserSchema` to include custom fields like `display_name` and `phone`, then pass them to the adapter. See [Custom User Schemas](#custom-user-schemas) below or the [API Reference](api-reference.md).

## 2. Set up the database

```python
# config.py
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

DATABASE_URL = "sqlite+aiosqlite:///app.db"
engine = create_async_engine(DATABASE_URL)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
```

## 3. Configure FullAuth

```python
# auth.py
from fastapi_fullauth import FullAuth, FullAuthConfig
from fastapi_fullauth.adapters.sqlmodel import SQLModelAdapter

from .config import session_maker
from .models import RefreshToken, Role, User, UserRole

fullauth = FullAuth(
    adapter=SQLModelAdapter(
        session_maker=session_maker,
        user_model=User,
        refresh_token_model=RefreshToken,
        role_model=Role,
        user_role_model=UserRole,
    ),
    config=FullAuthConfig(
        SECRET_KEY="your-secret-key-at-least-32-bytes",
    ),
)
```

!!! tip
    Omit `SECRET_KEY` during development and a random one is generated automatically. Tokens won't survive restarts, but it's convenient for dev.

## 4. Wire it into FastAPI

```python
# main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlmodel import SQLModel

from .auth import fullauth
from .config import engine

@asynccontextmanager
async def lifespan(app: FastAPI):
    async with engine.begin() as conn:
        await conn.run_sync(SQLModel.metadata.create_all)
    yield
    await engine.dispose()

app = FastAPI(lifespan=lifespan)
fullauth.init_app(app)
```

That's it. Start the server and you have a full auth system:

```bash
uvicorn main:app --reload
```

### Composable routers

`init_app()` registers every available router by default. Pass `include_routers` to opt in selectively:

```python
fullauth.init_app(app, include_routers=["auth", "profile"])
```

For full manual control, wire routers and middleware yourself:

```python
app = FastAPI(lifespan=lifespan)
fullauth.bind(app)  # required for dependencies to work

app.include_router(fullauth.auth_router, prefix="/api/v1/auth")
app.include_router(fullauth.profile_router, prefix="/api/v1/auth")
# middleware is opt-in = import what you need from fastapi_fullauth.middleware
```

| Router | Routes |
|--------|--------|
| `auth_router` | register, login, logout, refresh |
| `profile_router` | me, verified-me, update profile, delete account, change password |
| `verify_router` | email verification, password reset |
| `admin_router` | assign/remove roles and permissions (superuser) |
| `oauth_router` | OAuth provider routes (only if configured) |
| `passkey_router` | Passkey register, authenticate, list, delete (only if enabled) |

## 5. Try it out

**Register:**

```bash
curl -X POST http://localhost:8000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "securepass123"}'
```

**Login:**

```bash
curl -X POST http://localhost:8000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "securepass123"}'
```

Response:

```json
{
  "access_token": "eyJ...",
  "refresh_token": "eyJ...",
  "token_type": "bearer",
  "expires_in": 1800,
  "user": { "id": "…", "email": "user@example.com", "is_active": true, "is_verified": false, "is_superuser": false }
}
```

`user` contains the full user object on every successful login.

**Get current user:**

```bash
curl http://localhost:8000/api/v1/auth/me \
  -H "Authorization: Bearer eyJ..."
```

## 6. Add protected routes

```python
from typing import Annotated
from fastapi import Depends
from fastapi_fullauth.dependencies import current_user, current_active_verified_user, require_role

CurrentUser = Annotated[UserSchema, Depends(current_user)]
VerifiedUser = Annotated[UserSchema, Depends(current_active_verified_user)]

@app.get("/profile")
async def profile(user: CurrentUser):
    return user

@app.get("/dashboard")
async def dashboard(user: VerifiedUser):
    return {"email": user.email}

@app.get("/admin")
async def admin(user=Depends(require_role("admin"))):
    return {"msg": "admin area"}
```

See [Protected Routes](auth/dependencies.md) for all dependency types.

## Next steps

- [Configuration](configuration.md) = all config options
- [OAuth2 Social Login](oauth.md) = add Google/GitHub login
- [Event Hooks](auth/hooks.md) = send emails, log events
- [Rate Limiting](security/rate-limiting.md) = protect your endpoints

---

# Configuration

# Configuration

All configuration is managed through `FullAuthConfig`, a [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) class. Every option can be set via environment variables with the `FULLAUTH_` prefix.

## Usage

Pass config inline or as an object:

=== "Config object"

    ```python
    from fastapi_fullauth import FullAuth, FullAuthConfig

    fullauth = FullAuth(
        adapter=adapter,
        config=FullAuthConfig(
            SECRET_KEY="...",
            ACCESS_TOKEN_EXPIRE_MINUTES=60,
            API_PREFIX="/api/v2",
        ),
    )
    ```

=== "Environment variables"

    ```bash
    export FULLAUTH_SECRET_KEY="your-secret-key"
    export FULLAUTH_ACCESS_TOKEN_EXPIRE_MINUTES=60
    ```

    ```python
    from fastapi_fullauth import FullAuth

    # reads from env automatically
    fullauth = FullAuth(adapter=adapter)
    ```

## Reading from a `.env` file

`FullAuthConfig` reads a `.env` file in the current working directory by default. Drop one next to your app entry point:

```bash
# .env
FULLAUTH_SECRET_KEY=replace-me-with-32-random-bytes
FULLAUTH_ACCESS_TOKEN_EXPIRE_MINUTES=15
FULLAUTH_BLACKLIST_BACKEND=redis
FULLAUTH_REDIS_URL=redis://localhost:6379/0
```

Precedence is init kwargs → process env → `.env` file → field defaults. So `uvicorn --env-file` / Docker `env_file:` / `export` all win over the dotfile.

Use a different file via `FullAuthConfig(_env_file=".env.production")` or by subclassing with a custom `SettingsConfigDict`. Cloud deployments (FastAPI Cloud, Docker, Kubernetes, Fly, Railway) need no changes = their env vars land in `os.environ` and the library's `.env` default is a silent no-op when no file exists.

## Reference

### Core

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `SECRET_KEY` | `str \| None` | `None` | JWT signing key. Auto-generated in dev if not set. |
| `ALGORITHM` | `str` | `"HS256"` | JWT signing algorithm. |

### Tokens

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `int` | `30` | Access token lifetime. |
| `REFRESH_TOKEN_EXPIRE_DAYS` | `int` | `30` | Refresh token lifetime. |
| `REFRESH_TOKEN_ROTATION` | `bool` | `True` | Issue new refresh token on each refresh. |
| `JWT_LEEWAY_SECONDS` | `int` | `30` | Tolerance for clock drift when validating `exp`/`iat`. |
| `PASSWORD_RESET_EXPIRE_MINUTES` | `int` | `15` | Password-reset token lifetime. |
| `EMAIL_VERIFY_EXPIRE_MINUTES` | `int` | `1440` | Email-verification token lifetime (24 h). |

### Passwords

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `PASSWORD_HASH_ALGORITHM` | `"argon2id" \| "bcrypt"` | `"argon2id"` | Hashing algorithm. |
| `PASSWORD_MIN_LENGTH` | `int` | `8` | Minimum password length. |

### Login

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `LOGIN_FIELD` | `str` | `"email"` | Field used for login (`"email"`, `"username"`, etc.). |
| `LOCKOUT_ENABLED` | `bool` | `True` | Enable account lockout after failed login attempts. |
| `LOCKOUT_BACKEND` | `"memory" \| "redis"` | `"memory"` | Lockout storage backend. Use `"redis"` for multi-worker deployments. |
| `MAX_LOGIN_ATTEMPTS` | `int` | `5` | Failed attempts before account lockout. |
| `LOCKOUT_DURATION_MINUTES` | `int` | `15` | Lockout duration after max attempts. |

### Rate Limiting

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `RATE_LIMIT_BACKEND` | `"memory" \| "redis"` | `"memory"` | Rate limiter storage backend. Use `"redis"` in production = `"memory"` is per-process. |
| `TRUSTED_PROXY_HEADERS` | `list[str]` | `[]` | Headers to read real client IP from (e.g. `["X-Forwarded-For"]`). |
| `AUTH_RATE_LIMIT_ENABLED` | `bool` | `True` | Enable per-route auth rate limits. |
| `AUTH_RATE_LIMIT_LOGIN` | `int` | `5` | Max login attempts per window. |
| `AUTH_RATE_LIMIT_REGISTER` | `int` | `3` | Max registrations per window. |
| `AUTH_RATE_LIMIT_PASSWORD_RESET` | `int` | `3` | Max password reset requests per window. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `int` | `60` | Rate limit window in seconds. |

### Redis

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `REDIS_URL` | `str \| None` | `None` | Redis connection URL. Required when using Redis backends. |

### Token Blacklist

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `BLACKLIST_ENABLED` | `bool` | `True` | Check blacklist on token decode. |
| `BLACKLIST_BACKEND` | `"memory" \| "redis"` | `"memory"` | Blacklist storage backend. Use `"redis"` in production = `"memory"` is per-process, so revoked tokens stay valid on other workers. |

### Middleware

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `CSRF_SECRET` | `str \| None` | `None` | CSRF signing secret. Falls back to `SECRET_KEY`. |

### Cookies

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `COOKIE_NAME` | `str` | `"fullauth_access"` | Access token cookie name. |
| `COOKIE_SECURE` | `bool` | `True` | Set Secure flag on cookies. |
| `COOKIE_HTTPONLY` | `bool` | `True` | Set HttpOnly flag on cookies. |
| `COOKIE_SAMESITE` | `"lax" \| "strict" \| "none"` | `"lax"` | SameSite cookie policy. |
| `COOKIE_DOMAIN` | `str \| None` | `None` | Cookie domain. |

### OAuth

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `OAUTH_STATE_EXPIRE_SECONDS` | `int` | `300` | OAuth state token TTL (5 min). |
| `OAUTH_AUTO_LINK_BY_EMAIL` | `bool` | `True` | Auto-link OAuth accounts to existing users by email. |
| `PREVENT_REGISTRATION_ENUMERATION` | `bool` | `False` | When `True`, `/register` returns `202` + generic message regardless of whether the email was already registered. Default stays with `201` + user / `409` conflict. |

### Routing

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `API_PREFIX` | `str` | `"/api/v1"` | URL prefix for all routes. |
| `AUTH_ROUTER_PREFIX` | `str` | `"/auth"` | Auth router sub-prefix. |
| `ROUTER_TAGS` | `list[str]` | `["Auth"]` | OpenAPI tags for auth routes. |

### Passkeys

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `PASSKEY_ENABLED` | `bool` | `False` | Enable passkey (WebAuthn) routes. |
| `PASSKEY_RP_ID` | `str \| None` | `None` | Relying Party ID (your domain). |
| `PASSKEY_RP_NAME` | `str \| None` | `None` | Relying Party display name. |
| `PASSKEY_ORIGINS` | `list[str]` | `[]` | Allowed origins (e.g. `["https://example.com", "https://m.example.com"]`). |
| `PASSKEY_CHALLENGE_BACKEND` | `"memory" \| "redis"` | `"memory"` | Challenge store backend. Use `"redis"` in production = `"memory"` is per-process and breaks under multi-worker deployments. |
| `PASSKEY_CHALLENGE_TTL` | `int` | `60` | Challenge expiry in seconds. |
| `PASSKEY_REQUIRE_USER_VERIFICATION` | `bool` | `True` | Require user verification (PIN/biometric) on register and authenticate. |

---

# Adapters Overview

# Adapters

Adapters are the database layer for fastapi-fullauth. They implement `AbstractUserAdapter`, which defines how users, refresh tokens, roles, and OAuth accounts are stored and retrieved.

## Available adapters

| Adapter | Backend | Install |
|---------|---------|---------|
| [SQLModel](sqlmodel.md) | Any SQLAlchemy-supported DB | `pip install fastapi-fullauth[sqlmodel]` |
| [SQLAlchemy](sqlalchemy.md) | Any SQLAlchemy-supported DB | `pip install fastapi-fullauth[sqlalchemy]` |

## Choosing an adapter

- **SQLModel** = recommended for most projects. Clean model definitions, good type support. Use SQLite for prototyping.
- **SQLAlchemy** = use if your project already uses SQLAlchemy's declarative base.

## Custom adapters

Subclass `AbstractUserAdapter` for core auth. Add mixins for roles, permissions, or OAuth:

```python
from fastapi_fullauth.adapters.base import (
    AbstractUserAdapter,
    RoleAdapterMixin,
    PermissionAdapterMixin,
    OAuthAdapterMixin,
)

# Minimal = just auth
class MyAdapter(AbstractUserAdapter):
    async def get_user_by_id(self, user_id): ...
    async def get_user_by_email(self, email): ...
    async def create_user(self, data, hashed_password): ...
    # ... core methods only

# With roles and permissions
class MyFullAdapter(AbstractUserAdapter, RoleAdapterMixin, PermissionAdapterMixin):
    # ... core + role + permission methods
    pass
```

| Mixin | Methods | When to use |
|-------|---------|-------------|
| `RoleAdapterMixin` | `assign_role`, `remove_role`, `get_user_roles` | Role management |
| `PermissionAdapterMixin` | `get_role_permissions`, `assign/remove_permission_to_role` | RBAC permissions |
| `OAuthAdapterMixin` | 5 OAuth account methods | OAuth providers |

See the [source of AbstractUserAdapter](https://github.com/mdfarhankc/fastapi-fullauth/blob/main/fastapi_fullauth/adapters/base.py) for the full interface.

## Custom schemas

Define your own user schemas by extending `UserSchema` and `CreateUserSchema`, then pass them to the adapter:

```python
from fastapi_fullauth import UserSchema, CreateUserSchema

class MyUserSchema(UserSchema):
    display_name: str = ""

class MyCreateSchema(CreateUserSchema):
    display_name: str = ""

adapter = SQLModelAdapter(
    session_maker=session_maker,
    user_model=User,
    user_schema=MyUserSchema,
    create_user_schema=MyCreateSchema,
)
```

If your app uses roles, add `roles` to your custom schema:
```python
class MyUserSchema(UserSchema):
    roles: list[str] = Field(default_factory=list)
```

---

# SQLModel Adapter

# SQLModel Adapter

The recommended adapter for most projects.

## Installation

```bash
pip install fastapi-fullauth[sqlmodel]
```

## Setup

### 1. Define your tables

```python
from sqlmodel import Field, Relationship
from fastapi_fullauth.models.sqlmodel import (
    RefreshTokenMixin, RoleMixin, UserMixin, UserRoleMixin,
)


class RefreshToken(RefreshTokenMixin, table=True):
    pass


class Role(RoleMixin, table=True):
    pass


class UserRole(UserRoleMixin, table=True):
    pass


class User(UserMixin, table=True):
    display_name: str = Field(default="", max_length=100)
    phone: str = Field(default="", max_length=20)
    roles: list[Role] = Relationship(link_model=UserRole)
    refresh_tokens: list[RefreshToken] = Relationship()
```

`UserMixin` provides these fields:

| Field | Type | Description |
|-------|------|-------------|
| `id` | `UUID` (UUID7) | Primary key, auto-generated |
| `email` | `str` | Unique, indexed |
| `hashed_password` | `str \| None` | Password hash. `NULL` for OAuth-only users. |
| `is_active` | `bool` | Account active flag |
| `is_verified` | `bool` | Email verified flag |
| `is_superuser` | `bool` | Superuser flag |
| `created_at` | `datetime` | UTC creation timestamp |

### 2. Create the adapter

```python
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

engine = create_async_engine("sqlite+aiosqlite:///app.db")
session_maker = async_sessionmaker(engine, expire_on_commit=False)
```

You can use either SQLAlchemy's `AsyncSession` or SQLModel's `AsyncSession`:

=== "SQLAlchemy AsyncSession"

    ```python
    from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

    engine = create_async_engine("sqlite+aiosqlite:///app.db")
    session_maker = async_sessionmaker(engine, expire_on_commit=False)
    ```

=== "SQLModel AsyncSession"

    ```python
    from sqlalchemy.ext.asyncio import create_async_engine
    from sqlalchemy.ext.asyncio import async_sessionmaker
    from sqlmodel.ext.asyncio.session import AsyncSession

    engine = create_async_engine("sqlite+aiosqlite:///app.db")
    session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
    ```

Then create the adapter:

```python
from fastapi_fullauth.adapters.sqlmodel import SQLModelAdapter

adapter = SQLModelAdapter(session_maker=session_maker, user_model=User)
```

### 3. Wire into FullAuth

```python
from fastapi_fullauth import FullAuth, FullAuthConfig

fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        SECRET_KEY="your-secret-key",
    ),
)
```

## Tables created

The SQLModel adapter uses these tables:

| Table | Purpose |
|-------|---------|
| `fullauth_users` | User accounts (your model) |
| `fullauth_roles` | Role definitions |
| `fullauth_user_roles` | User-role link table |
| `fullauth_refresh_tokens` | Stored refresh tokens |
| `fullauth_oauth_accounts` | Linked OAuth provider accounts |

## Custom schemas

Define your own schemas and pass them to the adapter:

```python
from fastapi_fullauth import UserSchema, CreateUserSchema

class MyUserSchema(UserSchema):
    display_name: str = ""
    phone: str = ""

class MyCreateSchema(CreateUserSchema):
    display_name: str = ""

adapter = SQLModelAdapter(
    session_maker=session_maker,
    user_model=User,
    user_schema=MyUserSchema,
    create_user_schema=MyCreateSchema,
)
```

If you don't pass custom schemas, the base `UserSchema` and `CreateUserSchema` are used.

## OAuth support

The SQLModel adapter implements `OAuthAdapterMixin`. Import `OAuthAccountRecord` from `models.oauth` to register the table.

---

# SQLAlchemy Adapter

# SQLAlchemy Adapter

Use this adapter if your project already uses SQLAlchemy's declarative base.

## Installation

```bash
pip install fastapi-fullauth[sqlalchemy]
```

## Setup

### 1. Define your tables

Each library table is a **mixin** you combine with your own `DeclarativeBase`. Only subclass the mixins for features you use.

```python
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

from fastapi_fullauth.models.sqlalchemy import (
    RefreshTokenMixin, RoleMixin, UserMixin, UserRoleMixin,
)


class Base(DeclarativeBase):
    pass


class RefreshToken(RefreshTokenMixin, Base):
    pass


class Role(RoleMixin, Base):
    pass


class UserRole(UserRoleMixin, Base):
    pass


class User(UserMixin, Base):
    display_name: Mapped[str] = mapped_column(String(100), default="")
    phone: Mapped[str] = mapped_column(String(20), default="")

    roles: Mapped[list[Role]] = relationship(
        secondary="fullauth_user_roles", lazy="selectin",
    )
    refresh_tokens: Mapped[list[RefreshToken]] = relationship(lazy="noload")
```

`UserMixin` provides `id`, `email`, `hashed_password` (nullable = `NULL` for OAuth-only users), `is_active`, `is_verified`, `is_superuser`, `created_at`.

### 2. Create the adapter

```python
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from fastapi_fullauth.adapters.sqlalchemy import SQLAlchemyAdapter

engine = create_async_engine("sqlite+aiosqlite:///app.db")
session_maker = async_sessionmaker(engine, expire_on_commit=False)

adapter = SQLAlchemyAdapter(
    session_maker=session_maker,
    user_model=User,
    refresh_token_model=RefreshToken,
    role_model=Role,
    user_role_model=UserRole,
)
```

### 3. Wire into FullAuth

```python
from fastapi_fullauth import FullAuth, FullAuthConfig

fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        SECRET_KEY="your-secret-key",
    ),
)
```

## Table creation

Use your existing Alembic setup or create tables directly:

```python
async with engine.begin() as conn:
    await conn.run_sync(Base.metadata.create_all)
```

## Custom schemas

Define your own schemas and pass them to the adapter:

```python
from fastapi_fullauth import UserSchema, CreateUserSchema

class MyUserSchema(UserSchema):
    display_name: str = ""

class MyCreateSchema(CreateUserSchema):
    display_name: str

adapter = SQLAlchemyAdapter(
    session_maker=session_maker,
    user_model=User,
    refresh_token_model=RefreshToken,
    user_schema=MyUserSchema,
    create_user_schema=MyCreateSchema,
)
```

If you don't pass custom schemas, the base `UserSchema` and `CreateUserSchema` are used.

---

# Protected Routes

# Protected Routes

fastapi-fullauth provides FastAPI dependency functions to protect your routes. Build your own `Annotated` types with `Depends()`.

## Setting up dependencies

Create your typed dependencies once (e.g. in `deps.py`):

```python
from typing import Annotated
from fastapi import Depends
from fastapi_fullauth.dependencies import current_user, current_active_verified_user, current_superuser

from app.schemas import UserSchema

CurrentUser = Annotated[UserSchema, Depends(current_user)]
VerifiedUser = Annotated[UserSchema, Depends(current_active_verified_user)]
SuperUser = Annotated[UserSchema, Depends(current_superuser)]
```

Then use them in your routes:

```python
@app.get("/profile")
async def profile(user: CurrentUser):
    return {"email": user.email, "roles": user.roles}
```

## Dependency functions

### current_user

Any authenticated user (active account required). Returns `401` if the token is invalid or the user is inactive.

### current_active_verified_user

Authenticated user with a verified email address. Returns `403 Forbidden` if the user's email is not verified.

### current_superuser

Authenticated user with `is_superuser=True`. Returns `403 Forbidden` if the user is not a superuser.

### require_role

Check that the user has at least one of the specified roles. Superusers bypass all role checks.

```python
from fastapi import Depends
from fastapi_fullauth.dependencies import require_role

@app.get("/editor")
async def editor_panel(user=Depends(require_role("editor"))):
    return {"msg": "welcome, editor"}

# multiple roles = user needs at least one
@app.get("/content")
async def content(user=Depends(require_role("editor", "author"))):
    return {"msg": "welcome"}
```

### require_permission

Check that the user has at least one of the specified permissions. Permissions are resolved through roles = a user with role `"editor"` gets all permissions assigned to that role.

```python
from fastapi import Depends
from fastapi_fullauth.dependencies import require_permission

@app.delete("/posts/{id}")
async def delete_post(id: str, user=Depends(require_permission("posts:delete"))):
    ...

# multiple permissions = user needs at least one
@app.put("/posts/{id}")
async def edit_post(id: str, user=Depends(require_permission("posts:edit", "posts:admin"))):
    ...
```

Superusers bypass all permission checks.

#### Setting up permissions

Permissions are assigned to roles, not directly to users:

```bash
# Assign permissions to a role (superuser only)
curl -X POST http://localhost:8000/api/v1/auth/admin/assign-permission \
  -H "Authorization: Bearer " \
  -H "Content-Type: application/json" \
  -d '{"role": "editor", "permission": "posts:create"}'

curl -X POST http://localhost:8000/api/v1/auth/admin/assign-permission \
  -H "Authorization: Bearer " \
  -H "Content-Type: application/json" \
  -d '{"role": "editor", "permission": "posts:edit"}'

# List permissions for a role
curl http://localhost:8000/api/v1/auth/admin/role-permissions/editor \
  -H "Authorization: Bearer "
# → ["posts:create", "posts:edit"]
```

Or programmatically:

```python
await adapter.assign_permission_to_role("editor", "posts:create")
await adapter.assign_permission_to_role("editor", "posts:edit")
await adapter.remove_permission_from_role("editor", "posts:create")

# resolve all permissions for a user (through their roles)
perms = await adapter.get_user_permissions(user.id)
# → ["posts:edit"]
```

#### require_role vs require_permission

| | `require_role` | `require_permission` |
|---|---|---|
| Checks | Role names on the user | Permissions resolved through roles |
| Setup | Just assign roles | Assign roles + map permissions to roles |
| Use case | Simple apps ("admin vs user") | Fine-grained access ("can edit posts?") |
| Change access | Modify code | Update DB mappings |

## How it works

All dependencies follow the same flow:

1. Extract the JWT from the `Authorization: Bearer ` header (or cookie backend)
2. Decode and validate the token (expiry, blacklist, signature)
3. Look up the user by `sub` (user ID) from the token payload
4. Apply additional checks (verified, superuser, roles)

If any step fails, a `401 Unauthorized` or `403 Forbidden` response is returned automatically.

## Custom user schemas

When using custom schemas with extra fields, annotate with your schema type:

```python
from typing import Annotated
from fastapi import Depends
from fastapi_fullauth.dependencies import current_user

from app.schemas import MyUserSchema

CurrentUser = Annotated[MyUserSchema, Depends(current_user)]

@app.get("/profile")
async def profile(user: CurrentUser):
    return {"name": user.display_name}  # IDE knows this field exists
```

## Writing custom dependencies

You can write your own dependency functions for full control over the auth flow.

Using `get_fullauth` (useful when your dependency is in a separate module):

```python
from fastapi import Depends
from fastapi_fullauth.dependencies import get_fullauth

async def my_current_user(fullauth=Depends(get_fullauth), token: str = Depends(...)):
    payload = await fullauth.token_engine.decode_token(token)
    user = await fullauth.adapter.get_user_by_id(payload.sub)
    # your custom logic
    return user
```

Or use your `FullAuth` instance directly:

```python
auth = FullAuth(adapter=my_adapter, config=config)

async def my_current_user():
    user = await auth.adapter.get_user_by_id(...)
    return user
```

Through the `FullAuth` instance you can reach: `adapter` (DB operations), `token_engine` (JWT operations), `config`, `hooks`, `lockout`, `auth_rate_limiter`, `challenge_store`, `oauth_providers`.

## Role management

Roles are managed through the admin endpoints (superuser only):

```bash
# Assign a role
curl -X POST http://localhost:8000/api/v1/auth/admin/assign-role \
  -H "Authorization: Bearer " \
  -H "Content-Type: application/json" \
  -d '{"user_id": "...", "role": "editor"}'

# Remove a role
curl -X POST http://localhost:8000/api/v1/auth/admin/remove-role \
  -H "Authorization: Bearer " \
  -H "Content-Type: application/json" \
  -d '{"user_id": "...", "role": "editor"}'
```

You can also manage roles programmatically through the adapter:

```python
await adapter.assign_role(user_id, "editor")
await adapter.remove_role(user_id, "editor")
roles = await adapter.get_user_roles(user_id)
```

---

# Event Hooks

# Event Hooks

Hooks let you run custom logic when auth events happen - send emails, log analytics, sync with external systems - without modifying the core auth flows.

## Registering hooks

```python
fullauth = FullAuth(adapter=adapter, config=FullAuthConfig(SECRET_KEY="..."))

async def on_register(user):
    print(f"New user: {user.email}")

fullauth.hooks.on("after_register", on_register)
```

## Available events

### User lifecycle

| Event | Callback signature | When |
|-------|-------------------|------|
| `after_register` | `async def(user: UserSchema)` | After successful registration |
| `after_login` | `async def(user: UserSchema)` | After successful login |
| `after_logout` | `async def(user_id: str)` | After logout |
| `after_password_change` | `async def(user: UserSchema)` | After password change |
| `after_password_reset` | `async def(user: UserSchema)` | After password reset |
| `after_email_verify` | `async def(user: UserSchema)` | After email verification |

### Email events

| Event | Callback signature | When |
|-------|-------------------|------|
| `send_verification_email` | `async def(email: str, token: str)` | When verification is requested |
| `send_password_reset_email` | `async def(email: str, token: str)` | When password reset is requested |

### OAuth events

| Event | Callback signature | When |
|-------|-------------------|------|
| `after_oauth_login` | `async def(user: UserSchema, provider: str, is_new_user: bool)` | After OAuth callback |

## Example: email verification

```python
async def send_verification_email(email: str, token: str):
    # build your verification URL
    verify_url = f"https://myapp.com/verify?token={token}"
    await my_email_service.send(
        to=email,
        subject="Verify your email",
        body=f"Click here to verify: {verify_url}",
    )

async def send_password_reset_email(email: str, token: str):
    reset_url = f"https://myapp.com/reset-password?token={token}"
    await my_email_service.send(
        to=email,
        subject="Reset your password",
        body=f"Click here to reset: {reset_url}",
    )

fullauth.hooks.on("send_verification_email", send_verification_email)
fullauth.hooks.on("send_password_reset_email", send_password_reset_email)
```

!!! note
    If you don't register a `send_verification_email` hook, the verification token is still generated but never delivered. Same for password reset.

## Example: audit logging

```python
import logging

logger = logging.getLogger("auth")

async def log_login(user):
    logger.info(f"Login: {user.email} (id={user.id})")

async def log_failed_logout(user_id):
    logger.info(f"Logout: user_id={user_id}")

fullauth.hooks.on("after_login", log_login)
fullauth.hooks.on("after_logout", log_failed_logout)
```

## Multiple hooks per event

You can register multiple callbacks for the same event. They run in registration order:

```python
fullauth.hooks.on("after_register", send_welcome_email)
fullauth.hooks.on("after_register", create_default_workspace)
fullauth.hooks.on("after_register", track_signup_analytics)
```

A hook that raises is caught and logged via `logging.getLogger("fastapi_fullauth.hooks")`; the next hook still runs and the route returns its normal status. The auth response never 500s because of a notification or analytics failure. Check the logger if a hook side-effect goes silently missing.

---

# Password Validation

# Password Validation

fastapi-fullauth includes a configurable password validator that checks passwords on registration, password change, and password reset.

## Default behavior

By default, only minimum length is enforced (8 characters, configurable via `PASSWORD_MIN_LENGTH`).

## Custom rules

```python
from fastapi_fullauth import FullAuth, FullAuthConfig
from fastapi_fullauth.validators import PasswordValidator

validator = PasswordValidator(
    min_length=10,
    require_uppercase=True,
    require_lowercase=True,
    require_digit=True,
    require_special=True,
    blocked_passwords=["password123", "qwerty123"],
)

fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        SECRET_KEY="...",
    ),
    password_validator=validator,
)
```

## Validation rules

| Rule | Default | Description |
|------|---------|-------------|
| `min_length` | `8` | Minimum password length |
| `require_uppercase` | `False` | Must contain `[A-Z]` |
| `require_lowercase` | `False` | Must contain `[a-z]` |
| `require_digit` | `False` | Must contain `[0-9]` |
| `require_special` | `False` | Must contain `[!@#$%^&*(),.?":{}|<>]` |
| `blocked_passwords` | `[]` | List of disallowed passwords (case-insensitive) |

When validation fails, a `422 Unprocessable Entity` response is returned with all violated rules:

```json
{
  "detail": "Password must be at least 10 characters; Password must contain at least one uppercase letter"
}
```

## Password hashing

Passwords are hashed with **Argon2id** by default. Switch to bcrypt via config:

```python
fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        SECRET_KEY="...",
        PASSWORD_HASH_ALGORITHM="bcrypt",  # requires: pip install bcrypt
    ),
)
```

When switching algorithms, existing hashes are transparently detected by prefix (`$2b$` for bcrypt, `$argon2` for Argon2id). Users are rehashed on their next successful login.

---

# Custom Token Claims

# Custom Token Claims

Embed app-specific data into JWT tokens. Custom claims are available in the `extra` field of decoded token payloads.

## Setup

Pass an async callback to `on_create_token_claims`:

```python
from fastapi_fullauth import FullAuthConfig
from fastapi_fullauth.types import UserSchema

async def add_claims(user: UserSchema) -> dict:
    return {
        "tenant_id": "acme",
        "plan": "pro",
    }

fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        SECRET_KEY="...",
    ),
    on_create_token_claims=add_claims,
)
```

The returned dict is embedded in the `extra` field of every access token.

## Accessing claims

Custom claims are available when decoding tokens:

```python
payload = await fullauth.token_engine.decode_token(token)
tenant_id = payload.extra.get("tenant_id")
plan = payload.extra.get("plan")
```

## Reserved keys

The following keys cannot be used in custom claims (they're used by the JWT structure):

`sub`, `exp`, `iat`, `jti`, `type`, `roles`, `extra`, `family_id`

If your callback returns any of these, a `ValueError` is raised at token creation time.

## When claims are generated

Custom claims are generated on:

- **Login** = embedded in the access token
- **Token refresh** = regenerated from the current user state
- **OAuth callback** = embedded after OAuth user creation/linking

This means claims stay fresh on each refresh. If a user's plan changes, the next token refresh picks it up.

---

# Middleware

# Middleware

fastapi-fullauth ships three middleware classes. None of them are wired automatically = `init_app()` only mounts routers. Import what you want and add it yourself:

```python
from fastapi_fullauth.middleware import (
    SecurityHeadersMiddleware,
    CSRFMiddleware,
    RateLimitMiddleware,
)

app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(CSRFMiddleware, secret=fullauth.config.CSRF_SECRET or fullauth.config.SECRET_KEY)
fullauth.init_app(app)
```

## Security Headers

Adds standard security headers to every response:

| Header | Value |
|--------|-------|
| `X-Content-Type-Options` | `nosniff` |
| `X-Frame-Options` | `DENY` |
| `X-XSS-Protection` | `1; mode=block` |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `Permissions-Policy` | `geolocation=(), camera=(), microphone=()` |

### Custom headers

Override or add headers:

```python
from fastapi_fullauth.middleware import SecurityHeadersMiddleware

app.add_middleware(
    SecurityHeadersMiddleware,
    custom_headers={
        "X-Frame-Options": "SAMEORIGIN",  # override default
        "X-Custom-Header": "value",       # add new
    },
)
```

## CSRF Protection

Useful for cookie-based auth where the frontend and backend share a domain. Wire it manually = `secret` must be ≥ 32 chars:

```python
from fastapi_fullauth import FullAuth, FullAuthConfig

fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        SECRET_KEY="...",        CSRF_SECRET="optional-separate-secret",  # falls back to SECRET_KEY
    ),
)
```

### How it works

Uses the **double-submit cookie** pattern:

1. On `GET` requests, a signed CSRF cookie (`fullauth_csrf`) is set
2. On state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`), the client must send the cookie value in the `X-CSRF-Token` header
3. The middleware verifies the cookie signature and compares cookie vs header

### Frontend integration

```javascript
// read the CSRF cookie
const csrfToken = document.cookie
  .split('; ')
  .find(row => row.startsWith('fullauth_csrf='))
  ?.split('=')[1];

// include it in requests
fetch('/api/v1/auth/login', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken,
  },
  credentials: 'include',
  body: JSON.stringify({ email, password }),
});
```

### Exempt paths

```python
from fastapi_fullauth.middleware import CSRFMiddleware

app.add_middleware(
    CSRFMiddleware,
    secret="your-secret",
    exempt_paths=["/api/v1/webhooks"],
)
```

## Rate Limiting

See [Rate Limiting](rate-limiting.md) for full details.

---

# Rate Limiting

# Rate Limiting

fastapi-fullauth provides two levels of rate limiting:

1. **Auth rate limits** = per-route limits on login, register, and password reset (enabled by default)
2. **Global rate limit middleware** = limits all requests per IP (disabled by default)

Both support in-memory and Redis backends.

## Auth rate limits

Enabled by default. Protects auth endpoints from brute force:

| Route | Default limit | Config |
|-------|--------------|--------|
| Login | 5 per minute | `AUTH_RATE_LIMIT_LOGIN` |
| Register | 3 per minute | `AUTH_RATE_LIMIT_REGISTER` |
| Password reset | 3 per minute | `AUTH_RATE_LIMIT_PASSWORD_RESET` |

```python
from fastapi_fullauth import FullAuth, FullAuthConfig

fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        SECRET_KEY="...",
        AUTH_RATE_LIMIT_LOGIN=10,           # 10 login attempts per window
        AUTH_RATE_LIMIT_REGISTER=5,         # 5 registrations per window
        AUTH_RATE_LIMIT_WINDOW_SECONDS=120, # 2-minute window
    ),
)
```

Disable entirely:

```python
fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        SECRET_KEY="...",
        AUTH_RATE_LIMIT_ENABLED=False,
    ),
)
```

## Global rate limit middleware

Limits all requests per client IP. Disabled by default:

```python
fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        SECRET_KEY="...",
        ),
)
fullauth.init_app(app)  # middleware is auto-added
```

Default: 60 requests per 60 seconds per IP.

Response headers are included on every response:

```
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 45
```

When the limit is exceeded, a `429 Too Many Requests` response is returned.

## Proxy support

Behind a reverse proxy (Nginx, Cloudflare, AWS ALB), `request.client.host` is the proxy's IP, not the real user's. Configure trusted proxy headers so rate limiting works correctly:

```python
fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        SECRET_KEY="...",
        TRUSTED_PROXY_HEADERS=["X-Forwarded-For"],
    ),
)
```

!!! warning
    Only list headers you trust. If your server is directly exposed to the internet (no proxy), leave this empty = otherwise users can spoof their IP via the header.

When `X-Forwarded-For` contains a chain (e.g. `1.2.3.4, 10.0.0.1`), the first IP (original client) is used.

This setting applies to both auth rate limits and the global rate limit middleware.

## Redis backend

For multi-process or multi-server deployments, use the Redis backend:

```python
fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        SECRET_KEY="...",
        RATE_LIMIT_BACKEND="redis",
        REDIS_URL="redis://localhost:6379/0",
    ),
)
```

The in-memory backend is per-process. Redis shares state across all workers/servers.

## Custom backends

Register your own lockout or rate limiter backend:

```python
from fastapi_fullauth.protection.lockout import LockoutManager, register_lockout_backend

class DatabaseLockoutManager(LockoutManager):
    def __init__(self, max_attempts, lockout_seconds, **kwargs):
        super().__init__(max_attempts, lockout_seconds)

    async def is_locked(self, key: str) -> bool: ...
    async def record_failure(self, key: str) -> None: ...
    async def clear(self, key: str) -> None: ...

register_lockout_backend("database", DatabaseLockoutManager)
# Then set LOCKOUT_BACKEND="database" in config
```

Same pattern for rate limiters with `register_rate_limiter_backend()`.

## Manual middleware setup

If you need more control, disable auto-middleware and add it yourself:

```python
from fastapi_fullauth.protection.ratelimit import RateLimitMiddleware, RateLimiter

fullauth.init_app(app)

app.add_middleware(
    RateLimitMiddleware,
    max_requests=100,
    window_seconds=60,
    exempt_paths=["/health", "/metrics"],
    trusted_proxy_headers=["X-Forwarded-For"],
)
```

---

# OAuth2 Social Login

# OAuth2 Social Login

Add Google and GitHub login with a few config lines. Users can link multiple providers alongside email/password login.

## Installation

```bash
pip install fastapi-fullauth[oauth]
```

## Configuration

```python
from fastapi_fullauth import FullAuth, FullAuthConfig
from fastapi_fullauth.oauth.google import GoogleOAuthProvider
from fastapi_fullauth.oauth.github import GitHubOAuthProvider

fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(SECRET_KEY="..."),
    providers=[
        GoogleOAuthProvider(
            client_id="your-google-client-id",
            client_secret="your-google-secret",
            redirect_uris=[
                "http://localhost:3000/auth/callback",
                "https://myapp.com/auth/callback",
            ],
        ),
        GitHubOAuthProvider(
            client_id="your-github-client-id",
            client_secret="your-github-secret",
            redirect_uris=["http://localhost:3000/auth/callback"],
        ),
    ],
)
```

!!! tip
    `redirect_uris` is the list of allowed callback URLs. The client must pass `redirect_uri` as a query parameter in the authorize request = the library validates it against this list.

## Routes

When OAuth providers are configured, these routes are registered automatically:

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/providers` | List configured providers |
| GET | `/auth/oauth/{provider}/authorize` | Get authorization URL |
| POST | `/auth/oauth/{provider}/callback` | Exchange code for tokens |
| GET | `/auth/oauth/accounts` | List linked OAuth accounts |
| DELETE | `/auth/oauth/accounts/{provider}` | Unlink a provider |

## How the flow works

### 1. Get the authorization URL

```
GET /api/v1/auth/oauth/google/authorize?redirect_uri=http://localhost:3000/auth/callback
```

Response:

```json
{
  "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&state=..."
}
```

The `redirect_uri` parameter is optional. If omitted, the first URI in your `redirect_uris` list is used. The value is validated against the allowed list.

### 2. Redirect the user

Your frontend redirects the user to the `authorization_url`. The user authenticates with Google/GitHub.

### 3. Handle the callback

The provider redirects back to your `redirect_uri` with `code` and `state` query parameters. Your frontend sends these to the callback endpoint:

```
POST /api/v1/auth/oauth/google/callback
{
  "code": "4/0AX4XfW...",
  "state": "eyJ..."
}
```

Response:

```json
{
  "access_token": "eyJ...",
  "refresh_token": "eyJ...",
  "token_type": "bearer",
  "expires_in": 1800,
  "user": null
}
```

From this point on, the session works exactly like email/password login. The user can call `/me`, `/refresh`, `/logout`, etc. with the JWT tokens.

## What happens on callback

1. **State token is verified** (CSRF protection, 5-minute TTL)
2. **Authorization code is exchanged** for provider tokens
3. **User info is fetched** from the provider (email, name, picture)
4. **Account linking logic** runs:
    - If this provider account is already linked → update tokens, return existing user
    - If an account with the same email exists → link the OAuth account to it
    - Otherwise → create a new user with a random password
5. **JWT tokens are issued** (same as regular login)

!!! note
    If the provider reports the email as verified, the user's `is_verified` flag is set to `True` automatically.

## Auto-linking by email

By default, if a user registers with `user@example.com` via email/password, then later logs in with Google using the same email, the accounts are linked automatically. Disable this with:

```python
fullauth = FullAuth(
    adapter=adapter,
    config=FullAuthConfig(
        ...,
        OAUTH_AUTO_LINK_BY_EMAIL=False,
    ),
)
```

## Unlinking providers

Users can unlink an OAuth provider:

```
DELETE /api/v1/auth/oauth/accounts/google
```

This is blocked if the OAuth account is the user's only login method (no password set, no other OAuth providers). The user must set a password first.

## Event hooks

```python
async def on_oauth_login(user, provider, is_new_user):
    if is_new_user:
        print(f"New user via {provider}: {user.email}")
    else:
        print(f"Returning user via {provider}: {user.email}")

fullauth.hooks.on("after_oauth_login", on_oauth_login)
```

The `after_register` hook also fires for new OAuth users.

## Provider setup guides

### Google

1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a project (or select existing)
3. Go to **APIs & Services > Credentials**
4. Create an **OAuth 2.0 Client ID** (Web application)
5. Add your redirect URIs under **Authorized redirect URIs**
6. Copy the Client ID and Client Secret

Default scopes: `openid`, `email`, `profile`

### GitHub

1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Click **New OAuth App**
3. Set the **Authorization callback URL** to your redirect URI
4. Copy the Client ID and Client Secret

Default scopes: `read:user`, `user:email`

---

# Database Migrations

The library doesn't own your metadata registry. Your `models/` package owns every concrete table you subclass from a `*Mixin`, and your own `Base.metadata` (SQLAlchemy) or `SQLModel.metadata` is the single source of truth for Alembic.

## Quick start (without Alembic)

For development or simple projects, create tables directly off your own Base:

=== "SQLModel"

    ```python
    from sqlmodel import SQLModel

    async with engine.begin() as conn:
        await conn.run_sync(SQLModel.metadata.create_all)
    ```

=== "SQLAlchemy"

    ```python
    from app.core.db import Base   # your DeclarativeBase

    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    ```

## Alembic integration

For production, use Alembic for proper migration management.

### 1. Initialize Alembic

```bash
alembic init alembic
```

### 2. Update env.py

Import your `models` package so every concrete table you defined registers, then point Alembic at the metadata.

=== "SQLModel"

    ```python
    # alembic/env.py
    import app.models  # noqa: F401 = registers all your concrete tables
    from sqlmodel import SQLModel

    target_metadata = SQLModel.metadata
    ```

=== "SQLAlchemy"

    ```python
    # alembic/env.py
    import app.models  # noqa: F401
    from app.core.db import Base

    target_metadata = Base.metadata
    ```

### 3. Generate migrations

```bash
alembic revision --autogenerate -m "add fullauth tables"
alembic upgrade head
```

## Opt-in tables

Each library table is a mixin that registers only when you subclass it. Subclass only the features you use:

| Feature | Tables | Mixins to subclass |
|---------|--------|--------------------|
| Core | `fullauth_users`, `fullauth_refresh_tokens` | `UserMixin`, `RefreshTokenMixin` |
| Roles | `fullauth_roles`, `fullauth_user_roles` | `RoleMixin`, `UserRoleMixin` |
| Permissions | `fullauth_permissions`, `fullauth_role_permissions` | `PermissionMixin`, `RolePermissionMixin` |
| OAuth | `fullauth_oauth_accounts` | `OAuthAccountMixin` |
| Passkeys | `fullauth_passkeys` | `PasskeyMixin` |

When you turn on a new feature later, add the concrete class, re-run autogenerate, and you get a clean `CREATE TABLE` migration for just that table.

---

# API Reference

# API Reference

Quick reference for the main classes, types, and functions.

## FullAuth

The main auth manager. Central entry point for the library.

```python
from fastapi_fullauth import FullAuth, FullAuthConfig

fullauth = FullAuth(
    adapter=adapter,                # required = database adapter
    config=FullAuthConfig(...),     # FullAuthConfig object (see Configuration)
    providers=None,                 # list of OAuthProvider instances
    backends=None,                  # [BearerBackend()] by default
    password_validator=None,        # PasswordValidator instance
    on_create_token_claims=None,    # async callback for custom JWT claims
)
```

### Methods

| Method | Description |
|--------|-------------|
| `init_app(app, *, include_routers=None)` | Bind FullAuth and mount routers. `include_routers=None` mounts every available router; pass a list (e.g. `["auth", "profile"]`) to opt in selectively. Middleware is not wired automatically. |
| `bind(app)` | Bind FullAuth to a FastAPI app (sets `app.state.fullauth`). Required when using composable routers without `init_app()`. |
| `hooks.on(event, callback)` | Register an event hook |

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `config` | `FullAuthConfig` | Active configuration |
| `adapter` | `AbstractUserAdapter` | Database adapter |
| `token_engine` | `TokenEngine` | JWT creation/validation engine |
| `auth_router` | `APIRouter` | Login, logout, register, refresh routes |
| `profile_router` | `APIRouter` | Me, update profile, change password, delete account routes |
| `verify_router` | `APIRouter` | Email verification and password reset routes |
| `admin_router` | `APIRouter` | Role/permission management routes (superuser) |
| `oauth_router` | `APIRouter` | OAuth provider routes |
| `passkey_router` | `APIRouter` | Passkey WebAuthn routes |

## FullAuthConfig

```python
from fastapi_fullauth import FullAuthConfig
```

Pydantic Settings class. See [Configuration](configuration.md) for all options.

## Types

```python
from fastapi_fullauth.types import (
    UserSchema,         # base user response model
    CreateUserSchema,   # base registration model (email + password)
    TokenPair,          # access_token + refresh_token + token_type + expires_in
    TokenPayload,       # decoded JWT payload
    RefreshToken,       # stored refresh token record
    OAuthAccount,       # linked OAuth provider account
    OAuthUserInfo,      # user info from OAuth provider
)
```

### UserSchema

```python
class UserSchema(BaseModel):
    id: UUID
    email: EmailStr
    is_active: bool = True
    is_verified: bool = False
    is_superuser: bool = False

    PROTECTED_FIELDS: ClassVar[set[str]] = {
        "id", "email", "hashed_password", "is_active",
        "is_verified", "is_superuser", "roles", "password",
        "created_at", "refresh_tokens",
    }
```

Extend `PROTECTED_FIELDS` in subclasses to protect custom sensitive fields from profile updates.

### TokenPair

```python
class TokenPair(BaseModel):
    access_token: str
    refresh_token: str
    token_type: str = "bearer"
    expires_in: int | None = None
```

### TokenPayload

```python
class TokenPayload(BaseModel):
    sub: str              # user ID
    exp: datetime         # expiry
    iat: datetime         # issued at
    jti: str              # unique token ID
    type: str             # "access" or "refresh"
    roles: list[str]      # user roles
    extra: dict[str, Any] # custom claims
    family_id: str | None # refresh token family
```

## Dependencies

```python
from fastapi_fullauth.dependencies import (
    current_user,                   # any authenticated user
    current_active_verified_user,   # verified email required
    current_superuser,              # superuser required
    get_fullauth,                   # access the FullAuth instance in a dependency
    require_role,                   # require_role("admin", "editor")
    require_permission,             # require_permission("posts:edit", "posts:delete")
)
```

## Exceptions

```python
from fastapi_fullauth.exceptions import (
    FullAuthError,                  # base exception
    AuthenticationError,            # login failed
    AuthorizationError,             # insufficient permissions
    TokenError,                     # invalid token
    TokenExpiredError,              # token expired
    TokenBlacklistedError,          # token was revoked
    UserAlreadyExistsError,         # duplicate registration
    UserNotFoundError,              # user not found
    InvalidPasswordError,           # password validation failed
    AccountLockedError,             # too many failed attempts
    NoValidFieldsError,             # all profile update fields are protected
    UnknownFieldsError,             # profile update contains unknown fields
    OAuthError,                     # OAuth base error
    OAuthProviderError,             # provider-specific error
)
```

## Utilities

```python
from fastapi_fullauth import generate_secret_key, create_superuser

# generate a cryptographically secure secret key
key = generate_secret_key()

# create a superuser programmatically
user = await create_superuser(adapter, "admin@example.com", "password")
```

## Validators

```python
from fastapi_fullauth import PasswordValidator

validator = PasswordValidator(
    min_length=10,
    require_uppercase=True,
    require_lowercase=True,
    require_digit=True,
    require_special=True,
    blocked_passwords=["password123"],
)
```

---

# Contributing

# Contributing

Thanks for your interest in contributing to FastAPI FullAuth!

## Development setup

```bash
git clone https://github.com/mdfarhankc/fastapi-fullauth.git
cd fastapi-fullauth
uv sync --dev --extra sqlalchemy --extra sqlmodel --extra redis --extra oauth
```

## Running tests

```bash
uv run pytest tests/ -v
```

## Linting and formatting

```bash
uv run ruff check .
uv run ruff format .
```

Both must pass before submitting a PR. CI enforces this.

## Making changes

1. Fork the repo and create a branch from `main`
2. Make your changes
3. Add tests for new functionality
4. Ensure all tests pass and lint is clean
5. Submit a pull request

## Branch naming

| Prefix | Use |
|--------|-----|
| `feat/` | New features |
| `fix/` | Bug fixes |
| `refactor/` | Code improvements |
| `docs/` | Documentation |

## What to contribute

- Bug fixes
- New OAuth providers (Apple, Discord, Microsoft, etc.)
- Adapter implementations (MongoDB, Tortoise ORM, etc.)
- Documentation improvements
- Test coverage improvements
- Performance improvements

## Reporting bugs

Use the [bug report template](https://github.com/mdfarhankc/fastapi-fullauth/issues/new?template=bug_report.yml) on GitHub Issues.

## Requesting features

Use the [feature request template](https://github.com/mdfarhankc/fastapi-fullauth/issues/new?template=feature_request.yml) on GitHub Issues.

## Code style

- Follow existing patterns in the codebase
- Use type annotations
- Keep functions focused and small
- Log security-sensitive events via `logging.getLogger("fastapi_fullauth.*")`
- Don't add docstrings/comments unless the logic isn't self-evident

## License

By contributing, you agree that your contributions will be licensed under the MIT License.
