diff --git a/library/Tiger/Application/Bootstrap.php b/library/Tiger/Application/Bootstrap.php index 6681f387..0d9d644e 100644 --- a/library/Tiger/Application/Bootstrap.php +++ b/library/Tiger/Application/Bootstrap.php @@ -209,6 +209,17 @@ protected function _initSchedule() 'managed' => false, ]); + // Sweep expired personal access tokens (TIGER-378). Verification already refuses one past its + // expires_at; this reaps the rows so a "manage tokens" screen and the DB stay clean. + Tiger_Schedule::register([ + 'key' => 'core.auth.purge_expired_tokens', + 'label' => 'Purge expired access tokens', + 'every' => Tiger_Schedule::DAILY, + 'at' => '03:10', + 'run' => static function () { (new Tiger_Model_UserCredential())->purgeExpiredTokens(); }, + 'managed' => false, + ]); + Tiger_Schedule::discover(); $this->getResource('frontController') ->registerPlugin(new Tiger_Controller_Plugin_ScheduleTick()); diff --git a/library/Tiger/Model/UserCredential.php b/library/Tiger/Model/UserCredential.php index 985ec850..64a53955 100644 --- a/library/Tiger/Model/UserCredential.php +++ b/library/Tiger/Model/UserCredential.php @@ -148,21 +148,31 @@ public function addSms($userId, $e164) * ONCE (only its hash is stored). Format `tgr__`: the 12-char prefix is a * non-secret lookup key; the whole string is sent as `Authorization: Bearer `. * - * @param string $userId the owning user - * @return array {token: string (show once), credential_id: string, prefix: string} + * Pass `$ttlSeconds` to give the token an EXPIRY (TIGER-378): `verifyTokenCredential()` refuses it + * past `expires_at`, and the `core.auth.purge_expired_tokens` job sweeps it. A long-lived MACHINE + * PAT (a node↔control-plane credential) should ALWAYS be minted with a TTL so a leak self-heals; + * a human's own PAT may stay non-expiring (`$ttlSeconds = null`, the default — unchanged behaviour). + * + * @param string $userId the owning user + * @param int|null $ttlSeconds seconds until the token expires, or null for no expiry + * @return array {token: string (show once), credential_id: string, prefix: string, expires_at: ?string} */ - public function createToken($userId) + public function createToken($userId, $ttlSeconds = null) { $prefix = bin2hex(random_bytes(6)); // 12 hex $plain = 'tgr_' . $prefix . '_' . bin2hex(random_bytes(24)); // + 48 hex secret + $expiresAt = ($ttlSeconds !== null && (int) $ttlSeconds > 0) + ? date('Y-m-d H:i:s', time() + (int) $ttlSeconds) + : null; $id = $this->insert([ 'user_id' => $userId, 'type' => self::TYPE_PAT, 'identifier' => $prefix, 'secret' => hash('sha256', $plain), 'verified_at' => $this->_now(), + 'expires_at' => $expiresAt, ]); - return ['token' => $plain, 'credential_id' => $id, 'prefix' => $prefix]; + return ['token' => $plain, 'credential_id' => $id, 'prefix' => $prefix, 'expires_at' => $expiresAt]; } /** @@ -199,8 +209,11 @@ public function verifyTokenCredential($plain) if (!preg_match('/^tgr_([a-f0-9]{12})_[a-f0-9]{48}$/', (string) $plain, $m)) { return null; } + // An EXPIRED token (expires_at in the past) is refused exactly like a revoked one — it simply + // isn't found (TIGER-378). NULL expires_at = never expires. $row = $this->fetchRow( $this->activeSelect()->where('type = ?', self::TYPE_PAT)->where('identifier = ?', $m[1]) + ->where('(expires_at IS NULL OR expires_at > ?)', $this->_now()) ); if (!$row || !hash_equals((string) $row->secret, hash('sha256', (string) $plain))) { return null; @@ -226,11 +239,47 @@ public function tokensFor($userId) $out = []; foreach ($this->activeSelect()->where('user_id = ?', $userId)->where('type = ?', self::TYPE_PAT)->query()->fetchAll() as $r) { $out[] = ['credential_id' => $r['credential_id'], 'prefix' => $r['identifier'], - 'created_at' => $r['created_at'] ?? null, 'last_used_at' => $r['last_used_at'] ?? null]; + 'created_at' => $r['created_at'] ?? null, 'last_used_at' => $r['last_used_at'] ?? null, + 'expires_at' => $r['expires_at'] ?? null]; } return $out; } + /** + * Sweep personal access tokens whose `expires_at` has passed: soft-delete them + mark + * `status = 'expired'` (TIGER-378). A no-op for non-expiring (NULL `expires_at`) tokens. Idempotent — + * an already-deleted row is excluded by `activeSelect()`. Run by `core.auth.purge_expired_tokens`; + * verification already refuses an expired token, so this is hygiene, not a security gate. + * + * @return int the number of tokens swept + */ + public function purgeExpiredTokens() + { + return (int) $this->update( + ['deleted' => 1, 'status' => 'expired'], + $this->getAdapter()->quoteInto('type = ?', self::TYPE_PAT) + . ' AND `deleted` = 0' + . ' AND `expires_at` IS NOT NULL' + . $this->getAdapter()->quoteInto(' AND `expires_at` <= ?', $this->_now()) + ); + } + + /** + * Rotate a machine PAT: mint a fresh token for the SAME owner (the service user behind a node↔CP + * credential), returning the new plaintext ONCE, and revoke the presented-by-credential_id old one + * only AFTER the caller has distributed the new one (overlap-safe — never revoke before the far side + * holds the replacement). TIGER-378: the control-plane rotation job calls this on a schedule so a + * long-lived machine token is refreshed well before its TTL, and a leaked old token stops working. + * + * @param string $userId the service user that owns the machine token + * @param int|null $ttlSeconds TTL for the NEW token (null = no expiry; machine tokens pass one) + * @return array {token, credential_id, prefix, expires_at} — the new token (show once) + */ + public function rotateToken($userId, $ttlSeconds = null) + { + return $this->createToken($userId, $ttlSeconds); + } + /** * The credential id of an active personal access token by its (non-secret) prefix, or null. For POLICY * lookups only (e.g. an MCP token's scope) — the token's SECRET is verified separately by verifyToken(); diff --git a/migrations/0057_user_credential_expires_at.php b/migrations/0057_user_credential_expires_at.php new file mode 100644 index 00000000..ea6c31b2 --- /dev/null +++ b/migrations/0057_user_credential_expires_at.php @@ -0,0 +1,26 @@ + [ + "ALTER TABLE `user_credential` ADD COLUMN `expires_at` DATETIME NULL AFTER `verified_at`", + ], + 'down' => [ + "ALTER TABLE `user_credential` DROP COLUMN `expires_at`", + ], +]; diff --git a/tests/Integration/Model/UserCredentialTest.php b/tests/Integration/Model/UserCredentialTest.php index 5555b3b3..e6927c38 100644 --- a/tests/Integration/Model/UserCredentialTest.php +++ b/tests/Integration/Model/UserCredentialTest.php @@ -111,6 +111,105 @@ public function a_personal_access_token_verifies_once_and_a_revoked_one_never_do $this->assertNull($this->cred->verifyToken($minted['token']), 'a revoked (soft-deleted) token never verifies again'); } + /** + * A TTL makes a token self-expire (TIGER-378) — the Zeabur-#1 fix for long-lived machine PATs. + * A token minted with a TTL verifies while live and is refused the instant expires_at has passed, + * exactly like a revoked one; a default (no-TTL) token has NULL expires_at and never expires. + */ + #[Test] + public function a_token_with_a_ttl_expires_and_stops_verifying(): void + { + $user = $this->makeUser(); + $minted = $this->cred->createToken($user, 3600); // 1-hour TTL + + $this->assertNotNull($minted['expires_at'], 'a TTL yields an expires_at'); + $this->assertSame($user, $this->cred->verifyToken($minted['token']), 'a live, not-yet-expired token verifies'); + + // Force it past expiry (an hour ago) and it must stop verifying — refused like a revoked token. + $this->cred->update( + ['expires_at' => date('Y-m-d H:i:s', time() - 3600)], + $this->cred->getAdapter()->quoteInto('credential_id = ?', $minted['credential_id']) + ); + $this->assertNull($this->cred->verifyToken($minted['token']), 'an expired token never verifies'); + $this->assertNull($this->cred->verifyTokenCredential($minted['token']), 'verifyTokenCredential refuses it too'); + } + + #[Test] + public function a_default_token_has_no_expiry_and_keeps_working(): void + { + $user = $this->makeUser(); + $minted = $this->cred->createToken($user); // no TTL + $this->assertNull($minted['expires_at'], 'no TTL => no expires_at'); + $this->assertSame($user, $this->cred->verifyToken($minted['token']), 'a non-expiring token verifies'); + + // A zero/negative TTL is treated as "no expiry", not an instantly-dead token. + $zero = $this->cred->createToken($user, 0); + $this->assertNull($zero['expires_at'], 'a non-positive TTL means no expiry'); + $this->assertSame($user, $this->cred->verifyToken($zero['token'])); + } + + /** + * The housekeeping sweep (core.auth.purge_expired_tokens) soft-deletes ONLY expired tokens, marks + * them status=expired, and leaves live + non-expiring ones untouched. Verification already refuses + * an expired token; this keeps the manage-tokens list and the table clean. + */ + #[Test] + public function purge_expired_tokens_sweeps_only_the_expired_ones(): void + { + $user = $this->makeUser(); + $expired = $this->cred->createToken($user, 3600); + $live = $this->cred->createToken($user, 3600); + $forever = $this->cred->createToken($user); // no expiry + + // Backdate only the first one's expiry. + $this->cred->update( + ['expires_at' => date('Y-m-d H:i:s', time() - 60)], + $this->cred->getAdapter()->quoteInto('credential_id = ?', $expired['credential_id']) + ); + + $swept = $this->cred->purgeExpiredTokens(); + $this->assertSame(1, $swept, 'exactly one token was expired and swept'); + + $this->assertNull($this->cred->verifyToken($expired['token']), 'the swept token is gone'); + $this->assertSame($user, $this->cred->verifyToken($live['token']), 'a live token survives the sweep'); + $this->assertSame($user, $this->cred->verifyToken($forever['token']), 'a non-expiring token survives the sweep'); + + $prefixes = array_column($this->cred->tokensFor($user), 'prefix'); + $this->assertNotContains($expired['prefix'], $prefixes, 'tokensFor() no longer lists the swept token'); + $this->assertContains($live['prefix'], $prefixes); + $this->assertContains($forever['prefix'], $prefixes); + + $this->assertSame(0, $this->cred->purgeExpiredTokens(), 'the sweep is idempotent — nothing left to purge'); + } + + #[Test] + public function tokens_for_surfaces_the_expiry(): void + { + $user = $this->makeUser(); + $minted = $this->cred->createToken($user, 7200); + $rows = $this->cred->tokensFor($user); + $this->assertCount(1, $rows); + $this->assertSame($minted['prefix'], $rows[0]['prefix']); + $this->assertSame($minted['expires_at'], $rows[0]['expires_at'], 'the list carries expires_at for the UI'); + } + + #[Test] + public function rotate_token_mints_a_fresh_working_token(): void + { + $user = $this->makeUser(); + $old = $this->cred->createToken($user, 3600); + + $new = $this->cred->rotateToken($user, 3600); + $this->assertNotSame($old['token'], $new['token'], 'rotation yields a different token'); + $this->assertSame($user, $this->cred->verifyToken($new['token']), 'the rotated token verifies'); + $this->assertNotNull($new['expires_at'], 'the rotated machine token carries a fresh TTL'); + + // Overlap-safe: the old token keeps working until the caller revokes it (never a gap in auth). + $this->assertSame($user, $this->cred->verifyToken($old['token']), 'the old token still works during the overlap'); + $this->cred->revokeToken($user, $old['credential_id']); + $this->assertNull($this->cred->verifyToken($old['token']), 'and stops once revoked'); + } + /** * Verification reports WHICH credential was presented (TIGER-102). *