Skip to content

Auth: replace the forked auth stack with the crudauth library#265

Merged
igorbenav merged 8 commits into
mainfrom
crudauth-migration
Jun 24, 2026
Merged

Auth: replace the forked auth stack with the crudauth library#265
igorbenav merged 8 commits into
mainfrom
crudauth-migration

Conversation

@igorbenav

Copy link
Copy Markdown
Collaborator

Auth: replace the forked auth stack with the crudauth library

This branch retires the boilerplate's vendored authentication code — the session engine, OAuth provider framework, password hashing, and session storage backends — and runs the same auth surface on the crudauth library instead. The public contract is deliberately unchanged: the same routes, the same URLs, the same get_current_user dependency returning the same user dict, so a cookie-based frontend needs no changes. Internally the session engine, CSRF, login lockout, and OAuth are now crudauth's, and the forked implementations (~6,000 lines) are deleted. Net change is 81 files, +949 / −6,169.


Running on crudauth

The boilerplate carried its own SessionManager, an OAuth provider framework, password utilities, and three session storage backends (memory, Redis, memcached). All of it was maintenance surface that duplicated what crudauth already does correctly. This branch introduces a single composition root — one CRUDAuth instance wired over the existing User model in infrastructure/auth/setup.py — and routes the app through it.

The instance is constructed at module import, not in the lifespan, because routers and current_user dependencies reference it at import time; the lifespan only opens and closes its connections via auth.initialize() / auth.shutdown(), now wired into app_factory. It runs a single session transport (sessions + CSRF + escalating login lockout) over the configured backend, with a Redis rate limiter for the lockout counters when SESSION_BACKEND=redis.

Because the boilerplate's User model already carries every column crudauth needs — id, email, username, hashed_password, is_superuser, email_verified, and the OAuth columns — no column_map is required; the logical field names line up one-to-one.

The forked auth was the boilerplate's largest source of subtle security-relevant surface — token handling, CSRF, lockout, session storage. Delegating to a maintained library removes that surface while keeping the app's own routes and user model in charge.


Routes and dependencies, with the contract preserved

infrastructure/auth/routes.py keeps its six endpoints — /login, /logout, /refresh-csrf, /oauth/google, /oauth/callback/google, /check-auth — at their existing paths and response shapes, but their bodies now call crudauth's building blocks instead of the forked session manager. Login verifies credentials through auth.authenticate_password (timing-equalized check, disabled-account guard, escalating lockout) and creates a server-side session through auth.sessions; logout revokes server-side; CSRF is crudauth's double-submit synchronizer token.

infrastructure/auth/dependencies.py resolves each request to a crudauth Principal (get_current_principal / get_optional_principal), and then layers the dict-compatible get_current_user / get_optional_user / get_current_superuser on top — these re-load the full row so the byte-identical user dict the handlers and the API-key module already consume is preserved. The typed-alias hub in infrastructure/dependencies.py (CurrentUserDep, CurrentSuperUserDep, OptionalUserDep) is repointed at these, so every route that depended on the aliases keeps working without edits.


OAuth on crudauth, without changing the URLs

infrastructure/auth/oauth.py builds the Google provider via crudauth's OAuthProviderFactory, a signed-state store, and an OAuthAccountService for verified-email account linking. The route handlers drive those building blocks directly rather than mounting crudauth's own OAuth router — which would have changed the callback URLs. The flow keeps PKCE, signed state bound to the initiating request, and the redirect-vs-json response modes the boilerplate already exposed.

Only Google is wired, matching the one provider that previously had routes — but crudauth already ships a GitHub provider in OAuthProviderFactory (it auto-registers both google and github), so the boilerplate's forked GitHub scaffold was deleted as redundant. The User model keeps its github_id / oauth_provider columns, so turning GitHub on is adding one entry to the oauth_providers dict in oauth.py and its two routes — no provider implementation, no schema change.


The User model gains a derived is_active

