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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## Unreleased

### Fixed

- **`/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.

## 0.16.0

### Added
Expand Down
22 changes: 22 additions & 0 deletions docs/auth/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,28 @@ async def log_logout(user_id):
audit_log.info("Logout: user_id=%s", user_id)
```

### Verification email on registration

Registering does not send a verification email by itself: `/register` only fires `after_register`,
and `send_verification_email` is emitted by `POST /verify-email/request`, which requires the user
to be signed in already. To mail a link at signup, mint the token in the hook:

```python
from fastapi_fullauth.flows import create_email_verification_token


@fullauth.hooks.on("after_register")
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
await mailer.send_verification(user.email, token)
```

This hook runs after the response is sent, so the time your mail provider takes cannot reveal
whether the address was already registered.

### Post-registration setup

Create default resources for new users:
Expand Down
4 changes: 2 additions & 2 deletions fastapi_fullauth/flows/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ async def link_or_create_user(
info.provider_user_id,
)
raise OAuthProviderError(
"This email is already registered. Sign in with your existing "
"credentials and link your OAuth account from account settings."
"This email is already registered. Sign in with your password, or "
f"verify this email address with {info.provider} and try again."
)
_ensure_active(existing)
user = existing
Expand Down
6 changes: 4 additions & 2 deletions fastapi_fullauth/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ def create_auth_router(
"Create a new user account. Returns 202 + a generic message by "
"default (`PREVENT_REGISTRATION_ENUMERATION=True`) so attackers "
"can't probe whether an email is registered. Set it to `False` "
"for 201 + the created user, and 409 on a duplicate email."
"for 201 + the created user, and 409 on a duplicate email. "
"No verification email is sent from here; send one from the "
"`after_register` hook if you want that."
),
)
async def register_route(
Expand All @@ -80,7 +82,7 @@ async def register_route(
anti_enum = fullauth.config.PREVENT_REGISTRATION_ENUMERATION
hash_algorithm = fullauth.config.PASSWORD_HASH_ALGORITHM
generic = message_response_schema(
detail="If this email isn't already registered, a verification email has been sent."
detail="If this email isn't already registered, the account has been created."
)

try:
Expand Down
13 changes: 5 additions & 8 deletions skills/fastapi-fullauth/references/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,14 +402,11 @@ Default prefix `/api/v1/auth`:
| POST | `/verify-email/confirm` | verify | no |
| POST | `/password-reset/request` | verify | no |
| POST | `/password-reset/confirm` | verify | no |
| GET | `/admin/users` | admin | role:admin |
| GET | `/admin/users/{id}` | admin | role:admin |
| PATCH | `/admin/users/{id}` | admin | role:admin |
| DELETE | `/admin/users/{id}` | admin | role:admin |
| POST | `/admin/users/{id}/roles` | admin | role:admin |
| DELETE | `/admin/users/{id}/roles/{role}` | admin | role:admin |
| POST | `/admin/roles/{role}/permissions` | admin | role:admin |
| DELETE | `/admin/roles/{role}/permissions/{permission}` | admin | role:admin |
| POST | `/admin/assign-role` | admin | superuser |
| POST | `/admin/remove-role` | admin | superuser |
| POST | `/admin/assign-permission` | admin | superuser |
| POST | `/admin/remove-permission` | admin | superuser |
| GET | `/admin/role-permissions/{role_name}` | admin | superuser |
| GET | `/oauth/providers` | oauth | no |
| GET | `/oauth/{provider}/authorize` | oauth | no |
| POST | `/oauth/{provider}/callback` | oauth | no |
Expand Down
6 changes: 2 additions & 4 deletions skills/fastapi-fullauth/references/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,9 @@ PKCE (S256) is enabled by default for providers that support it (Google, GitHub,

**Security caveat**: as of v0.8.0, auto-link only proceeds when `info.email_verified=True` from the provider. Without this gate, anyone who registers a secondary email on GitHub (which GitHub doesn't verify ownership for) could sign in via GitHub and get attached to the victim's local account.

When the gate fires, the callback returns a 4xx with:
When the gate fires, the flow raises `OAuthProviderError`. The router collapses it, like every other OAuth failure, into a generic `400 {"detail": "OAuth authentication failed"}`, so the endpoint cannot be used to probe which emails are registered; the specific reason is only in the server log (`fastapi_fullauth.oauth`, "oauth auto-link refused").

> This email is already registered. Sign in with your existing credentials and link your OAuth account from account settings.

UX path: log in with password → `POST /oauth/{provider}/authorize` to get the auth URL → go through the provider flow → `POST /oauth/{provider}/callback`. The callback finds the existing user via the session's authenticated user and links cleanly.
There is no authenticated "link this provider to my account" endpoint. Linking happens only as a side effect of signing in, on a provider-verified email, so a user whose provider email is unverified or differs from their account email cannot link that provider at all. Tell them to sign in with their password instead. `GET /oauth/accounts` and `DELETE /oauth/accounts/{provider}` list and remove links that already exist.

To disable auto-link entirely: `FULLAUTH_OAUTH_AUTO_LINK_BY_EMAIL=False`. Then every OAuth sign-in either finds an existing linked identity or creates a brand-new user, never cross-links.

Expand Down
26 changes: 16 additions & 10 deletions skills/fastapi-fullauth/references/rbac.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,25 @@ Both must pass. Superuser short-circuits both.

When `RoleAdapterMixin` is present, `init_app` includes the `admin` router:

- `GET /api/v1/auth/admin/users`: list all users (paginated)
- `GET /api/v1/auth/admin/users/{id}`: get one
- `PATCH /api/v1/auth/admin/users/{id}`: update (activate/deactivate, verify, set superuser, update fields)
- `DELETE /api/v1/auth/admin/users/{id}`: delete
- `POST /api/v1/auth/admin/users/{id}/roles`: assign role
- `DELETE /api/v1/auth/admin/users/{id}/roles/{role}`: remove role
- `POST /api/v1/auth/admin/assign-role`: body `{user_id, role}`; 404 if the user does not exist. The role is created if it does not exist yet.
- `POST /api/v1/auth/admin/remove-role`: body `{user_id, role}`

When `PermissionAdapterMixin` is also present, role<->permission management routes are added:
There is **no user CRUD** here: no list, get, update, or delete by id. Build those
yourself against the adapter behind `SuperUser` or `require_role(...)`, and never
hand a request body straight to `update_user`, which writes privileged columns
verbatim.

- `POST /api/v1/auth/admin/roles/{role}/permissions`: assign permission
- `DELETE /api/v1/auth/admin/roles/{role}/permissions/{permission}`: remove
When `PermissionAdapterMixin` is also present, role/permission routes are added:

Every admin route requires `require_role("admin")` or `is_superuser=True`. You can't bypass by role-assigning yourself; writes to role tables go through the mixin methods that also enforce the dependency.
- `POST /api/v1/auth/admin/assign-permission`: body `{role, permission}`
- `POST /api/v1/auth/admin/remove-permission`: body `{role, permission}`
- `GET /api/v1/auth/admin/role-permissions/{role_name}`: the role's permissions

`user_id` in the role bodies is typed from your user schema's key type, so an
`int` or `str` key validates as itself and a malformed id is a 422.

Every admin route requires `is_superuser=True` (the `SuperUser` dependency).
A role named "admin" does **not** grant access to them.

## Creating the first admin

Expand Down
Loading