feat: JWT access token manager - short-lived HS256 tokens, not raw API keys

This commit is contained in:
2026-05-08 22:07:11 +00:00
parent 47f658bf6f
commit ef4cab7e2f
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace AskTamMCP\Core;
defined('ABSPATH') || exit;
/**
* JWT token manager — issues short-lived signed access tokens.
*
* 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 {
private const ALGO = 'sha256';
private const TTL = 3600; // 1 hour
private const OPTION = 'asktam_mcp_jwt_secret';
/**
* Issue a signed JWT access token for a given API key row.
*/
public static function issue(array $keyRow): string {
$secret = self::getSecret();
$now = time();
$header = self::b64url(wp_json_encode(['typ' => 'JWT', 'alg' => 'HS256']));
$payload = self::b64url(wp_json_encode([
'iss' => get_site_url(),
'sub' => (string) $keyRow['id'], // key ID
'kid' => substr($keyRow['api_key'], 0, 8), // key fingerprint
'scp' => $keyRow['scopes'],
'iat' => $now,
'exp' => $now + self::TTL,
'jti' => bin2hex(random_bytes(8)), // unique per token
]));
$sig = self::b64url(hash_hmac(self::ALGO, "$header.$payload", $secret, true));
return "$header.$payload.$sig";
}
/**
* Verify a JWT and return its payload, or null if invalid/expired.
*/
public static function verify(string $token): ?array {
$parts = explode('.', $token);
if (count($parts) !== 3) return null;
[$header, $payload, $sig] = $parts;
$secret = self::getSecret();
$expected = self::b64url(hash_hmac(self::ALGO, "$header.$payload", $secret, true));
// Constant-time comparison
if (!hash_equals($expected, $sig)) return null;
$data = json_decode(self::b64urlDecode($payload), true);
if (!$data) return null;
// Check expiry
if (($data['exp'] ?? 0) < time()) return null;
// Check issuer
if (($data['iss'] ?? '') !== get_site_url()) return null;
return $data;
}
/**
* Resolve a token string to an API key DB row.
* Accepts either a JWT (new) or a raw API key (legacy fallback).
*/
public static function resolve(string $token): ?array {
global $wpdb;
$table = $wpdb->prefix . 'asktam_mcp_keys';
// Try JWT first
$payload = self::verify($token);
if ($payload) {
$keyId = (int) ($payload['sub'] ?? 0);
$row = $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$table} WHERE id = %d AND is_active = 1", $keyId),
ARRAY_A
);
return $row ?: null;
}
// Legacy fallback: raw API key (for direct API use, not Claude.ai)
if (str_starts_with($token, 'mcp_')) {
return $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$table} WHERE api_key = %s AND is_active = 1", $token),
ARRAY_A
) ?: null;
}
return null;
}
// -------------------------------------------------------------------------
private static function getSecret(): string {
$secret = get_option(self::OPTION);
if (!$secret) {
$secret = bin2hex(random_bytes(32));
update_option(self::OPTION, $secret, false); // autoload=false
}
return $secret;
}
private static function b64url(string $data): string {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private static function b64urlDecode(string $data): string {
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4));
}
}