crudauth gates authentication on an is_active logical field. Rather than add a column, User gains a derived property — is_active returns not is_deleted — so is_deleted stays the single source of truth and a soft-deleted user can no longer log in or resolve a session. This is the one new invariant the migration introduces, and it is covered directly by tests (a soft-deleted user's login is rejected, and /check-auth now filters soft-deleted rows for consistency with get_current_user).


Settings, lockout, and the memcached drop

The session lockout is now crudauth's, and it surfaces as 429 with a Retry-After header instead of the previous generic failure. The lockout's client-IP resolution is governed by a new TRUSTED_PROXY_HOPS setting (default 0 — the socket peer; set to 1 behind a single reverse proxy). The dead JWT config the boilerplate never used (ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES, REFRESH_TOKEN_EXPIRE_DAYS) and the session knobs now owned by the transport (SESSION_COOKIE_MAX_AGE, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MINUTES) are removed. SECRET_KEY goes from dead config to actually used.

crudauth supports redis and memory session backends only, so the memcached session backend and its SessionBackend.MEMCACHED enum value are gone. The general cache and the non-auth rate limiter still support memcached — only the session path lost it.


Deleting the fork

With the routes running on crudauth, the forked engine was removed wholesale: infrastructure/auth/{session,oauth}/ (the session manager, the memory/Redis/memcached backends, storage, schemas, dependencies, the user-agent parser, and the OAuth provider framework), auth/utils.py (password hashing), and auth/constants.py. Password hashing now imports from crudauth; the one app-wide constant that lived in auth/constants.py (HSTS_MAX_AGE_SECONDS) moved next to its only consumer in middleware.py. auth/http_exceptions.py is kept — it only re-exports FastCRUD exceptions and has no dependency on the deleted code, which is why the user, tier, rate_limit, and common modules needed no exception-import changes.


Test coverage

The fork's unit tests (the session manager, the three backends, the OAuth provider framework — about ten files) tested code that no longer exists and were deleted; that behavior is now crudauth's to test. The integration suite was rewritten against crudauth: login (success, wrong password, login-then-logout round-trip with real cookies and CSRF), the OAuth login and callback flows, and check-auth.

On top of that, the gaps a coverage pass surfaced were filled: the is_active soft-delete gating (a soft-deleted user's login returns 401), the real get_current_superuser 403 branch (the test fixtures override it, so it needed a direct test), the OAuth callback happy path that actually creates a user through repo.create against a real Postgres (proving crudauth's writer works on the dataclass-mapped model), refresh-csrf, and the logout 401 / CSRF-rejection paths. New direct unit tests cover the dependency bodies and the User.is_active property.


Documentation Updates

docs/user-guide/authentication/*: rewrote sessions.md (new "Auth Architecture" section for the CRUDAuth singleton, plus the How-Sessions-Work, Device Tracking, Storage Backends, and Key Files sections), index.md (crudauth intro, the corrected GitHub-OAuth status, the 401→429 lockout), and user-management.md (the crudauth login flow and User.is_active).

Config docs (getting-started/configuration.md, configuration/*): removed the deleted settings, added TRUSTED_PROXY_HOPS, dropped memcached from SESSION_BACKEND, and fixed the AuthSettings reference.

Cross-cutting: import-path moves (auth.session.dependenciesauth.dependencies, get_password_hash from crudauth) across the API, development, project-structure, rate-limiting, and database docs; the auth/ subtree diagram redrawn; the dead dependency-alias rows removed from the endpoints table.

New content: a 0.19.0 changelog entry (the 0.18.0 entry left intact), and a "Need JWT for mobile or native apps?" section showing how to opt into crudauth's bearer transport alongside sessions, linking to crudauth's bearer and multiple-transports guides.


Test Plan

Automated

  • uv run ruff check src tests — clean
  • uv run mypy src — clean (100 source files)
  • uv run pytest — 227 passed (serial); also 227 passed under pytest -n auto after the Ryuk fix
  • uvx zensical build — no issues, no broken links

Sessions / login

  • Valid credentials → 200 + CSRF token + session cookie (real authenticate_passwordcreate_session)
  • Wrong password → 401
  • Login then logout (echoing the CSRF header) → 200, session cleared
  • Logout with no session → 401; logout without the X-CSRF-Token header → 403
  • refresh-csrf with a valid session → 200 + new token; with no session → 401

Soft-delete gating (is_active)

  • User.is_active is True when not deleted, False when soft-deleted
  • A soft-deleted user's login is rejected (401)
  • /check-auth filters soft-deleted rows (consistency with get_current_user)

Dependencies

  • get_current_user: no principal → 401; missing/soft-deleted row → 401; happy path returns the dict and applies is_deleted=False
  • get_optional_user: no principal → None; happy path returns the dict
  • get_current_superuser: non-superuser → 403; superuser → returns the dict

OAuth

  • /oauth/google returns the authorization URL and stores signed state
  • Callback with an unknown state → 302 / 400 (json); provider mismatch → 302 / 400 (json)
  • Callback happy path creates/links the user via OAuthAccountService against real Postgres and starts a session (json body shape, is_new_user)
  • Provider failure while building the auth URL → 500

check-auth

  • Authenticated principal → user info; anonymous (no cookie, real resolution) → authenticated: false not 401; user row missing → authenticated: false

Not covered

  • Real-provider OAuth against Google (needs credentials; the callback is covered with the network calls mocked)
  • The 429 escalating lockout end-to-end (no Redis rate limiter is wired under the in-memory test backend; the behavior is crudauth's and covered by its suite)

Dependencies

  • New: crudauth[all]>=0.6.0,<0.7.0 (the all extra — httpx, redis, user-agents — was already satisfied by existing direct deps).
  • Removed: the direct bcrypt dependency (password hashing moved into crudauth, which still provides bcrypt transitively).

Breaking Changes

  • Forked auth modules deleted. infrastructure/auth/{session,oauth}/, auth/utils.py, and auth/constants.py are gone. Code importing them fails at import time (loud, not silent). The dependency import path moved from infrastructure.auth.session.dependencies to infrastructure.auth.dependencies.
  • get_password_hash moved. Import it from crudauth, not infrastructure.auth.utils.
  • Memcached session backend removed. crudauth supports redis and memory only; SESSION_BACKEND=memcached is no longer valid and SessionBackend.MEMCACHED no longer exists. (Cache and the non-auth rate limiter still support memcached.)
  • Auth settings removed. ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES, REFRESH_TOKEN_EXPIRE_DAYS, SESSION_COOKIE_MAX_AGE, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MINUTES no longer exist. Added: TRUSTED_PROXY_HOPS (set it to the number of proxies in front of the app — 1 for a single reverse proxy).
  • Login lockout now returns 429 + Retry-After instead of a generic 401-style failure. Clients that special-cased the old response should handle 429.
  • GitHub OAuth provider removed. Only Google is wired; the github_id / oauth_provider columns remain, so re-adding GitHub is a provider registration, not a schema change.
  • Password hash format changed. crudauth hashes bcrypt with a SHA-256 pre-hash, a different format than the boilerplate's plain bcrypt. Irrelevant for a fresh template database; an existing deployment would need a rehash-on-next-login path for pre-existing hashes.

@igorbenav igorbenav merged commit cf4f10f into main Jun 24, 2026
9 checks passed
@igorbenav igorbenav deleted the crudauth-migration branch June 24, 2026 02:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant