fix(users): bound each bearer credential by what it was issued for - #309
Merged
Merged
Conversation
`_TOKEN_LIFETIME_SECONDS` is one number for the whole process — `current_user` resolves its strategy from the shared backend, so it cannot vary per request — and it has to be the widest window any sign-in asks for. Every other credential inherited it. An ordinary sign-in wrote an `sm_auth` cookie whose `Max-Age` said fourteen days while the row behind it was accepted for thirty, and `Max-Age` is browser-enforced only: a cookie lifted off disk is replayed without one. That is exactly the hole `SESSION_EXPIRES_AT_KEY` closed for the session cookie. `/api/users/auth/token` had the same shape in reverse — it returned `expires_in=900` for a row good for a month, so a client that honoured it re-authenticated every fifteen minutes while the token it discarded stayed live. `UserAccessToken` now carries its own `expires_at`, stamped from what that sign-in actually asked for (`cookie_max_age_seconds`, `remember_me_max_age_seconds`, or `bearer_token_lifetime_seconds`), plus the `session_version` it was minted under. `ExpiringDatabaseStrategy` stamps both and enforces them, and `_resolve_bearer` applies the same two clauses in SQL — so the two bearer readers agree rather than drifting. The process-wide constant stays as the read *ceiling*: narrowing it would reject the longer rows a remembered sign-in legitimately wrote. The stamped counter is the revocation `_resolve_bearer` never had. A password change bumped `session_version` and stranded every session while every bearer token minted before it kept working — including any an attacker who knew the old password had already collected. `change_my_password` now also deletes the access tokens and revokes the refresh tokens, matching the bearer half of `revoke-all`: the counter alone strands them, but leaving the rows lets a stolen token resolve until its own deadline and lets refresh tokens mint replacements. `token_refresh` re-reads the account rather than trusting `rt.user_id`, since a refresh token outlives the access tokens it mints. The migration backfills both columns with the values existing rows were actually minted under — thirty days from `created_at`, and the owning account's counter — so upgrading signs nobody out and leaves no NULL for the read path to fail open on. Last, the legacy session fail-open. `session_has_expired` accepted a session with no deadline, with nothing tracking when that could safely be closed. The provider now stamps one on first sight (`ensure_session_expiry`), which can only tighten: the signer independently enforces the signature window from the session's original mint time, so a stamp written now cannot outlast the ceiling the session already had. Nobody is signed out, and with the only caller stamping first, `session_has_expired` fails closed on absence. Closes #292
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Deploying simple-module-python with
|
| Latest commit: |
7d41b56
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://d28d8392.simple-module-python.pages.dev |
| Branch Preview URL: | https://fix-bearer-token-expiry.simple-module-python.pages.dev |
antosubash
added a commit
that referenced
this pull request
Sep 5, 2026
… its own contract (#315) Two failures on main, neither of which any single PR's CI could have caught. **`provider.py` at 305 lines.** #309 and #314 were each green against the main they branched from; squash-merging both put the file over the 300-line cap. Split on the seam already there: `_resolve_bearer` moves to `token_strategy`, which is where `ExpiringDatabaseStrategy` lives. Those two are the only readers of `users_access_token` and they have to apply the same deadline and `session_version` rules — keeping them in one file is what stops them drifting. `provider.py` keeps a one-line delegate so the method stays on the provider's surface. 305 → 265, and `token_strategy` → 164. **`setup_pending_app` boots *with* an administrator.** `UsersModule.on_startup` seeds one from `SM_USERS_BOOTSTRAP_*`, read from the environment *and* from a `.env` on disk. A developer who followed `.env.example` has those set, so the fixture whose entire contract is "an app with no administrator" hands back an app that has two — the setup gate releases, the wizard routes 404, and eleven tests in `framework/hosting/tests` fail. CI has no `.env`, so it never saw this: the failure was local-only, which is the worst shape for a fixture to be wrong in. It also looked like test-ordering noise, because whether it reproduced depended on what else had booted an app first. The fixture now scrubs the bootstrap vars and stubs the dotenv reader for the app it builds, the same way `modules/users/tests/conftest.py` does for its own. Adding that pushed `fixtures.py` over the cap too, so the schema machinery (model imports, alembic heads, table creation) moves to `_schema.py` — that module declares fixtures, this one is what they stand on.
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.
Closes #292
The problem
_TOKEN_LIFETIME_SECONDSis one number for the whole process —current_userresolves its strategy from the shared backend, so it cannot vary per request — and it has to be the widest window any sign-in asks for (30 days, for "keep me signed in"). Every other credential inherited it:auth_backend.loginwrote ansm_authcookie with a 14-dayMax-Age, but the row behind it was accepted for 30.Max-Ageis browser-enforced only, so a cookie lifted off disk is replayable for a month — exactly the holeSESSION_EXPIRES_AT_KEYclosed for the session cookie./api/users/auth/tokenlied aboutexpires_in. It returnedbearer_token_lifetime_seconds(15 minutes) for a row good for 30 days, so a client that honoured it re-authenticated every 15 minutes while the token it discarded stayed valid.change_my_passwordbumpedsession_versionand stranded every session, but deleted noUserAccessTokenrows, and_resolve_bearernever readsession_versionat all — unlike_load_user, which does.session_has_expiredaccepted a session with noexpires_at, with the docstring pointing at a future revisit that nothing tracked.The fix
Each row now carries the two bounds the session path has: its own
expires_at, stamped from what that sign-in actually asked for, and thesession_versionit was minted under.expires_atfromsm_auth)cookie_max_age_seconds(14d)remember_me_max_age_seconds(30d)/api/users/auth/tokenbearer_token_lifetime_seconds(15m) — the same number it reports asexpires_inExpiringDatabaseStrategystamps both and enforces them;UsersAuthProvider._resolve_bearerapplies the same two clauses in SQL, so the two readers ofusers_access_tokenagree rather than drifting._TOKEN_LIFETIME_SECONDSstays as the read ceiling — narrowing it would reject the longer-lived rows a remembered sign-in legitimately wrote.The stamped counter is the revocation
_resolve_bearernever had. On top of it,change_my_passwordnow deletes the access tokens and revokes the refresh tokens, matching the bearer half ofrevoke-all: the counter alone strands them, but leaving the rows lets a stolen token keep resolving until its own deadline, and lets refresh tokens mint replacements behind it.token_refreshre-reads the account rather than trustingrt.user_id, since a refresh token outlives the access tokens it mints.Migration
b4c1e7d9a025backfills both columns with the values existing rows were actually minted under — 30 days fromcreated_at, and the owning account's counter — so upgrading signs nobody out and leaves no NULL for the read path to fail open on. Verified against a scratch SQLite DB seeded under the old schema: a legacy row came out withexpires_at = created_at + 30dandsession_version = 7inherited from its user.The legacy session fail-open is closed without a deploy-time mass sign-out. The provider stamps a deadline on first sight (
ensure_session_expiry), which can only ever tighten: the signer independently enforcesSESSION_SIGNATURE_MAX_AGEfrom the session's original mint time, so a stamp written now cannot outlast the ceiling the session already had. A remembered legacy session is stamped with its recorded window rather than demoted to 14 days. With the only caller stamping first,session_has_expirednow fails closed on absence.Verification
uv run pytest -q(full suite) — 2806 passed, 2 skipped, 60 deselecteduv run pytest modules/users framework/hosting— 973 passed (21 new)expires_atclause, thetoken_is_livecheck, theensure_session_expirycall) and the fail-closed flip fails 7 of the 21 new tests — the provider refusing an expired row, the version bump stranding a token on both paths, the threesession_has_expiredabsence cases, and both stamping cases. All restored, all pass.ruff format --check/ruff check/ty check modules/users framework/hosting/check_file_size.py/make doctor— all passNotes for review
Authorization: Bearerheader is read byUsersAuthProviderviaAuthMiddleware, not byfastapi_users.current_user— the shared backend's transport is aCookieTransport. The tests probe each reader the way the app actually reaches it (sm_authcookie for the strategy,_resolve_bearerdirectly for the header), rather than assuming one header exercises both.UserAccessToken.expires_athas a 30-day default factory for a caller that builds the model by hand. Both real mint paths always stamp explicitly; the default keeps that fallback no worse than the old behaviour rather than minting something that never expires.