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

@@ -259,15 +259,28 @@ the actual code:
signature-verification surface. Traded deliberately: this is signature-verification surface. Traded deliberately: this is
poll-interval-late rather than real-time, which is judged acceptable for an poll-interval-late rather than real-time, which is judged acceptable for an
HR/offboarding-driven event, not a to-the-second requirement. HR/offboarding-driven event, not a to-the-second requirement.
2. **Also shrink `AUTH_SESSION_HOURS`** as defense in depth, independent of the 2. **Revised 2026-09-23, in response to Matt's question about idle time
sync job, so the already-live-session window itself is smaller regardless instead of a flat session length: sessions now slide on activity, with a
of how fast the sync runs. Proposed default: **2 hours**, down from 12 — hard ceiling underneath.** A flat `AUTH_SESSION_HOURS` forces a re-check
flagged as a recommendation, not confirmed: Okta's own SSO session (set on with Okta on a fixed schedule regardless of activity; a pure idle timer
the Okta side, separate from this app's cookie) may make re-authentication with no ceiling does the opposite — a continuously-active session would
silent rather than a real re-login if it outlives this app's shorter never force a fresh Okta check on its own, which is a worse fit for the
session, in which case 2 hours costs little. Confirm against the tenant's exact threat this item exists to address (someone still clicking around
actual Okta session policy before treating 2 as final; if Okta's SSO after being deprovisioned). Decided: **both**.
session is itself long-lived, a shorter number here doesn't hurt, either. - `AUTH_IDLE_MINUTES` (new, default **30**): a session with no request
for this long stops being valid. Implemented as a sliding JWT expiry —
the token is reissued with a fresh `exp` on activity, throttled so the
cookie isn't rewritten on literally every request.
- `AUTH_SESSION_HOURS` (existing var, meaning changes to an **absolute
ceiling**): no session survives past this many hours from the original
sign-in, no matter how continuously active it is. Default changing from
12 to a proposed **8** — flagged as a recommendation, not confirmed.
- Both defaults, and the mechanism itself, should be sanity-checked
against the tenant's actual Okta SSO session policy — if Okta's own
session silently outlives either number, re-authentication here is
likely a fast redirect, not a real re-login screen, so these numbers
cost less than they look like they do. Confirm before treating either
as final.
3. **The sync only ever disables an account — it never re-enables one.** A 3. **The sync only ever disables an account — it never re-enables one.** A
rehire showing active in Okta again does not automatically restore access; rehire showing active in Okta again does not automatically restore access;
an admin re-enabling the account is a deliberate act, consistent with an admin re-enabling the account is a deliberate act, consistent with

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` - **Items:** `D18`
- **Depends on:** nothing (first task, independent of the rest) - **Depends on:** nothing (first task, independent of the rest)
- **Blocks:** nothing - **Blocks:** nothing
- **Surface:** `server/` - **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 **Revised 2026-09-23** — originally just "shrink `AUTH_SESSION_HOURS`". Matt
reasoning (defense in depth against an already-live session outliving a asked whether an idle timeout would be a better fit than a flat session
deprovisioning event) in `server/.env.example` next to `AUTH_SESSION_HOURS`, length. It is, but not by itself — see the decision record's reasoning on why
and in `DEPLOYMENT.md`'s auth section. Note in the PR that this is proposed, idle-with-no-ceiling is actually a worse fit for this item's own threat model
not confirmed against the tenant's actual Okta SSO session policy — flag it than a flat expiry would have been. Build both.
for Nick/IT rather than treating 2 as unquestionable.
**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:** **Done when:**
- [ ] default is 2 hours; still overridable via env, unchanged mechanism - [ ] a session with continuous activity stays alive past 30 minutes but is
- [ ] `.env.example` and `DEPLOYMENT.md` explain why cut off at the `AUTH_SESSION_HOURS` ceiling regardless
- [ ] existing session/auth tests updated for the new default where they - [ ] a session with no activity for 30+ minutes is rejected on its next
assumed 12 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
--- ---