92 lines
3.3 KiB
PHP
92 lines
3.3 KiB
PHP
<?php
|
|||
|
|
|
||
|
|
namespace TinkerTickets\Mcp\Auth;
|
||
|
|
|
||
|
|
use Psr\Http\Message\ResponseFactoryInterface;
|
||
|
|
use Psr\Http\Message\ResponseInterface;
|
||
|
|
use Psr\Http\Message\ServerRequestInterface;
|
||
|
|
use Psr\Http\Server\MiddlewareInterface;
|
||
|
|
use Psr\Http\Server\RequestHandlerInterface;
|
||
|
|
use TinkerTickets\Mcp\ToolCatalog;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Per-operation scope enforcement, before anything is dispatched.
|
||
|
|
*
|
||
|
|
* - Lifecycle messages (initialize, ping, notifications/*, server/discover)
|
||
|
|
* need only a valid token.
|
||
|
|
* - tools/call on a write tool needs tickets:write.
|
||
|
|
* - Everything else (tools/list, read tools, ...) needs tickets:read, which
|
||
|
|
* tickets:write implies.
|
||
|
|
*
|
||
|
|
* Denials use the MCP spec's step-up challenge: HTTP 403 +
|
||
|
|
* WWW-Authenticate: Bearer error="insufficient_scope", listing every scope
|
||
|
|
* the request needs in one go.
|
||
|
|
*/
|
||
|
|
final class ToolScopeMiddleware implements MiddlewareInterface
|
||
|
|
{
|
||
|
|
private const LIFECYCLE_METHODS = ['initialize', 'ping', 'server/discover'];
|
||
|
|
|
||
|
|
public function __construct(
|
||
|
|
private readonly ResponseFactoryInterface $responseFactory,
|
||
|
|
private readonly string $resourceMetadataUrl,
|
||
|
|
) {
|
||
|
|
}
|
||
|
|
|
||
|
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||
|
|
{
|
||
|
|
if ($request->getMethod() !== 'POST') {
|
||
|
|
return $handler->handle($request);
|
||
|
|
}
|
||
|
|
|
||
|
|
$payload = json_decode((string)$request->getBody(), true);
|
||
|
|
$request->getBody()->rewind();
|
||
|
|
if (!is_array($payload)) {
|
||
|
|
return $handler->handle($request); // the SDK answers malformed JSON-RPC itself
|
||
|
|
}
|
||
|
|
|
||
|
|
$messages = array_is_list($payload) ? $payload : [$payload];
|
||
|
|
$needWrite = false;
|
||
|
|
$needRead = false;
|
||
|
|
foreach ($messages as $message) {
|
||
|
|
if (!is_array($message) || !is_string($message['method'] ?? null)) {
|
||
|
|
continue; // responses to server->client requests carry no method
|
||
|
|
}
|
||
|
|
$method = $message['method'];
|
||
|
|
if (in_array($method, self::LIFECYCLE_METHODS, true) || str_starts_with($method, 'notifications/')) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if ($method === 'tools/call' && ToolCatalog::isWriteTool((string)($message['params']['name'] ?? ''))) {
|
||
|
|
$needWrite = true;
|
||
|
|
} else {
|
||
|
|
$needRead = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$scopes = $request->getAttribute('oauth.scopes') ?? [];
|
||
|
|
if ($needWrite && !McpIdentity::canWrite($scopes)) {
|
||
|
|
return $this->insufficient(McpIdentity::SCOPE_WRITE, 'This operation requires the tickets:write scope.');
|
||
|
|
}
|
||
|
|
if ($needRead && !McpIdentity::canRead($scopes)) {
|
||
|
|
return $this->insufficient(
|
||
|
|
$needWrite ? McpIdentity::SCOPE_WRITE : McpIdentity::SCOPE_READ,
|
||
|
|
'This operation requires the tickets:read scope.'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return $handler->handle($request);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function insufficient(string $scope, string $description): ResponseInterface
|
||
|
|
{
|
||
|
|
return $this->responseFactory->createResponse(403)->withHeader(
|
||
|
|
'WWW-Authenticate',
|
||
|
|
sprintf(
|
||
|
|
'Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="%s"',
|
||
|
|
$scope,
|
||
|
|
$this->resourceMetadataUrl,
|
||
|
|
$description
|
||
|
|
)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|