Skip to content

Account & device management: session/CSRF routes + in-session password change#11

Merged
igorbenav merged 5 commits into
mainfrom
account-management
Jun 21, 2026
Merged

Account & device management: session/CSRF routes + in-session password change#11
igorbenav merged 5 commits into
mainfrom
account-management

Conversation

@igorbenav

Copy link
Copy Markdown
Contributor

Account & device management: session/CSRF routes + in-session password change

Apps kept hand-writing the same four endpoints on top of auth.sessions (sign out everywhere, a
device list, revoke one device, refresh a lost CSRF cookie) and an in-session password change that
crudauth didn't offer at all. This branch exposes both as thin, opt-in routes that reuse existing
SessionManager and repository primitives, so there's no new auth/session/crypto logic, just
auth + validation + a call to a method that already exists. All additive: nothing breaks.


Session & CSRF management routes (opt-in)

auth.sessions already had list_for_user / revoke / revoke_all / regenerate_csrf_token, but
no routes, so every app rebuilt the same handlers. A new SessionTransport(management_routes=True)
flag mounts four of them on the shared router: GET /sessions, DELETE /sessions/{id},
POST /logout-all (with ?keep_current=true), and POST /csrf/refresh. The flag lives on the
transport (where session config lives) but the routes are assembled centrally in CRUDAuth, gated on
self._session_transport and management_routes, because that's where current_user() and
self.sessions are. Default is False: adding routes and a device list to an existing app on a minor
bump should be a choice.

The three mutating routes sit behind current_user(transport="session"), so CSRF on the unsafe verbs
is enforced by the session transport, the handlers add none. DELETE /sessions/{id} is
ownership-checked and returns 404 for both "not found" and "not yours", so it can't probe other
users' session ids.

POST /csrf/refresh is the deliberate exception. It does not go through current_user() because
requiring a valid CSRF header to refresh CSRF would defeat the recovery purpose; it resolves the
session cookie directly and self-heals:

if cookie and await sessions.validate_csrf_token(session_id, cookie):
    token = cookie                       # already valid -> return unchanged, no rotation
else:
    token = await sessions.regenerate_csrf_token(session.user_id, session_id)
    sessions.set_csrf_cookie(response, token, max_age=...)

Why this is safe: an attacker can trigger it cross-origin (the session cookie auto-rides) but
can't read the response or the new cookie (CORS), so they never learn the token; and the self-heal
guard means a triggered call against a healthy cookie returns it unchanged rather than rotating, so
there's no cross-origin DoS on a valid token.

This added one small primitive, SessionManager.set_csrf_cookie(response, token, max_age=None) (the
CSRF half of set_session_cookies, which now delegates to it), so the recovery path can re-mint the
CSRF cookie alone.


In-session password change

crudauth had /set-password (first password for an OAuth-only account) but no way for a user who
has a password to change it while signed in. POST /change-password fills that, as a sibling of
/set-password and unconditional for the same reason (a core flow, harmless when present because it
requires auth plus the current password).

Re-auth is the current password: the active session or token proves presence, the current password
proves intent, so no sudo or token round-trip. A wrong current password is 401; an account with no
usable password (OAuth-only) is 400 and pointed at /set-password. A successful change is treated
as a compromise response, matching what a password reset does: it bumps token_version (evicting
bearer tokens, a no-op when the model has no such column) and revokes the user's other sessions
while keeping the current one (revoke_all(exclude=current_session_id)), then fires a hook. CSRF is
free on the session path (the POST runs behind current_user()); bearer has no CSRF surface.

Why the eviction: "change my password, sign out my other devices, keep me here" is the behavior
users expect, and it degrades cleanly, no token_version column means bearer eviction is simply
skipped, no session transport means session eviction is skipped.


Supporting additions

A new optional hook AuthHooks.on_after_password_changed (with its run_after_password_changed
runner) fires after /change-password, kept distinct from on_after_password_reset (the token flow)
so an app can send a "your password was changed" notice. DEFAULT_RATE_LIMITS gained change_password
(5/h, per user), logout_all (10/h, per user), and csrf_refresh (30/h, per IP, since it runs before
authentication). SessionInfo (the GET /sessions response model) is now exported from crudauth
with an Example:, and a flat api/endpoints.md reference now maps every mountable route in one place.


Documentation Updates

docs/cookbook/account-management.md (new): a from-scratch "settings page" recipe wiring all five
routes, with a "why this is safe to expose" payoff.

docs/guides/accounts/session-management.md: leads with the opt-in built-in routes (table), keeps
the manual auth.sessions approach for custom UIs.

docs/guides/accounts/passwords.md: a "Changing a known password" section between set-password and
reset.

docs/guides/infra/hooks.md: the on_after_password_changed row.

docs/api/endpoints.md (new) + api/transports.md (SessionInfo) + api/index.md + nav.


Test Plan

Automated

  • uv run ruff check crudauth tests — clean
  • uv run mypy crudauth tests --config-file pyproject.toml — clean
  • uv run pytest — 316 passing (13 new; no regressions)
  • uv run --group docs zensical build — no issues

Session & CSRF management routes (tests/transports/test_management_routes.py)

  • Routes 404 when management_routes is off (default)
  • /logout-all revokes all + clears cookies; keep_current=true keeps the caller's session and cookie, revokes only the others
  • GET /sessions shape + current flag
  • DELETE /sessions/{id}: own (200 + cookie cleared if current), another user's id → 404, unknown → 404
  • /csrf/refresh: valid cookie → same token (no rotation), missing cookie → new token + Set-Cookie, no session → 401, CSRF disabled → 400

In-session password change (tests/test_change_password.py)

  • Wrong current → 401; unusable password → 400
  • Success rotates the hash (new logs in, old doesn't), bumps token_version, revokes other sessions but not the current, fires the hook
  • Session POST without the CSRF header → 403 (CSRF is enforced)
  • Rate-limited after 5 attempts → 429

Dependencies

None new.

Breaking Changes

None. Everything is additive: management_routes defaults to False, /change-password is a new
route, on_after_password_changed is a new optional hook field, set_csrf_cookie and SessionInfo
are new public symbols, and set_session_cookies is behavior-preserving (it now delegates the CSRF
cookie to set_csrf_cookie; the existing session suite passes unchanged).

Comment thread tests/transports/test_management_routes.py Fixed
Comment thread tests/transports/test_management_routes.py Fixed
@igorbenav
igorbenav merged commit c72eced into main Jun 21, 2026
16 checks passed
@igorbenav
igorbenav deleted the account-management branch June 21, 2026 22:40
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