Skip to content

users

The default auth provider + user-management module: email/password login, OAuth/OIDC sign-in, sessions, registration, invites, password reset, email verification, role assignment, admin UI, and mailer backends. It registers a UsersAuthProvider on app.state.auth.auth_provider, which the auth module's AuthMiddleware delegates to for request.state.user. (It's one of two bundled auth providers — keycloak is the alternative; install exactly one.)

ModuleMeta

FieldValue
nameUsers
route_prefix/api/users
view_prefix/users
depends_on["Auth"]

Auth flow

The module is built on fastapi-users for password hashing, registration, password reset, and verification. On top of that it layers:

  • A signed session cookie (sm_auth by default) — the auth module's AuthMiddleware calls UsersAuthProvider.resolve_user, which reads it on every request and populates request.state.user. Bearer tokens (Authorization: Bearer <token>) resolve against users_access_token.
  • OAuth / OIDC sign-in (Google, GitHub, Microsoft/Entra ID, and any generic OIDC provider) — see OAuth providers.
  • An invite flow — admins generate an invite link; the recipient sets their password via POST /api/users/auth/accept-invite.
  • LoginRateLimiter — N failures within a window triggers a cooldown per email::ip key.
  • ThroughputLimiter — per-IP rate limit across register / forgot-password / accept-invite / verify-token endpoints.

Auth endpoints

Method + pathBodyNotes
POST /api/users/auth/loginOAuth2PasswordRequestFormsets sm_auth cookie + session["user_id"]; rate-limited per email
POST /api/users/auth/registerUserCreategated by users.allow_signup; rate-limited
POST /api/users/auth/forgot-passwordPasswordResetrate-limited
POST /api/users/auth/reset-password{token, password}
POST /api/users/auth/request-verify-tokenRequestVerifyTokenrate-limited
POST /api/users/auth/verifyVerifyRequest
POST /api/users/auth/accept-inviteAcceptInviteRequestsets password + signs the user in
POST /api/users/auth/tokenTokenRequest (email + password)bearer login for mobile / API clients → {access_token, refresh_token, token_type, expires_in}; 401 for external/SSO users
POST /api/users/auth/token/refreshRefreshRequest (refresh_token)rotates a refresh token into a new pair (old one revoked)
DELETE /api/users/auth/tokenRefreshRequest (refresh_token)revokes a refresh token (idempotent)
GET /api/users/auth/{provider}/loginOAuth: redirect to the IdP (provider ∈ configured set)
GET /api/users/auth/{provider}/callback?code=&state=OAuth: find-or-create user, set cookie, 303 to login_redirect_url

Self-service endpoints

Method + pathBody / responsePermission
GET /api/users/meUserReadactive user
PATCH /api/users/meSelfProfileUpdateUserReadactive user

Admin endpoints (users.manage)

Method + pathBody / response
GET /api/users/admin?q=&status=&role=&verified=&sort=&order=&page=&per_page=list[UserListItem]
POST /api/users/adminUserAdminCreateUserListItem (201) — active+verified user with an admin-set password
POST /api/users/admin/inviteUserInviteUserListItem (201)
PATCH /api/users/admin/{user_id}UserDetailsUpdateUserListItem — edit email + full name
DELETE /api/users/admin/{user_id}204 (hard delete; an admin cannot delete their own account → 400)
PATCH /api/users/admin/{user_id}/disableUserListItem
PATCH /api/users/admin/{user_id}/enableUserListItem
PUT /api/users/admin/{user_id}/rolesRoleAssignmentUserListItem
PATCH /api/users/admin/{user_id}/verifyUserListItem (mark verified; idempotent)
POST /api/users/admin/{user_id}/reset-password-linkPasswordResetLink (409 for external/SSO users)

POST /api/users/admin creates an active + verified user directly — no invite email, no verification flow; the admin sets the password. It returns 409 if the email is already taken and 400 for an invalid password. The matching admin UI (Create form, Edit details card, and a delete "danger zone") lives under /users/admin — see View routes and Inertia pages.

View routes

