Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions library/Tiger/Application/Bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
59 changes: 54 additions & 5 deletions library/Tiger/Model/UserCredential.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,21 +148,31 @@ public function addSms($userId, $e164)
* ONCE (only its hash is stored). Format `tgr_<prefix>_<secret>`: the 12-char prefix is a
* non-secret lookup key; the whole string is sent as `Authorization: Bearer <token>`.
*
* @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];
}

/**
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down
26 changes: 26 additions & 0 deletions migrations/0057_user_credential_expires_at.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Migration 0057 — add `expires_at` to `user_credential` (TIGER-378).
*
* A personal access token (`type = 'personal_access_token'`) was mint-once and immortal: it died only by
* an explicit revoke (soft-delete). That is the Zeabur-#1 echo for the long-lived MACHINE PATs the fleet
* mints (a node↔control-plane `host`/`billing`/`vps` token) — a leaked one is valid until a human notices.
*
* This adds a single additive, NULLABLE column: `expires_at`. NULL = never expires (every existing row,
* and every ordinary user PAT that opts out of a TTL — unchanged behaviour). A non-NULL value is enforced
* at verify time (`verifyTokenCredential` rejects a token past it) and swept by the
* `core.auth.purge_expired_tokens` housekeeping job. Machine PATs are minted WITH a TTL so a leak self-heals.
*
* Nullable + no default, so the ALTER is instant and no backfill is needed; it applies to every factor row
* but only PATs ever set it.
*/
return [
'up' => [
"ALTER TABLE `user_credential` ADD COLUMN `expires_at` DATETIME NULL AFTER `verified_at`",
],
'down' => [
"ALTER TABLE `user_credential` DROP COLUMN `expires_at`",
],
];
99 changes: 99 additions & 0 deletions tests/Integration/Model/UserCredentialTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down
Loading