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
199 changes: 164 additions & 35 deletions src/Auth/ConfirmationTokenManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

namespace Cboxdk\StatamicMcp\Auth;

use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;

class ConfirmationTokenManager
{
/**
Expand All @@ -14,11 +17,29 @@ class ConfirmationTokenManager
public function generate(string $tool, array $arguments): string
{
$timestamp = time();
$payload = $this->buildPayload($tool, $arguments, $timestamp);
$nonce = bin2hex(random_bytes(16));
$arguments = $this->stripConfirmationToken($arguments);
$payload = $this->buildPayload($tool, $arguments, $timestamp, $nonce);

$signature = hash_hmac('sha256', $payload, $this->getKey());

return base64_encode($timestamp . '.' . $signature);
$encoded = json_encode([
'timestamp' => $timestamp,
'nonce' => $nonce,
'arguments' => $arguments,
'signature' => $signature,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

if ($encoded === false) {
throw new \InvalidArgumentException('Cannot encode confirmation token: ' . json_last_error_msg());
}

$compressed = gzdeflate($encoded, 9);
if ($compressed === false) {
throw new \InvalidArgumentException('Cannot compress confirmation token.');
}

return Crypt::encryptString(base64_encode($compressed));
}

/**
Expand All @@ -28,41 +49,37 @@ public function generate(string $tool, array $arguments): string
*/
public function validate(string $token, string $tool, array $arguments): bool
{
if ($token === '') {
return false;
}

$decoded = base64_decode($token, true);
if ($decoded === false) {
$parts = $this->parseToken($token);
if ($parts === null) {
return false;
}

$dotPos = strpos($decoded, '.');
if ($dotPos === false) {
if ((time() - $parts['timestamp']) > $this->ttl()) {
return false;
}

$timestampStr = substr($decoded, 0, $dotPos);
$signature = substr($decoded, $dotPos + 1);

if (! is_numeric($timestampStr) || $signature === '') {
return false;
}
// Rebuild payload and compare signatures
$expectedPayload = $this->buildPayload($tool, $parts['arguments'], $parts['timestamp'], $parts['nonce']);
$expectedSignature = hash_hmac('sha256', $expectedPayload, $this->getKey());

$timestamp = (int) $timestampStr;
return hash_equals($expectedSignature, $parts['signature'])
&& $this->canonicalArgumentsMatch($arguments, $parts['arguments']);
}

// Check expiry
/** @var int $ttl */
$ttl = config('statamic.mcp.confirmation.ttl', 300);
if ((time() - $timestamp) > $ttl) {
return false;
/**
* Validate a confirmation token and return the originally confirmed arguments.
*
* @param array<string, mixed> $arguments
*
* @return array<string, mixed>|null
*/
public function validatedArguments(string $token, string $tool, array $arguments): ?array
{
if (! $this->validate($token, $tool, $arguments)) {
return null;
}

// Rebuild payload and compare signatures
$expectedPayload = $this->buildPayload($tool, $arguments, $timestamp);
$expectedSignature = hash_hmac('sha256', $expectedPayload, $this->getKey());

return hash_equals($expectedSignature, $signature);
return $this->parseToken($token)['arguments'] ?? null;
}

/**
Expand All @@ -86,21 +103,133 @@ public function isEnabled(): bool
*
* @param array<string, mixed> $arguments
*/
private function buildPayload(string $tool, array $arguments, int $timestamp): string
private function buildPayload(string $tool, array $arguments, int $timestamp, string $nonce): string
{
// Strip the confirmation_token from arguments before canonicalizing
unset($arguments['confirmation_token']);
$arguments = $this->stripConfirmationToken($arguments);
$exact = json_encode($arguments, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

// Sort keys for canonical ordering
ksort($arguments);
$canonicalArguments = $this->canonicalize($arguments);

$canonical = json_encode($arguments, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$canonical = json_encode($canonicalArguments, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

if ($canonical === false) {
if ($canonical === false || $exact === false) {
throw new \InvalidArgumentException('Cannot canonicalize arguments: ' . json_last_error_msg());
}

return $tool . '|' . $canonical . '|' . $timestamp;
return $tool . '|' . $canonical . '|' . $exact . '|' . $timestamp . '|' . $nonce;
}

/**
* Strip the confirmation token from arguments before signing.
*
* @param array<string, mixed> $arguments
*
* @return array<string, mixed>
*/
private function stripConfirmationToken(array $arguments): array
{
unset($arguments['confirmation_token']);

return $arguments;
}

/**
* Recursively sort associative arrays while preserving list order.
*
* @param array<array-key, mixed> $value
*
* @return array<array-key, mixed>
*/
private function canonicalize(array $value): array
{
foreach ($value as $key => $item) {
if (is_array($item)) {
$value[$key] = $this->canonicalize($item);
}
}

if (! array_is_list($value)) {
ksort($value);
}

return $value;
}

/**
* @param array<string, mixed> $provided
* @param array<string, mixed> $confirmed
*/
private function canonicalArgumentsMatch(array $provided, array $confirmed): bool
{
$provided = $this->canonicalize($this->stripConfirmationToken($provided));
$confirmed = $this->canonicalize($this->stripConfirmationToken($confirmed));

return json_encode($provided, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
=== json_encode($confirmed, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}

/**
* @return array{timestamp: int, nonce: string, arguments: array<string, mixed>, signature: string}|null
*/
private function parseToken(string $token): ?array
{
if ($token === '') {
return null;
}

try {
$encryptedPayload = Crypt::decryptString($token);
} catch (DecryptException) {
return null;
}

$compressed = base64_decode($encryptedPayload, true);
if ($compressed === false) {
return null;
}

$decoded = gzinflate($compressed);
if ($decoded === false) {
return null;
}

$payload = json_decode($decoded, true);
if (! is_array($payload)) {
return null;
}

$timestamp = $payload['timestamp'] ?? null;
$nonce = $payload['nonce'] ?? null;
$arguments = $payload['arguments'] ?? null;
$signature = $payload['signature'] ?? null;

if (! is_int($timestamp) || ! is_string($nonce) || $nonce === '' || ! is_array($arguments) || ! is_string($signature) || $signature === '') {
return null;
}

$confirmedArguments = [];
foreach ($arguments as $key => $value) {
if (! is_string($key)) {
return null;
}

$confirmedArguments[$key] = $value;
}

return [
'timestamp' => $timestamp,
'nonce' => $nonce,
'arguments' => $confirmedArguments,
'signature' => $signature,
];
}

private function ttl(): int
{
/** @var int $ttl */
$ttl = config('statamic.mcp.confirmation.ttl', 300);

return $ttl;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/Mcp/Tools/BaseRouter.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ protected function defineSchema(JsonSchemaContract $schema): array
'resource_type' => JsonSchema::string()
->description('Resource subtype for routers that manage multiple resource kinds. See the specific tool description for valid values and required combinations with actions.')
->enum($types),
'confirmation_token' => JsonSchema::string()
->description('Optional token returned by a previous confirmation-required response. Provide it unchanged to confirm and execute gated actions.'),
];
}

Expand Down
7 changes: 5 additions & 2 deletions src/Mcp/Tools/Concerns/RequiresConfirmation.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ protected function requiresConfirmation(string $action): bool
*
* @return array<string, mixed>|null
*/
protected function handleConfirmation(string $action, array $arguments): ?array
protected function handleConfirmation(string $action, array &$arguments): ?array
{
// Skip if confirmation is not required for this action
if (! $this->requiresConfirmation($action)) {
Expand All @@ -62,7 +62,10 @@ protected function handleConfirmation(string $action, array $arguments): ?array
$token = $arguments['confirmation_token'] ?? null;
if (is_string($token) && $token !== '') {
$toolName = $this->name();
if ($manager->validate($token, $toolName, $arguments)) {
$confirmedArguments = $manager->validatedArguments($token, $toolName, $arguments);
if ($confirmedArguments !== null) {
$arguments = $confirmedArguments;

return null; // Token valid, proceed
}

Expand Down
Loading
Loading