Public:

  • GET /users/loginUsers/Login (shows dev-account buttons in dev)
  • POST /users/logout → 303 redirect, clears cookie
  • GET /users/registerUsers/Register (404 if signup disabled)
  • GET /users/forgot-passwordUsers/ForgotPassword
  • GET /users/reset-passwordUsers/ResetPassword
  • GET /users/verifyUsers/VerifyEmail
  • GET /users/invite/acceptUsers/AcceptInvite

Authenticated:

  • GET /users/meUsers/Profile
  • PATCH /users/me → form action (redirects)

Admin (users.manage):

  • GET /users/adminUsers/Users/Index
  • GET /users/admin/inviteUsers/Users/Invite
  • GET /users/admin/createUsers/Users/Create
  • GET /users/admin/{user_id}Users/Users/Edit

Public contracts

python
from users.contracts.schemas import (
    UserRead, UserCreate, UserUpdate, UserInvite,
    UserAdminCreate, UserDetailsUpdate,
    UserListItem, RoleListItem, RoleAssignment,
    AcceptInviteRequest, PasswordResetLink, SelfProfileUpdate,
)
from users.contracts.events import (
    UserRegistered, UserInvited, UserCreated, UserDeleted,
    UserDisabled, RoleAssigned,
)
ClassPurpose
UserReadid, email, is_active, is_superuser, is_verified, is_external, full_name, tenant_id, disabled_at, last_login_at.
UserAdminCreateAdmin create-user input: email, password, full_name, role_names.
UserDetailsUpdateAdmin edit input: email, full_name.
UserListItemAdmin list row; adds is_external, created_at, roles.
RoleListItemid, name, description, user_count.
UserRegistered, UserInvited, UserCreated, UserDeleted, UserDisabled, RoleAssignedEvents — see Events.

Models

User (table users_user)

ColumnTypeNotes
idUUIDPK
emailstrunique, indexed; functional index on lower(email)
hashed_passwordstr | Nonenullable — external (SSO) users have no local password (see External / SSO users)
is_active / is_superuser / is_verifiedbool
is_externalboolTrue for users provisioned via an external IdP; default False (server_default false)
full_namestr | None
tenant_idstr | Noneindexed; only set when multi-tenant
disabled_atdatetime | None
last_login_atdatetime | Noneindexed
rolesrelationship → Role via UserRoleeagerly loaded by AuthMiddleware

Role (table users_role) — id, name (unique, indexed), description.

UserRole (table users_user_role) — composite-PK join table with assigned_at, assigned_by. The (user_id, role_id) PK is user-id-first; a separate index covers reverse lookups by role_id.

UserAccessToken (table users_access_token) — bearer API tokens; resolved by UsersAuthProvider on Authorization: Bearer requests (sessions remain the primary browser auth).

OAuthAccount (table users_oauth_account) — linked OAuth/OIDC accounts: oauth_name, account_id, account_email, access_token, refresh_token, expires_at.

RefreshToken (table users_refresh_token) — opaque refresh tokens for the bearer flow: token (PK), user_id, created_at, expires_at, revoked_at.

Two pre-seeded roles get fixed UUIDs so other modules can reference them safely:

RoleUUIDDefault permissions
admin00000000-0000-0000-0000-000000000001implicitly all (admin bypass in RequiresPermission)
user00000000-0000-0000-0000-000000000002users.self.profile, file_storage.{upload,download,delete}

Settings

DB-backed via register_module_settings. Two values are read only from the env at module import time because they bootstrap token signing before the DB is reachable:

Env varDefaultPurpose
SM_USERS_RESET_PASSWORD_TOKEN_SECRETdev-reset-token-secret-change-mepassword-reset token signing
SM_USERS_VERIFICATION_TOKEN_SECRETdev-verify-token-secret-change-meemail-verify token signing

Both must be replaced with non-placeholder values in production — the boot-time check refuses to start otherwise.

Everything else is DB-backed (initial values are pydantic defaults; edit at /settings/modules/users):

