Skip to content

feat(jwt): session lifetime caps (inactivity + absolute family cap)#89

Merged
kennethphough merged 2 commits into
masterfrom
feat/jwt-session-caps
May 28, 2026
Merged

feat(jwt): session lifetime caps (inactivity + absolute family cap)#89
kennethphough merged 2 commits into
masterfrom
feat/jwt-session-caps

Conversation

@kennethphough

Copy link
Copy Markdown
Member

Summary

  • JWT sessions in 4.4.x were effectively unlimited — sliding refresh rotation extended them indefinitely. Violates OWASP ASVS V3 absolute-timeout requirement.
  • KYTE_JWT_REFRESH_TTL default 7d → 4h (inactivity timeout).
  • New KYTE_JWT_FAMILY_MAX_LIFETIME (default 12h) — absolute cap from original login, enforced in RefreshTokenStore::rotate().
  • New column KyteRefreshToken.family_started_at anchors the cap; migration migrations/4.5.0_jwt_family_lifetime.sql.
  • Legacy (pre-upgrade) tokens are capped (anchored to date_created), not exempted — deploy forces re-login for sessions older than 12h.

Test plan

  • RefreshTokenStoreTest: anchor set on issue, preserved across rotation, cap rejection past window, allowed inside window, legacy-within-cap anchors to date_created, legacy-past-cap revoked
  • Deployed to dev device 19; verified fresh logins get 4h TTL, legacy 7d tokens purged + capped
  • localhost Shipyard logged out as expected

Rollout notes (in CHANGELOG)

  • config.php explicit KYTE_JWT_REFRESH_TTL=604800 shadows the new default — update to 14400 on upgrade
  • pairs with kyte-api-js >= v2.0.2 (cookie TTL fix)
  • deploy logs out JWT sessions older than 12h

🤖 Generated with Claude Code

kennethphough and others added 2 commits May 23, 2026 16:43
JWT sessions in 4.4.x were effectively unlimited — each /jwt/refresh
issued a fresh refresh token with expires_at = now + 7d, and the
15-min access TTL meant any page activity rotated the refresh token
forward indefinitely. Violates OWASP ASVS V3 absolute-timeout
requirements and is the wrong default for admin / regulated-industry
deployments.

Two new knobs:
- KYTE_JWT_REFRESH_TTL default lowered from 7d to 4h (inactivity)
- KYTE_JWT_FAMILY_MAX_LIFETIME (new, default 12h) caps absolute
  session length from the original /jwt/login moment, independent of
  activity. Enforced in RefreshTokenStore::rotate() — when crossed,
  whole token family is revoked with revoked_reason='family_max_lifetime'.

New column KyteRefreshToken.family_started_at anchors the absolute cap
to the original login moment, copied forward unchanged on each
rotation so sliding refresh cannot extend it. Backward compatibility:
pre-upgrade tokens with family_started_at=0 are treated as
"legacy, uncapped" on first post-upgrade rotation and the successor
anchors the cap from that moment forward — no mass logout at deploy.

Schema migration: migrations/4.5.0_jwt_family_lifetime.sql (ALTER TABLE
adding the new column). Same operational pattern as the 4.4.0 JWT
migration.

Tests: RefreshTokenStoreTest covers (a) family_started_at set on
issue, (b) preserved across rotation, (c) cap rejection past window,
(d) cap allows refresh inside window, (e) zero-anchor legacy backfill.

Defaults align with AWS Console (12h max), Microsoft 365 admin
(1h/12h), and OWASP ASVS V3. Customer mobile/consumer apps that need
longer sessions can override both constants per-deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The initial 4.5.0 cap logic gave tokens with family_started_at=0 a
free pass on first rotation, anchoring the cap to "now". Side effect:
pre-upgrade sessions kept their original 7-day inactivity TTL AND
escaped the 12h absolute cap, so they survived uncapped for up to a
week after deploy. Observed on dev — staging Shipyard and getpage.co
apps stayed logged in ~7 days while a freshly-logged-in localhost
(post-config-change, 4h tokens) expired correctly.

Fix: when family_started_at=0, anchor the absolute cap to the token's
date_created (best proxy for original login) instead of exempting it.
A legacy session older than KYTE_JWT_FAMILY_MAX_LIFETIME is now revoked
on its next rotation, exactly like a native session. First deploy of
4.5.0 therefore forces re-login for any JWT session older than 12h —
intentional, and the correct security posture for an absolute cap.

CHANGELOG updated to document the deploy-time logout, the config.php
KYTE_JWT_REFRESH_TTL override gotcha (explicit 604800 shadows the new
4h default), the optional hard-cutover purge SQL, and the kyte-api-js
>= 2.0.2 client pairing requirement.

Tests: replace the lenient backfill test with two cases — legacy token
within cap anchors to date_created; legacy token past cap is revoked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kennethphough
kennethphough merged commit adb50c1 into master May 28, 2026
4 checks passed
@kennethphough
kennethphough deleted the feat/jwt-session-caps branch May 28, 2026 06:47
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