Skip to content

Latest commit

 

History

History
612 lines (465 loc) · 79.3 KB

File metadata and controls

612 lines (465 loc) · 79.3 KB

Changelog

Unreleased

Added

  • Pluggable user id types. The user primary key no longer has to be a UUID: UserSchema is now generic over its key type, so class MyUser(UserSchema[int]) gives you integer or sequence keys and UserSchema[str] gives you string keys. Everything else is unchanged - class MyUser(UserSchema) still means UUID, so existing applications need no edits at all. Adapters gain parse_user_id(), which converts a token subject back to the declared key type using the user schema you already pass in, so there is nothing extra to configure. Integer keys are enumerable in a way UUIDv7 keys are not, so choose deliberately if you expose ids.
  • Fail-fast guard for mismatched key types. Declaring UserSchema[int] over a UUID table used to parse fine, miss every lookup, and return 401 on every request with nothing to point at the cause. Adapters now compare the schema's id type against the user model's primary key at construction and raise a ValueError naming both sides. Storage whose key type cannot be read reliably skips the check rather than guessing.

Changed

  • UserID widened from UUID to UUID | int | str. Runtime behaviour is unchanged, but this is a typing change for anyone who annotated with UserID - most notably custom adapter authors, since UUID-specific access such as user_id.hex no longer type-checks. Either parameterise on your concrete key type or narrow at the point of use; adapters that only pass user_id through to a query need no change.

0.15.0

