Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 23 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,42 +128,44 @@ This requires the API to be running locally (or set `OPENAPI_URL` to point to a

## API Endpoints

All application routes are served under the `/v1` prefix so the API surface can be versioned as a whole — when a breaking change is needed, mount the new routers under `/v2` alongside `/v1`. Infrastructure routes (`/`, `/health`, `/docs`, `/openapi.json`) stay unversioned. Routers are registered in the `ROUTERS` tuple in `app/main.py`, which loop-mounts each one with the `/v1` prefix.

### Auth

| Method | Endpoint | Description |
| ------ | ------------------- | ----------------------------------- |
| POST | `/auth/register` | Create a new account |
| POST | `/auth/jwt/login` | Log in (sets access + refresh cookies) |
| POST | `/auth/jwt/logout` | Log out (revokes tokens, clears cookies) |
| POST | `/auth/refresh` | Rotate refresh token, reissue access token |
| GET | `/auth/me` | Get current authenticated user |
| Method | Endpoint | Description |
| ------ | ---------------------- | ------------------------------------------ |
| POST | `/v1/auth/register` | Create a new account |
| POST | `/v1/auth/jwt/login` | Log in (sets access + refresh cookies) |
| POST | `/v1/auth/jwt/logout` | Log out (revokes tokens, clears cookies) |
| POST | `/v1/auth/refresh` | Rotate refresh token, reissue access token |
| GET | `/v1/auth/me` | Get current authenticated user |

### Admin

Admin endpoints require the `admin` role (superusers also have access).

| Method | Endpoint | Description |
| ------ | ------------------------------ | ------------------------ |
| PATCH | `/admin/users/{id}/role` | Update a user's role |
| Method | Endpoint | Description |
| ------ | ------------------------------ | -------------------- |
| PATCH | `/v1/admin/users/{id}/role` | Update a user's role |

### Notes (Example CRUD)

All note endpoints require authentication. Users can only access their own notes.

| Method | Endpoint | Description |
| ------ | ------------- | ------------------------ |
| GET | `/notes` | List current user's notes |
| GET | `/notes/{id}` | Get a note by ID |
| POST | `/notes` | Create a note |
| PATCH | `/notes/{id}` | Update a note |
| DELETE | `/notes/{id}` | Delete a note |
| Method | Endpoint | Description |
| ------ | ---------------- | ------------------------- |
| GET | `/v1/notes` | List current user's notes |
| GET | `/v1/notes/{id}` | Get a note by ID |
| POST | `/v1/notes` | Create a note |
| PATCH | `/v1/notes/{id}` | Update a note |
| DELETE | `/v1/notes/{id}` | Delete a note |

## Authentication

Authentication uses httpOnly cookies with short-lived access tokens and rotating refresh tokens.

- **Access token**: 15-minute JWT stored in a `{COOKIE_PREFIX}_access` httpOnly cookie
- **Refresh token**: 7-day JWT stored in a `{COOKIE_PREFIX}_refresh` httpOnly cookie (scoped to `/auth/refresh`)
- **Refresh token**: 7-day JWT stored in a `{COOKIE_PREFIX}_refresh` httpOnly cookie (scoped to `/v1/auth/refresh` — the cookie's `path` tracks `API_V1_PREFIX` in `app/config.py`)
- **Token rotation**: Each refresh issues a new token in the same family; reuse of an old token revokes the entire family (theft detection)
- **Rate limiting**: Login (5/min), registration (3/min), refresh (30/min)

Expand All @@ -176,7 +178,7 @@ Users have a `role` field (default: `user`). Roles are defined as a `StrEnum` in
- **user** — default role for all registered users
- **admin** — can access admin endpoints (e.g. updating user roles)

Superusers (`is_superuser=True`) bypass all role checks. Roles are read-only via `GET /auth/me` and can only be changed by admins via `PATCH /admin/users/{id}/role`. The `require_role()` dependency factory can be used to gate any route:
Superusers (`is_superuser=True`) bypass all role checks. Roles are read-only via `GET /v1/auth/me` and can only be changed by admins via `PATCH /v1/admin/users/{id}/role`. The `require_role()` dependency factory can be used to gate any route:

```python
from app.auth import require_role
Expand Down Expand Up @@ -219,7 +221,7 @@ Use the `get_analytics()` FastAPI dependency to access it in route handlers.

Feature flags are read from `FEATURE_*` environment variables at startup (no database required). Set `FEATURE_<NAME>=true` or `false` in your `.env`.

The `GET /flags` endpoint (requires authentication) returns all flags as a JSON object, consumed by the web-template's `FeatureFlagProvider`.
The `GET /v1/flags` endpoint (requires authentication) returns all flags as a JSON object, consumed by the web-template's `FeatureFlagProvider`.

Use the `get_feature_flags()` dependency in route handlers to check flags server-side via `flags.is_enabled("flag_name")`.

Expand Down Expand Up @@ -332,7 +334,7 @@ api-template/
│ │ └── user.py # User model (FastAPI-Users)
│ ├── routers/
│ │ ├── admin.py # Admin endpoints (role management)
│ │ ├── auth_refresh.py # /auth/refresh and /auth/jwt/logout
│ │ ├── auth_refresh.py # /v1/auth/refresh and /v1/auth/jwt/logout
│ │ └── notes.py # Notes CRUD (user-scoped)
│ ├── schemas/
│ │ ├── note.py # Note request/response schemas
Expand Down
10 changes: 7 additions & 3 deletions app/auth/refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession

from app.config import settings
from app.config import API_V1_PREFIX, settings
from app.models.refresh_token import RefreshToken

REFRESH_TOKEN_LIFETIME = timedelta(days=7)
REFRESH_COOKIE_NAME = f"{settings.cookie_prefix}_refresh"
# Scoped to the refresh endpoint's URL so the browser only sends the refresh
# token there. Must track where auth_refresh_router is mounted in app/main.py
# (API_V1_PREFIX + "/auth/refresh") — a mismatch silently breaks token refresh.
REFRESH_COOKIE_PATH = f"{API_V1_PREFIX}/auth/refresh"
REFRESH_AUDIENCE = ["app:refresh"]


Expand Down Expand Up @@ -113,7 +117,7 @@ def set_refresh_cookie(response: Response, jwt: str) -> None:
key=REFRESH_COOKIE_NAME,
value=jwt,
max_age=int(REFRESH_TOKEN_LIFETIME.total_seconds()),
path="/auth/refresh",
path=REFRESH_COOKIE_PATH,
domain=settings.cookie_domain,
secure=not settings.is_development,
httponly=True,
Expand All @@ -124,7 +128,7 @@ def set_refresh_cookie(response: Response, jwt: str) -> None:
def clear_refresh_cookie(response: Response) -> None:
response.delete_cookie(
key=REFRESH_COOKIE_NAME,
path="/auth/refresh",
path=REFRESH_COOKIE_PATH,
domain=settings.cookie_domain,
secure=not settings.is_development,
httponly=True,
Expand Down
6 changes: 6 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

# Prefix under which every application route (auth + business resources) is
# mounted, so the whole API surface is versioned together. Infrastructure routes
# (/, /health, /docs, /openapi.json) stay unversioned. Not env-overridable on
# purpose — the path version is part of the code contract, not deployment config.
API_V1_PREFIX = "/v1"


class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
Expand Down
36 changes: 21 additions & 15 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from app.auth import auth_backend, current_active_user, fastapi_users
from app.auth.security_logging import SecurityEvent, log_security_event
from app.config import settings
from app.config import API_V1_PREFIX, settings
from app.features import router as features_router
from app.logging import setup_logging
from app.models.user import User
Expand Down Expand Up @@ -62,32 +62,32 @@ async def limit_request_body_size(request: Request, call_next) -> Response:
return await call_next(request)


# --- Auth routes ---
# Custom refresh/logout routes (included before FastAPI-Users so /auth/jwt/logout is shadowed)
app.include_router(auth_refresh_router)
# --- Auth routes (mounted under /v1) ---
# Custom refresh/logout routes (included before FastAPI-Users so /v1/auth/jwt/logout is shadowed)
app.include_router(auth_refresh_router, prefix=API_V1_PREFIX)
app.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/auth/jwt",
prefix=f"{API_V1_PREFIX}/auth/jwt",
tags=["auth"],
)
app.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
prefix=f"{API_V1_PREFIX}/auth",
tags=["auth"],
)
# --- End auth routes ---


@app.get("/auth/me", response_model=UserRead, tags=["auth"])
@app.get(f"{API_V1_PREFIX}/auth/me", response_model=UserRead, tags=["auth"])
async def get_current_user(user: User = Depends(current_active_user)):
return user


# Path-specific rate limits for auth endpoints
_AUTH_RATE_LIMITS: dict[str, RateLimitItem] = {
"/auth/jwt/login": parse("5/minute"),
"/auth/register": parse("3/minute"),
"/auth/refresh": parse("30/minute"),
f"{API_V1_PREFIX}/auth/jwt/login": parse("5/minute"),
f"{API_V1_PREFIX}/auth/register": parse("3/minute"),
f"{API_V1_PREFIX}/auth/refresh": parse("30/minute"),
}


Expand Down Expand Up @@ -138,7 +138,7 @@ async def request_id_middleware(request: Request, call_next) -> Response:
async def cache_control_middleware(request: Request, call_next) -> Response:
"""Set Cache-Control headers: no-store for auth paths, public caching for GETs."""
response = await call_next(request)
if request.url.path.startswith("/auth/"):
if request.url.path.startswith(f"{API_V1_PREFIX}/auth/"):
response.headers["Cache-Control"] = "no-store"
elif request.method == "GET" and response.status_code == 200:
response.headers["Cache-Control"] = "public, max-age=3600"
Expand All @@ -165,10 +165,16 @@ async def request_logging_middleware(request: Request, call_next) -> Response:
return response


# API routes
app.include_router(admin_router)
app.include_router(notes_router)
app.include_router(features_router)
# Application routers, all mounted under /v1. Add new resource routers to this
# tuple — they're loop-mounted with the /v1 prefix automatically. (The auth
# routers above are mounted separately because their ordering matters.)
ROUTERS = (
admin_router,
notes_router,
features_router,
)
for router in ROUTERS:
app.include_router(router, prefix=API_V1_PREFIX)


@app.get("/docs", include_in_schema=False)
Expand Down
8 changes: 4 additions & 4 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async def test_logout_revokes_token_family(self, client: AsyncClient, test_user:
await session.commit()

response = await client.post(
"/auth/jwt/logout",
"/v1/auth/jwt/logout",
cookies={REFRESH_COOKIE_NAME: _make_refresh_jwt(test_user.id, jti, family)},
)
assert response.status_code == 204
Expand Down Expand Up @@ -77,7 +77,7 @@ async def test_logout_revokes_all_tokens_in_family(
await session.commit()

response = await client.post(
"/auth/jwt/logout",
"/v1/auth/jwt/logout",
cookies={REFRESH_COOKIE_NAME: _make_refresh_jwt(test_user.id, tokens[-1].id, family)},
)
assert response.status_code == 204
Expand All @@ -95,13 +95,13 @@ async def test_logout_revokes_all_tokens_in_family(
async def test_logout_with_invalid_token_returns_204(self, client: AsyncClient):
"""Malformed cookie: logout still succeeds (user-facing) and does not crash."""
response = await client.post(
"/auth/jwt/logout",
"/v1/auth/jwt/logout",
cookies={REFRESH_COOKIE_NAME: "not-a-real-jwt"},
)
assert response.status_code == 204

async def test_logout_without_cookie_returns_204(self, client: AsyncClient):
response = await client.post("/auth/jwt/logout")
response = await client.post("/v1/auth/jwt/logout")
assert response.status_code == 204


Expand Down
47 changes: 33 additions & 14 deletions tests/test_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
class TestListNotes:
async def test_list_empty(self, client: AsyncClient, test_user: User):
app.dependency_overrides[current_active_user] = lambda: test_user
response = await client.get("/notes")
response = await client.get("/v1/notes")
assert response.status_code == 200
assert response.json() == []

Expand All @@ -21,7 +21,7 @@ async def test_list_with_data(self, client: AsyncClient, test_user: User, sessio
session.add(note)
await session.commit()

response = await client.get("/notes")
response = await client.get("/v1/notes")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
Expand All @@ -36,7 +36,7 @@ async def test_user_isolation(
session.add(note)
await session.commit()

response = await client.get("/notes")
response = await client.get("/v1/notes")
assert response.status_code == 200
assert response.json() == []

Expand All @@ -45,7 +45,7 @@ class TestCreateNote:
async def test_create_success(self, client: AsyncClient, test_user: User):
app.dependency_overrides[current_active_user] = lambda: test_user
response = await client.post(
"/notes",
"/v1/notes",
json={"title": "My note", "body": "Hello world"},
)
assert response.status_code == 201
Expand All @@ -57,13 +57,13 @@ async def test_create_success(self, client: AsyncClient, test_user: User):

async def test_create_without_body(self, client: AsyncClient, test_user: User):
app.dependency_overrides[current_active_user] = lambda: test_user
response = await client.post("/notes", json={"title": "Title only"})
response = await client.post("/v1/notes", json={"title": "Title only"})
assert response.status_code == 201
assert response.json()["body"] is None

async def test_create_invalid_title(self, client: AsyncClient, test_user: User):
app.dependency_overrides[current_active_user] = lambda: test_user
response = await client.post("/notes", json={"title": ""})
response = await client.post("/v1/notes", json={"title": ""})
assert response.status_code == 422


Expand All @@ -75,13 +75,13 @@ async def test_get_existing(self, client: AsyncClient, test_user: User, session)
await session.commit()
await session.refresh(note)

response = await client.get(f"/notes/{note.id}")
response = await client.get(f"/v1/notes/{note.id}")
assert response.status_code == 200
assert response.json()["title"] == "Get me"

async def test_get_not_found(self, client: AsyncClient, test_user: User):
app.dependency_overrides[current_active_user] = lambda: test_user
response = await client.get(f"/notes/{uuid4()}")
response = await client.get(f"/v1/notes/{uuid4()}")
assert response.status_code == 404

async def test_get_other_users_note(
Expand All @@ -94,7 +94,7 @@ async def test_get_other_users_note(
await session.commit()
await session.refresh(note)

response = await client.get(f"/notes/{note.id}")
response = await client.get(f"/v1/notes/{note.id}")
assert response.status_code == 404


Expand All @@ -106,15 +106,15 @@ async def test_update_partial(self, client: AsyncClient, test_user: User, sessio
await session.commit()
await session.refresh(note)

response = await client.patch(f"/notes/{note.id}", json={"title": "Updated"})
response = await client.patch(f"/v1/notes/{note.id}", json={"title": "Updated"})
assert response.status_code == 200
data = response.json()
assert data["title"] == "Updated"
assert data["body"] == "Original body"

async def test_update_not_found(self, client: AsyncClient, test_user: User):
app.dependency_overrides[current_active_user] = lambda: test_user
response = await client.patch(f"/notes/{uuid4()}", json={"title": "Nope"})
response = await client.patch(f"/v1/notes/{uuid4()}", json={"title": "Nope"})
assert response.status_code == 404


Expand All @@ -126,14 +126,33 @@ async def test_delete_success(self, client: AsyncClient, test_user: User, sessio
await session.commit()
await session.refresh(note)

response = await client.delete(f"/notes/{note.id}")
response = await client.delete(f"/v1/notes/{note.id}")
assert response.status_code == 204

# Verify deleted
response = await client.get(f"/notes/{note.id}")
response = await client.get(f"/v1/notes/{note.id}")
assert response.status_code == 404

async def test_delete_not_found(self, client: AsyncClient, test_user: User):
app.dependency_overrides[current_active_user] = lambda: test_user
response = await client.delete(f"/notes/{uuid4()}")
response = await client.delete(f"/v1/notes/{uuid4()}")
assert response.status_code == 404


class TestRoutesAreVersioned:
"""Guard: application routes live under /v1 only — the unversioned path 404s.

Catches a router accidentally mounted at the root (e.g. a bare
`app.include_router(notes_router)` instead of appending to the ROUTERS tuple).
"""

async def test_unversioned_route_not_served(self, client: AsyncClient, test_user: User):
app.dependency_overrides[current_active_user] = lambda: test_user
assert (await client.get("/notes")).status_code == 404
# ...but the versioned path works.
assert (await client.get("/v1/notes")).status_code == 200

async def test_health_routes_stay_unversioned(self, client: AsyncClient):
# Infrastructure routes are deliberately not under /v1.
assert (await client.get("/health")).status_code == 200
assert (await client.get("/v1/health")).status_code == 404
Loading
Loading