Skip to content

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

Description

@antosubash

Found while reviewing worktree-hifi-pages, which added a per-session absolute deadline to close the
"keep me signed in for 30 days widens the signature window for everyone" hole (spec
docs/superpowers/specs/2026-09-03-hifi-pages-design.md, Deliberate departures, last row). The
session-cookie half landed: stamp_session_expiry writes SESSION_EXPIRES_AT_KEY
(framework/hosting/simple_module_hosting/session.py:165-176) and UsersAuthProvider.resolve_user
refuses a session past it (modules/users/users/provider.py:87-89). The token half did not.

_resolve_bearer is reached before the session check (modules/users/users/provider.py:73-74) and
its only age test is UserAccessToken.created_at > now - _TOKEN_LIFETIME_SECONDS
(modules/users/users/provider.py:201-205). _TOKEN_LIFETIME_SECONDS is a single process-wide 30 days
(modules/users/users/backend.py:31), and UserAccessToken stores no per-row deadline — only
created_at (modules/users/users/models/access_token.py:22-33).

Three consequences:

  • A non-remembered sign-in mints a 30-day credential. auth_backend.login writes an
    sm_auth cookie with a 14-day Max-Age (modules/users/users/deps.py:38-42), but the row
    behind it is accepted for 30. The 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 lies about expires_in. _create_token_pair returns
    expires_in=settings.bearer_token_lifetime_seconds, which defaults to 15 minutes
    (modules/users/users/auth_local/token_api.py:168, modules/users/users/settings.py:74).
    Nothing reads that setting anywhere else — a client that honours it re-authenticates every 15
    minutes while the token it discarded stays valid for 30 days.
  • A password change does not revoke bearer credentials. change_my_password bumps
    session_version and forgets the cache (modules/users/users/auth_local/self_account.py:89-93)
    but deletes no UserAccessToken rows, and _resolve_bearer never reads session_version at
    all (modules/users/users/provider.py:205-219) — unlike _load_user, which does
    (modules/users/users/provider.py:245-248). POST /me/sessions/revoke-all is sound: it
    deletes the rows (modules/users/users/auth_local/self_account.py:127).
  • Flip the legacy fail-open to fail-closed. session_has_expired accepts a session with no
    expires_at so a deploy does not sign everyone out; the docstring
    (framework/hosting/simple_module_hosting/session.py:179-186) says to revisit once deployed
    sessions have turned over past 30 days. Nothing tracks that date today.

What would fix it: give the token path the same two bounds the session path has — record a per-row
deadline on UserAccessToken at mint time (honouring bearer_token_lifetime_seconds for
/auth/token and cookie_max_age_seconds / remember_me_max_age_seconds for sm_auth) and filter
on it instead of the global constant, and add the session_version check to _resolve_bearer so a
password change strands tokens the way it strands sessions.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions