From 825376409d4144fe8ca215e1ff8df20e11fd3b69 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Sat, 23 May 2026 16:43:51 -0500 Subject: [PATCH 1/2] feat(jwt): cap session lifetime with inactivity + absolute timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 20 ++++++ migrations/4.5.0_jwt_family_lifetime.sql | 24 +++++++ src/Core/Auth/RefreshTokenStore.php | 85 +++++++++++++++++++----- src/Mvc/Model/KyteRefreshToken.php | 18 ++++- tests/RefreshTokenStoreTest.php | 82 +++++++++++++++++++++++ 5 files changed, 211 insertions(+), 18 deletions(-) create mode 100644 migrations/4.5.0_jwt_family_lifetime.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a50a92..11ca7fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## 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. Backward-compatibility: pre-upgrade tokens have `family_started_at = 0` and are treated as "uncapped" on first post-upgrade rotation — the rotation succeeds and the successor anchors the cap to that moment forward. No mass logout at upgrade time. + +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. Existing rows backfill to 0 → treated as legacy → cap anchors on the next rotation, so there is NO mass logout at deploy time. Same operational pattern as the 4.4.0 sensitive-columns + JWT-refresh migrations. + +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) zero-anchor legacy backfill. + ## 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..b1cdd1a 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. + // + // Backward compatibility: tokens issued before this field existed + // have family_started_at = 0. Treat 0 as "unknown, skip cap" so + // existing sessions don't get a sudden mass logout. They will get + // the cap on next rotation (which copies family_started_at = the + // CURRENT now() — effectively starting the absolute clock then). + $familyStartedAt = (int)($token->family_started_at ?? 0); + if ($familyStartedAt > 0) { + $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.'); + } + } else { + // Backfill on first rotation post-upgrade: anchor the cap + // here so the session gets a 12h ceiling from this point + // forward instead of remaining uncapped forever. + $familyStartedAt = $now; + } + // 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..eee0bf8 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,84 @@ 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 testRotateBackfillsWhenFamilyStartedAtIsZero(): void + { + $token = RefreshTokenStore::issue($this->userId, $this->accountId, null); + + // Simulate a pre-upgrade token that predates the family_started_at + // column (default 0). Should NOT be rejected — instead the + // successor row anchors family_started_at to ~now. + \Kyte\Core\DBI::query( + "UPDATE `KyteRefreshToken` SET family_started_at = 0 WHERE id = " . (int)$token['id'] + ); + + $before = time(); + $successor = RefreshTokenStore::rotate($token['raw']); + $successorRow = $this->loadById($successor['id']); + + $this->assertGreaterThanOrEqual($before, (int)$successorRow['family_started_at']); + $this->assertLessThanOrEqual(time(), (int)$successorRow['family_started_at']); + } + public function testRotateOnExpiredTokenMarksExpiredAndDoesNotKillFamily(): void { $token = RefreshTokenStore::issue($this->userId, $this->accountId, null); From 7676b9686224e8c73b1fa27ad6bab12544f8a93a Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Thu, 28 May 2026 01:40:22 -0500 Subject: [PATCH 2/2] fix(jwt): apply absolute cap to legacy (pre-upgrade) refresh tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 14 +++++++--- src/Core/Auth/RefreshTokenStore.php | 36 ++++++++++++------------- tests/RefreshTokenStoreTest.php | 42 +++++++++++++++++++++++------ 3 files changed, 63 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11ca7fe..56cd4de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,21 @@ This release introduces a two-knob policy that matches the industry-standard pat 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. Backward-compatibility: pre-upgrade tokens have `family_started_at = 0` and are treated as "uncapped" on first post-upgrade rotation — the rotation succeeds and the successor anchors the cap to that moment forward. No mass logout at upgrade time. +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. Existing rows backfill to 0 → treated as legacy → cap anchors on the next rotation, so there is NO mass logout at deploy time. Same operational pattern as the 4.4.0 sensitive-columns + JWT-refresh migrations. +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) zero-anchor legacy backfill. +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 diff --git a/src/Core/Auth/RefreshTokenStore.php b/src/Core/Auth/RefreshTokenStore.php index b1cdd1a..973ad13 100644 --- a/src/Core/Auth/RefreshTokenStore.php +++ b/src/Core/Auth/RefreshTokenStore.php @@ -95,25 +95,25 @@ public static function rotate(string $rawToken, string $ip = ''): array // same family is also forced to re-login. Mirrors AWS/Microsoft // admin-session absolute caps; satisfies OWASP ASVS V3. // - // Backward compatibility: tokens issued before this field existed - // have family_started_at = 0. Treat 0 as "unknown, skip cap" so - // existing sessions don't get a sudden mass logout. They will get - // the cap on next rotation (which copies family_started_at = the - // CURRENT now() — effectively starting the absolute clock then). + // 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) { - $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.'); - } - } else { - // Backfill on first rotation post-upgrade: anchor the cap - // here so the session gets a 12h ceiling from this point - // forward instead of remaining uncapped forever. - $familyStartedAt = $now; + 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 diff --git a/tests/RefreshTokenStoreTest.php b/tests/RefreshTokenStoreTest.php index eee0bf8..a074557 100644 --- a/tests/RefreshTokenStoreTest.php +++ b/tests/RefreshTokenStoreTest.php @@ -183,23 +183,49 @@ public function testRotateAllowsRefreshBeforeFamilyMaxLifetime(): void 'family_started_at anchor preserved across allowed rotations'); } - public function testRotateBackfillsWhenFamilyStartedAtIsZero(): void + public function testLegacyTokenWithinCapAnchorsToDateCreated(): void { $token = RefreshTokenStore::issue($this->userId, $this->accountId, null); - // Simulate a pre-upgrade token that predates the family_started_at - // column (default 0). Should NOT be rejected — instead the - // successor row anchors family_started_at to ~now. + // 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 WHERE id = " . (int)$token['id'] + "UPDATE `KyteRefreshToken` SET family_started_at = 0, date_created = " . $createdAt . " WHERE id = " . (int)$token['id'] ); - $before = time(); $successor = RefreshTokenStore::rotate($token['raw']); $successorRow = $this->loadById($successor['id']); - $this->assertGreaterThanOrEqual($before, (int)$successorRow['family_started_at']); - $this->assertLessThanOrEqual(time(), (int)$successorRow['family_started_at']); + $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