Auth: replace the forked auth stack with the crudauth library#265
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
crudauthlibrary instead. The public contract is deliberately unchanged: the same routes, the same URLs, the sameget_current_userdependency 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 — oneCRUDAuthinstance wired over the existingUsermodel ininfrastructure/auth/setup.py— and routes the app through it.The instance is constructed at module import, not in the lifespan, because routers and
current_userdependencies reference it at import time; the lifespan only opens and closes its connections viaauth.initialize()/auth.shutdown(), now wired intoapp_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 whenSESSION_BACKEND=redis.Because the boilerplate's
Usermodel already carries every column crudauth needs —id,email,username,hashed_password,is_superuser,email_verified, and the OAuth columns — nocolumn_mapis 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.pykeeps 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 throughauth.authenticate_password(timing-equalized check, disabled-account guard, escalating lockout) and creates a server-side session throughauth.sessions; logout revokes server-side; CSRF is crudauth's double-submit synchronizer token.infrastructure/auth/dependencies.pyresolves each request to a crudauthPrincipal(get_current_principal/get_optional_principal), and then layers the dict-compatibleget_current_user/get_optional_user/get_current_superuseron 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 ininfrastructure/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.pybuilds the Google provider via crudauth'sOAuthProviderFactory, a signed-state store, and anOAuthAccountServicefor 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 bothgoogleandgithub), so the boilerplate's forked GitHub scaffold was deleted as redundant. TheUsermodel keeps itsgithub_id/oauth_providercolumns, so turning GitHub on is adding one entry to theoauth_providersdict inoauth.pyand its two routes — no provider implementation, no schema change.The User model gains a derived
is_activecrudauth gates authentication on an
is_activelogical field. Rather than add a column,Usergains a derived property —is_activereturnsnot is_deleted— sois_deletedstays 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-authnow filters soft-deleted rows for consistency withget_current_user).Settings, lockout, and the memcached drop
The session lockout is now crudauth's, and it surfaces as 429 with a
Retry-Afterheader instead of the previous generic failure. The lockout's client-IP resolution is governed by a newTRUSTED_PROXY_HOPSsetting (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_KEYgoes from dead config to actually used.crudauth supports
redisandmemorysession backends only, so the memcached session backend and itsSessionBackend.MEMCACHEDenum 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), andauth/constants.py. Password hashing now imports fromcrudauth; the one app-wide constant that lived inauth/constants.py(HSTS_MAX_AGE_SECONDS) moved next to its only consumer inmiddleware.py.auth/http_exceptions.pyis kept — it only re-exports FastCRUD exceptions and has no dependency on the deleted code, which is why theuser,tier,rate_limit, andcommonmodules 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_activesoft-delete gating (a soft-deleted user's login returns 401), the realget_current_superuser403 branch (the test fixtures override it, so it needed a direct test), the OAuth callback happy path that actually creates a user throughrepo.createagainst 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 theUser.is_activeproperty.Documentation Updates
docs/user-guide/authentication/*: rewrotesessions.md(new "Auth Architecture" section for theCRUDAuthsingleton, 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), anduser-management.md(the crudauth login flow andUser.is_active).Config docs (
getting-started/configuration.md,configuration/*): removed the deleted settings, addedTRUSTED_PROXY_HOPS, dropped memcached fromSESSION_BACKEND, and fixed theAuthSettingsreference.Cross-cutting: import-path moves (
auth.session.dependencies→auth.dependencies,get_password_hashfromcrudauth) across the API, development, project-structure, rate-limiting, and database docs; theauth/subtree diagram redrawn; the dead dependency-alias rows removed from the endpoints table.New content: a
0.19.0changelog 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— cleanuv run mypy src— clean (100 source files)uv run pytest— 227 passed (serial); also 227 passed underpytest -n autoafter the Ryuk fixuvx zensical build— no issues, no broken linksSessions / login
authenticate_password→create_session)X-CSRF-Tokenheader → 403refresh-csrfwith a valid session → 200 + new token; with no session → 401Soft-delete gating (
is_active)User.is_activeisTruewhen not deleted,Falsewhen soft-deleted/check-authfilters soft-deleted rows (consistency withget_current_user)Dependencies
get_current_user: no principal → 401; missing/soft-deleted row → 401; happy path returns the dict and appliesis_deleted=Falseget_optional_user: no principal →None; happy path returns the dictget_current_superuser: non-superuser → 403; superuser → returns the dictOAuth
/oauth/googlereturns the authorization URL and stores signed stateOAuthAccountServiceagainst real Postgres and starts a session (json body shape,is_new_user)check-auth
authenticated: falsenot 401; user row missing →authenticated: falseNot covered
Dependencies
crudauth[all]>=0.6.0,<0.7.0(theallextra — httpx, redis, user-agents — was already satisfied by existing direct deps).bcryptdependency (password hashing moved into crudauth, which still provides bcrypt transitively).Breaking Changes
infrastructure/auth/{session,oauth}/,auth/utils.py, andauth/constants.pyare gone. Code importing them fails at import time (loud, not silent). The dependency import path moved frominfrastructure.auth.session.dependenciestoinfrastructure.auth.dependencies.get_password_hashmoved. Import it fromcrudauth, notinfrastructure.auth.utils.redisandmemoryonly;SESSION_BACKEND=memcachedis no longer valid andSessionBackend.MEMCACHEDno longer exists. (Cache and the non-auth rate limiter still support memcached.)ALGORITHM,ACCESS_TOKEN_EXPIRE_MINUTES,REFRESH_TOKEN_EXPIRE_DAYS,SESSION_COOKIE_MAX_AGE,LOGIN_MAX_ATTEMPTS,LOGIN_WINDOW_MINUTESno longer exist. Added:TRUSTED_PROXY_HOPS(set it to the number of proxies in front of the app — 1 for a single reverse proxy).Retry-Afterinstead of a generic 401-style failure. Clients that special-cased the old response should handle 429.github_id/oauth_providercolumns remain, so re-adding GitHub is a provider registration, not a schema change.