feat: token revocation + per-key invalidation + secret rotation + prune cron
This commit is contained in:
@@ -5,91 +5,77 @@ namespace AskTamMCP\Core;
|
|||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JWT token manager — issues short-lived signed access tokens.
|
* JWT token manager with revocation support.
|
||||||
*
|
|
||||||
* Format: base64url(header).base64url(payload).base64url(signature)
|
|
||||||
* Algorithm: HMAC-SHA256 using a per-site secret stored in WP options.
|
|
||||||
* Expiry: 1 hour by default.
|
|
||||||
*
|
|
||||||
* The access token is NOT the API key — it's a signed JWT that references
|
|
||||||
* the key ID. Even if intercepted, it expires in 1 hour and cannot be
|
|
||||||
* forged without the site secret.
|
|
||||||
*/
|
*/
|
||||||
class TokenManager {
|
class TokenManager {
|
||||||
|
|
||||||
private const ALGO = 'sha256';
|
private const ALGO = 'sha256';
|
||||||
private const TTL = 3600; // 1 hour
|
public const TTL = 3600;
|
||||||
private const OPTION = 'asktam_mcp_jwt_secret';
|
private const OPTION = 'asktam_mcp_jwt_secret';
|
||||||
|
private const REV_TABLE = 'asktam_mcp_revoked';
|
||||||
|
|
||||||
/**
|
// -------------------------------------------------------------------------
|
||||||
* Issue a signed JWT access token for a given API key row.
|
// Issue
|
||||||
*/
|
// -------------------------------------------------------------------------
|
||||||
public static function issue(array $keyRow): string {
|
public static function issue(array $keyRow): string {
|
||||||
$secret = self::getSecret();
|
$secret = self::getSecret();
|
||||||
$now = time();
|
$now = time();
|
||||||
|
$jti = bin2hex(random_bytes(8));
|
||||||
|
|
||||||
$header = self::b64url(wp_json_encode(['typ' => 'JWT', 'alg' => 'HS256']));
|
$header = self::b64url(wp_json_encode(['typ' => 'JWT', 'alg' => 'HS256']));
|
||||||
$payload = self::b64url(wp_json_encode([
|
$payload = self::b64url(wp_json_encode([
|
||||||
'iss' => get_site_url(),
|
'iss' => get_site_url(),
|
||||||
'sub' => (string) $keyRow['id'], // key ID
|
'sub' => (string) $keyRow['id'],
|
||||||
'kid' => substr($keyRow['api_key'], 0, 8), // key fingerprint
|
'kid' => substr($keyRow['api_key'], 0, 8),
|
||||||
'scp' => $keyRow['scopes'],
|
'scp' => $keyRow['scopes'],
|
||||||
'iat' => $now,
|
'iat' => $now,
|
||||||
'exp' => $now + self::TTL,
|
'exp' => $now + self::TTL,
|
||||||
'jti' => bin2hex(random_bytes(8)), // unique per token
|
'jti' => $jti,
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$sig = self::b64url(hash_hmac(self::ALGO, "$header.$payload", $secret, true));
|
$sig = self::b64url(hash_hmac(self::ALGO, "$header.$payload", $secret, true));
|
||||||
|
|
||||||
return "$header.$payload.$sig";
|
return "$header.$payload.$sig";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -------------------------------------------------------------------------
|
||||||
* Verify a JWT and return its payload, or null if invalid/expired.
|
// Verify
|
||||||
*/
|
// -------------------------------------------------------------------------
|
||||||
public static function verify(string $token): ?array {
|
public static function verify(string $token): ?array {
|
||||||
$parts = explode('.', $token);
|
$parts = explode('.', $token);
|
||||||
if (count($parts) !== 3) return null;
|
if (count($parts) !== 3) return null;
|
||||||
|
|
||||||
[$header, $payload, $sig] = $parts;
|
[$header, $payload, $sig] = $parts;
|
||||||
$secret = self::getSecret();
|
$expected = self::b64url(hash_hmac(self::ALGO, "$header.$payload", self::getSecret(), true));
|
||||||
$expected = self::b64url(hash_hmac(self::ALGO, "$header.$payload", $secret, true));
|
|
||||||
|
|
||||||
// Constant-time comparison
|
|
||||||
if (!hash_equals($expected, $sig)) return null;
|
if (!hash_equals($expected, $sig)) return null;
|
||||||
|
|
||||||
$data = json_decode(self::b64urlDecode($payload), true);
|
$data = json_decode(self::b64urlDecode($payload), true);
|
||||||
if (!$data) return null;
|
if (!$data) return null;
|
||||||
|
|
||||||
// Check expiry
|
|
||||||
if (($data['exp'] ?? 0) < time()) return null;
|
if (($data['exp'] ?? 0) < time()) return null;
|
||||||
|
|
||||||
// Check issuer
|
|
||||||
if (($data['iss'] ?? '') !== get_site_url()) return null;
|
if (($data['iss'] ?? '') !== get_site_url()) return null;
|
||||||
|
|
||||||
|
// Check revocation list
|
||||||
|
if (!empty($data['jti']) && self::isRevoked($data['jti'])) return null;
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -------------------------------------------------------------------------
|
||||||
* Resolve a token string to an API key DB row.
|
// Resolve bearer token → key row (JWT or legacy raw key)
|
||||||
* Accepts either a JWT (new) or a raw API key (legacy fallback).
|
// -------------------------------------------------------------------------
|
||||||
*/
|
|
||||||
public static function resolve(string $token): ?array {
|
public static function resolve(string $token): ?array {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . 'asktam_mcp_keys';
|
$table = $wpdb->prefix . 'asktam_mcp_keys';
|
||||||
|
|
||||||
// Try JWT first
|
|
||||||
$payload = self::verify($token);
|
$payload = self::verify($token);
|
||||||
if ($payload) {
|
if ($payload) {
|
||||||
$keyId = (int) ($payload['sub'] ?? 0);
|
return $wpdb->get_row(
|
||||||
$row = $wpdb->get_row(
|
$wpdb->prepare("SELECT * FROM {$table} WHERE id = %d AND is_active = 1", (int) $payload['sub']),
|
||||||
$wpdb->prepare("SELECT * FROM {$table} WHERE id = %d AND is_active = 1", $keyId),
|
|
||||||
ARRAY_A
|
ARRAY_A
|
||||||
);
|
) ?: null;
|
||||||
return $row ?: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy fallback: raw API key (for direct API use, not Claude.ai)
|
// Legacy: raw mcp_ 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),
|
||||||
@@ -101,16 +87,60 @@ class TokenManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
// Revocation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
public static function revoke(string $jti, int $exp): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->insert($wpdb->prefix . self::REV_TABLE, [
|
||||||
|
'jti' => $jti,
|
||||||
|
'expires_at' => date('Y-m-d H:i:s', $exp),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private static function getSecret(): string {
|
public static function revokeAllForKey(int $keyId): void {
|
||||||
|
// Store a per-key revocation timestamp — any token issued before this is invalid
|
||||||
|
update_option('asktam_mcp_revoke_key_' . $keyId, time(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Invalidate all existing JWTs by changing the signing secret
|
||||||
|
// Also purge the revocation table since all old tokens are now invalid anyway
|
||||||
|
update_option(self::OPTION, bin2hex(random_bytes(32)), false);
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query("TRUNCATE TABLE {$wpdb->prefix}" . self::REV_TABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSecret(): string {
|
||||||
$secret = get_option(self::OPTION);
|
$secret = get_option(self::OPTION);
|
||||||
if (!$secret) {
|
if (!$secret) {
|
||||||
$secret = bin2hex(random_bytes(32));
|
$secret = bin2hex(random_bytes(32));
|
||||||
update_option(self::OPTION, $secret, false); // autoload=false
|
update_option(self::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
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
private static function b64url(string $data): string {
|
private static function b64url(string $data): string {
|
||||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user