feat: token revocation list + secret rotation

This commit is contained in:
2026-05-08 22:29:28 +00:00
parent 121e1d35f1
commit eb2496953c
+36 -44
View File
@@ -5,14 +5,15 @@ namespace AskTamMCP\Core;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* JWT token manager with revocation support. * JWT token manager — issues short-lived signed access tokens.
* Includes revocation list and secret rotation support.
*/ */
class TokenManager { class TokenManager {
private const ALGO = 'sha256'; private const ALGO = 'sha256';
public const TTL = 3600; private const TTL = 3600;
private const OPTION = 'asktam_mcp_jwt_secret'; private const SECRET_OPTION = 'asktam_mcp_jwt_secret';
private const REV_TABLE = 'asktam_mcp_revoked'; private const REVOKED_OPTION = 'asktam_mcp_revoked_jtis';
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Issue // Issue
@@ -55,13 +56,13 @@ class TokenManager {
if (($data['iss'] ?? '') !== get_site_url()) return null; if (($data['iss'] ?? '') !== get_site_url()) return null;
// Check revocation list // Check revocation list
if (!empty($data['jti']) && self::isRevoked($data['jti'])) return null; if (self::isRevoked($data['jti'] ?? '')) return null;
return $data; return $data;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Resolve bearer token → key row (JWT or legacy raw key) // Resolve token → key row
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
public static function resolve(string $token): ?array { public static function resolve(string $token): ?array {
global $wpdb; global $wpdb;
@@ -75,7 +76,7 @@ class TokenManager {
) ?: null; ) ?: null;
} }
// Legacy: raw mcp_ key // Legacy: raw API key
if (str_starts_with($token, 'mcp_')) { if (str_starts_with($token, 'mcp_')) {
return $wpdb->get_row( return $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$table} WHERE api_key = %s AND is_active = 1", $token), $wpdb->prepare("SELECT * FROM {$table} WHERE api_key = %s AND is_active = 1", $token),
@@ -89,55 +90,46 @@ class TokenManager {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Revocation // Revocation
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
public static function revoke(string $jti, int $exp): void {
global $wpdb; /**
$wpdb->insert($wpdb->prefix . self::REV_TABLE, [ * Revoke a single JTI — stored as a transient matching the token TTL.
'jti' => $jti, */
'expires_at' => date('Y-m-d H:i:s', $exp), public static function revoke(string $jti): void {
]); $revoked = get_option(self::REVOKED_OPTION, []);
$revoked[$jti] = time() + self::TTL;
// Prune expired entries while we're here
$revoked = array_filter($revoked, fn($exp) => $exp > time());
update_option(self::REVOKED_OPTION, $revoked, false);
} }
public static function revokeAllForKey(int $keyId): void { /**
// Store a per-key revocation timestamp — any token issued before this is invalid * Revoke all active tokens for a key by rotating the secret.
update_option('asktam_mcp_revoke_key_' . $keyId, time(), false); * This is a nuclear option — invalidates every JWT site-wide.
} */
private static function isRevoked(string $jti): bool {
global $wpdb;
$table = $wpdb->prefix . self::REV_TABLE;
return (bool) $wpdb->get_var(
$wpdb->prepare("SELECT id FROM {$table} WHERE jti = %s", $jti)
);
}
// -------------------------------------------------------------------------
// Secret rotation
// -------------------------------------------------------------------------
public static function rotateSecret(): void { public static function rotateSecret(): void {
// Invalidate all existing JWTs by changing the signing secret update_option(self::SECRET_OPTION, bin2hex(random_bytes(32)), false);
// Also purge the revocation table since all old tokens are now invalid anyway // Clear revocation list — all old tokens are now invalid anyway
update_option(self::OPTION, bin2hex(random_bytes(32)), false); update_option(self::REVOKED_OPTION, [], false);
global $wpdb;
$wpdb->query("TRUNCATE TABLE {$wpdb->prefix}" . self::REV_TABLE);
} }
public static function isRevoked(string $jti): bool {
if (empty($jti)) return false;
$revoked = get_option(self::REVOKED_OPTION, []);
return isset($revoked[$jti]) && $revoked[$jti] > time();
}
// -------------------------------------------------------------------------
// Secret management
// -------------------------------------------------------------------------
public static function getSecret(): string { public static function getSecret(): string {
$secret = get_option(self::OPTION); $secret = get_option(self::SECRET_OPTION);
if (!$secret) { if (!$secret) {
$secret = bin2hex(random_bytes(32)); $secret = bin2hex(random_bytes(32));
update_option(self::OPTION, $secret, false); update_option(self::SECRET_OPTION, $secret, false);
} }
return $secret; return $secret;
} }
// -------------------------------------------------------------------------
// Cleanup expired revocations (run on cron)
// -------------------------------------------------------------------------
public static function pruneRevocations(): void {
global $wpdb;
$wpdb->query("DELETE FROM {$wpdb->prefix}" . self::REV_TABLE . " WHERE expires_at < NOW()");
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Helpers // Helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------