Skip to content

auth: sliding web sessions + stop expiry from eating unsent drafts - #289

Open
pufit wants to merge 1 commit into
mainfrom
pufit/auth-session-fix
Open

auth: sliding web sessions + stop expiry from eating unsent drafts#289
pufit wants to merge 1 commit into
mainfrom
pufit/auth-session-fix

Conversation

@pufit

@pufit pufit commented Aug 8, 2026

Copy link
Copy Markdown
Member

Three stacked bugs. The third is the one that destroys work.

1. Forced re-login every 24h

gateway/auth.py minted tokens with a hardcoded JWT_EXPIRY_HOURS = 24 and there was no refresh path/api/auth/ exposes only login, status and check. A token was minted at login and died exactly 24h later regardless of whether the tab had been in continuous use. Not a session; an egg timer.

2. The 401 handler nuked the page

if (res.status === 401) {
  clearToken();
  window.location.reload();   // immediate, unconditional
}

Any background request — session-list poll, the 15s review-loop poll, a models fetch — that caught the 401 hard-reloaded the tab. No prompt, no state preserved, and it can fire mid-keystroke.

3. …and that reload ate unsent drafts

Drafts are persisted per session (nerve_draft_<id>), so a draft in an existing chat survived. But a new chat is a virtual session: createSession() mints a randomUUID() that lives only in React state. So:

  1. reload → the id is gone → the draft key is orphaned
  2. App mount → loadSessions()pruneDrafts(keep) with keep = real server sessions only
  3. the orphan doesn't match → localStorage.removeItemthe draft is deleted, not merely lost

Precision-targeted at exactly the case of a long prompt composed in a new chat.

Changes

Backend

  • auth.jwt_expiry_hours (default 720 / 30 days) replaces the 24h constant
  • require_auth re-mints a token past half its lifetime and stashes it on request.state; an http middleware returns it as X-Nerve-Token, and CORS exposes that header
  • continuous use therefore never expires — the window becomes an idle timeout
  • audience-scoped MCP tokens deliberately do not slide; they keep their short TTLs

Frontend

  • 401 no longer reloads. The app stays mounted and SessionExpiredOverlay takes the password over the top, so the composer, scroll position and half-written prompt survive
  • a cold start with a dead token still shows the normal full-page login
  • the virtual session id is persisted, so a reload restores the unsent chat and its draft
  • pruneDrafts marks unrecognized drafts and reclaims them after a 7-day grace window instead of deleting on first sight
  • explicit logout still wipes drafts (the shared-browser control); expiry never does

Testing

  • tests/test_auth_session_sliding.py — TTL config, refresh threshold, MCP tokens not sliding, malformed payloads
  • tests/test_auth_session_header.py — real ASGI round-trip asserting X-Nerve-Token survives the middleware boundary, that the replacement authenticates, and that expired/anonymous requests get no header
  • full backend suite: 3038 passed
  • frontend tsc -b clean, production build clean, new/edited files lint-clean
  • the draft-loss chain was reproduced against the new code with a localStorage harness (all 10 scenarios pass, including "draft survives the post-reload prune")

Note

There is no frontend test framework in the repo, so the draft-storage logic — which is the actual data-loss fix — is verified by harness rather than committed tests. Worth adding vitest separately; deliberately not smuggled into this PR.

Generated by Nerve

Session tokens were minted with a hardcoded 24h TTL and no refresh path,
so an actively-used tab was logged out exactly one day after login. The
401 handler then called window.location.reload(), which any background
poll could trip while typing.

That reload also destroyed unsent work. A new chat is a client-side
"virtual" session whose random id lived only in React state, and drafts
are keyed by session id, so a reload orphaned the draft; the next
loadSessions() called pruneDrafts(), which deleted unrecognized keys on
sight. A long prompt written into a new chat was unrecoverable.

- auth.jwt_expiry_hours (default 720h), replacing the 24h constant
- require_auth re-mints a token past half its life; the gateway returns
  it as X-Nerve-Token and the client swaps it in, so continuous use
  never expires and the window becomes an idle timeout
- 401 no longer reloads: the app stays mounted and SessionExpiredOverlay
  takes the password over the top, preserving composer state
- the virtual session id is persisted, so a reload restores the unsent
  chat and its draft
- pruneDrafts marks unrecognized drafts and reclaims them after 7 days
  instead of deleting on first sight
- explicit logout still wipes drafts; expiry never does

MCP tokens keep their short TTLs — only aud-less session tokens slide.
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