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
40 changes: 38 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,47 @@

## Unreleased

### Added

- **Link an OAuth provider while signed in.** Until now a provider could only be attached as a side effect of signing in, and only when the provider reported the email as verified and it already matched an account, which left out a work account under a different address, a provider that does not verify emails, and one that returns no email at all. `GET /oauth/{provider}/link/authorize` and `POST /oauth/{provider}/link/callback` (both authenticated, same `binding` contract as sign-in) now attach the identity to the caller's account and return it; no tokens are issued, since linking is not a sign-in. The provider email is stored for display only, so it does not have to match the account and does not verify it. Two conflicts answer `409`: the identity already belongs to another user (never moved, that would be account takeover), or the user already linked a different account from the same provider (unlinking is keyed by provider alone and could not tell them apart). Linking an account the user already has refreshes the stored provider tokens. New flow functions `link_oauth_account()`, `oauth_link_callback()`, and `build_link_authorization_url()`; new exceptions `OAuthAccountAlreadyLinkedError` and `OAuthProviderAlreadyLinkedError`. This closes the last `get_oauth_associate_router` gap in the [fastapi-users migration guide](https://mdfarhankc.github.io/fastapi-fullauth/migrating-from-fastapi-users/).
- **Refresh-token pruning.** `adapter.prune_expired_refresh_tokens(before=None)` deletes rows whose expiry has passed and returns how many went. Rotation writes a row per refresh, so the table only ever grew, and `GET /sessions` reads every row a user has ever had. Only expired rows are removed: a revoked but unexpired row is what a replayed token is matched against, and deleting it early would turn a stolen-token replay into an ordinary rejection and leave the family alive. Call it from your own scheduler; nothing runs it for you. Custom adapters inherit a no-op, so nothing breaks.
- **`PASSWORD_MAX_LENGTH` (default 4096).** Without an upper bound, a large request body became hashing work for the server. bcrypt already refused over 72 bytes; argon2id would hash whatever arrived.
- **`REAUTH_MAX_AGE_SECONDS` (default 300) and an `auth_time` claim.** Tokens now record when the credentials behind the session were actually checked, stamped at login, OAuth callback and passkey sign-in, and carried unchanged through refresh rotation. New flow helpers `verify_recent_auth()` and `remaining_login_methods()`.
- **`after_oauth_link` and `after_oauth_unlink` hooks**, both `async def(user, provider)`. Attaching or removing a sign-in method is an event applications need to audit-log, and unlinking had no hook at all.

### Changed

- **The Redis lockout manager fails open instead of raising.** It had no error handling at all, so with `LOCKOUT_BACKEND=redis` a Redis outage turned every login into a 500. Attempts are now neither counted nor blocked while Redis is unreachable, and the error is logged; brute-force protection is lost for the duration. The rate limiter already made this trade; the token blacklist still fails closed.
- **`refresh_tokens.user_id`, `refresh_tokens.expires_at` and `oauth_accounts.user_id` are indexed** in the SQLModel and SQLAlchemy mixins. Every session listing, revoke-all, and prune filters on them, and a foreign key does not imply an index on PostgreSQL. The Beanie documents already indexed them. **Migration:** existing deployments need to add these indexes; new tables get them automatically. For Alembic, autogenerate picks them up, or add them by hand:

```sql
CREATE INDEX ix_fullauth_refresh_tokens_user_id ON fullauth_refresh_tokens (user_id);
CREATE INDEX ix_fullauth_refresh_tokens_expires_at ON fullauth_refresh_tokens (expires_at);
CREATE INDEX ix_fullauth_oauth_accounts_user_id ON fullauth_oauth_accounts (user_id);
```

- **`POST /verify-email/request` no longer mails an already-verified account.** `create_email_verification_token()` returns `None` for a verified user; the route's response is unchanged, so this leaks nothing.
- **Redis clients are shared per event loop, not process-wide.** A redis-py asyncio client binds its connections to the loop that first used them, so one client handed to a second loop fails at runtime. Acquiring outside a running loop, which is the usual case since `FullAuth` is built at import time, still shares one client per URL.

### Security

- **Deleting an account and setting a first password now require recent authentication.** A valid access token said a session existed, not that whoever held it had proved anything, so a token lifted from a logged-in machine could delete the account outright, or give an OAuth-only account a password and a second way in. Both now need either `current_password` or a session whose credentials were checked within `REAUTH_MAX_AGE_SECONDS`, and answer `403` otherwise. The age comes from `auth_time`, which rotation preserves, so refreshing a stolen token does not renew it. **Breaking:** `DELETE /me` and first-password sets from sessions older than five minutes now fail; clients should prompt for the password and send it in the body, or re-authenticate. Tokens issued before this release carry no `auth_time`, so the session counts as last authenticated when its current token was issued: an access token minted minutes ago still passes, and rotating an old refresh token carries that token's own issue time forward rather than resetting the clock.
- **A password-reset link is invalidated by a password change.** The link stayed usable for its whole TTL even after the password had been changed by another reset link or by `/change-password`, so an older mail in an inbox could undo a deliberate change. The token now carries a digest of the password hash it was issued against, and a reset whose digest no longer matches is rejected and burned. No new storage. **Migration:** reset links issued before the upgrade stop working, up to `PASSWORD_RESET_EXPIRE_MINUTES` (15 by default) after deploy.
- **Removing a sign-in method can no longer lock an account out.** `DELETE /oauth/accounts/{provider}` counted a password and other providers but not passkeys, so it told a passkey user to "set a password first"; `DELETE /passkeys/{id}` had no such check at all, so a passkey-only account could delete its last credential and never get back in. Both routes now ask one shared question, `remaining_login_methods()`, which counts a stored password, every linked provider, and every registered passkey, and the `400` names only the alternatives the deployment actually offers.
- **A link state is bound to the user it was issued for.** The state for a link flow carries that user's id and the callback rejects it unless it matches the authenticated caller, so an attacker who finishes their own provider flow cannot have a victim submit the result and end up attached to the victim's account. The id travels in a claim rather than the token subject, since the state passes through the address bar and the provider and has to stay useless as a session token. Sign-in states and link states carry different purposes and cannot be swapped. `generate_oauth_state()` and `build_authorization_url()` gained keyword-only `purpose` and `claims` parameters, both defaulted, so existing calls are unaffected.

### Fixed

- **The admin permission routes answer 501 instead of 500 without permission models.** The admin router mounts as soon as the adapter supports roles, so `/admin/assign-permission`, `/admin/remove-permission` and `/admin/role-permissions/{role}` existed against adapters configured for roles alone, where the adapter's `RuntimeError` escaped as a 500. Role management on such an adapter is unaffected.
- **The Agent Skill listed eight admin routes that do not exist.** `references/rbac.md` and the route table in `references/api-reference.md` documented user CRUD (`/admin/users`, `/admin/users/{id}/roles`, `/admin/roles/{role}/permissions`) that the library has never shipped, and said admin routes accept `require_role("admin")` when they require `is_superuser`. Both now describe the five routes that exist, and say plainly that there is no user CRUD.
- **The in-memory passkey challenge store never evicted anything.** Challenges were removed only by completing the flow, so every abandoned one stayed for the life of the process, and `POST /passkeys/authenticate/begin` needs no account. It now sweeps expired entries on write, like the blacklist, lockout and rate-limit stores.
- **`fastapi_fullauth.routers` exports every router factory.** It listed four of the seven, so `create_oauth_router`, `create_passkey_router`, and `create_sessions_router` had to be imported from their modules even though the package documented the shorter path.
- **The OAuth router is built once, not twice.** It was the only router property without a cache, and the router builder reads it twice (once to test it, once to mount it), so every `init_app()` built two full routers with their dynamically created response models and discarded one.
- **Signing in no longer attaches a second identity from the same provider.** If two accounts at one provider verify the same email address, auto-link-by-email attached both to the same user. `DELETE /oauth/accounts/{provider}` resolves by provider alone, so unlinking deleted whichever row came first and left the other one attached, with no way to reach it: the user unlinked the provider and stayed linked to it. The second sign-in is now refused, like the link route refuses it, and answers the same generic `400 OAuth authentication failed` as every other sign-in failure. Reachable with custom or self-hosted providers; the major providers do not verify one address on two accounts.
- **An OAuth error that is not an `OAuthProviderError` no longer returns a 500.** Both OAuth callbacks caught `OAuthProviderError`, so any other `OAuthError` subclass escaped as an unhandled exception. They now catch `OAuthError`.
- **`/register` no longer claims to have sent a verification email.** With `PREVENT_REGISTRATION_ENUMERATION` on (the default) it answered `202` with "a verification email has been sent", but registration never emits `send_verification_email`: that hook fires only from `POST /verify-email/request`, which requires the user to already be signed in. Unless the application sent its own mail from `after_register`, the person was told to wait for an email that was never sent. The response now reads "If this email isn't already registered, the account has been created.", which is true either way and gives an attacker no more than before. [Sending one at signup](https://mdfarhankc.github.io/fastapi-fullauth/auth/hooks/#verification-email-on-registration) is now documented as a hook recipe. **If you assert on this string in tests, update it.**
- **Corrected the guidance given when auto-link-by-email is refused.** When a provider returns an unverified email that already belongs to an account, the flow refused the link (correctly) but told the user to "link your OAuth account from account settings", which no endpoint supports. It now says to sign in with a password or verify the address with the provider. The message is internal to `OAuthProviderError`: the router still answers every OAuth failure with a generic `400 OAuth authentication failed`, so nothing about the response changes.
- **The Agent Skill described routes and flows that do not exist.** `references/rbac.md` and the route table in `references/api-reference.md` listed eight admin routes (user CRUD under `/admin/users`, role and permission routes under `/admin/roles`) that the library has never shipped, and said admin routes accept `require_role("admin")` when they require `is_superuser`. `references/oauth.md` said the OAuth callback links through the caller's session, which it does not. All three now describe what the library actually does.

- **Corrected the guidance given when auto-link-by-email is refused.** When a provider returns an unverified email that already belongs to an account, the flow refused the link (correctly) but told the user to "link your OAuth account from account settings", which no endpoint supports. It now names the path that the link routes above make real: sign in with a password, then link the provider. The message is internal to `OAuthProviderError`: the router still answers every OAuth failure with a generic `400 OAuth authentication failed`, so nothing about the response changes. The Agent Skill's OAuth reference described the same impossible flow, claiming the callback links through the caller's session; it now documents what the callback actually does.

## 0.16.0

Expand Down
2 changes: 2 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ from fastapi_fullauth.exceptions import (
AccountLockedError, # too many failed attempts
OAuthError, # OAuth base error
OAuthProviderError, # provider-specific error
OAuthAccountAlreadyLinkedError, # identity belongs to another user
OAuthProviderAlreadyLinkedError,# user already linked this provider
)
```

Expand Down
5 changes: 4 additions & 1 deletion docs/auth/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ fullauth.hooks.on("after_register", on_register)
| `after_email_verify` | `async def(user: UserSchema)` | After email is verified |
| `after_oauth_login` | `async def(user: UserSchema, provider: str, is_new_user: bool)` | After OAuth login succeeds |
| `after_oauth_register` | `async def(user: UserSchema, user_info: OAuthUserInfo)` | After a new user is created via OAuth |
| `after_oauth_link` | `async def(user: UserSchema, provider: str)` | After a signed-in user links a provider |
| `after_oauth_unlink` | `async def(user: UserSchema, provider: str)` | After a signed-in user unlinks a provider |
| `send_verification_email` | `async def(email: str, token: str)` | When email verification is requested |
| `send_password_reset_email` | `async def(email: str, token: str)` | When password reset is requested |

Expand Down Expand Up @@ -111,7 +113,7 @@ async def send_verification(user):
token = await create_email_verification_token(
fullauth.adapter, fullauth.token_engine, user.id
)
if token: # None when the user no longer exists
if token: # None when the account is already verified
await mailer.send_verification(user.email, token)
```

Expand Down Expand Up @@ -182,6 +184,7 @@ The `hooks.on()` method uses `@overload` with Protocol types so your IDE can aut
| `EmailHook` | Email events | `async def(email: str, token: str)` |
| `AfterOAuthLoginHook` | `after_oauth_login` | `async def(user: UserSchema, provider: str, is_new_user: bool)` |
| `AfterOAuthRegisterHook` | `after_oauth_register` | `async def(user: UserSchema, user_info: OAuthUserInfo)` |
| `AfterOAuthLinkHook` | `after_oauth_link`, `after_oauth_unlink` | `async def(user: UserSchema, provider: str)` |

You can import these from `fastapi_fullauth.hooks` if you want to type-annotate your callbacks:

Expand Down
14 changes: 14 additions & 0 deletions docs/auth/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ Revoking a session signs that device out at once: its refresh token stops workin

This relies on the token blacklist (`BLACKLIST_ENABLED`, on by default). Run it on Redis when you have more than one worker; an in-memory blacklist only knows about revocations made in the same process.

## Pruning old refresh tokens

Rotation writes a row per refresh, so the table only grows. Delete the expired
rows periodically from your own scheduler:

```python
removed = await fullauth.adapter.prune_expired_refresh_tokens()
```

It removes only rows whose expiry has passed. A revoked but unexpired row stays:
that row is what a replayed token is matched against, and removing it early would
turn a stolen-token replay into an ordinary rejection, leaving the family alive.
Custom adapters inherit a no-op, so override it if your storage needs pruning.

## Using a custom adapter

The built-in adapters implement session listing out of the box. A custom adapter opts in by inheriting `SessionAdapterMixin` and implementing three methods:
Expand Down
4 changes: 3 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,14 @@ If you want to be defensively explicit that no file is ever read, pass `FullAuth
|--------|------|---------|-------------|
| `PASSWORD_HASH_ALGORITHM` | `"argon2id" \| "bcrypt"` | `"argon2id"` | Hashing algorithm. |
| `PASSWORD_MIN_LENGTH` | `int` | `8` | Minimum password length. |
| `PASSWORD_MAX_LENGTH` | `int` | `4096` | Maximum password length, so a huge body can't be turned into hashing work. bcrypt caps at 72 bytes regardless. |

### Login

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `LOGIN_FIELD` | `str` | `"email"` | Field used for login (`"email"`, `"username"`, etc.). |
| `REAUTH_MAX_AGE_SECONDS` | `int` | `300` | How recently credentials must have been checked for a destructive action (deleting the account, setting a first password). Supplying the current password proves it too. `0` accepts only the password, which locks passwordless accounts out of those actions. |
| `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. |
Expand All @@ -171,7 +173,7 @@ middleware (`RateLimitMiddleware`) is opt-in; import it from
| `TRUSTED_PROXY_HEADERS` | `list[str]` | `[]` | Headers to read the real client IP from (e.g. `["X-Forwarded-For"]`). The IP is taken from the right of the chain, not the left; left-most entries are client-supplied and never trusted. |
| `TRUSTED_PROXY_COUNT` | `int` | `1` | Number of trusted proxy hops in front of the app. The client IP is the entry this many positions from the right of the forwarded chain. Must be `>= 1`. Set it too high and an attacker can spoof their IP; see [Rate Limiting](security/rate-limiting.md#proxy-support). |
| `AUTH_RATE_LIMIT_ENABLED` | `bool` | `True` | Enable per-route auth rate limits. |
| `AUTH_RATE_LIMITS` | `AuthRateLimits` | see below | Per-route request caps: `login=5`, `register=3`, `password_reset=3`, `passkey_auth=10`, `refresh=30`. |
| `AUTH_RATE_LIMITS` | `AuthRateLimits` | see below | Per-route request caps: `login=5`, `register=3`, `password_reset=3`, `passkey_auth=10`, `refresh=30`, `reauth=5`. `reauth` meters the `current_password` check on `DELETE /me` and `/change-password`, in its own bucket so failed attempts cannot exhaust an IP's sign-in budget. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `int` | `60` | Rate limit window in seconds. |

Override individual routes without touching the others. In Python, pass an
Expand Down
Loading
Loading