Skip to content

CRUDAuth as a toolbox: reusable auth primitives, uniform service access, an exported surface#12

Merged
igorbenav merged 5 commits into
mainfrom
toolbox
Jun 22, 2026
Merged

CRUDAuth as a toolbox: reusable auth primitives, uniform service access, an exported surface#12
igorbenav merged 5 commits into
mainfrom
toolbox

Conversation

@igorbenav

Copy link
Copy Markdown
Contributor

CRUDAuth as a toolbox: reusable auth primitives, uniform service access, an exported surface

CRUDAuth was half a toolbox. The building blocks existed, but the hardened auth flows were trapped
inside route handlers, the wired services were exposed inconsistently, and none of the primitives
were discoverable from the package root. This branch closes that gap: the credential check and token
issuance become reusable methods (so a hand-written login or a webhook-minted token keeps the
hardening), every wired service is reachable off auth, and the building blocks are exported. It
also lands an end-to-end suite against real Postgres that was the before/after anchor for the
refactor. Everything is additive; no public behavior changed.


Reusable password authentication and token issuance

The credential check (escalating lockout, timing-equalized verification, the disabled-account check,
the uniform non-enumerable error) was inlined and duplicated in both the session /login and the
bearer /token handlers, and callable from nowhere. Token issuance (access plus refresh, scope
clamping, the token_version epoch) was likewise inlined in /token, with /refresh repeating the
access-token half.

This extracts two primitives. authenticate_password(db, identifier, password, *, request) lives on
AuthRuntime and is surfaced on the facade as auth.authenticate_password; both /login and
/token now delegate to it, removing the duplication. auth.issue_tokens(user, *, scopes=None) (and
BearerTransport.issue_tokens) is the issuance behind /token; /refresh shares the underlying
access-token minting through a new private helper. The route handlers are now thin: authenticate or
issue, then the transport-specific bits (cookies, hook).

Why: the most security-critical code in the library, verifying a password and minting a token, was
the one part you could not reuse. A hand-rolled login over the raw pieces would almost certainly get
lockout, timing-equalization, or non-enumeration wrong, and a token minted with bare
create_access_token silently skips the scope clamp and the revocation epoch. The primitives now
carry the hardening, so reusing them is the safe path and the duplication is gone.


Uniform service access off auth

auth.repo, auth.sessions, and auth.sudo were public, but the EmailFlowService was reachable
only as the private auth._email_service and the OAuthAccountService was never stored at all. A
toolbox should surface its wired services the same way.

Adds auth.emails (the EmailFlowService) and auth.oauth (the OAuthAccountService), each
returning None when the feature is not configured. So a custom route can trigger a password reset
(auth.emails.request_password_reset(...)) or reuse the OAuth linking rules
(auth.oauth.get_or_create_user(...)) without reaching into private attributes.


An exported toolbox surface

crudauth.__all__ carried the config, port, and exception types but none of the reusable primitives,
so the toolbox could not be found from import crudauth or autocomplete.

Exports the service types (UserRepository, SessionManager, SudoManager, EmailFlowService,
OAuthAccountService) and the password helpers (get_password_hash, verify_password,
is_unusable_password, make_unusable_password). The raw token-mint functions are deliberately left
unexported so auth.issue_tokens stays the blessed path (it brings the clamp and epoch along). The
newly public classes that lacked them gained class docstrings and Example: blocks.


End-to-end tests against real Postgres

The existing suite runs on in-memory SQLite. SQLite is lenient about types, so it hides the kind of
dialect bug Convention 2 warns about (coercing a token's string sub to an integer primary key).

Adds a tests/e2e/ suite that spins up a real PostgreSQL via testcontainers and drives full user
journeys over HTTP: the session password lifecycle, bearer token plus refresh plus revocation, the
shared login lockout across /login and /token, device management, recovery verification, and
registration hardening. It was green before the refactor and stayed green after, which is what made
the extractions safe to land. It skips automatically when Docker is not available.


Documentation Updates

docs/cookbook/use-the-building-blocks.md (new): the à-la-carte tour, with a hand-rolled login
over authenticate_password, a webhook token via issue_tokens, the wired-services table, and the
"when to use which" guidance.

Guide notes, each linking to the recipe: guides/auth/bearer.md (issue_tokens),
guides/auth/sessions.md (authenticate_password), guides/accounts/email.md (auth.emails),
guides/auth/oauth.md (auth.oauth).

crudauth/.agents/skills/crudauth/SKILL.md: a "Use the building blocks" section so the bundled
library skill steers agents to the hardened primitives.

The symbol-level API reference picks up the new methods, accessors, and exports automatically through
mkdocstrings.


Test Plan

Automated

  • uv run ruff check crudauth tests — clean
  • uv run mypy crudauth tests --config-file pyproject.toml — clean (106 files)
  • uv run pytest — 329 passing (7 new toolbox tests, 6 new Postgres e2e journeys)
  • uv run --group docs zensical build — no issues (internal links validated)

Reusable primitives (tests/test_toolbox.py)

  • A hand-rolled /my-login over authenticate_password establishes a working session
  • Bad credentials raise 401; the shared lockout trips the hand-rolled login (429)
  • issue_tokens mints a token that authenticates; refresh token is in the body
  • issue_tokens clamps scopes to grantable_scopes; raises without a BearerTransport
  • auth.emails / auth.oauth return the service when configured, None otherwise

Behavior preservation (tests/e2e/, real Postgres)

  • Session lifecycle: register, login, change-password, logout, re-login with the new password
  • Bearer: token, /me, refresh, then a password reset revokes the old token
  • Shared lockout locks both /login and /token for the same identity
  • Device management, recovery verification, registration hardening

Dependencies

No new runtime dependencies. The dev group gains testcontainers[postgres] and asyncpg for the
e2e suite.

Breaking Changes

None. Every change is additive: two new facade methods, two new accessors, nine new exports, and a
new test suite. The /login, /token, and /refresh handlers were refactored to delegate to the
new primitives but their HTTP behavior is unchanged, which the Postgres e2e journeys verify.

@igorbenav
igorbenav merged commit bc73418 into main Jun 22, 2026
16 checks passed
@igorbenav
igorbenav deleted the toolbox branch June 22, 2026 02:45
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