Skip to content

fix(users): bound each bearer credential by what it was issued for - #309

Merged
antosubash merged 1 commit into
mainfrom
fix/bearer-token-expiry
Sep 5, 2026
Merged

antosubash merged 1 commit into
mainfrom
fix/bearer-token-expiry

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Closes #292

The problem

_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 (30 days, for "keep me signed in"). Every other credential inherited it:

  • A non-remembered sign-in minted a 30-day credential. auth_backend.login wrote an sm_auth cookie with a 14-day Max-Age, but the row behind it was accepted for 30. Max-Age is browser-enforced only, so a cookie lifted off disk is replayable for a month — exactly the hole SESSION_EXPIRES_AT_KEY closed for the session cookie.
  • /api/users/auth/token lied about expires_in. It returned bearer_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.
  • A password change did not revoke bearer credentials. change_my_password bumped session_version and stranded every session, but deleted no UserAccessToken rows, and _resolve_bearer never read session_version at all — unlike _load_user, which does.
  • The legacy session fail-open had no end date. session_has_expired accepted a session with no expires_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 the session_version it was minted under.

mint site expires_at from
ordinary sign-in (sm_auth) cookie_max_age_seconds (14d)
"keep me signed in" remember_me_max_age_seconds (30d)
/api/users/auth/token bearer_token_lifetime_seconds (15m) — the same number it reports as expires_in

ExpiringDatabaseStrategy stamps both and enforces them; UsersAuthProvider._resolve_bearer applies the same two clauses in SQL, so the two readers of users_access_token agree rather than drifting. _TOKEN_LIFETIME_SECONDS stays 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_bearer never had. On top of it, change_my_password now 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 keep resolving until its own deadline, and lets refresh tokens mint replacements behind it. token_refresh re-reads the account rather than trusting rt.user_id, since a refresh token outlives the access tokens it mints.

Migration b4c1e7d9a025 backfills both columns with the values existing rows were actually minted under — 30 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. Verified against a scratch SQLite DB seeded under the old schema: a legacy row came out with expires_at = created_at + 30d and session_version = 7 inherited 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 enforces SESSION_SIGNATURE_MAX_AGE from 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_expired now fails closed on absence.

Verification

  • uv run pytest -q (full suite) — 2806 passed, 2 skipped, 60 deselected
  • uv run pytest modules/users framework/hosting973 passed (21 new)
  • Fail-before proof: reverting the three enforcement points (the expires_at clause, the token_is_live check, the ensure_session_expiry call) 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 three session_has_expired absence cases, and both stamping cases. All restored, all pass.
  • Migration applied cleanly on a scratch SQLite DB; backfill asserted programmatically (see above).
  • ruff format --check / ruff check / ty check modules/users framework/hosting / check_file_size.py / make doctor — all pass

Notes for review

  • The Authorization: Bearer header is read by UsersAuthProvider via AuthMiddleware, not by fastapi_users.current_user — the shared backend's transport is a CookieTransport. The tests probe each reader the way the app actually reaches it (sm_auth cookie for the strategy, _resolve_bearer directly for the header), rather than assuming one header exercises both.
  • UserAccessToken.expires_at has 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.

`_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
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-04T21:40:26.617524Z 7d41b56 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

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

View logs

@antosubash
antosubash merged commit 4f88a09 into main Sep 5, 2026
13 checks passed
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.
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.

Bearer/sm_auth tokens ignore the per-session expiry and their advertised lifetime

1 participant