Added

  • Tortoise ORM adapter. A new TortoiseAdapter (from fastapi_fullauth.adapters import TortoiseAdapter) plus a matching set of abstract Tortoise model mixins under fastapi_fullauth.models.tortoise (UserMixin, RefreshTokenMixin, RoleMixin, PermissionMixin, OAuthAccountMixin, PasskeyMixin), for apps built on Tortoise ORM. It implements the full feature set - roles, permissions, OAuth, passkeys, sessions, and atomic refresh-token rotation (via Tortoise's in_transaction, with SAVEPOINT isolation for conflict-prone inserts). Roles and permissions use native Tortoise many-to-many relations you declare on your User / Role models, so the adapter takes no user_role_model / role_permission_model. Name your concrete models User / Role / Permission and register them under the Tortoise app label models. Install with pip install fastapi-fullauth[tortoise] (or [tortoise-standard] for the adapter plus every optional feature). See the Tortoise adapter guide.
  • Beanie (MongoDB) adapter. A new BeanieAdapter (from fastapi_fullauth.adapters import BeanieAdapter) plus ready-to-use Beanie document classes under fastapi_fullauth.models.beanie (UserDocument, RefreshTokenDocument, RoleDocument, PermissionDocument, OAuthAccountDocument, PasskeyDocument), for apps on MongoDB via Beanie. It implements the full feature set - roles, permissions, OAuth, passkeys, and sessions. MongoDB has no foreign keys or join tables, so role membership is an embedded array on the user document and a role's permissions are an embedded array on the role document; the adapter takes no user_role_model / role_permission_model. Refresh-token reuse detection is a single-document atomic compare-and-swap (find_one_and_update), so rotation stays correct without a replica set or multi-document transactions - transaction() is best-effort, and a crash mid-rotation orphans at most one refresh token without ever weakening reuse detection. delete_user removes the user's refresh-token / OAuth / passkey documents explicitly, since MongoDB does not cascade. Install with pip install fastapi-fullauth[beanie] (or [beanie-standard] for the adapter plus every optional feature). See the Beanie adapter guide.
  • fullauth.hooks.on() works as a decorator. Call it with just the event name to register the function it decorates: @fullauth.hooks.on("after_register") over async def on_register(user): .... The two-argument form (on(event, callback)) is unchanged.
  • Discord and GitLab OAuth providers. DiscordOAuthProvider and GitLabOAuthProvider (from fastapi_fullauth.oauth import ...) join the existing Google and GitHub providers. Both support PKCE and require no app review to set up. Discord maps global_name/username to the display name and builds the CDN avatar URL from the returned hash; GitLab uses standard OIDC userinfo (defaults target gitlab.com - subclass and override the three endpoints for a self-hosted instance). See the OAuth guide.
  • StandardOAuthProvider base class for custom OAuth providers. The Google, GitHub, Discord, and GitLab providers now share one implementation of the standard authorization-code flow (authorize URL, code exchange with PKCE, userinfo fetch, uniform error handling). Custom providers whose identity provider follows the standard wire format can subclass StandardOAuthProvider (from fastapi_fullauth.oauth import ...), set the three endpoints plus name/display_name/default_scopes, and implement only parse_user_info(); providers with wire-format quirks override the small _authorize_params / _token_request_body hooks (see the GitHub provider). Subclassing the bare OAuthProvider ABC keeps working unchanged.
  • fastapi_fullauth.flows.refresh. The refresh-token rotation logic (compare-and-swap rotation, reuse detection with family revocation, and the non-rotating path) moved from the router into a reusable flow function, matching the other flows. The /auth/refresh endpoint behaves exactly as before; custom apps can now call refresh(adapter, token_engine, token) directly. Reuse now raises the (previously unused) RefreshTokenReuseError.
  • Typed dependencies for custom user schemas. New typed_current_user, typed_verified_user, and typed_superuser factories (from fastapi_fullauth.dependencies import ...) return the existing dependencies with their return type narrowed to your UserSchema subclass, so custom fields type-check without casts: CurrentUser = Annotated[MyUser, Depends(typed_current_user(MyUser))]. The ready-made CurrentUser / VerifiedUser / SuperUser annotated types are now also importable from fastapi_fullauth.dependencies, and AdapterFeature (the Literal of supports_feature() names) is exported from fastapi_fullauth.adapters.
  • fullauth check CLI command. Loads FULLAUTH_* config from the environment and .env, prints the resolved effective settings (including the BACKEND / PASSKEY_ENABLED values that are inferred from REDIS_URL / PASSKEY_RP_ID), and reports the warnings the app would emit at startup. Exits non-zero when the config fails to construct, so it doubles as a CI pre-flight check.

Changed

  • Password hashing no longer blocks the event loop. Argon2id and bcrypt are CPU-bound (tens to hundreds of milliseconds); running them inline serialized every concurrent request in a worker for the duration of each hash. All built-in flows (login, register, change-password, password-reset, the timing-defense dummy verify, and create_superuser) now offload hashing and verification to a worker thread via anyio.to_thread.run_sync, so concurrent logins run in parallel. New public helpers ahash_password / averify_password (from fastapi_fullauth.core.crypto import ...) expose the offloaded variants for custom async flows; the synchronous hash_password / verify_password are unchanged and produce interoperable hashes.

  • Event-hook registration now validates as you register. Registering a hook for an unknown event name (e.g. a typo like after_registr) emits a UserWarning with a "did you mean" suggestion instead of silently registering a hook that never fires, and a callback whose signature can't accept the event's arguments warns too. Custom events you emit yourself via fullauth.hooks.emit() still work; the unknown-event warning is informational. Adds EventHooks.has_listeners(event).

  • Custom-claims hooks no longer run for failed logins. The login route computed on_create_token_claims before the password was verified, so the hook (and any services it calls) ran for every probing or failed attempt. The login flow now takes an extra_claims_provider callback and invokes it only after authentication succeeds; direct callers of fastapi_fullauth.flows.login can keep passing precomputed extra_claims, which the provider overrides when both are given.

  • Redis-backed features share one client per URL. The token blacklist, lockout manager, per-route auth rate limiters, RateLimitMiddleware, and the passkey challenge store each opened their own Redis client and connection pool from REDIS_URL - eight or more pools to the same server in a default deployment. They now acquire one shared client per URL, released on shutdown when its last user closes; constructor signatures and behavior are unchanged.

  • All middleware is now pure ASGI. CSRFMiddleware, RateLimitMiddleware, and SecurityHeadersMiddleware no longer subclass Starlette's BaseHTTPMiddleware, removing its per-request task/stream overhead and its known interactions with background tasks and streaming responses. Registration (app.add_middleware(...)), constructor arguments, and observable behavior are unchanged.

  • init_app() warns when the verify router is mounted without its email hooks. Email verification and password reset only deliver anything if you register send_verification_email / send_password_reset_email; without them the endpoints return success while the token is silently dropped. init_app() now surfaces this at startup. Register the hook(s) before init_app(), or exclude the router with init_app(include_routers=...).

  • Feature/adapter mismatch is now detected for the bundled SQL adapters. The startup warning that fires when a feature is configured against an adapter that can't serve it previously only checked whether the adapter implemented the matching mixin. The SQLAlchemyAdapter and SQLModelAdapter statically inherit every mixin, so the check never caught the real mistake: enabling passkeys or OAuth without passing passkey_model / oauth_account_model. Capability is now reported from the model classes actually passed to the constructor (a new AbstractUserAdapter.supports_feature()), so the warning fires correctly and the affected routers are no longer mounted (they would have returned a 500 on first use). Custom adapters are unaffected: the default still reports capability from the implemented mixins.

  • SQLModel mixin foreign keys now use ondelete="CASCADE" to match the SQLAlchemy mixins, which already did. Refresh tokens, OAuth accounts, passkeys, and the role/permission association rows are now removed by the database when their parent user/role/permission is deleted, instead of being left orphaned. Migration: this changes the generated DDL for SQLModel users - regenerate or add a migration that recreates the affected foreign keys with ON DELETE CASCADE.

Security

  • Refresh tokens are now stored as sha256 digests. The database previously held the raw refresh JWT, so a leaked database (or backup) handed out live sessions for up to REFRESH_TOKEN_EXPIRE_DAYS. The flows now hash every token before storing or looking it up (new helper hash_refresh_token in fastapi_fullauth.core.crypto), so adapters - including custom ones - only ever see digests, with no schema change. Migration: existing stored raw tokens no longer match, so all active sessions are invalidated and users must sign in again. To preserve sessions instead, backfill before deploying: rewrite each fullauth_refresh_tokens.token value to its sha256 hex digest.
  • Enumeration and timing defenses are now on by default. PREVENT_REGISTRATION_ENUMERATION and PREVENT_LOGIN_TIMING_ATTACKS both flip from False to True, so a fresh install no longer reveals which emails are registered through registration responses or login timing. Breaking: /auth/register now answers 202 + a generic message instead of 201 + the created user (and no longer 409s on duplicates); failed logins for unknown users cost one extra password hash. Set either flag to False to restore the previous behavior.
  • Client IP is now read from the right of the forwarded chain, defeating IP spoofing. get_client_ip returned the left-most X-Forwarded-For entry, which is client-supplied: any deployment that trusted the header (the required config behind a proxy) let an attacker rotate the header to dodge per-IP rate limiting and lockout. The resolver now takes the entry TRUSTED_PROXY_COUNT (new setting, default 1, minimum 1) positions from the right, since each trusted proxy appends the address it received from; left-most padding is ignored, and a chain shorter than the configured count falls back to the direct peer. Action: set TRUSTED_PROXY_COUNT to the number of proxies in front of the app when using TRUSTED_PROXY_HEADERS. RateLimitMiddleware gains a matching trusted_proxy_count argument.
  • Logout rejects purpose-scoped tokens. /auth/logout decoded any access-typed token, so a password-reset or email-verify token (access-typed but carrying a purpose) could be presented as a session credential. It now rejects purpose-scoped tokens, matching the session dependencies.
  • Passkey authentication failures return a uniform 401. /passkeys/authenticate/complete previously answered 400 with a specific message for an unknown credential, clone detection, or an inactive user while signature/challenge failures returned a generic 401 - the difference let an attacker enumerate credentials and account state. All failures now return the same 401 and the reason is logged server-side.
  • Login timing defense matches the configured hash algorithm. With PREVENT_LOGIN_TIMING_ATTACKS=True the unknown-user dummy verify always used argon2, so a bcrypt deployment still leaked user existence through the argon2-vs-bcrypt timing difference. The dummy hash is now built with the configured algorithm.
  • OAuth provider error responses are no longer logged with their body. The Google/GitHub token and userinfo error paths logged response.text, which can contain the authorization code, client credentials, or user PII. Only the status code is logged now.
  • CSRF origin check no longer defers on an unparseable Referer. With trusted_origins set, a request sending a Referer that has no scheme/host (and no Origin) fell through to the token-only path. A present but unparseable Referer is now treated as a failed origin check.
  • Custom-claim hooks can no longer return a purpose key. on_create_token_claims returning {"purpose": ...} landed in the token's extra, which the session dependencies reject - silently breaking every login. purpose is now a reserved claim key and raises at claim-build time.
  • Startup warning when OAuth runs with the token blacklist disabled. OAuth state is single-use only via the blacklist; with BLACKLIST_ENABLED=False a captured (code, state) pair is replayable within the state TTL. This is now surfaced as a UserWarning.

Fixed

  • Passkey credential_id is now VARCHAR(512) on the SQL adapters. It was TEXT, which MySQL cannot build a unique index on (the same limitation already fixed for the refresh-token column), so the passkeys table failed DDL on MySQL. The Tortoise model already used VARCHAR(512). Migration: SQLAlchemy/SQLModel users on MySQL should add a migration that alters fullauth_passkeys.credential_id to VARCHAR(512); SQLite/PostgreSQL are unaffected.
  • revoke_user_session is idempotent across databases. It returned False (a 404 from the sessions route) when re-revoking an already-revoked family on MySQL, where an UPDATE that changes no rows reports rowcount 0. It now returns True whenever the family exists for the user, on every backend.
  • In-memory rate limiter no longer leaks memory. remaining() / reset_time() re-indexed a defaultdict, re-inserting an empty entry for every idle client IP the middleware touched on each response. They now read without re-inserting.
  • Rate-limit retry hints round up. Retry-After and X-RateLimit-Reset truncated a sub-second window to 0; they now round up, and RateLimitMiddleware's 429 also sets Retry-After.
  • SQL adapters no longer error on an empty update. update_user / update_oauth_account issued update(...).values() with no columns when given an empty dict (a SQLAlchemy compile error); both now skip the no-op UPDATE, matching the Tortoise adapter.
  • get_user_by_field rejects non-column fields. A field naming a relationship or method (e.g. roles) slipped past the check and produced an opaque SQL error; it now raises a clear ValueError.
  • Redis lockout sets the lock and clears the counter atomically (one pipeline instead of two round-trips).
  • Missing-model errors now name the exact constructor argument. When a role, permission, OAuth, or passkey method runs on an adapter that wasn't given the corresponding model, the raised RuntimeError now lists the precise kwarg(s) to pass (e.g. Pass oauth_account_model.) instead of a generic "the corresponding model class" message.
  • fullauth.aclose() no longer leaks resources when one fails to close. It closed each pooled resource sequentially with no error handling, so a failure on an early close (for example a Redis socket error during shutdown) skipped every remaining Redis pool and OAuth HTTP client, leaking them. Each resource is now closed independently; failures are logged and the rest still close.
  • init_app() runs fullauth.aclose() on shutdown even under a custom lifespan. Starlette ignores shutdown event handlers when an app is built with a lifespan=, which previously leaked pooled Redis connections and OAuth HTTP clients. init_app() now wraps the app's existing lifespan so cleanup composes with it - the existing lifespan becomes the inner context and aclose() runs after its teardown, with no manual call needed. Call init_app() after FastAPI(lifespan=...) so it wraps your lifespan.
  • Clearer error when dependencies run before the app is wired. FullAuth not initialized on app.state now points to init_app(app) (or bind(app) for manual router mounting).

Docs

  • The homepage quick example now defines the engine/session_maker it references, so it runs as written.

0.14.1

Added

  • current_token_payload dependency. A public FastAPI dependency that returns the decoded access-token TokenPayload for the request - reading the token from the Authorization header or a cookie backend and validating it - without a database lookup. Use it to read custom claims from payload.extra in your own dependencies instead of reimplementing token extraction. current_user now builds on it, so behaviour is unchanged.

Fixed

  • SQLModelAdapter(session_maker=...) no longer trips a type error. Its session_maker parameter was typed as a SQLModel | SQLAlchemy session union, which - because async_sessionmaker is invariant - rejected the async_sessionmaker[AsyncSession] that the documented SQLModel setup (class_=AsyncSession) actually produces, forcing a type: ignore at the call site. It now accepts the SQLModel AsyncSession directly. Runtime behaviour is unchanged.

Docs

  • New Custom Adapters guide with a complete, runnable in-memory adapter example, the key method contracts, and how to opt into roles, permissions, OAuth, passkeys, and sessions.
  • New Customization hub linking every extension point, and a Recipes page with end-to-end examples (multi-tenant SaaS, username login).
  • Fixed the custom-dependency examples in the claims and protected-routes docs, which referenced a private token-extraction helper.

0.14.0

Added

  • Session management. A new opt-in sessions router lets a user see and manage where they're signed in. GET /auth/sessions lists active sessions (one per refresh-token family) with device, IP, sign-in time, last-used time, and a current flag for the device making the request; DELETE /auth/sessions/{family_id} signs out one device; POST /auth/sessions/revoke-others signs out everywhere else. The bundled SQLAlchemy and SQLModel adapters support it automatically (the router mounts like admin does for roles); custom adapters opt in via the new SessionAdapterMixin. Refresh tokens now record the user_agent and ip_address they were issued from, and the access token carries its family_id claim so the list can flag the current session. Migration: the refresh-token table gains two nullable columns, user_agent and ip_address - add them with an additive migration (existing rows stay NULL). See Database Migrations.
  • Cookie transport now carries the refresh token. Previously CookieBackend only moved the access token, so a cookie-based app still had to hold the long-lived refresh token in JavaScript-reachable storage. The backend abstraction now transports the refresh token too: CookieBackend sets a separate HttpOnly refresh cookie (name fullauth_refresh, path configurable via CookieBackend(config, refresh_path=...)), and /refresh and /logout read it from the cookie, so cookie clients call them with no body. When a cookie backend is active the refresh token is kept out of the JSON response body entirely. This also wires the previously missing pieces of cookie support: /refresh now re-sets the access cookie on rotation, and passkey authentication now sets the auth cookies like login and OAuth do. Bearer transport (the default) is unchanged.

Changed

  • TokenPair.refresh_token is now str | None. It stays populated under the default bearer transport; it is null when a cookie backend carries the refresh token (the token lives only in the HttpOnly cookie). Clients that read refresh_token from the body in bearer mode are unaffected.
  • AbstractUserAdapter gained a default transaction(). The base implementation yields self with no atomicity guarantee so existing custom adapters keep working unchanged; the bundled SQL adapters override it to run the block in one database transaction. Refresh-token rotation now uses it so revoking the old token and storing its replacement commit or roll back together.
  • SecurityHeadersMiddleware no longer emits HSTS on plaintext HTTP and defaults X-XSS-Protection to 0. Strict-Transport-Security is now sent only when the request is HTTPS (directly or via an X-Forwarded-Proto: https from a trusted proxy); a stray HTTP deploy can no longer pin sibling subdomains. The deprecated X-XSS-Protection: 1; mode=block (which can introduce cross-site leak oracles) is replaced by 0; rely on a Content-Security-Policy instead. HSTS is configurable via SecurityHeadersMiddleware(app, hsts=..., hsts_value=...).
  • CSRFMiddleware accepts trusted_origins. When set, state-changing requests carrying an Origin/Referer must match one of the allowed origins - defence in depth that stops a cookie-injecting attacker even with a valid double-submit token. Requests with no Origin/Referer (non-browser clients) still fall back to the token check.
  • require_permission fails with a clear error on an unsupported adapter. If the configured adapter does not implement PermissionAdapterMixin, the dependency previously raised a bare AttributeError (an opaque 500) on every protected request; it now raises a descriptive RuntimeError naming the missing mixin.

Security

  • Logout now ends the session from the access token alone. Previously /logout only blacklisted the short-lived access token and revoked the refresh-token family only if the client re-sent the refresh token, so a bearer client logging out with just its Authorization header left the refresh family alive and able to mint new access tokens until natural expiry. Logout now revokes the family using the family_id carried on the access token, with the refresh-token path kept as a fallback for older tokens.
  • OAuth state is single-use. The state token is now burned on first use at the callback, so a captured (code, state) pair can't be replayed within the state's TTL (requires the token blacklist, on by default).
  • Token-role confinement is centralised in decode_token. decode_token gained expected_type / expected_purpose checks, now used by the session, refresh, email-verify, and password-reset paths, so a token minted for one role can't be accepted for another.
  • Explicit Redis failure policy. The token blacklist now fails closed (a Redis outage treats a token as revoked rather than letting a possibly-revoked token through), while the rate limiter fails open (a Redis outage allows the request rather than locking every client out of login). Both log the backend error.
  • OAuth login now rejects deactivated accounts. The password (login) and passkey flows already refused an inactive user; the OAuth callback did not, so a deactivated/banned user with a linked social account could still sign in. The callback now enforces is_active before issuing tokens.
  • CSRF exempt paths are segment-anchored. exempt_paths matched on a bare prefix, so exempting /api/foo also exempted /api/foobar. Matching is now anchored on path-segment boundaries; only /api/foo and /api/foo/... match.
  • Password-reset and email-verification reject deactivated accounts. login and the OAuth/passkey flows already refused an inactive user, but a still-valid reset or verification token could be redeemed against an account an admin had deactivated. Both flows now enforce is_active after resolving the user, burning the token on rejection so it can't be retried.

Fixed

  • Revocations no longer over-retain in the blacklist. Logout, email-verification, and password-reset revocations passed no TTL, which made the in-memory blacklist grow without bound and made the Redis blacklist expire a long-lived verify/reset token after the short default TTL - letting it be replayed before its real expiry. Each revocation is now blacklisted for exactly the token's remaining lifetime.
  • Redis rate limiter no longer undercounts bursts. The sliding-window sorted-set used the bare timestamp as the member, so requests within the same clock tick collided and were counted once, letting the limit be exceeded under concurrency. Each hit now uses a unique member.
  • Redis rate limiter check-and-add is now atomic. Counting and adding ran as two separate round-trips, a check-then-act race that let a concurrent burst all read "under limit" before any of them incremented. The cleanup, add, and count now run in one MULTI/EXEC, backing the hit out if it pushed the window over the limit.
  • Refresh-token rotation is atomic. Revoking the old token and persisting its replacement committed in separate transactions, so a crash between them could revoke a family's only live token with no successor - silently orphaning the session. Both now run in one transaction that rolls back together on failure.
  • Email-verification tokens are single-use even when already verified. A verification token was burned only on the first successful verify; if the account was already verified by another path, the still-valid token was returned without being revoked. It is now blacklisted on any successful resolution.
  • decode_token tolerates a non-dict extra claim. A signed token whose extra claim was not an object raised AttributeError (a 500) instead of decoding cleanly; it now coerces to an empty mapping.
  • Sessions router confines the token type. GET /auth/sessions and revoke-others resolve the current session with expected_type="access", matching the centralised confinement used everywhere else.
  • OAuth provider clients are hardened. The shared httpx.AsyncClient now sets an explicit 10s timeout (instead of relying on the library default), and the Google/GitHub clients validate the token and userinfo responses, raising OAuthProviderError (a clean 4xx) instead of a KeyError 500 when a provider returns an unexpected body.
  • family_id column has an explicit length. The SQLAlchemy refresh-token model declared family_id without a length, emitting an unbounded indexed column that fails DDL on MySQL; it is now String(36), matching the SQLModel model.
  • SQLModel OAuth token columns no longer truncate. The SQLModel OAuthAccount mapped access_token / refresh_token to the default VARCHAR(255) while the SQLAlchemy model used Text; provider tokens longer than 255 characters were silently truncated on MySQL (or rejected in strict mode). Both SQLModel columns are now Text, matching the SQLAlchemy model.
  • Refresh-token token column is a bounded VARCHAR(512) on both adapters. The SQLModel model used the AutoString VARCHAR(255) default (truncating a refresh JWT on MySQL) while the SQLAlchemy model used Text (which MySQL cannot build the column's unique index on). Both are now String(512) - wide enough for a refresh JWT and uniquely indexable on MySQL.
  • SQLAlchemy Role.name / Permission.name schema matches SQLModel. Both were declared unique=True with no length and no index, so MySQL could not build the unique key (unbounded VARCHAR) and the two ORMs emitted divergent schemas. They are now String(100) / String(200) with an index, matching the SQLModel mixins.
  • Token-blacklist TTL of 0 no longer misbehaves. A 0 TTL was treated as "unset" via ttl_seconds or default, making the in-memory entry live forever and the Redis entry silently take the 30-minute default (a literal setex(0) would also raise). A non-None TTL is now floored to a finite 1s; None still means no expiry.
  • Role and permission assignment is idempotent under concurrency. Two concurrent identical assign_role / assign_permission_to_role calls could both pass the existence check and race to insert the same association row; the loser surfaced the composite-PK IntegrityError as a 500. The conflict is now swallowed on the standalone path (the row the other caller created is the intended result), while inside a transaction() it still propagates.
  • Cookie misconfiguration is rejected early. CookieBackend and CSRFMiddleware now raise if samesite="none" is set without secure=True (browsers silently drop such a cookie, which would break auth).
  • bcrypt is now an installable extra. PASSWORD_HASH_ALGORITHM="bcrypt" was selectable but the bcrypt package was never declared as a dependency, so choosing it raised a bare ImportError at the first hash. Install it with pip install fastapi-fullauth[bcrypt] (it's also bundled in the sqlmodel-standard / sqlalchemy-standard extras). Hashing or verifying a bcrypt hash without the package now raises an actionable install hint instead of a bare error, and verifying a stored bcrypt hash no longer fails the login silently when the package is missing.

0.13.0

Added

  • fullauth secret CLI. A console command that prints a random SECRET_KEY for FULLAUTH_SECRET_KEY, so first-run setup no longer needs the python -c 'import secrets; ...' one-liner.
  • List settings accept a comma-separated string from the environment. ORIGINS, TRUSTED_PROXY_HEADERS, PASSKEY_ORIGINS, and ROUTER_TAGS now take FULLAUTH_ORIGINS=https://a.com,https://b.com in addition to the JSON-array form. JSON still works for values that need it.
  • Startup warnings for silent misconfiguration. Enabling passkeys (or passing OAuth providers) with an adapter that doesn't implement the matching mixin now warns instead of silently dropping those routes, and wiring a CookieBackend without CSRFMiddleware warns about the CSRF exposure.
  • Effective-config log line at init. FullAuth logs the resolved backend per subsystem plus passkey/OAuth status at INFO, so the inference from REDIS_URL and PASSKEY_RP_ID is auditable at a glance.

Changed

  • Cookie settings moved to the CookieBackend constructor (breaking). COOKIE_NAME, COOKIE_SECURE, COOKIE_HTTPONLY, COOKIE_SAMESITE, and COOKIE_DOMAIN are gone from FullAuthConfig. Pass them when you build the (opt-in) cookie backend: CookieBackend(config, secure=..., samesite=..., domain=...). Bearer-token users, the default, no longer carry five cookie settings they never use.
  • Per-route rate limits collapsed into AUTH_RATE_LIMITS (breaking). The five AUTH_RATE_LIMIT_LOGIN / _REGISTER / _PASSWORD_RESET / _PASSKEY_AUTH / _REFRESH fields are replaced by one typed AuthRateLimits object with login / register / password_reset / passkey_auth / refresh attributes (importable from fastapi_fullauth). Set only the routes you want to change; the rest keep their defaults. Override in Python with AUTH_RATE_LIMITS=AuthRateLimits(login=10) or from the environment with FULLAUTH_AUTH_RATE_LIMITS='{"login": 10}'. AUTH_RATE_LIMIT_ENABLED and AUTH_RATE_LIMIT_WINDOW_SECONDS are unchanged.
  • PASSKEY_ENABLED is inferred from PASSKEY_RP_ID. Setting PASSKEY_RP_ID now turns passkeys on without also setting PASSKEY_ENABLED=True. Set PASSKEY_ENABLED=False explicitly to configure passkeys while keeping the routes off.
  • Removed CSRF_SECRET from config (breaking). The library never read it; only the opt-in CSRFMiddleware needs a secret, and it already takes one. Wire it directly: app.add_middleware(CSRFMiddleware, secret=config.SECRET_KEY) (or pass your own key).
  • REDIS_URL now switches the backends on by itself. When BACKEND is left unset and REDIS_URL is configured, the effective backend becomes redis for the blacklist, lockout, rate limiter, and passkey challenge store. Previously a REDIS_URL without BACKEND=redis was silently ignored and every subsystem stayed in-memory, which on a multi-worker deploy meant logout did not revoke across workers and lockout/rate-limit counters were per-process. An explicit BACKEND still wins: set BACKEND=memory to keep everything in-memory despite a configured REDIS_URL, and individual *_BACKEND settings continue to override per feature.

0.12.0

Added

  • sqlmodel-standard / sqlalchemy-standard install extras. Each pulls one adapter plus every optional feature (redis, oauth, passkey), so pip install fastapi-fullauth[sqlmodel-standard] gets the full feature set without dragging in the other adapter.
  • adapter.transaction() on the SQLAlchemy and SQLModel adapters. Runs several adapter calls in one transaction that commits together when the block exits or rolls back entirely on error. Conflict-prone inserts (create_user, create_oauth_account) use SAVEPOINTs so a unique-constraint hit rolls back only that statement and leaves the surrounding transaction usable. Works as-is on PostgreSQL and MySQL; on SQLite, configure the engine with SQLAlchemy's BEGIN-emulation recipe for correct SAVEPOINT/rollback behavior.
  • Injectable response schemas. FullAuth(..., login_response_schema=..., message_response_schema=...) accept custom LoginResponse/MessageResponse subclasses (add optional fields to extend the token or message bodies). LoginResponse, MessageResponse, and TokenPair are now exported from the top-level package.
  • FullAuth.enforce_rate_limit(request, route_name) resolves the client IP and applies the auth rate limit in one call.
  • PKCE for OAuth. The authorization-code flow sends an S256 code_challenge on authorize and the matching code_verifier on token exchange for providers that support it (Google and GitHub). The verifier is derived from the signed state token's nonce keyed by SECRET_KEY, so the flow stays stateless and the verifier never travels through the browser. This is defense-in-depth for a confidential client that already sends a client_secret; it is not a substitute for binding the OAuth state to the browser session. Enabled by default via OAUTH_PKCE_ENABLED; custom providers opt in with supports_pkce = True.
  • Resource cleanup via FullAuth.aclose(). Closes pooled resources: Redis connections (blacklist, lockout, rate limiter, challenge store) and OAuth HTTP clients. init_app() registers it on app shutdown automatically; call it yourself if you pass a custom lifespan to FastAPI. OAuth providers now reuse a single pooled httpx.AsyncClient across requests instead of opening one per call.

Changed

  • Removed the [all] install extra. It pulled in both database adapters, which no single application uses. Use [sqlmodel-standard] / [sqlalchemy-standard] for one adapter with every feature; contributors who need both adapters install them by name or run uv sync --all-extras.
  • Typed profile-update body. PATCH /me now uses a model generated from the user schema's non-protected fields, so the updatable fields appear in the OpenAPI schema instead of a free-form object. Request handling is unchanged: protected fields are ignored and unknown fields still return 422.
  • Internal: the SQLAlchemy and SQLModel adapters now share a single implementation (_BaseSQLAlchemyAdapter). Public adapter classes, signatures, and type hints are unchanged.
  • Internal: login, OAuth, passkey, and refresh-token rotation now share an issue_token_pair helper, and the per-route rate-limit plus client-IP boilerplate is centralized on FullAuth.enforce_rate_limit.
  • Internal: the permission mixin's cross-mixin dependency on get_user_roles is now expressed with a Protocol instead of a type: ignore, and the shared adapter's session factory is precisely typed.

Fixed

  • bcrypt hashes with $2a$ and $2y$ prefixes now verify. Password verification previously recognized only the $2b$ prefix, so bcrypt hashes imported from other implementations or older versions were rejected and the user could not log in. All three prefixes are now accepted.
  • decode_token now requires the exp, iat, and sub claims. A validly signed token missing one of these raises TokenError instead of surfacing an unhandled error.

0.11.0

Breaking changes

  • CurrentUser, VerifiedUser, SuperUser removed from public API. Build your own typed dependencies with Annotated[YourSchema, Depends(current_user)].
  • Factory functions removed. get_current_user_dependency(), get_verified_user_dependency(), get_superuser_dependency() are gone. Use current_user, current_active_verified_user, current_superuser directly with Depends().

Added

  • get_fullauth exported from fastapi_fullauth.dependencies. Gives custom dependencies access to the full FullAuth instance (adapter, token engine, config, hooks, etc.).
  • Architecture docs - explains how the library works internally (token lifecycle, adapters, protection subsystems).
  • Passkeys docs - complete WebAuthn guide with setup, registration/authentication flows, frontend integration, clone detection.
  • Frontend integration guide - framework-agnostic walkthrough of OAuth, passkey, email verification, and password reset flows.
  • Testing guide - how to test apps built with fastapi-fullauth.
  • Troubleshooting guide - common errors and solutions.
  • All existing doc pages expanded with explanations, examples, and missing content.

0.10.0

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 now 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:

    from fastapi_fullauth.adapters.sqlalchemy.models.base import FullAuthBase, UserBase
    from fastapi_fullauth.adapters.sqlalchemy.models.role import RoleModel
    
    class User(UserBase, FullAuthBase):
        __tablename__ = "fullauth_users"
        roles: Mapped[list[RoleModel]] = relationship(secondary="fullauth_user_roles")

    After:

    from sqlalchemy.orm import DeclarativeBase, Mapped, relationship
    from fastapi_fullauth.models.sqlalchemy import (
        UserMixin, RefreshTokenMixin, RoleMixin, UserRoleMixin,
    )
    
    class Base(DeclarativeBase):
        pass
    
    class RefreshToken(RefreshTokenMixin, Base): pass
    class Role(RoleMixin, Base): pass
    class UserRole(UserRoleMixin, Base): pass
    
    class User(UserMixin, Base):
        roles: Mapped[list[Role]] = relationship(
            secondary="fullauth_user_roles", lazy="selectin"
        )
        refresh_tokens: Mapped[list[RefreshToken]] = relationship(lazy="noload")
  • Model package moved to fastapi_fullauth.models.{sqlalchemy,sqlmodel}. Old path fastapi_fullauth.adapters.{sqlalchemy,sqlmodel}.models is gone. Class names also normalised to *Mixin:

    • UserBaseUserMixin
    • RefreshTokenModel / RefreshTokenRecordRefreshTokenMixin
    • RoleModel / RoleRoleMixin
    • UserRoleModel / UserRoleLinkUserRoleMixin
    • PermissionModel / PermissionPermissionMixin
    • RolePermissionModel / RolePermissionLinkRolePermissionMixin
    • OAuthAccountModel / OAuthAccountRecordOAuthAccountMixin
    • PasskeyModel / PasskeyRecordPasskeyMixin
    • FullAuthBase: removed
  • Adapter constructors take every concrete model as a keyword argument. Required: user_model, refresh_token_model. Optional: role_model, user_role_model, permission_model, role_permission_model, oauth_account_model, passkey_model: pass only the ones for features you use. Calling a feature method without its model raises RuntimeError.

    adapter = SQLAlchemyAdapter(
        session_maker=session_maker,
        user_model=User,
        refresh_token_model=RefreshToken,
        role_model=Role,
        user_role_model=UserRole,
        permission_model=Permission,
        role_permission_model=RolePermission,
        oauth_account_model=OAuthAccount,
    )
  • fastapi_fullauth.migrations module removed. include_fullauth_models() and get_fullauth_metadata() are gone. The library no longer owns a metadata registry; your own Base.metadata is the source of truth. In alembic/env.py, import app.models to register the tables and set target_metadata = Base.metadata.

  • INCLUDE_USER_IN_LOGIN config removed. Login, OAuth callback, and passkey-authenticate responses now always include the user field. The toggle existed only to preserve a pre-0.7 response shape; clients that key off user is null should switch to reading the field unconditionally.

  • ACCOUNT_LOCKED_EXCEPTION removed from fastapi_fullauth.exceptions. Locked accounts have returned 401 (not 423) since 0.9.0 to prevent enumeration via status code; the unused 423 helper is now gone too.

  • ALGORITHM constrained to Literal["HS256", "HS384", "HS512"]. Free-form strings are rejected at config construction. Asymmetric algorithms (RS*/ES*) aren't supported yet; open an issue if you need them.

  • SECRET_KEY must be at least 32 characters when explicitly set. Short keys are rejected at config construction. Auto-generated dev keys already exceed this.

  • Middleware is no longer auto-wired. init_app() only mounts routers now; CSRFMiddleware, SecurityHeadersMiddleware, and RateLimitMiddleware are imported from fastapi_fullauth.middleware and added with app.add_middleware(...) like any other FastAPI middleware. Dropped: the auto_middleware kwarg on init_app(), the public init_middleware() method, and the CSRF_ENABLED / INJECT_SECURITY_HEADERS / RATE_LIMIT_ENABLED config flags. create_rate_limiter() is now exported from fastapi_fullauth.protection for users who want Redis-backed global limits.

  • exclude_routers renamed to include_routers on init_app(). Allowlist instead of denylist. include_routers=None (default) registers every available router, the same behaviour as before with no kwarg. Pass an explicit list (e.g. ["auth", "profile"]) to opt in selectively.

Migration guide (0.9.x → 0.10.0)

No data migration is required; table names and column shapes are unchanged.

  1. Replace fastapi_fullauth.adapters.{sqlalchemy,sqlmodel}.models.* imports with fastapi_fullauth.models.{sqlalchemy,sqlmodel}.* and rename to the *Mixin classes.
  2. Declare your project's Base (or use the existing one).
  3. Define a concrete class per feature group you use (RefreshToken, Role, UserRole, etc.).
  4. Pass all of them to the adapter via keyword args.
  5. Drop include_fullauth_models(...) and get_fullauth_metadata(...) from alembic/env.py. Import app.models, then target_metadata = Base.metadata.

Security

  • /auth/refresh now requires the refresh-token row to exist before issuing a new token pair. Previously, a JWT that decoded cleanly (valid signature, unexpired) was enough; even if the corresponding row had been pruned or never existed. This affected both rotation and non-rotation paths.
  • Login timing oracle hardening (opt-in). New PREVENT_LOGIN_TIMING_ATTACKS: bool = False config. When True, /auth/login runs a dummy argon2 verify on the unknown-user and missing-password paths, so response time no longer leaks whether the email exists. Off by default because it adds ~argon2 time to every failed lookup; flip it on when enumeration via timing is in your threat model.
  • CSRF middleware no longer pulls config from env at instantiation. CSRFMiddleware(secret=...) is now required and validated (≥ 32 chars). _resolve_secret() (which built a fresh FullAuthConfig to pull CSRF_SECRET / SECRET_KEY on demand and auto-generated a random SECRET_KEY if neither was set) is gone. FullAuthConfig also gains a validator that fails at construction if CSRF_ENABLED=True and the effective secret is shorter than 32 chars.
  • /auth/refresh is now rate-limited via AUTH_RATE_LIMIT_REFRESH (default 30 req/min per IP). Without this, an attacker holding a stolen refresh token could hammer the endpoint for fresh access tokens, or use the response shape as a token-validation oracle. The default sits well above legitimate usage (a single user typically refreshes a handful of times per session) but caps abuse.
  • /verify-email/request, /verify-email/confirm, /password-reset/confirm, /oauth/{provider}/callback, and /passkeys/authenticate/complete are now rate-limited using the existing password-reset, login, and passkey-authenticate buckets respectively. The reset/verify confirm endpoints were previously unbounded once an attacker possessed (or forged) a token candidate; OAuth callback and passkey completion are login flows but weren't gated.
  • Passkey authenticate-begin no longer leaks email existence. When a client passes email, allowCredentials is always a list (possibly empty) instead of being omitted for unknown emails. Without an email, the route still allows discoverable credentials. Previously, an attacker could enumerate accounts by comparing the response shape.
  • Malformed JWT sub no longer 500s. Token decoding succeeded but UUID(payload.sub) raised ValueError on non-UUID values. Now caught at every call site (current_user, /refresh, verify_email, reset_password, logout hook) and treated as an invalid token (401 / TokenError).
  • verify_password no longer crashes on a malformed stored hash. A garbage value in hashed_password (corrupted row, migration bug) caused InvalidHashError (argon2) or ValueError (bcrypt). Both are now caught and treated as a credential mismatch.

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.
  • FullAuth.get_custom_claims annotated as -> dict[str, Any] instead of bare dict.
  • Passkey and OAuth flows now pass user.id (UUID) to RefreshToken(user_id=...) instead of str(user.id), matching the password login path.
  • OAuth state without a stored redirect_uri now falls back to provider.redirect_uris[0] instead of passing None to provider.exchange_code(...). Test mocks were unaffected; production callers always set it.

Changed

  • Strict mypy is now clean across the entire codebase. The 184 strict-mode errors that had been parked for a future typed-hardening release are gone; uv run mypy --strict fastapi_fullauth passes 0/0. Mostly mechanical: missing dict type args, missing parameter/return annotations, hash_password(algorithm=...) Literal at call sites, ASGI middleware app/call_next types. Mixin-method calls through AbstractUserAdapter are now narrowed via cast() to the appropriate RoleAdapterMixin / PermissionAdapterMixin / OAuthAdapterMixin at each call site. SQLAlchemy 2.0 / SQLModel column-comparison stub limitations (where(self.user_model.id == user_id) types as bool instead of ColumnElement[bool]) are scoped to the two adapter modules via a focused [[tool.mypy.overrides]] block; the rest of the codebase stays strict.
  • CI now runs mypy --strict on every push and PR to keep the type surface clean.
  • Development Status classifier bumped from 3 - Alpha to 4 - Beta. Reflects 189 tests passing, multi-version CI on Python 3.10-3.14, OIDC-based PyPI publishing, the security hardening trail through 0.7.0-0.9.1, and py.typed shipped. Reserved 5 - Production/Stable for v1.0.
  • Added Operating System :: OS Independent classifier (CI runs on Linux and Windows).
  • All dependency floors bumped to current stable versions: fastapi>=0.136, pydantic[email]>=2.13, pydantic-settings>=2.14, pyjwt>=2.12, argon2-cffi>=25.1, plus extras (sqlalchemy>=2.0.49, alembic>=1.18, sqlmodel>=0.0.38, redis>=7.4, httpx>=0.28, webauthn>=2.7).
  • 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

Added

  • py.typed marker (PEP 561) ships with the package. Type annotations, including the generic FullAuth[UserSchema, CreateUserSchema] with PEP 696 defaults, the typed CurrentUser / VerifiedUser / SuperUser dependencies, and the adapter mixin surfaces, are now visible to mypy, pyright, and IDE language servers when the library is installed from PyPI. Previously the annotations existed internally but were treated as Any by consumers.
  • mypy added to the dev dependency group for contributors who want to type-check locally. Not yet enforced in CI; strict mode has a backlog of ~190 existing errors (mostly missing return annotations, mixin-method lookups through AbstractUserAdapter, and passkey-config narrowings) that will be cleaned up in a dedicated typed-hardening release.

0.9.0

Breaking changes

  • Lockout now returns 401 instead of 423 Locked. Clients that branched on 423 to render a "your account is locked" UI will silently fall into the generic credentials-error path. The change is deliberate; see Security below.
  • Email lookup is now case-insensitive. On case-sensitive database collations (MySQL default, SQL Server), rows registered with mixed-case emails (Alice@X.com) will stop matching logins submitted in a different case. Run a one-off UPDATE fullauth_users SET email = LOWER(TRIM(email)) before upgrading. PostgreSQL/SQLite with default collations are unaffected.

Security

  • Emails are now normalised (stripped + lowercased) on create, update, and lookup in both built-in adapters. Previously Alice@X.com and alice@X.com could register as separate accounts on case-sensitive collations (MySQL default, SQL Server).
  • Login now returns the same generic 401 Could not validate credentials response for a locked account as for a wrong password. Previously a 423 Locked status let an attacker distinguish "email exists and is locked out" from "wrong password"; an enumeration signal once they'd exhausted the lockout counter on a target email. The AccountLockedError message no longer includes the identifier (cleaner logs too).
  • Opt-in PREVENT_REGISTRATION_ENUMERATION setting (default False). When True, /register always responds 202 + {"detail": "If this email isn't already registered, a verification email has been sent."} whether the email was taken or not; attackers can't probe the user table through the registration endpoint. Off by default to keep the 201 + user / 409 conflict shape that most client apps expect.

Fixed

  • BearerBackend accepts any case of the Bearer auth scheme (Bearer, bearer, BEARER, mixed) per RFC 7235. Clients that sent a lowercase scheme were previously rejected with a 401.
  • require_role tolerates a UserSchema subclass with no roles field; returns a clean 403 instead of AttributeError / 500. The default schema doesn't ship with roles; apps using RBAC still need to add it to their custom schema.
  • hash_password(..., algorithm="bcrypt") rejects passwords over 72 UTF-8 bytes with InvalidPasswordError instead of silently truncating. bcrypt's built-in truncation would otherwise cause subtle lockouts if an app later migrated to argon2id.
  • SQLModel UserBase.hashed_password column is now Text. Argon2id hashes are ~97 characters; MySQL / MSSQL default VARCHAR(255) was still fine but the column type is explicit now, matching the SQLAlchemy adapter.
  • FullAuthConfig validates passkey settings at construction time when PASSKEY_ENABLED=True: empty PASSKEY_RP_ID / PASSKEY_ORIGINS, RP ID with scheme or path, origin without scheme, and Redis backend without REDIS_URL all raise at config creation instead of surfacing as 500s at first request.

0.8.0

Security

  • OAuth auto-link-by-email now requires info.email_verified=True from the provider when an account with that email already exists. Without this gate, any provider that returns an unverified email (e.g. GitHub secondary addresses) could be used to hijack an existing account by registering the provider with the victim's email.
  • Cookie backend's delete_token now matches the same secure/samesite/path/domain attributes used on set. Browsers ignore (or reject, for SameSite=None) a deletion that doesn't match; logout previously left the cookie in place on some setups.
  • Refresh-token revocation is now an atomic compare-and-swap (UPDATE ... WHERE revoked=false). Two concurrent refresh calls with the same token can no longer both succeed by racing the old stored-state check. AbstractUserAdapter.revoke_refresh_token now returns bool; custom adapters should honour the CAS semantics.
  • create_user catches IntegrityError from duplicate-email races and raises UserAlreadyExistsError. The register flow's pre-check only guards the common case; concurrent signups used to surface as 500s.
  • OAuth account table now has a composite unique constraint on (provider, provider_user_id). Existing SQL users should autogenerate an Alembic migration to add it. create_oauth_account now returns the existing row on concurrent-insert collisions instead of erroring.
  • Password-reset and email-verification tokens now use their own TTLs (PASSWORD_RESET_EXPIRE_MINUTES, default 15; EMAIL_VERIFY_EXPIRE_MINUTES, default 1440) instead of inheriting ACCESS_TOKEN_EXPIRE_MINUTES. A production tweak to access-token lifetime for mobile clients no longer silently extends the window in which a stolen password-reset email grants an account takeover.

Breaking changes

  • Models split into packages: adapters/sqlmodel/models.py and adapters/sqlalchemy/models.py are now models/ directories with base.py, role.py, permission.py, oauth.py. Old import paths (from fastapi_fullauth.adapters.sqlmodel.models import ...) still work via __init__.py re-exports. New selective imports: from fastapi_fullauth.adapters.sqlmodel.models.base import UserBase, RefreshTokenRecord.
  • roles removed from default UserSchema: apps that use roles should extend UserSchema with roles: list[str] = Field(default_factory=list). Apps without roles are unaffected.
  • Admin router auto-skipped when adapter doesn't implement RoleAdapterMixin. OAuth/passkey routers auto-skipped similarly.
  • AbstractUserAdapter.revoke_refresh_token now returns bool: custom adapters need to return True only when the token actually transitioned from not-revoked to revoked (CAS semantics).

Added

  • Composable models: only imported model groups register tables. Apps that don't need roles/permissions/oauth skip those tables entirely.
  • Selective migration helper: include_fullauth_models("sqlmodel", include=["base", "role"]) imports only specified model groups for Alembic.
  • exclude_routers param on init_app(): fullauth.init_app(app, exclude_routers=["admin"]) to skip routers you don't need.
  • bind(app) method: bind FullAuth to a FastAPI app for composable router usage. Called automatically by init_app() and init_middleware().
  • init_middleware() method: wire up middleware independently when using composable routers.
  • RouterName type: Literal["auth", "profile", "verify", "admin", "oauth"] for type-safe router exclusion.
  • AuthRateLimiter class: per-route auth rate limiting extracted from FullAuth into its own class.
  • exchange_oauth_code(), link_or_create_user(), issue_oauth_tokens(): OAuth callback split into composable flow functions. oauth_callback() still works as before (delegates to the three).
  • register_lockout_backend(): register custom lockout backends for create_lockout() factory.
  • register_rate_limiter_backend(): register custom rate limiter backends for create_rate_limiter() factory.
  • Passkey (WebAuthn) authentication: passwordless login with fingerprint, Face ID, security keys. Register, authenticate, list, and delete passkeys. Requires pip install fastapi-fullauth[passkey] and PASSKEY_ENABLED=True.
  • ChallengeStore: abstract challenge store with InMemory and Redis backends for WebAuthn flows.
  • PasskeyAdapterMixin: adapter mixin for passkey credential persistence.
  • Adapter mixins: AbstractUserAdapter split into composable interfaces: RoleAdapterMixin, PermissionAdapterMixin, OAuthAdapterMixin, PasskeyAdapterMixin. Custom adapters implement only what they need. Built-in adapters inherit all mixins (backward compatible).

Changed

  • Adapter model imports are lazy; importing the adapter no longer registers role/permission/oauth tables
  • Rate limiting extracted from FullAuth __init__ into AuthRateLimiter
  • SQLModelAdapter session_maker type hint accepts both session types cleanly
  • TokenClaimsBuilder and RouterName moved to types.py
  • init_app() and init_middleware() are now idempotent. Calling either twice on the same FastAPI app emits a UserWarning and is a no-op. Previously a second call (e.g. init_app(app) followed by a stray init_middleware(app)) doubled the middleware stack; duplicate security headers, two rate-limiter instances halving the effective limit, and a CSRF layer validating another CSRF layer's cookies.
  • JWT decode now tolerates clock drift between services via JWT_LEEWAY_SECONDS (default 30). Eliminates sporadic 401s caused by ±30 s skew between client and server clocks or across load-balanced instances.
  • FullAuthConfig reads .env in the current working directory by default (env_file=".env"), and ignores unknown FULLAUTH_* vars instead of erroring (extra="ignore"). Local dev "just works" without passing _env_file=".env" explicitly. Cloud deployments are unaffected; pydantic-settings' precedence is init kwargs → os.environ.env → defaults, so platform-injected env vars always win, and a missing .env is a silent no-op. Use FullAuthConfig(_env_file="…") or a SettingsConfigDict subclass to read a different file.

0.7.0

Breaking changes

  • InMemory adapter removed: use SQLModel + SQLite for prototyping instead.
  • UserID is now UUID (was str | int | UUID): all adapter methods, RefreshToken.user_id, OAuthAccount.user_id, and RoleAssignment.user_id are now UUID.
  • OAuth providers passed as objects: FullAuth(providers=[GoogleOAuthProvider(...)]) replaces OAUTH_PROVIDERS dict in config. OAuthProviderConfig removed.
  • OAuthProvider simplified: only redirect_uris: list[str] (removed singular redirect_uri). get_redirect_uri() removed.
  • redirect_uri required in authorize URL: clients must pass ?redirect_uri= in the OAuth authorize request.
  • include_user_in_login moved to config: use FullAuthConfig(INCLUDE_USER_IN_LOGIN=True) or FULLAUTH_INCLUDE_USER_IN_LOGIN=true env var instead of FullAuth(include_user_in_login=True).
  • Login response always includes user field: when INCLUDE_USER_IN_LOGIN=False, user is null (previously the key was absent). When True, user contains the full user schema object.

Added

  • Redis lockout backend: LOCKOUT_BACKEND="redis" for multi-worker deployments
  • LOCKOUT_ENABLED config: disable account lockout entirely (False)
  • INCLUDE_USER_IN_LOGIN config: include user object in login/OAuth callback response
  • LoginResponse dynamic model: login and OAuth callback routes now have proper response_model with typed user field matching the configured user schema
  • validate_profile_updates flow: profile field filtering extracted from router to flows/update_profile.py
  • NoValidFieldsError, UnknownFieldsError exceptions for profile update validation
  • change_password flow: business logic extracted from profile router
  • PROTECTED_FIELDS ClassVar on UserSchema: users can extend in subclasses
  • Password validation moved to flows (register, reset_password, change_password)
  • Makefile with make check, make test, make lint, make format, make docs, etc.

Changed

  • LockoutManager is now an abstract base class with async methods
  • InMemoryLockoutManager replaces the old sync LockoutManager
  • migrations/ package flattened to single migrations.py module (import paths unchanged)
  • 4 type: ignore comments fixed (replaced with getattr, assertions, model_validate)
  • 204 routes (delete_me, unlink_oauth_account) no longer return unnecessary Response objects
  • Logout route return type corrected to Response
  • All tests migrated from InMemory to SQLModel + SQLite
  • Tests regrouped: test_auth, test_profile, test_config, test_hooks, test_security, test_rbac
  • UUID(payload.sub) conversion at token boundaries (dependencies, router, flows)
  • Removed isinstance str-to-UUID guards from adapters
  • Removed str(user.id) / str(row.user_id) conversions; UUID used directly

Removed

  • InMemoryAdapter and examples/memory_app/
  • OAuthProviderConfig from config
  • OAUTH_PROVIDERS from FullAuthConfig
  • FullAuth._build_oauth_providers() and _OAUTH_PROVIDER_REGISTRY
  • OAuthProvider.get_redirect_uri() method
  • rbac/ package (was empty, just re-exported from dependencies)

0.6.0

Breaking changes

  • Config-only API: FullAuth no longer accepts secret_key=, **config_kwargs, or positional config. Pass config=FullAuthConfig(SECRET_KEY="...") or set FULLAUTH_SECRET_KEY env var. All params are keyword-only.
  • enabled_routes removed: replaced by composable routers. Include only the routers you need instead of filtering route names.
  • RouteName type removed: no longer needed with composable routers.
  • configure_hasher() removed: hash algorithm is now passed explicitly from config through flows. No more global mutable state.
  • Schema auto-derivation removed: _derive_user_schema() and _resolve_create_schema() deleted from all adapters and FullAuth. Define your own schemas extending UserSchema / CreateUserSchema and pass them to the adapter.
  • create_user_schema moved to adapter: pass it to the adapter, not FullAuth: InMemoryAdapter(user_schema=MyUser, create_user_schema=MyCreate).

Added

  • Generic type parameters: AbstractUserAdapter[UserSchemaType, CreateUserSchemaType], FullAuth[UserSchemaType, CreateUserSchemaType] with PEP 696 defaults for full type safety
  • Composable routers: fullauth.auth_router, fullauth.profile_router, fullauth.verify_router, fullauth.admin_router, fullauth.oauth_router. Each lazily created, include only what you need
  • Typed dependency factories: get_current_user_dependency(MyUser), get_verified_user_dependency(MyUser), get_superuser_dependency(MyUser) for custom schema type safety
  • create_blacklist(config): extracted from FullAuth to core/tokens.py
  • create_rate_limiter(config, max, window): extracted from FullAuth to protection/ratelimit.py
  • UserSchemaType, CreateUserSchemaType TypeVars exported from top-level package
  • UserSchema, CreateUserSchema base classes exported from top-level package

Changed

  • Router split: 613-line monolithic create_auth_router() split into create_auth_router() (login/register/logout/refresh), create_profile_router() (me/update/delete/change-password), create_verify_router() (email verify/password reset), create_admin_router() (roles/permissions)
  • FullAuth slimmed: factory methods extracted, composable router properties added, _OAUTH_PROVIDER_REGISTRY stays on class for now
  • fullauth.router still works as before (composes all sub-routers), fullauth.init_app(app) unchanged
  • hash_password() and password_needs_rehash() now accept explicit algorithm parameter (default argon2id)
  • Shared request/response models extracted to router/_models.py
  • RBAC permissions (require_role, require_permission) available via fastapi_fullauth.dependencies

Removed

  • FullAuth._resolve_create_schema(): auto-derivation of create schema from ORM model
  • SQLModelAdapter._derive_user_schema(): auto-derivation of user schema
  • SQLAlchemyAdapter._derive_user_schema(): auto-derivation of user schema
  • _SA_TYPE_MAP and _get_sa_type_map(): SQLAlchemy type mapping for auto-derivation
  • FullAuth._create_blacklist(): moved to core/tokens.create_blacklist()
  • FullAuth._create_rate_limiter(): moved to protection.ratelimit.create_rate_limiter()
  • configure_hasher() and _algorithm global from core/crypto.py

0.5.0

Added

  • Structured logging across all auth flows, security middleware, and OAuth: failed logins, account lockouts, token reuse, CSRF violations, rate limit hits, role changes, and account deletions are all logged via logging.getLogger("fastapi_fullauth.*")
  • Documentation site: MkDocs with Material theme, auto-deployed to GitHub Pages via CI
  • Proxy-aware rate limiting: new TRUSTED_PROXY_HEADERS config to read real client IPs from X-Forwarded-For and similar headers
  • SQLAlchemy example app (examples/sqlalchemy_app/)
  • update_user field validation: rejects unknown fields with 422 instead of passing them to the DB
  • SQLModel adapter now accepts both SQLModel's and SQLAlchemy's AsyncSession
  • OAuthAccountRecord exported from fastapi_fullauth.adapters.sqlmodel

Fixed

  • OAuth state token TTL was ignored: OAUTH_STATE_EXPIRE_SECONDS config had no effect; state tokens used ACCESS_TOKEN_EXPIRE_MINUTES (30 min) instead of the configured 5 min
  • Refresh token reuse detection race condition: two concurrent /refresh requests could both succeed before either revoked the token; added explicit blacklist check before issuing new tokens
  • OAuth error messages leaked provider internals: raw API responses from Google/GitHub were exposed in HTTP error details; now logged internally and replaced with generic messages

Changed

  • README rewritten with centered hero layout, badges, and documentation links
  • Documentation URL updated in pyproject.toml to point to GitHub Pages

0.4.0

Added

  • OAuth2 social login: Google and GitHub out of the box, extensible for custom providers
    • GET /oauth/{provider}/authorize: get authorization URL
    • POST /oauth/{provider}/callback: exchange code for JWT tokens
    • GET /oauth/providers: list configured providers
    • GET /oauth/accounts: list linked OAuth accounts
    • DELETE /oauth/accounts/{provider}: unlink a provider (with lockout prevention)
  • OAuthProvider abstract base class for implementing custom providers
  • OAuthAccount and OAuthUserInfo types
  • OAuthAccountRecord / OAuthAccountModel for SQLModel and SQLAlchemy adapters
  • OAuth adapter methods on all adapters (memory, SQLModel, SQLAlchemy)
  • OAUTH_PROVIDERS, OAUTH_STATE_EXPIRE_SECONDS, OAUTH_AUTO_LINK_BY_EMAIL config fields
  • after_oauth_login hook event
  • oauth optional dependency group (pip install fastapi-fullauth[oauth])
  • Auto-link OAuth to existing user by email (configurable)
  • Auto-verify email when provider confirms it
  • Lockout prevention: can't unlink last login method
  • Multiple redirect_uris per OAuth provider: supports web, mobile, and production frontends from one config. Client passes ?redirect_uri= on authorize, validated against allowed list.

0.3.0

Breaking changes

  • create_refresh_token returns RefreshTokenMeta: previously returned a plain str. Now returns a NamedTuple with .token, .expires_at, .family_id. Callers that used the raw string must access .token.
  • create_token_pair returns tuple[str, RefreshTokenMeta]: second element is now RefreshTokenMeta instead of str.
  • revoke_all_user_refresh_tokens is now required on custom adapters: new abstract method on AbstractUserAdapter.

Added

  • current_superuser dependency and SuperUser annotated type
  • CurrentUser, VerifiedUser, SuperUser annotated types in dependencies.current_user for cleaner route signatures
  • RefreshTokenMeta named tuple: avoids decoding freshly created tokens just to read expires_at and family_id
  • FullAuth.get_custom_claims(user): moved custom claims logic from router into the class, with validation against reserved JWT keys (sub, exp, type, etc.)
  • revoke_all_user_refresh_tokens(user_id) on all adapters: bulk session revocation
  • Session revocation on password reset, password change, and account deletion
  • configure_hasher(): wires PASSWORD_HASH_ALGORITHM config to the actual hasher; supports argon2id and bcrypt
  • Automatic password rehash on login when hash algorithm or params have changed
  • Register now checks uniqueness on login_field (not just email) when login_field != "email"
  • InMemoryBlacklist now respects ttl_seconds; expired entries are evicted on lookup
  • RateLimiter evicts keys with empty timestamp lists to prevent unbounded dict growth
  • description parameter on all route decorators for Swagger docs

Fixed

  • current_active_verified_user was missing payload.type != "access" check; refresh tokens could pass through
  • Purpose tokens (password reset, email verify) could be used as regular access tokens; current_user now rejects tokens with extra.purpose
  • Duplicate token decode + user lookup across dependencies, router endpoints, and admin routes; consolidated into reusable current_user dependency chain
  • Duplicate roles + extra_claims fetch in refresh route; pulled above the if/else branch
  • Login flow fetched the user from DB twice (once in router, once in login()); now accepts pre-fetched user
  • Unused request: Request parameters in dependencies and routes
  • Removed duplicate docstrings on routes (kept description= on decorators)
  • require_permission was a full copy of require_role; now delegates to it

Internal

  • Route order follows auth lifecycle: register → login → refresh → logout → user → email/password → admin
  • require_role / require_permission use Depends(current_user) instead of duplicating token logic
  • Removed _get_custom_claims module-level function from router

0.2.0

Breaking changes

  • JSON login: POST /login now accepts {"email": "...", "password": "..."} instead of form data. Swagger auth uses bearer token input instead of username/password form.
  • No default User model: SQLModel and SQLAlchemy adapters no longer ship a concrete User/UserModel table class. Users must define their own model from UserBase. This eliminates relationship conflicts when subclassing.
  • user_model is required: SQLModelAdapter(session_maker, user_model=MyUser); no default.
  • Removed min_length=8 from CreateUserSchema; password length is now fully controlled by PasswordValidator and PASSWORD_MIN_LENGTH config.
  • SQLAlchemyAdapter renamed UserModel to UserBase: import UserBase instead.

Added

  • POST /auth/change-password: verifies current password, validates new
  • PATCH /auth/me: update profile with protected field filtering
  • DELETE /auth/me: self-deletion
  • expires_in in login/refresh responses
  • Per-IP auth rate limiting on login, register, password-reset (AUTH_RATE_LIMIT_* config)
  • LOGIN_FIELD config: login by email, username, phone, or any model field
  • get_user_by_field() on all adapters for generic field lookups
  • Structured example apps (examples/memory_app/, examples/sqlmodel_app/)

Fixed

  • InMemoryAdapter.update_user returning base UserSchema instead of custom schema
  • Stale User.id / UserModel references in adapter queries after model removal
  • Parameter ordering in adapter constructors (required params before optional)

0.1.0

Initial release.

  • JWT access/refresh tokens with rotation and blacklisting
  • Argon2id password hashing
  • Auth flows: register, login, logout, password reset, email verification
  • Brute-force lockout, per-IP rate limiting, CSRF, security headers
  • Bearer and cookie backends
  • SQLAlchemy, SQLModel, and InMemory adapters
  • Redis blacklist backend
  • Refresh token persistence with family tracking and reuse detection
  • Flat config (secret_key=...) or full FullAuthConfig object
  • Auto-derive schemas from ORM model fields
  • Auto-wire middleware from config flags
  • Route enum, event hooks, email hooks
  • current_user, current_active_verified_user, require_role dependencies
  • 97 tests