Account & device management: session/CSRF routes + in-session password change#11
Merged
Conversation
…ent_routes, on_after_password_changed hook)
…ord/hooks guides)
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.
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, adevice 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
SessionManagerand repository primitives, so there's no new auth/session/crypto logic, justauth + validation + a call to a method that already exists. All additive: nothing breaks.
Session & CSRF management routes (opt-in)
auth.sessionsalready hadlist_for_user/revoke/revoke_all/regenerate_csrf_token, butno 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), andPOST /csrf/refresh. The flag lives on thetransport (where session config lives) but the routes are assembled centrally in
CRUDAuth, gated onself._session_transport and management_routes, because that's wherecurrent_user()andself.sessionsare. Default isFalse: adding routes and a device list to an existing app on a minorbump should be a choice.
The three mutating routes sit behind
current_user(transport="session"), so CSRF on the unsafe verbsis enforced by the session transport, the handlers add none.
DELETE /sessions/{id}isownership-checked and returns
404for both "not found" and "not yours", so it can't probe otherusers' session ids.
POST /csrf/refreshis the deliberate exception. It does not go throughcurrent_user()becauserequiring a valid CSRF header to refresh CSRF would defeat the recovery purpose; it resolves the
session cookie directly and self-heals:
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)(theCSRF half of
set_session_cookies, which now delegates to it), so the recovery path can re-mint theCSRF cookie alone.
In-session password change
crudauth had
/set-password(first password for an OAuth-only account) but no way for a user whohas a password to change it while signed in.
POST /change-passwordfills that, as a sibling of/set-passwordand unconditional for the same reason (a core flow, harmless when present because itrequires 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 nousable password (OAuth-only) is
400and pointed at/set-password. A successful change is treatedas a compromise response, matching what a password reset does: it bumps
token_version(evictingbearer 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 isfree 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_versioncolumn means bearer eviction is simplyskipped, no session transport means session eviction is skipped.
Supporting additions
A new optional hook
AuthHooks.on_after_password_changed(with itsrun_after_password_changedrunner) fires after
/change-password, kept distinct fromon_after_password_reset(the token flow)so an app can send a "your password was changed" notice.
DEFAULT_RATE_LIMITSgainedchange_password(5/h, per user),
logout_all(10/h, per user), andcsrf_refresh(30/h, per IP, since it runs beforeauthentication).
SessionInfo(theGET /sessionsresponse model) is now exported fromcrudauthwith an
Example:, and a flatapi/endpoints.mdreference now maps every mountable route in one place.Documentation Updates
docs/cookbook/account-management.md(new): a from-scratch "settings page" recipe wiring all fiveroutes, with a "why this is safe to expose" payoff.
docs/guides/accounts/session-management.md: leads with the opt-in built-in routes (table), keepsthe manual
auth.sessionsapproach for custom UIs.docs/guides/accounts/passwords.md: a "Changing a known password" section between set-password andreset.
docs/guides/infra/hooks.md: theon_after_password_changedrow.docs/api/endpoints.md(new) +api/transports.md(SessionInfo) +api/index.md+ nav.Test Plan
Automated
uv run ruff check crudauth tests— cleanuv run mypy crudauth tests --config-file pyproject.toml— cleanuv run pytest— 316 passing (13 new; no regressions)uv run --group docs zensical build— no issuesSession & CSRF management routes (
tests/transports/test_management_routes.py)management_routesis off (default)/logout-allrevokes all + clears cookies;keep_current=truekeeps the caller's session and cookie, revokes only the othersGET /sessionsshape +currentflagDELETE /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 → 400In-session password change (
tests/test_change_password.py)token_version, revokes other sessions but not the current, fires the hookDependencies
None new.
Breaking Changes
None. Everything is additive:
management_routesdefaults toFalse,/change-passwordis a newroute,
on_after_password_changedis a new optional hook field,set_csrf_cookieandSessionInfoare new public symbols, and
set_session_cookiesis behavior-preserving (it now delegates the CSRFcookie to
set_csrf_cookie; the existing session suite passes unchanged).