diff --git a/docs/waves/decisions-2026-09-17.md b/docs/waves/decisions-2026-09-17.md index 6f5aaeb..459ca7e 100644 --- a/docs/waves/decisions-2026-09-17.md +++ b/docs/waves/decisions-2026-09-17.md @@ -259,15 +259,28 @@ the actual code: signature-verification surface. Traded deliberately: this is poll-interval-late rather than real-time, which is judged acceptable for an HR/offboarding-driven event, not a to-the-second requirement. -2. **Also shrink `AUTH_SESSION_HOURS`** as defense in depth, independent of the - sync job, so the already-live-session window itself is smaller regardless - of how fast the sync runs. Proposed default: **2 hours**, down from 12 — - flagged as a recommendation, not confirmed: Okta's own SSO session (set on - the Okta side, separate from this app's cookie) may make re-authentication - silent rather than a real re-login if it outlives this app's shorter - session, in which case 2 hours costs little. Confirm against the tenant's - actual Okta session policy before treating 2 as final; if Okta's SSO - session is itself long-lived, a shorter number here doesn't hurt, either. +2. **Revised 2026-09-23, in response to Matt's question about idle time + instead of a flat session length: sessions now slide on activity, with a + hard ceiling underneath.** A flat `AUTH_SESSION_HOURS` forces a re-check + with Okta on a fixed schedule regardless of activity; a pure idle timer + with no ceiling does the opposite — a continuously-active session would + never force a fresh Okta check on its own, which is a worse fit for the + exact threat this item exists to address (someone still clicking around + after being deprovisioned). Decided: **both**. + - `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 rehire showing active in Okta again does not automatically restore access; an admin re-enabling the account is a deliberate act, consistent with diff --git a/docs/waves/wave-13.md b/docs/waves/wave-13.md index 47cb05e..fcf4f4b 100644 --- a/docs/waves/wave-13.md +++ b/docs/waves/wave-13.md @@ -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 ---