diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a50a92..56cd4de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +## 4.5.0 + +### Feature: JWT session lifetime caps (inactivity + absolute) + +JWT sessions in 4.4.x were effectively unlimited — every `/jwt/refresh` issued a fresh refresh token with `expires_at = now + 7d`, and the 15-minute access TTL meant any page activity rotated the refresh token forward indefinitely. A user could stay logged in for weeks just by opening the app every few days. This violates OWASP ASVS V3 ("absolute timeout MUST exist") and is the wrong default for an admin tool / regulated-industry web app. + +This release introduces a two-knob policy that matches the industry-standard pattern (sliding inactivity timeout + absolute family cap): + +1. **Lowered inactivity timeout.** `KYTE_JWT_REFRESH_TTL` default drops from 604800s (7d) to 14400s (4h). Closing the browser at 5pm now forces a re-login the next morning. Per-deployment override still applies — consumer mobile apps with "remember me" can opt for longer. + +2. **New absolute family cap.** `KYTE_JWT_FAMILY_MAX_LIFETIME` (new constant, default 43200s / 12h) caps total session lifetime from the original `/jwt/login`, independent of how active the user is. Enforced in `RefreshTokenStore::rotate()` — when crossed, the whole token family is revoked with `revoked_reason='family_max_lifetime'` and the user must re-authenticate. + +3. **New column: `KyteRefreshToken.family_started_at`.** Anchors the absolute cap to the original login moment. Set in `issue()`, copied forward unchanged in `issueInFamily()` on each rotation. + + **Legacy tokens (issued before this column existed) are capped, not exempted.** A pre-upgrade row has `family_started_at = 0`; `rotate()` anchors its cap to the token's `date_created` (the best available proxy for the original login). A pre-upgrade session whose login was more than `KYTE_JWT_FAMILY_MAX_LIFETIME` ago is revoked on its next rotation. **This means the first deploy of 4.5.0 forces re-login for any JWT session older than 12 hours** — intentional. An earlier draft of this change gave legacy tokens a free pass "to avoid disruption," but that let pre-upgrade 7-day sessions survive uncapped for up to a week after deploy (observed on dev), which defeats the purpose of an absolute cap. + + **Operational note for upgrades with active JWT sessions:** after running the migration, existing sessions older than the cap end on their next request (clean 401 → client re-login). If you want a hard cutover instead of a staggered one, revoke all pre-upgrade rows directly: `UPDATE KyteRefreshToken SET revoked_at=UNIX_TIMESTAMP(), revoked_reason='legacy_purge_v4.5.0' WHERE family_started_at=0 AND revoked_at=0;` + +The defaults align with AWS Console (12h max), Microsoft 365 admin (1h/12h), and OWASP ASVS V3 absolute-timeout requirements. Customer mobile/consumer apps that need longer sessions can override both constants per-deployment. + +Schema migration: `KyteRefreshToken` gains one unsigned-int column. **Run `migrations/4.5.0_jwt_family_lifetime.sql` after `composer update`** — Kyte does not auto-ALTER system tables. Same operational pattern as the 4.4.0 sensitive-columns + JWT-refresh migrations. + +**Config note:** deployments that explicitly set `KYTE_JWT_REFRESH_TTL` in `config.php` (e.g. the 4.4.0 default of `604800`) override the new 4h default — update those configs to `14400` and add `KYTE_JWT_FAMILY_MAX_LIFETIME` or the new inactivity timeout is silently shadowed. + +**Client pairing:** kyte-api-js must be ≥ v2.0.2 (refresh-cookie TTL derived from `refresh_expires_at`). With the older v2.0.1 client, the browser cookie keeps a 30-day TTL, so an idle tab *looks* logged in under a dead server-side session until the next request fails. Both halves are required for correct UX. + +Tests: `RefreshTokenStoreTest` covers (a) family_started_at set on issue, (b) preserved across rotation, (c) cap rejection past the window, (d) cap allows refresh inside the window, (e) legacy token within cap anchors to date_created, (f) legacy token past cap is revoked. + ## 4.4.5 ### Bug Fix + Feature: JWT login parity with HMAC session response diff --git a/migrations/4.5.0_jwt_family_lifetime.sql b/migrations/4.5.0_jwt_family_lifetime.sql new file mode 100644 index 0000000..47b25dd --- /dev/null +++ b/migrations/4.5.0_jwt_family_lifetime.sql @@ -0,0 +1,24 @@ +-- ========================================================================= +-- Kyte v4.5.0 - JWT refresh token family lifetime anchor +-- ========================================================================= +-- IMPORTANT: Backup your database before running this migration. +-- +-- Adds `family_started_at` to KyteRefreshToken. This column anchors the +-- absolute session-lifetime cap (KYTE_JWT_FAMILY_MAX_LIFETIME, default 12h) +-- to the original `/jwt/login` moment, independent of how active the user +-- is. Without it, sliding refresh-token rotation extends sessions +-- indefinitely (the 4.4.x behavior we're capping in 4.5.0). +-- +-- Backward compatibility: existing rows backfill to 0. RefreshTokenStore::rotate() +-- treats family_started_at = 0 as "legacy, uncapped" on the first +-- post-upgrade rotation, and the successor token anchors family_started_at +-- to that moment. No mass logout at deploy time. +-- +-- See src/Mvc/Model/KyteRefreshToken.php for the model spec and +-- src/Core/Auth/RefreshTokenStore.php for the cap enforcement logic. +-- ========================================================================= + +ALTER TABLE `KyteRefreshToken` + ADD COLUMN `family_started_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 + COMMENT 'Unix epoch when this token family was born at /jwt/login. Copied forward on rotation; anchors the absolute-cap clock.' + AFTER `expires_at`; diff --git a/src/Core/Auth/RefreshTokenStore.php b/src/Core/Auth/RefreshTokenStore.php index 687ac79..973ad13 100644 --- a/src/Core/Auth/RefreshTokenStore.php +++ b/src/Core/Auth/RefreshTokenStore.php @@ -24,7 +24,20 @@ final class RefreshTokenStore { private const PREFIX = 'kref_v1_'; - private const DEFAULT_REFRESH_TTL = 604800; // 7 days + // Default refresh-token TTL. The token slides forward this many seconds + // on every successful rotation — it acts as the inactivity timeout. Set + // to 4h (14400s) so that closing the browser at 5pm forces a re-login + // the next morning. Override with KYTE_JWT_REFRESH_TTL for deployments + // that need longer (e.g., consumer mobile apps with "remember me"). + private const DEFAULT_REFRESH_TTL = 14400; // 4 hours + + // Default absolute cap on how long a single rotation family may live + // before forced re-authentication, regardless of activity. Anchored to + // `family_started_at`, NOT to the current token's expires_at — so + // sliding rotation cannot extend it. Override with + // KYTE_JWT_FAMILY_MAX_LIFETIME. 12h aligns with AWS/Microsoft admin + // session caps and OWASP ASVS V3 "absolute timeout MUST exist". + private const DEFAULT_FAMILY_MAX_LIFETIME = 43200; // 12 hours /** * Issue a brand new refresh token for a user. @@ -34,7 +47,10 @@ final class RefreshTokenStore public static function issue(int $userId, int $accountId, ?int $appId, string $ip = ''): array { $family = bin2hex(random_bytes(32)); - return self::issueInFamily($userId, $accountId, $appId, $family, $ip); + // New family — anchor family_started_at to now. Rotation copies + // this forward unchanged so the absolute cap remains tied to the + // original /jwt/login moment. + return self::issueInFamily($userId, $accountId, $appId, $family, $ip, time()); } /** @@ -72,6 +88,34 @@ public static function rotate(string $rawToken, string $ip = ''): array throw new SessionException('Refresh token expired.'); } + // Absolute family lifetime cap. Anchored to family_started_at — + // the moment of original /jwt/login — so it cannot be extended + // by sliding rotation. Once the cap is crossed, revoke the whole + // family (not just this token) so any concurrent device on the + // same family is also forced to re-login. Mirrors AWS/Microsoft + // admin-session absolute caps; satisfies OWASP ASVS V3. + // + // Legacy tokens: rows issued before the family_started_at column + // existed have family_started_at = 0. We do NOT give them a free + // pass — that let pre-upgrade 7-day sessions survive uncapped for + // up to a full week after deploy. Instead, anchor the cap to the + // token's date_created (the best available proxy for family birth). + // A pre-upgrade session older than the cap is revoked on its next + // rotation, exactly like a native session would be. Effect: the + // first deploy of this code logs out any session whose original + // login was more than KYTE_JWT_FAMILY_MAX_LIFETIME ago. + $familyStartedAt = (int)($token->family_started_at ?? 0); + if ($familyStartedAt === 0) { + $familyStartedAt = (int)($token->date_created ?? $now); + } + $familyMaxLifetime = defined('KYTE_JWT_FAMILY_MAX_LIFETIME') + ? (int)KYTE_JWT_FAMILY_MAX_LIFETIME + : self::DEFAULT_FAMILY_MAX_LIFETIME; + if ($now - $familyStartedAt > $familyMaxLifetime) { + self::revokeFamily((string)$token->token_family, 'family_max_lifetime'); + throw new SessionException('Session has reached its maximum lifetime; please log in again.'); + } + // Normal rotation: issue successor with same family, then mark // current revoked with rotated_to = successor id. $userId = (int)$token->user; @@ -79,7 +123,7 @@ public static function rotate(string $rawToken, string $ip = ''): array $appId = $token->application !== null ? (int)$token->application : null; $family = (string)$token->token_family; - $successor = self::issueInFamily($userId, $accountId, $appId, $family, $ip); + $successor = self::issueInFamily($userId, $accountId, $appId, $family, $ip, $familyStartedAt); $token->save([ 'revoked_at' => $now, @@ -167,8 +211,13 @@ public static function revokeAllForUser(int $userId, int $accountId, string $rea /** * Internal: create a refresh token row in a specific family. * Used by both issue() (new family) and rotate() (existing family). + * + * $familyStartedAt is set once at family birth (issue()) and copied + * forward unchanged on each rotation. Callers MUST pass it — there + * is no default — so the absolute-cap anchor cannot accidentally + * reset to "now" mid-rotation and silently extend the session. */ - private static function issueInFamily(int $userId, int $accountId, ?int $appId, string $family, string $ip): array + private static function issueInFamily(int $userId, int $accountId, ?int $appId, string $family, string $ip, int $familyStartedAt): array { $raw = self::PREFIX . self::randomTokenBody(); $hash = hash('sha256', $raw); @@ -178,22 +227,24 @@ private static function issueInFamily(int $userId, int $accountId, ?int $appId, $token = new ModelObject(KyteRefreshToken); $token->create([ - 'token_hash' => $hash, - 'token_prefix' => substr($raw, 0, 24), - 'token_family' => $family, - 'user' => $userId, - 'application' => $appId, - 'expires_at' => $expiresAt, - 'last_used_at' => $now, - 'last_used_ip' => $ip !== '' ? $ip : null, - 'kyte_account' => $accountId, + 'token_hash' => $hash, + 'token_prefix' => substr($raw, 0, 24), + 'token_family' => $family, + 'user' => $userId, + 'application' => $appId, + 'expires_at' => $expiresAt, + 'family_started_at' => $familyStartedAt, + 'last_used_at' => $now, + 'last_used_ip' => $ip !== '' ? $ip : null, + 'kyte_account' => $accountId, ]); return [ - 'raw' => $raw, - 'id' => (int)$token->id, - 'family' => $family, - 'expires_at' => $expiresAt, + 'raw' => $raw, + 'id' => (int)$token->id, + 'family' => $family, + 'expires_at' => $expiresAt, + 'family_started_at' => $familyStartedAt, ]; } diff --git a/src/Mvc/Model/KyteRefreshToken.php b/src/Mvc/Model/KyteRefreshToken.php index 8d8e012..7fd2877 100644 --- a/src/Mvc/Model/KyteRefreshToken.php +++ b/src/Mvc/Model/KyteRefreshToken.php @@ -90,7 +90,7 @@ ], // Expiration (unix epoch). 0 means never — strongly discouraged; UI - // defaults to KYTE_JWT_REFRESH_TTL (7 days). + // defaults to KYTE_JWT_REFRESH_TTL (4 hours). 'expires_at' => [ 'type' => 'i', 'required' => true, @@ -100,6 +100,22 @@ 'date' => true, ], + // Family-wide absolute lifetime anchor (unix epoch). Set once when + // the family is born at /jwt/login and copied forward unchanged on + // every rotation in `RefreshTokenStore::issueInFamily()`. Used to + // enforce KYTE_JWT_FAMILY_MAX_LIFETIME — the absolute cap on how + // long a single login session can survive before forced re-auth, + // independent of how active the user is. Without this, sliding + // `expires_at` rotation allows indefinite sessions. + 'family_started_at' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'default' => 0, + 'date' => true, + ], + // Last-observed use (unix epoch). Updated on every rotation attempt. 'last_used_at' => [ 'type' => 'i', diff --git a/tests/RefreshTokenStoreTest.php b/tests/RefreshTokenStoreTest.php index 447a02e..a074557 100644 --- a/tests/RefreshTokenStoreTest.php +++ b/tests/RefreshTokenStoreTest.php @@ -69,12 +69,14 @@ protected function setUp(): void public function testIssueReturnsRawAndPersistsHashedRow(): void { + $before = time(); $result = RefreshTokenStore::issue($this->userId, $this->accountId, null, '1.2.3.4'); $this->assertArrayHasKey('raw', $result); $this->assertArrayHasKey('id', $result); $this->assertArrayHasKey('family', $result); $this->assertArrayHasKey('expires_at', $result); + $this->assertArrayHasKey('family_started_at', $result); $this->assertStringStartsWith('kref_v1_', $result['raw']); $this->assertSame(64, strlen($result['family']), 'family is 64 hex chars'); @@ -83,6 +85,8 @@ public function testIssueReturnsRawAndPersistsHashedRow(): void $this->assertSame(0, (int)$row['revoked_at']); $this->assertSame((string)$this->userId, $row['user']); $this->assertGreaterThan(time(), (int)$row['expires_at']); + $this->assertGreaterThanOrEqual($before, (int)$row['family_started_at']); + $this->assertLessThanOrEqual(time(), (int)$row['family_started_at']); } public function testRotateRevokesOldAndIssuesSuccessorInSameFamily(): void @@ -120,6 +124,110 @@ public function testRotateOnRevokedTokenRevokesEntireFamily(): void $this->assertSame('reuse_detected', $successorRow['revoked_reason']); } + public function testRotationPreservesFamilyStartedAt(): void + { + $original = RefreshTokenStore::issue($this->userId, $this->accountId, null); + $originalRow = $this->loadById($original['id']); + $originalAnchor = (int)$originalRow['family_started_at']; + $this->assertGreaterThan(0, $originalAnchor); + + // Sleep would slow tests; instead force-shift expires_at so rotate() + // is willing to fire even if we run two rotations in the same second. + $successor = RefreshTokenStore::rotate($original['raw']); + $successorRow = $this->loadById($successor['id']); + + $this->assertSame($originalAnchor, (int)$successorRow['family_started_at'], + 'family_started_at must be copied forward, not reset to now'); + } + + public function testRotateRejectsWhenFamilyMaxLifetimeExceeded(): void + { + $token = RefreshTokenStore::issue($this->userId, $this->accountId, null); + + // Force family_started_at far enough in the past that the default + // 12h cap is blown. Keep expires_at fresh so we hit the cap check, + // not the per-token expiration check. + $longAgo = time() - 86400; // 24h ago + \Kyte\Core\DBI::query( + "UPDATE `KyteRefreshToken` SET family_started_at = " . $longAgo . " WHERE id = " . (int)$token['id'] + ); + + try { + RefreshTokenStore::rotate($token['raw']); + $this->fail('Rotation past family_max_lifetime should have thrown.'); + } catch (SessionException $e) { + $this->assertStringContainsString('maximum lifetime', strtolower($e->getMessage())); + } + + $row = $this->loadById($token['id']); + $this->assertSame('family_max_lifetime', $row['revoked_reason'], + 'whole family revoked with family_max_lifetime reason'); + } + + public function testRotateAllowsRefreshBeforeFamilyMaxLifetime(): void + { + $token = RefreshTokenStore::issue($this->userId, $this->accountId, null); + + // Set family_started_at to just inside the 12h window — refresh + // should still succeed. + $oneHourAgo = time() - 3600; + \Kyte\Core\DBI::query( + "UPDATE `KyteRefreshToken` SET family_started_at = " . $oneHourAgo . " WHERE id = " . (int)$token['id'] + ); + + $successor = RefreshTokenStore::rotate($token['raw']); + $this->assertNotSame($token['raw'], $successor['raw']); + + $successorRow = $this->loadById($successor['id']); + $this->assertSame($oneHourAgo, (int)$successorRow['family_started_at'], + 'family_started_at anchor preserved across allowed rotations'); + } + + public function testLegacyTokenWithinCapAnchorsToDateCreated(): void + { + $token = RefreshTokenStore::issue($this->userId, $this->accountId, null); + + // Simulate a pre-upgrade token: family_started_at = 0, but + // date_created is recent (within the 12h cap). Should rotate + // successfully and the successor inherits the date_created anchor + // (NOT now) so the absolute clock counts from the legacy login. + $createdAt = time() - 3600; // 1h ago — inside the cap + \Kyte\Core\DBI::query( + "UPDATE `KyteRefreshToken` SET family_started_at = 0, date_created = " . $createdAt . " WHERE id = " . (int)$token['id'] + ); + + $successor = RefreshTokenStore::rotate($token['raw']); + $successorRow = $this->loadById($successor['id']); + + $this->assertSame($createdAt, (int)$successorRow['family_started_at'], + 'legacy token anchors the cap to date_created, not to now'); + } + + public function testLegacyTokenPastCapIsRevoked(): void + { + $token = RefreshTokenStore::issue($this->userId, $this->accountId, null); + + // Pre-upgrade token whose original login (date_created) is older + // than the 12h cap. Even though family_started_at = 0 (legacy), + // it must NOT get a free pass — it's revoked on next rotation. + // This is the bug that let week-old sessions survive on dev. + $longAgo = time() - 86400; // 24h ago + \Kyte\Core\DBI::query( + "UPDATE `KyteRefreshToken` SET family_started_at = 0, date_created = " . $longAgo . " WHERE id = " . (int)$token['id'] + ); + + try { + RefreshTokenStore::rotate($token['raw']); + $this->fail('Legacy token older than the cap should have thrown.'); + } catch (SessionException $e) { + $this->assertStringContainsString('maximum lifetime', strtolower($e->getMessage())); + } + + $row = $this->loadById($token['id']); + $this->assertSame('family_max_lifetime', $row['revoked_reason'], + 'legacy token past the cap revoked with family_max_lifetime reason'); + } + public function testRotateOnExpiredTokenMarksExpiredAndDoesNotKillFamily(): void { $token = RefreshTokenStore::issue($this->userId, $this->accountId, null);