FieldDefault
allow_signupFalse
require_verificationTrue
login_redirect_url"/dashboard/" (if the Dashboard module isn't installed, auto-falls back to the first other module that exposes view routes, or / only as a last resort)
reset_password_token_lifetime_seconds3600
verification_token_lifetime_seconds604_800 (7 days)
bearer_token_lifetime_seconds900 (15 min)
refresh_token_lifetime_seconds2_592_000 (30 days)
cookie_name"sm_auth"
cookie_max_age_seconds1_209_600 (14 days)
cookie_secureTrue (flipped to False in dev at startup)
cookie_samesite"lax"
mailer"console" (or "smtp")
base_url"http://localhost:8000"
smtp_host / smtp_port / smtp_username / smtp_password / smtp_from / smtp_tlsSMTP config when mailer="smtp"
login_rate_limit_failures5
login_rate_limit_window_seconds300
login_rate_limit_cooldown_seconds900
auth_rate_limit_attempts10
auth_rate_limit_window_seconds300
bootstrap_email, bootstrap_password, bootstrap_user_email, bootstrap_user_password"" — see Bootstrap
oauth_google_client_id / oauth_google_client_secret"" — Google OAuth
oauth_github_client_id / oauth_github_client_secret"" — GitHub OAuth
oauth_microsoft_client_id / oauth_microsoft_client_secret / oauth_microsoft_tenant"" / "" / "common" — Microsoft (Entra ID)
oauth_oidc_client_id / oauth_oidc_client_secret / oauth_oidc_discovery_url / oauth_oidc_display_name"" / "" / "" / "OIDC" — generic OIDC

Permissions

CodePurpose
users.manageadmin: list / invite / disable / role-assign
users.self.profileedit own profile (granted to user role)
LabelURLIconSectionGroupOrderRoles
Users/users/adminusersSIDEBARAdministration100["admin"]
Profile/users/meuserUSER_DROPDOWN990logged-in
Logout/users/logout (POST)log-outUSER_DROPDOWN999logged-in

Events

EventFieldsFired
UserRegistereduser_id, emailon signup
UserInviteduser_id, email, invited_byon admin invite
UserCreateduser_id, email, created_byon admin create (POST /api/users/admin)
UserDeleteduser_idon admin delete
UserDisableduser_idon admin disable
RoleAssigneduser_id, role_nameonce per role on PUT /admin/{user_id}/roles

CLI

  • smpy users create-admin --email <e> --password <p> [--full-name <name>] [--force] — creates (or, with --force, updates) an admin user. Idempotent: re-running with the same email is a no-op without --force.
bash
uv run smpy users create-admin --email admin@example.com --password changeme

Programmatically:

python
from users.bootstrap import create_admin

result = await create_admin(db, email="admin@example.com", password="...")
# result.user, result.created -> bool

Bootstrap (the first admin)

Two paths to seed the first admin:

  1. CLIsmpy users create-admin ....
  2. Env vars — set SM_USERS_BOOTSTRAP_EMAIL + SM_USERS_BOOTSTRAP_PASSWORD before first make dev. bootstrap_admin_from_env(app) runs at startup and creates the admin if the users_user table is empty. Optionally seed a non-admin too via SM_USERS_BOOTSTRAP_USER_EMAIL + SM_USERS_BOOTSTRAP_USER_PASSWORD.

Mailer backends

mailer/ ships two implementations of the Mailer protocol:

BackendWhen to useConfigured via
ConsoleMailerdev — prints invite / verify / reset links to stdoutmailer="console"
SmtpMailerprod — talks SMTPmailer="smtp" + smtp_* settings

build_mailer(settings) returns the right instance based on users.mailer.

OAuth providers

OAuth/OIDC sign-in is DB-settings-driven: a provider is enabled simply by setting its client_id + client_secret (and, for generic OIDC, a discovery URL) in the settings UI at /settings/modules/users. No code change or restart — register_event_handlers rebuilds the client cache (app.state.users.oauth_clients) on the SettingsReloaded event, so changes take effect live. Providers with no credentials are silently skipped.

Built-in provider keys (the {provider} URL segment):

ProviderKeyNotes
Googlegoogle
GitHubgithub
Microsoft (Entra ID)microsoftoauth_microsoft_tenant: "common" (any account), "organizations" (work/school), or a tenant GUID
Generic OIDCoidcany provider exposing a discovery URL (Keycloak, Authentik, Auth0, Zitadel, …); discovery failure logs + disables rather than breaking boot

A single dispatcher pair (/api/users/auth/{provider}/login + /callback) serves every provider; the client is resolved per request from the cache. The /callback returns a 303 redirect (not the stock fastapi-users 204) so Inertia lands on a real page, with the auth cookie attached. Find-or-create + email association goes through UserManager.oauth_callback (associate_by_email=True, is_verified_by_default=True); state CSRF uses the signed session cookie. Linked accounts are stored in users_oauth_account. A newly provisioned OAuth account is marked external (see below); an existing password account linked by email keeps its password and is left unchanged.

External / SSO users

Users created through an external IdP (Google, GitHub, Microsoft/Entra ID, or generic OIDC) are provisioned with is_external=True and no local password (hashed_password is NULL). The OAuth callback's find-or-create only nulls the password + marks external for accounts it creates — an existing password account that gets linked by email keeps its password and stays non-external. External users sign in only through their IdP; roles are still assigned locally like any other user.

Every password-credential path guards against external users, server-side:

ActionBehaviour for an external user
POST /api/users/auth/login (session)401 — treated like a missing user (a dummy bcrypt verify still runs, so timing doesn't leak that the account is SSO-only)
POST /api/users/auth/token (bearer)401 — same guard as session login
POST /api/users/auth/forgot-password200 but a silent no-op — preserves anti-enumeration
POST /api/users/admin/{user_id}/reset-password-link409 — raises ExternalUserNoPasswordError; there is no password to reset

In the admin UI, the user list and edit page show an "External · SSO" badge, and the password-reset action is hidden with an explanation. Disable / enable, role assignment, and delete work the same as for any other user.

UsersAuthProvider

UsersAuthProvider (registered on app.state.auth.auth_provider in register_settings) is the AuthProvider the auth module's AuthMiddleware delegates to. On each request resolve_user:

  • For a Bearer token, looks the token up in users_access_token and returns the active user.
  • Otherwise reads session["user_id"], returns the cached context from session["user_ctx"] on a hit, else loads the User row with eagerly-loaded roles, builds a UserContext, caches it in session["user_ctx"], and clears stale session keys when the user is missing/disabled.

get_login_url()/users/login, get_logout_url()/users/logout. The provider's get_public_paths() declares these prefixes anonymous (in addition to the framework defaults /health, /static/, /api/docs, /api/redoc, /openapi.json, /i18n/, /):

/users/login, /users/register, /users/forgot-password, /users/reset-password, /users/verify, /users/invite/accept, /api/users/auth/, /api/users/register.

Anything else without a session redirects to /users/login (or returns 401 JSON for /api/* / bearer requests).

Roles cache

roles_cache.py keeps an in-memory list of RoleSummary(id, name) on app.state.users.roles_cache. Refreshed at startup and on demand via refresh_roles_cache(app). Used by the admin UI to render role pickers without hitting the DB on every request.

Inertia pages

Auth flow:

  • Users/Login.tsx, Users/Register.tsx, Users/ForgotPassword.tsx, Users/ResetPassword.tsx, Users/VerifyEmail.tsx, Users/AcceptInvite.tsx, Users/Profile.tsx.

Admin:

  • Users/Users/Index.tsx (list + Create button), Users/Users/Invite.tsx, Users/Users/Create.tsx, Users/Users/Edit.tsx.

Components (under pages/Users/components/):

  • AccountStatusCard.tsx — status block, including the External · SSO badge and the hidden-password-reset explanation for external users.
  • DetailsCard.tsx — edit email + full name.
  • DangerZone.tsx — delete-user action with confirmation.

Notes

  • cookie_secure is automatically flipped to False in dev (SM_ENVIRONMENT=development) so login works over plain HTTP. Don't override it in non-dev environments.
  • LoginRateLimiter keys login attempts by lowercased-email::client-ip. Counters live in-process (no Redis), so a multi-worker deployment has independent counters per worker — swap for a Redis-backed store when running more than one worker.
  • The functional lower(email) index makes case-insensitive lookups fast on Postgres; on SQLite the lookup falls back to LOWER(email) = ? which still uses the regular index.

Released under the MIT License.