D18: revise T13.1 to idle timeout + absolute ceiling

Matt asked whether idle time would be a better fit than a flat session
length. It is, but idle-alone weakens D18's own purpose - a continuously
active session would never force a fresh Okta recheck on its own. Decided:
both. AUTH_IDLE_MINUTES (new, default 30) slides the session on activity;
AUTH_SESSION_HOURS (existing var, meaning changes to an absolute ceiling,
default 12 -> proposed 8) caps it regardless of activity.

Docs only in this commit - implementation is T13.1, next.
This commit is contained in:
2026-09-23 11:38:37 -07:00
parent 850b78972b
commit 358469531c
2 changed files with 76 additions and 21 deletions

View File

@@ -11,27 +11,69 @@ important done-when list in this wave — do not relax it to ship faster.**
---
### T13.1 — D18: shrink `AUTH_SESSION_HOURS`
### T13.1 — D18: idle timeout with an absolute ceiling
- **Items:** `D18`
- **Depends on:** nothing (first task, independent of the rest)
- **Blocks:** nothing
- **Surface:** `server/`
- **Files:** `server/auth.py`, `server/.env.example`, `DEPLOYMENT.md`
- **Files:** `server/auth.py`, `server/app.py` (`auth_gate` middleware),
`server/.env.example`, `DEPLOYMENT.md`
**Do:** Change the default from 12 to 2 hours. Document the change and the
reasoning (defense in depth against an already-live session outliving a
deprovisioning event) in `server/.env.example` next to `AUTH_SESSION_HOURS`,
and in `DEPLOYMENT.md`'s auth section. Note in the PR that this is proposed,
not confirmed against the tenant's actual Okta SSO session policy — flag it
for Nick/IT rather than treating 2 as unquestionable.
**Revised 2026-09-23** — originally just "shrink `AUTH_SESSION_HOURS`". Matt
asked whether an idle timeout would be a better fit than a flat session
length. It is, but not by itself — see the decision record's reasoning on why
idle-with-no-ceiling is actually a worse fit for this item's own threat model
than a flat expiry would have been. Build both.
**Do:**
- Add `login_at` to the JWT payload in `create_token()` — the original
sign-in time, distinct from `iat`, which becomes "when THIS token was
issued" once tokens start getting reissued. `login_at` never changes across
reissues; it's what the absolute ceiling is measured from.
- Add `AUTH_IDLE_MINUTES` (default 30). `AUTH_SESSION_HOURS` stays the name
for the absolute ceiling, default changing from 12 to a proposed 8 — update
its docstring/comment in `auth.py` and `.env.example`, since its MEANING is
changing (session length -> hard ceiling on top of a sliding idle window),
not just its value.
- In `auth_gate` (`server/app.py`), after confirming a request is
authenticated: if `now < login_at + AUTH_SESSION_HOURS` (the ceiling hasn't
passed), compute `new_exp = min(now + AUTH_IDLE_MINUTES, login_at +
AUTH_SESSION_HOURS)`. If `new_exp` is meaningfully later than the current
token's `exp` (throttle this — do not reissue on every single request, only
when enough time has passed to be worth a new cookie; a few minutes of
slack is fine), mint a refreshed token carrying forward `sub`/`username`/
`role`/`ver`/`login_at` unchanged, and set it on the response.
- If the ceiling HAS passed, do not refresh — let the existing token expire
on its own terms (it may already be invalid, or may tick over within the
idle window; either way, no new one is issued past the ceiling).
- The middleware should not need a DB round trip to do this — everything
needed (`sub`, `username`, `role`, `ver`, `login_at`) is already in the
validated claims. `get_current_user`'s existing per-request `is_active`/
`token_version` check is unaffected and still runs separately.
**Do not:** reissue the cookie on every request unconditionally — that's a
`Set-Cookie` header on every API call for no benefit over a coarser refresh.
Do not let the ceiling check silently vanish for tokens issued before this
change ships — a token with no `login_at` claim should fall back to treating
its own `iat` as `login_at`, not bypass the ceiling entirely.
**Done when:**
- [ ] default is 2 hours; still overridable via env, unchanged mechanism
- [ ] `.env.example` and `DEPLOYMENT.md` explain why
- [ ] existing session/auth tests updated for the new default where they
assumed 12
- [ ] a session with continuous activity stays alive past 30 minutes but is
cut off at the `AUTH_SESSION_HOURS` ceiling regardless
- [ ] a session with no activity for 30+ minutes is rejected on its next
request
- [ ] the cookie is not rewritten on every single request — verify the
refresh is throttled, not unconditional
- [ ] a pre-existing token with no `login_at` claim (simulating a session
issued before this shipped) still gets a hard ceiling, via the `iat`
fallback
- [ ] `.env.example` and `DEPLOYMENT.md` explain both variables and flag both
defaults as proposed, not confirmed against Okta's own session policy
- [ ] existing session/auth tests updated for the new mechanism, not just a
new number
---