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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

### Breaking changes

- **`hashed_password` is nullable** on `UserMixin` (both SQLAlchemy and SQLModel). OAuth-only users are inserted with `hashed_password=NULL` instead of a fake random hash. The previous `has_usable_password` boolean is gone — `hashed_password IS NOT NULL` is the single signal.
- **`/auth/set-password` route removed.** First-time password creation for OAuth-only users now goes through `/auth/change-password` with `current_password` omitted — the route accepts the missing field only when the stored hash is `NULL`. Users with an existing password must still supply it. The previous `set_password` flow checked `getattr(user, "has_usable_password", True)` against a `UserSchema` that didn't include the field, so OAuth-only users on the default schema could never call it successfully; this is now closed.
- **`flows.set_password` module removed.** Folded into `flows.change_password`, whose `current_password` parameter is now `str | None = None`.
- **`AbstractUserAdapter.create_user` signature change.** `hashed_password: str` is now `hashed_password: str | None`. Custom adapters must accept `None` and persist it. Built-in adapters already do.
- **`flows.oauth.link_or_create_user` and `flows.oauth.oauth_callback` no longer take `hash_algorithm`.** OAuth users have no password to hash anymore.
- **`ChangePasswordRequest.current_password` is now `str | None`.** Clients that always sent it keep working; clients can omit it when the user has no stored password.
- **`ChallengeStore` moved from `core.challenges` to `protection.challenges`.** Import path change: `from fastapi_fullauth.protection.challenges import ChallengeStore, InMemoryChallengeStore, RedisChallengeStore, create_challenge_store, register_challenge_store_backend`. Also exported from the `fastapi_fullauth.protection` package. The challenge store is a stateful anti-replay defence for WebAuthn — it belongs with the other defensive stores (`lockout`, `ratelimit`) rather than next to `TokenEngine` in `core/`.

- **Built-in models are now mixins.** The concrete `*Model` / `*Record` classes and the `FullAuthBase` declarative base are gone. Bring your own `DeclarativeBase` (SQLAlchemy) or `SQLModel` and combine each `*Mixin` to define the tables. The previous "must subclass `FullAuthBase`" rule forced every project to put its own tables on the library's metadata; mixins let you reuse one `Base` across `fastapi-fullauth` and the rest of the app.

Before:
Expand Down Expand Up @@ -102,6 +110,10 @@ No data migration is required — table names and column shapes are unchanged.

### Fixed

- **Hooks are now isolated.** A raising hook is logged via `fastapi_fullauth.hooks` and the next hook still runs. Previously a single failing hook (e.g. an email-send raising on a transient SMTP error) aborted every subsequent hook and surfaced as a 500 to the client — even though the user had already been created / password reset / etc. Hooks fire after the primary side effect commits, so a notification failure should never undo the operation the response reports.
- **`SQLAlchemyAdapter` eager-loads `roles` regardless of relationship lazy setting.** Added a `_user_query()` helper mirroring the SQLModel adapter that calls `selectinload(user_model.roles)` when the model has a `roles` attribute. Used by `get_user_by_id`, `get_user_by_field` (and `get_user_by_email`), `update_user`, `create_user`, and `get_user_roles`. Previously these methods built a bare `select(user_model)` and relied on the app to declare `lazy="selectin"` on the relationship; if the app left it default (`select`), `_to_schema` triggered an async lazy-load outside the session and raised `MissingGreenlet`. Behaviour now matches the SQLModel adapter.
- **Passkey router preserves tracebacks for unexpected failures.** The broad `except Exception as e: logger.error("...: %s", e)` in `/passkeys/register/complete` and `/passkeys/authenticate/complete` dropped the stack trace, making webauthn library failures effectively undebuggable. Now uses `logger.exception(...)` so the traceback lands in the `fastapi_fullauth.routers.passkey` logger alongside the request log line.
- **SQLAlchemy `UserMixin.email` is now `String(320)`** to match the explicit length on `OAuthAccountMixin.provider_email` and the SQLModel mixin's `max_length=320`. Previously the unsized column produced MySQL/MSSQL default-length VARCHARs (255 / 256) that silently truncated long addresses — Postgres/SQLite were unaffected. The local-part can legally be up to 64 chars and the domain up to 255 (RFC 5321), so 320 is the right ceiling.
- `/auth/refresh` was passing `str(user.id)` to `RefreshToken(user_id=...)` which expects `UUID`. Pydantic v2 coerced silently so there was no runtime break, but the path now passes `user.id` directly — consistent with `flows/login.py` and clean under static type checking.
- `LoginResponse` is now a real subclass of `TokenPair` with `user: UserSchema | None = None` instead of a dynamically created model with no static type. The dynamic factory still narrows the `user` field to the configured user schema for OpenAPI, but `LoginResponse(...)` calls now type-check cleanly in mypy/pyright.
- `DELETE /oauth/accounts/{provider}` was calling `delete_oauth_account(provider, user.id)` — passing the local user UUID where the provider's `provider_user_id` (e.g. a Google subject ID) was expected. The query never matched, so unlinking silently no-op'd. Now resolves the OAuth account for the current user first and passes the right `provider_user_id`. Returns `404` if the user doesn't have an account on that provider.
Expand All @@ -119,6 +131,8 @@ No data migration is required — table names and column shapes are unchanged.
- Version is now read dynamically from `fastapi_fullauth/__init__.py` via `[tool.hatch.version]` so a bump only touches one file.
- `[tool.pytest.ini_options]` migrated to `[tool.pytest]` (pytest 9 supports the flat key).
- `pytest-cov` added to the dev dependency group so contributors can run `uv run pytest --cov=fastapi_fullauth` locally.
- **`AuthRateLimiter.check()` now sets `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After` headers on its `429` responses.** The global `RateLimitMiddleware` already sets the `X-RateLimit-*` triplet; the per-route auth limiter (login, register, password-reset, refresh, passkey-authenticate buckets) used to raise a bare `429` so clients couldn't tell when to retry. Headers come from the same limiter instance's `reset_time(client_ip)`.
- `_b64_decode` helper in `flows/passkey.py` now computes padding as `(-len(data)) % 4` instead of `4 - len(data) % 4`. Mathematically equivalent except when the input length is already a multiple of 4 — the old form appended `====` (four bytes) instead of nothing. `urlsafe_b64decode` is lenient enough to tolerate either, but the new form is the standard idiom.

## 0.9.1

Expand Down
2 changes: 1 addition & 1 deletion docs/adapters/sqlalchemy.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class User(UserMixin, Base):
refresh_tokens: Mapped[list[RefreshToken]] = relationship(lazy="noload")
```

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

### 2. Create the adapter

Expand Down
3 changes: 1 addition & 2 deletions docs/adapters/sqlmodel.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ class User(UserMixin, table=True):
|-------|------|-------------|
| `id` | `UUID` (UUID7) | Primary key, auto-generated |
| `email` | `str` | Unique, indexed |
| `hashed_password` | `str` | Password hash |
| `has_usable_password` | `bool` | False for OAuth-only users |
| `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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/auth/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,5 @@ 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.
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class User(UserMixin, table=True):
refresh_tokens: list[RefreshToken] = Relationship()
```

`UserMixin` provides `id`, `email`, `hashed_password`, `has_usable_password`, `is_active`, `is_verified`, `is_superuser`, and `created_at`. Add any extra fields you need.
`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).
Expand Down
9 changes: 5 additions & 4 deletions docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ class User(UserMixin, table=True):
refresh_tokens: list[RefreshToken] = Relationship()
```

`UserMixin` provides `id`, `email`, `hashed_password`, `has_usable_password`, `is_active`, `is_verified`, `is_superuser`, and `created_at`. Add any extra fields you need.
`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).
Expand Down Expand Up @@ -831,8 +831,7 @@ class User(UserMixin, table=True):
|-------|------|-------------|
| `id` | `UUID` (UUID7) | Primary key, auto-generated |
| `email` | `str` | Unique, indexed |
| `hashed_password` | `str` | Password hash |
| `has_usable_password` | `bool` | False for OAuth-only users |
| `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 |
Expand Down Expand Up @@ -985,7 +984,7 @@ class User(UserMixin, Base):
refresh_tokens: Mapped[list[RefreshToken]] = relationship(lazy="noload")
```

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

### 2. Create the adapter

Expand Down Expand Up @@ -1356,6 +1355,8 @@ 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,4 +221,4 @@ adapter = SQLModelAdapter(
## Performance notes

- `PermissionAdapterMixin.get_permissions_for_roles` is batched — a single JOIN instead of N+1. The default override loops per-role; the built-in adapters override it with one query. Do the same for custom adapters when you can.
- Relationships on SQLAlchemy models use `lazy="selectin"` where async matters. A custom model that defaults to `lazy="select"` on a relationship will throw `MissingGreenlet` the first time user roles are touched in async.
- The built-in adapters call `selectinload(User.roles)` themselves, so the user model's `roles` relationship can use the default `lazy="select"` and still work in async. Any other relationships you add (e.g. `organizations`) need either `lazy="selectin"` on the definition or `options(selectinload(...))` at the call site, otherwise touching them outside the session raises `MissingGreenlet`.
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ from fastapi_fullauth.flows.login import login
from fastapi_fullauth.flows.register import register
from fastapi_fullauth.flows.logout import logout
from fastapi_fullauth.flows.change_password import change_password
from fastapi_fullauth.flows.set_password import set_password
from fastapi_fullauth.flows.update_profile import update_profile
from fastapi_fullauth.flows.email_verify import (
create_email_verification_token,
Expand Down Expand Up @@ -165,25 +164,28 @@ from fastapi_fullauth.protection.ratelimit import (
AuthRateLimiter,
RateLimiter,
RedisRateLimiter,
RateLimitMiddleware,
create_rate_limiter,
register_rate_limiter_backend,
)
from fastapi_fullauth.protection.challenges import (
ChallengeStore,
InMemoryChallengeStore,
RedisChallengeStore,
create_challenge_store,
register_challenge_store_backend,
)
```

## Core

```python
from fastapi_fullauth.core.tokens import TokenEngine, TokenBlacklist, InMemoryBlacklist, create_blacklist
from fastapi_fullauth.core.tokens import TokenEngine, create_blacklist
from fastapi_fullauth.core.crypto import hash_password, verify_password, password_needs_rehash
from fastapi_fullauth.core.challenges import (
ChallengeStore,
InMemoryChallengeStore,
RedisChallengeStore,
create_challenge_store,
register_challenge_store_backend,
from fastapi_fullauth.core.blacklist import (
TokenBlacklist,
InMemoryTokenBlacklist,
RedisTokenBlacklist,
)
from fastapi_fullauth.core.redis_blacklist import RedisBlacklist
```

## Middleware
Expand Down Expand Up @@ -364,7 +366,6 @@ Default prefix `/api/v1/auth`:
| GET | `/me` | profile | yes |
| PATCH | `/me` | profile | yes |
| POST | `/change-password` | profile | yes |
| POST | `/set-password` | profile | yes |
| POST | `/password-reset/request` | verify | no |
| POST | `/password-reset/confirm` | verify | no |
| POST | `/verify/request` | verify | yes |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,14 @@ The adapter's `create_user` calls `data.model_dump(exclude={"email", "password"}

See `adapters.md` for the full picture. Short version: inherit the mixin for each feature you want, skip the ones you don't. Route auto-skip does the rest.

## What `has_usable_password` is for
## OAuth-only users have a `NULL` `hashed_password`

OAuth-only users are created with a random password they don't know and `has_usable_password=False`. Two consequences:
The OAuth flow inserts the user with `hashed_password=None` — no fake random hash, no separate `has_usable_password` flag. Consequences:

- `change-password` rejects them (they can't supply the current password they don't have).
- `set-password` accepts them (it's their first time setting one).
- `/login` rejects them (the password path needs a hash to verify).
- `/change-password` accepts them without `current_password` — the access token is the auth boundary, and there's no current password to defend against. Once they set one, subsequent calls require it like a normal user.

`has_usable_password` is on `UserMixin` and `UserSchema` by default. This is one opinionated inclusion — splitting it further would complicate every OAuth app. If you really don't want OAuth, setting the field is free and costs nothing.
There's no separate `set-password` route; `/change-password` is the single entry point for both first-time set and subsequent changes.

## Why not a "config flag for everything"?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@ Your callable matches the Protocol structurally — no base class to inherit.

## Contract: hooks run after the operation succeeds

The operation (user created, login authenticated, token rotated) has already committed by the time the hook runs. If your hook raises, the exception propagates out of the route and becomes a 500, but the user's state is already changed.
The operation (user created, login authenticated, token rotated) has already committed by the time the hook runs. A raising hook is caught and logged via `logging.getLogger("fastapi_fullauth.hooks")`; subsequent hooks for the same event still run and the route returns its normal status. The route never 500s because of a hook.

Implications:

- **Don't rely on hooks for validation.** Validate in the flow or the adapter. A hook raising doesn't roll back the registration.
- **Do your own error handling inside the hook** if the side-effect is optional — emailing a welcome that fails shouldn't 500 the register endpoint.
- **Idempotency helps.** A user who retries a failed registration might see `after_register` fire twice if the first attempt's hook raised after the user was created. Make email sends idempotent or keyed on user id.
- **Don't rely on hooks for validation.** Validate in the flow or the adapter. A raising hook doesn't roll back the registration and doesn't reach the client.
- **Check your logs.** Hook failures are silent to the user. If an email isn't sending, look for the exception trace in the `fastapi_fullauth.hooks` logger.
- **Idempotency still helps.** A user who retries an action might see `after_register` fire twice; make email sends idempotent or keyed on user id.

## Typical wire-up for email

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,20 +100,20 @@ code + state

1. Look up existing OAuth account by `(provider, provider_user_id)`. If found → log that user in, update access/refresh tokens.
2. No existing link but `auto_link_by_email=True` and `info.email_verified=True` and the email matches an existing local user → link that user.
3. No existing link, email doesn't match or email_verified is False → create a new user with a random password and `has_usable_password=False`.
3. No existing link, email doesn't match or email_verified is False → create a new user with `hashed_password=NULL`.
4. Insert the OAuth account row. If that fails with `IntegrityError` on the composite unique `(provider, provider_user_id)` (concurrent callback), fetch the existing row and return it — both callers linked the same identity.

`after_oauth_login(user, provider, is_new_user)` fires for every successful login, including returning users.

`after_oauth_register(user, user_info)` fires on first-time OAuth signup — use this to prefill name / avatar URL from `user_info.name` / `user_info.picture`.

## The set-password flow for OAuth-only users
## OAuth-only users setting a password

Users created via OAuth have `has_usable_password=False`. They can't log in with password because there isn't one they know.
OAuth users have `hashed_password=NULL`. They can't log in with a password because there isn't one to verify against.

`POST /api/v1/auth/set-password` (authenticated, body: `{password}`) sets an initial password and flips `has_usable_password=True`. `change-password` is gated the other way — it rejects users who don't have one.
`POST /api/v1/auth/change-password` (authenticated, body: `{new_password}` — `current_password` may be omitted) sets the first password. The route accepts the missing `current_password` only when the stored hash is `NULL`; once a password exists, `current_password` is required like any other change.

This is why you don't just call `change-password` for OAuth users: they have no "current password" to supply.
There's no separate `set-password` route — `/change-password` handles both first-time set and subsequent changes.

## Writing a custom provider

Expand Down
Loading
Loading