[Server] Oauth2 based on middleware (#221)
* Add Middleware handlers to StreamableHttpTransport * OAuth Implementation based on middleware * OAuth Implementation based on middleware * Add Middleware handlers to StreamableHttpTransport * OAuth Implementation based on middleware * OAuth Implementation based on middleware Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com> * Address PR review feedback for OAuth middleware * Apply php-cs-fixer to satisfy QA * Use Mcp exception classes in OAuth middleware * Refactor OAuth HTTP stack and add Microsoft example policies * Fix QA PHPStan findings in OAuth examples and auth middleware * Require OIDC discovery in JWKS provider and refine OAuth proxy behavior * Remove local development docker artifacts from VCS * Revert .gitignore changes for local docker files * Fix body retrieval in OAuthRequestMetaMiddleware for proper string conversion Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com> * Address OAuth PR review feedback and align example configs * Fix QA style issues and add authorization docs * Fix PHPStan typing issues in OAuth middleware * Remove duplicate Middleware namespace, consolidate under Http\Middleware Remove old Mcp\Server\Transport\Middleware\ classes that were duplicated in Mcp\Server\Transport\Http\Middleware\ and Http\OAuth\. Update docs to reference the correct namespaces. * Address PR review: replace Throwable catch with specific exceptions, add JSON_THROW_ON_ERROR - JwtTokenValidator: replace catch(\Throwable) with explicit \InvalidArgumentException|\UnexpectedValueException|\DomainException, letting RuntimeException from JwksProvider propagate as infrastructure errors - OAuthProxyMiddleware: add \JSON_THROW_ON_ERROR to json_encode for metadata response - Update tests to expect RuntimeException for JWKS infrastructure failures --------- Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com> Co-authored-by: Volodymyr Panivko <volodymyr.panivko@togethernetworks.com>
This commit is contained in:
parent
61c20f1450
commit
e3f4b88768
14 changed files with 6038 additions and 0 deletions
182
src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php
Normal file
182
src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\Middleware;
|
||||
|
||||
use Http\Discovery\Psr17FactoryDiscovery;
|
||||
use Mcp\Exception\RuntimeException;
|
||||
use Mcp\Server\Transport\Http\OAuth\AuthorizationResult;
|
||||
use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface;
|
||||
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Enforces MCP HTTP authorization requirements.
|
||||
*
|
||||
* This middleware:
|
||||
* - Validates Bearer tokens via the configured validator
|
||||
* - Returns 401 with WWW-Authenticate header on missing/invalid tokens
|
||||
* - Returns 403 on insufficient scope
|
||||
*
|
||||
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
final class AuthorizationMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private ResponseFactoryInterface $responseFactory;
|
||||
|
||||
/**
|
||||
* @param AuthorizationTokenValidatorInterface $validator Token validator implementation
|
||||
* @param ProtectedResourceMetadata $resourceMetadata Protected resource metadata object used for challenge hints
|
||||
* @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null)
|
||||
*/
|
||||
public function __construct(
|
||||
private AuthorizationTokenValidatorInterface $validator,
|
||||
private ProtectedResourceMetadata $resourceMetadata,
|
||||
?ResponseFactoryInterface $responseFactory = null,
|
||||
) {
|
||||
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$authorization = $request->getHeaderLine('Authorization');
|
||||
if ('' === $authorization) {
|
||||
return $this->buildErrorResponse($request, AuthorizationResult::unauthorized());
|
||||
}
|
||||
|
||||
$accessToken = $this->parseBearerToken($authorization);
|
||||
if (null === $accessToken) {
|
||||
return $this->buildErrorResponse(
|
||||
$request,
|
||||
AuthorizationResult::badRequest('invalid_request', 'Malformed Authorization header.'),
|
||||
);
|
||||
}
|
||||
|
||||
$result = $this->validator->validate($accessToken);
|
||||
if (!$result->isAllowed()) {
|
||||
return $this->buildErrorResponse($request, $result);
|
||||
}
|
||||
|
||||
return $handler->handle($this->applyAttributes($request, $result->getAttributes()));
|
||||
}
|
||||
|
||||
private function buildErrorResponse(ServerRequestInterface $request, AuthorizationResult $result): ResponseInterface
|
||||
{
|
||||
$response = $this->responseFactory->createResponse($result->getStatusCode());
|
||||
$header = $this->buildAuthenticateHeader($request, $result);
|
||||
|
||||
$response = $response->withHeader('WWW-Authenticate', $header);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function buildAuthenticateHeader(ServerRequestInterface $request, AuthorizationResult $result): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
$parts[] = 'resource_metadata="'.$this->escapeHeaderValue($this->resolveResourceMetadataUrl($request)).'"';
|
||||
|
||||
$scopes = $this->resolveScopes($result);
|
||||
if (null !== $scopes) {
|
||||
$parts[] = 'scope="'.$this->escapeHeaderValue(implode(' ', $scopes)).'"';
|
||||
}
|
||||
|
||||
if (null !== $result->getError()) {
|
||||
$parts[] = 'error="'.$this->escapeHeaderValue($result->getError()).'"';
|
||||
}
|
||||
|
||||
if (null !== $result->getErrorDescription()) {
|
||||
$parts[] = 'error_description="'.$this->escapeHeaderValue($result->getErrorDescription()).'"';
|
||||
}
|
||||
|
||||
return 'Bearer '.implode(', ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null
|
||||
*/
|
||||
private function resolveScopes(AuthorizationResult $result): ?array
|
||||
{
|
||||
$scopes = $this->normalizeScopes($result->getScopes());
|
||||
if (null !== $scopes) {
|
||||
return $scopes;
|
||||
}
|
||||
|
||||
return $this->normalizeScopes($this->resourceMetadata->getScopesSupported());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|null $scopes
|
||||
*
|
||||
* @return list<string>|null
|
||||
*/
|
||||
private function normalizeScopes(?array $scopes): ?array
|
||||
{
|
||||
if (null === $scopes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = array_values(array_filter(array_map('trim', $scopes), static function (string $scope): bool {
|
||||
return '' !== $scope;
|
||||
}));
|
||||
|
||||
return [] === $normalized ? null : $normalized;
|
||||
}
|
||||
|
||||
private function resolveResourceMetadataUrl(ServerRequestInterface $request): string
|
||||
{
|
||||
$metadataPath = $this->resourceMetadata->getPrimaryMetadataPath();
|
||||
|
||||
$uri = $request->getUri();
|
||||
$scheme = $uri->getScheme();
|
||||
$authority = $uri->getAuthority();
|
||||
|
||||
if ('' === $scheme || '' === $authority) {
|
||||
throw new RuntimeException('Cannot resolve resource metadata URL: request URI must have scheme and authority');
|
||||
}
|
||||
|
||||
return $scheme.'://'.$authority.$metadataPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
private function applyAttributes(ServerRequestInterface $request, array $attributes): ServerRequestInterface
|
||||
{
|
||||
foreach ($attributes as $name => $value) {
|
||||
$request = $request->withAttribute($name, $value);
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function parseBearerToken(string $authorization): ?string
|
||||
{
|
||||
if (!preg_match('/^Bearer\\s+(.+)$/', $authorization, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = trim($matches[1]);
|
||||
|
||||
return '' === $token ? null : $token;
|
||||
}
|
||||
|
||||
private function escapeHeaderValue(string $value): string
|
||||
{
|
||||
return str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
|
||||
}
|
||||
}
|
||||
273
src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php
Normal file
273
src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\Middleware;
|
||||
|
||||
use Http\Discovery\Psr17FactoryDiscovery;
|
||||
use Http\Discovery\Psr18ClientDiscovery;
|
||||
use Mcp\Exception\RuntimeException;
|
||||
use Mcp\Server\Transport\Http\OAuth\OidcDiscoveryInterface;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Http\Message\RequestFactoryInterface;
|
||||
use Psr\Http\Message\ResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
/**
|
||||
* Proxies OAuth authorization requests to an upstream authorization server.
|
||||
*
|
||||
* This middleware implements the OAuth proxy pattern for MCP servers that
|
||||
* delegate authorization to third-party OAuth providers (Microsoft, Keycloak, etc.).
|
||||
*
|
||||
* It handles:
|
||||
* - /authorize: Redirects to the upstream authorization server
|
||||
* - /token: Proxies token requests to the upstream token endpoint
|
||||
* - /.well-known/oauth-authorization-server: Serves authorization server metadata
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
final class OAuthProxyMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private const CLIENT_SECRET_BASIC = 'client_secret_basic';
|
||||
private const CLIENT_SECRET_POST = 'client_secret_post';
|
||||
|
||||
private ?ClientInterface $httpClient;
|
||||
private ?RequestFactoryInterface $requestFactory;
|
||||
private ResponseFactoryInterface $responseFactory;
|
||||
private StreamFactoryInterface $streamFactory;
|
||||
|
||||
/**
|
||||
* @param string $upstreamIssuer The issuer URL of the upstream OAuth provider
|
||||
* @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000)
|
||||
* @param string|null $clientSecret Optional client secret for confidential clients
|
||||
* @param OidcDiscoveryInterface $discovery OIDC discovery provider for upstream metadata
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $upstreamIssuer,
|
||||
private readonly string $localBaseUrl,
|
||||
private readonly OidcDiscoveryInterface $discovery,
|
||||
private readonly ?string $clientSecret = null,
|
||||
?ClientInterface $httpClient = null,
|
||||
?RequestFactoryInterface $requestFactory = null,
|
||||
?ResponseFactoryInterface $responseFactory = null,
|
||||
?StreamFactoryInterface $streamFactory = null,
|
||||
) {
|
||||
$this->httpClient = $httpClient;
|
||||
$this->requestFactory = $requestFactory;
|
||||
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
|
||||
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$path = $request->getUri()->getPath();
|
||||
|
||||
if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) {
|
||||
return $this->createAuthServerMetadataResponse();
|
||||
}
|
||||
|
||||
if ('GET' === $request->getMethod() && '/authorize' === $path) {
|
||||
return $this->handleAuthorize($request);
|
||||
}
|
||||
|
||||
if ('POST' === $request->getMethod() && '/token' === $path) {
|
||||
return $this->handleToken($request);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
private function handleAuthorize(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
try {
|
||||
$authorizationEndpoint = $this->discovery->getAuthorizationEndpoint($this->upstreamIssuer);
|
||||
} catch (RuntimeException) {
|
||||
return $this->createErrorResponse(500, 'Upstream authorization endpoint not found');
|
||||
}
|
||||
|
||||
$rawQueryString = $request->getUri()->getQuery();
|
||||
$upstreamUrl = $authorizationEndpoint;
|
||||
if ('' !== $rawQueryString) {
|
||||
$upstreamUrl .= '?'.$rawQueryString;
|
||||
}
|
||||
|
||||
return $this->responseFactory
|
||||
->createResponse(302)
|
||||
->withHeader('Location', $upstreamUrl)
|
||||
->withHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
private function handleToken(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
try {
|
||||
$tokenEndpoint = $this->discovery->getTokenEndpoint($this->upstreamIssuer);
|
||||
} catch (RuntimeException) {
|
||||
return $this->createErrorResponse(500, 'Upstream token endpoint not found');
|
||||
}
|
||||
|
||||
$body = $request->getBody()->__toString();
|
||||
parse_str($body, $params);
|
||||
|
||||
$upstreamAuthorization = trim($request->getHeaderLine('Authorization'));
|
||||
if ('' === $upstreamAuthorization) {
|
||||
$upstreamAuthorization = null;
|
||||
}
|
||||
|
||||
if (null !== $this->clientSecret && !isset($params['client_secret']) && null === $upstreamAuthorization) {
|
||||
$authMethod = $this->resolveTokenEndpointAuthMethod();
|
||||
|
||||
if (self::CLIENT_SECRET_BASIC === $authMethod) {
|
||||
$clientId = $params['client_id'] ?? null;
|
||||
|
||||
if (\is_string($clientId) && '' !== trim($clientId)) {
|
||||
$upstreamAuthorization = 'Basic '.base64_encode(trim($clientId).':'.$this->clientSecret);
|
||||
} else {
|
||||
$params['client_secret'] = $this->clientSecret;
|
||||
}
|
||||
} else {
|
||||
$params['client_secret'] = $this->clientSecret;
|
||||
}
|
||||
}
|
||||
|
||||
$body = http_build_query($params);
|
||||
|
||||
$upstreamRequest = $this->getRequestFactory()
|
||||
->createRequest('POST', $tokenEndpoint)
|
||||
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
|
||||
->withBody($this->streamFactory->createStream($body));
|
||||
|
||||
if (null !== $upstreamAuthorization) {
|
||||
$upstreamRequest = $upstreamRequest->withHeader('Authorization', $upstreamAuthorization);
|
||||
}
|
||||
|
||||
try {
|
||||
$upstreamResponse = $this->getHttpClient()->sendRequest($upstreamRequest);
|
||||
$responseBody = $upstreamResponse->getBody()->__toString();
|
||||
|
||||
return $this->responseFactory
|
||||
->createResponse($upstreamResponse->getStatusCode())
|
||||
->withHeader('Content-Type', $upstreamResponse->getHeaderLine('Content-Type'))
|
||||
->withHeader('Cache-Control', 'no-store')
|
||||
->withBody($this->streamFactory->createStream($responseBody));
|
||||
} catch (ClientExceptionInterface $e) {
|
||||
return $this->createErrorResponse(502, 'Failed to contact upstream token endpoint: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function createAuthServerMetadataResponse(): ResponseInterface
|
||||
{
|
||||
try {
|
||||
$upstreamMetadata = $this->discovery->discover($this->upstreamIssuer);
|
||||
} catch (RuntimeException) {
|
||||
return $this->createErrorResponse(500, 'Failed to discover upstream server metadata');
|
||||
}
|
||||
|
||||
$localBaseUrl = rtrim($this->localBaseUrl, '/');
|
||||
$localMetadata = [
|
||||
'issuer' => $localBaseUrl,
|
||||
'authorization_endpoint' => $localBaseUrl.'/authorize',
|
||||
'token_endpoint' => $localBaseUrl.'/token',
|
||||
'response_types_supported' => $upstreamMetadata['response_types_supported'] ?? ['code'],
|
||||
'grant_types_supported' => $upstreamMetadata['grant_types_supported'] ?? ['authorization_code', 'refresh_token'],
|
||||
'code_challenge_methods_supported' => $upstreamMetadata['code_challenge_methods_supported'] ?? ['S256'],
|
||||
];
|
||||
|
||||
$copyFields = [
|
||||
'scopes_supported',
|
||||
'token_endpoint_auth_methods_supported',
|
||||
'jwks_uri',
|
||||
];
|
||||
|
||||
foreach ($copyFields as $field) {
|
||||
if (isset($upstreamMetadata[$field])) {
|
||||
$localMetadata[$field] = $upstreamMetadata[$field];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->responseFactory
|
||||
->createResponse(200)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withHeader('Cache-Control', 'max-age=3600')
|
||||
->withBody($this->streamFactory->createStream(json_encode($localMetadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)));
|
||||
}
|
||||
|
||||
private function createErrorResponse(int $status, string $message): ResponseInterface
|
||||
{
|
||||
$body = json_encode(['error' => 'server_error', 'error_description' => $message]);
|
||||
|
||||
return $this->responseFactory
|
||||
->createResponse($status)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->streamFactory->createStream($body));
|
||||
}
|
||||
|
||||
private function resolveTokenEndpointAuthMethod(): string
|
||||
{
|
||||
$supportedMethods = $this->getTokenEndpointAuthMethods();
|
||||
|
||||
if (\in_array(self::CLIENT_SECRET_BASIC, $supportedMethods, true)) {
|
||||
return self::CLIENT_SECRET_BASIC;
|
||||
}
|
||||
|
||||
if (\in_array(self::CLIENT_SECRET_POST, $supportedMethods, true)) {
|
||||
return self::CLIENT_SECRET_POST;
|
||||
}
|
||||
|
||||
return self::CLIENT_SECRET_POST;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function getTokenEndpointAuthMethods(): array
|
||||
{
|
||||
try {
|
||||
$metadata = $this->discovery->discover($this->upstreamIssuer);
|
||||
} catch (RuntimeException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$methods = $metadata['token_endpoint_auth_methods_supported'] ?? null;
|
||||
if (!\is_array($methods)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($methods as $method) {
|
||||
if (!\is_string($method)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$method = trim($method);
|
||||
if ('' === $method) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $method;
|
||||
}
|
||||
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
|
||||
private function getHttpClient(): ClientInterface
|
||||
{
|
||||
return $this->httpClient ??= Psr18ClientDiscovery::find();
|
||||
}
|
||||
|
||||
private function getRequestFactory(): RequestFactoryInterface
|
||||
{
|
||||
return $this->requestFactory ??= Psr17FactoryDiscovery::findRequestFactory();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\Middleware;
|
||||
|
||||
use Http\Discovery\Psr17FactoryDiscovery;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
/**
|
||||
* Copies OAuth request attributes to JSON-RPC request meta.
|
||||
*
|
||||
* This middleware bridges HTTP-layer authorization attributes (oauth.*) to
|
||||
* JSON-RPC `_meta.oauth` so tools can access them via RequestContext.
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
final class OAuthRequestMetaMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ?StreamFactoryInterface $streamFactory = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
if ('POST' !== $request->getMethod()) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$oauthMeta = $this->extractOAuthAttributes($request);
|
||||
if ([] === $oauthMeta) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$body = $request->getBody()->__toString();
|
||||
if ('' === trim($body)) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = json_decode($body, true, 512, \JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$updatedPayload = $this->injectOauthMeta($payload, $oauthMeta);
|
||||
if (null === $updatedPayload) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
try {
|
||||
$updatedBody = json_encode($updatedPayload, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
|
||||
} catch (\JsonException) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$request = $request->withBody($this->getStreamFactory()->createStream($updatedBody));
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function extractOAuthAttributes(ServerRequestInterface $request): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($request->getAttributes() as $key => $value) {
|
||||
if (\is_string($key) && str_starts_with($key, 'oauth.')) {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $oauthMeta
|
||||
*/
|
||||
private function injectOauthMeta(mixed $payload, array $oauthMeta): mixed
|
||||
{
|
||||
if (!\is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (array_is_list($payload)) {
|
||||
$updated = [];
|
||||
foreach ($payload as $entry) {
|
||||
if (!\is_array($entry)) {
|
||||
$updated[] = $entry;
|
||||
continue;
|
||||
}
|
||||
|
||||
$updated[] = $this->injectIntoMessage($entry, $oauthMeta);
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
return $this->injectIntoMessage($payload, $oauthMeta);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $message
|
||||
* @param array<string, mixed> $oauthMeta
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function injectIntoMessage(array $message, array $oauthMeta): array
|
||||
{
|
||||
$params = $message['params'] ?? [];
|
||||
if (!\is_array($params)) {
|
||||
return $message;
|
||||
}
|
||||
|
||||
$meta = $params['_meta'] ?? [];
|
||||
if (!\is_array($meta)) {
|
||||
$meta = [];
|
||||
}
|
||||
|
||||
$existingOAuth = $meta['oauth'] ?? [];
|
||||
if (!\is_array($existingOAuth)) {
|
||||
$existingOAuth = [];
|
||||
}
|
||||
|
||||
$meta['oauth'] = array_merge($existingOAuth, $oauthMeta);
|
||||
$params['_meta'] = $meta;
|
||||
$message['params'] = $params;
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
private function getStreamFactory(): StreamFactoryInterface
|
||||
{
|
||||
return $this->streamFactory ??= Psr17FactoryDiscovery::findStreamFactory();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\Middleware;
|
||||
|
||||
use Http\Discovery\Psr17FactoryDiscovery;
|
||||
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
|
||||
use Psr\Http\Message\ResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
/**
|
||||
* Serves OAuth 2.0 Protected Resource Metadata (RFC 9728) at well-known endpoints.
|
||||
*
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc9728
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
final class ProtectedResourceMetadataMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private ResponseFactoryInterface $responseFactory;
|
||||
private StreamFactoryInterface $streamFactory;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProtectedResourceMetadata $metadata,
|
||||
?ResponseFactoryInterface $responseFactory = null,
|
||||
?StreamFactoryInterface $streamFactory = null,
|
||||
) {
|
||||
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
|
||||
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
if (!$this->isMetadataRequest($request)) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
return $this->responseFactory
|
||||
->createResponse(200)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->streamFactory->createStream(json_encode($this->metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)));
|
||||
}
|
||||
|
||||
private function isMetadataRequest(ServerRequestInterface $request): bool
|
||||
{
|
||||
if ('GET' !== $request->getMethod()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \in_array($request->getUri()->getPath(), $this->metadata->getMetadataPaths(), true);
|
||||
}
|
||||
}
|
||||
135
src/Server/Transport/Http/OAuth/AuthorizationResult.php
Normal file
135
src/Server/Transport/Http/OAuth/AuthorizationResult.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\OAuth;
|
||||
|
||||
/**
|
||||
* Describes the outcome of an authorization decision.
|
||||
*
|
||||
* Use the static factory methods to create instances:
|
||||
* - allow() - Access is granted
|
||||
* - unauthorized() - No valid credentials provided (401)
|
||||
* - forbidden() - Valid credentials but insufficient permissions (403)
|
||||
* - badRequest() - Malformed request (400)
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
final class AuthorizationResult
|
||||
{
|
||||
/**
|
||||
* @param list<string>|null $scopes Scopes to include in WWW-Authenticate challenge
|
||||
* @param array<string, mixed> $attributes Attributes to attach to the request on success
|
||||
*/
|
||||
private function __construct(
|
||||
private readonly bool $allowed,
|
||||
private readonly int $statusCode,
|
||||
private readonly ?string $error,
|
||||
private readonly ?string $errorDescription,
|
||||
private readonly ?array $scopes,
|
||||
private readonly array $attributes,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a result indicating access is allowed.
|
||||
*
|
||||
* @param array<string, mixed> $attributes Attributes to attach to the request (e.g., user_id, scopes)
|
||||
*/
|
||||
public static function allow(array $attributes = []): self
|
||||
{
|
||||
return new self(true, 200, null, null, null, $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a result indicating the request is unauthorized (401).
|
||||
*
|
||||
* Use when no valid credentials are provided or the token is invalid.
|
||||
*
|
||||
* @param string|null $error OAuth error code (e.g., "invalid_token")
|
||||
* @param string|null $errorDescription Human-readable error description
|
||||
* @param list<string>|null $scopes Required scopes to include in challenge
|
||||
*/
|
||||
public static function unauthorized(
|
||||
?string $error = null,
|
||||
?string $errorDescription = null,
|
||||
?array $scopes = null,
|
||||
): self {
|
||||
return new self(false, 401, $error, $errorDescription, $scopes, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a result indicating the request is forbidden (403).
|
||||
*
|
||||
* Use when the token is valid but lacks required permissions/scopes.
|
||||
*
|
||||
* @param string|null $error OAuth error code (defaults to "insufficient_scope")
|
||||
* @param string|null $errorDescription Human-readable error description
|
||||
* @param list<string>|null $scopes Required scopes to include in challenge
|
||||
*/
|
||||
public static function forbidden(
|
||||
?string $error = 'insufficient_scope',
|
||||
?string $errorDescription = null,
|
||||
?array $scopes = null,
|
||||
): self {
|
||||
return new self(false, 403, $error ?? 'insufficient_scope', $errorDescription, $scopes, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a result indicating a bad request (400).
|
||||
*
|
||||
* Use when the Authorization header is malformed.
|
||||
*
|
||||
* @param string|null $error OAuth error code (defaults to "invalid_request")
|
||||
* @param string|null $errorDescription Human-readable error description
|
||||
*/
|
||||
public static function badRequest(
|
||||
?string $error = 'invalid_request',
|
||||
?string $errorDescription = null,
|
||||
): self {
|
||||
return new self(false, 400, $error ?? 'invalid_request', $errorDescription, null, []);
|
||||
}
|
||||
|
||||
public function isAllowed(): bool
|
||||
{
|
||||
return $this->allowed;
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function getError(): ?string
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
public function getErrorDescription(): ?string
|
||||
{
|
||||
return $this->errorDescription;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null
|
||||
*/
|
||||
public function getScopes(): ?array
|
||||
{
|
||||
return $this->scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getAttributes(): array
|
||||
{
|
||||
return $this->attributes;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\OAuth;
|
||||
|
||||
/**
|
||||
* Validates bearer tokens for HTTP transports.
|
||||
*
|
||||
* Implementations should validate the access token and return an AuthorizationResult
|
||||
* indicating whether access is allowed or denied.
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
interface AuthorizationTokenValidatorInterface
|
||||
{
|
||||
/**
|
||||
* Validates an access token extracted from the Authorization header.
|
||||
*
|
||||
* @param string $accessToken The bearer token (without "Bearer " prefix)
|
||||
*
|
||||
* @return AuthorizationResult The result of the validation
|
||||
*/
|
||||
public function validate(string $accessToken): AuthorizationResult;
|
||||
}
|
||||
122
src/Server/Transport/Http/OAuth/JwksProvider.php
Normal file
122
src/Server/Transport/Http/OAuth/JwksProvider.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\OAuth;
|
||||
|
||||
use Http\Discovery\Psr17FactoryDiscovery;
|
||||
use Http\Discovery\Psr18ClientDiscovery;
|
||||
use Mcp\Exception\RuntimeException;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Http\Message\RequestFactoryInterface;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
|
||||
/**
|
||||
* Loads JWKS key sets from explicit URI or discovered issuer metadata.
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
class JwksProvider implements JwksProviderInterface
|
||||
{
|
||||
private const CACHE_KEY_PREFIX = 'mcp_jwks_';
|
||||
|
||||
private ClientInterface $httpClient;
|
||||
private RequestFactoryInterface $requestFactory;
|
||||
|
||||
/**
|
||||
* @param OidcDiscoveryInterface $discovery OIDC discovery provider (required for JWKS URI resolution when $jwksUri is not explicit)
|
||||
* @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null)
|
||||
* @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null)
|
||||
* @param CacheInterface|null $cache Optional PSR-16 cache
|
||||
* @param int $cacheTtl JWKS cache TTL in seconds
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly OidcDiscoveryInterface $discovery,
|
||||
?ClientInterface $httpClient = null,
|
||||
?RequestFactoryInterface $requestFactory = null,
|
||||
private readonly ?CacheInterface $cache = null,
|
||||
private readonly int $cacheTtl = 3600,
|
||||
) {
|
||||
$this->httpClient = $httpClient ?? Psr18ClientDiscovery::find();
|
||||
$this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getJwks(string $issuer, ?string $jwksUri = null): array
|
||||
{
|
||||
$jwksUri ??= $this->discovery->getJwksUri($issuer);
|
||||
$cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $jwksUri);
|
||||
|
||||
if (null !== $this->cache) {
|
||||
$cached = $this->cache->get($cacheKey);
|
||||
if ($this->isJwksValid($cached)) {
|
||||
/* @var array<string, mixed> $cached */
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$jwks = $this->fetchJwks($jwksUri);
|
||||
|
||||
if (!$this->isJwksValid($jwks)) {
|
||||
throw new RuntimeException(\sprintf('JWKS response from %s has invalid format: expected non-empty "keys" array.', $jwksUri));
|
||||
}
|
||||
|
||||
if (null !== $this->cache) {
|
||||
$this->cache->set($cacheKey, $jwks, $this->cacheTtl);
|
||||
}
|
||||
|
||||
return $jwks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function fetchJwks(string $jwksUri): array
|
||||
{
|
||||
$request = $this->requestFactory->createRequest('GET', $jwksUri)
|
||||
->withHeader('Accept', 'application/json');
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->sendRequest($request);
|
||||
} catch (ClientExceptionInterface $e) {
|
||||
throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: %s', $jwksUri, $e->getMessage()), 0, $e);
|
||||
}
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: HTTP %d', $jwksUri, $response->getStatusCode()));
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
throw new RuntimeException(\sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e);
|
||||
}
|
||||
|
||||
if (!\is_array($data)) {
|
||||
throw new RuntimeException('Invalid JWKS format: expected JSON object.');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function isJwksValid(mixed $jwks): bool
|
||||
{
|
||||
if (!\is_array($jwks) || !isset($jwks['keys']) || !\is_array($jwks['keys'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nonEmptyKeys = array_filter($jwks['keys'], static fn (mixed $key): bool => \is_array($key) && [] !== $key);
|
||||
|
||||
return [] !== $nonEmptyKeys;
|
||||
}
|
||||
}
|
||||
28
src/Server/Transport/Http/OAuth/JwksProviderInterface.php
Normal file
28
src/Server/Transport/Http/OAuth/JwksProviderInterface.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\OAuth;
|
||||
|
||||
/**
|
||||
* Contract for loading JWKS key sets used for JWT signature verification.
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
interface JwksProviderInterface
|
||||
{
|
||||
/**
|
||||
* @param string $issuer authorization server issuer URL
|
||||
* @param string|null $jwksUri Optional explicit JWKS URI. If null, implementation may resolve via discovery.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getJwks(string $issuer, ?string $jwksUri = null): array;
|
||||
}
|
||||
205
src/Server/Transport/Http/OAuth/JwtTokenValidator.php
Normal file
205
src/Server/Transport/Http/OAuth/JwtTokenValidator.php
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\OAuth;
|
||||
|
||||
use Firebase\JWT\BeforeValidException;
|
||||
use Firebase\JWT\ExpiredException;
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\SignatureInvalidException;
|
||||
use Mcp\Exception\RuntimeException;
|
||||
|
||||
/**
|
||||
* Validates JWT access tokens using JWKS from an OAuth 2.0 / OpenID Connect provider.
|
||||
*
|
||||
* This validator:
|
||||
* - Fetches JWKS from the authorization server (auto-discovered or explicit)
|
||||
* - Validates signature, audience, issuer, and expiration
|
||||
* - Extracts scopes and claims as authorization attributes
|
||||
*
|
||||
* Requires: firebase/php-jwt
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
class JwtTokenValidator implements AuthorizationTokenValidatorInterface
|
||||
{
|
||||
/**
|
||||
* @param string|list<string> $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp")
|
||||
* @param string|list<string> $audience Expected audience(s) for the token
|
||||
* @param JwksProviderInterface $jwksProvider JWKS provider
|
||||
* @param string|null $jwksUri Explicit JWKS URI (auto-discovered from first issuer if null)
|
||||
* @param list<string> $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512)
|
||||
* @param string $scopeClaim Claim name for scopes (default: "scope")
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string|array $issuer,
|
||||
private readonly string|array $audience,
|
||||
private readonly JwksProviderInterface $jwksProvider,
|
||||
private readonly ?string $jwksUri = null,
|
||||
private readonly array $algorithms = ['RS256', 'RS384', 'RS512'],
|
||||
private readonly string $scopeClaim = 'scope',
|
||||
) {
|
||||
if (!class_exists(JWT::class)) {
|
||||
throw new RuntimeException('For using the JwtTokenValidator, the firebase/php-jwt package is required. Try running "composer require firebase/php-jwt".');
|
||||
}
|
||||
}
|
||||
|
||||
public function validate(string $accessToken): AuthorizationResult
|
||||
{
|
||||
try {
|
||||
/** @var array<string, mixed> $claims */
|
||||
$claims = (array) JWT::decode($accessToken, $this->getJwks());
|
||||
|
||||
// Validate issuer
|
||||
if (!$this->validateIssuer($claims)) {
|
||||
return AuthorizationResult::unauthorized('invalid_token', 'Token issuer mismatch.');
|
||||
}
|
||||
|
||||
// Validate audience
|
||||
if (!$this->validateAudience($claims)) {
|
||||
return AuthorizationResult::unauthorized('invalid_token', 'Token audience mismatch.');
|
||||
}
|
||||
|
||||
// Build attributes to attach to request
|
||||
$attributes = [
|
||||
'oauth.claims' => $claims,
|
||||
'oauth.scopes' => $this->extractScopes($claims),
|
||||
];
|
||||
|
||||
// Add common claims as individual attributes
|
||||
if (isset($claims['sub'])) {
|
||||
$attributes['oauth.subject'] = $claims['sub'];
|
||||
}
|
||||
|
||||
if (isset($claims['client_id'])) {
|
||||
$attributes['oauth.client_id'] = $claims['client_id'];
|
||||
}
|
||||
|
||||
// Add azp (authorized party) for OIDC tokens
|
||||
if (isset($claims['azp'])) {
|
||||
$attributes['oauth.authorized_party'] = $claims['azp'];
|
||||
}
|
||||
|
||||
return AuthorizationResult::allow($attributes);
|
||||
} catch (ExpiredException) {
|
||||
return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.');
|
||||
} catch (SignatureInvalidException) {
|
||||
return AuthorizationResult::unauthorized('invalid_token', 'Token signature verification failed.');
|
||||
} catch (BeforeValidException) {
|
||||
return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.');
|
||||
} catch (\InvalidArgumentException|\UnexpectedValueException|\DomainException $e) {
|
||||
return AuthorizationResult::unauthorized('invalid_token', 'Token validation failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a token has the required scopes.
|
||||
*
|
||||
* Use this after validation to check specific scope requirements.
|
||||
*
|
||||
* @param AuthorizationResult $result The result from validate()
|
||||
* @param list<string> $requiredScopes Scopes required for this operation
|
||||
*
|
||||
* @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise
|
||||
*/
|
||||
public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult
|
||||
{
|
||||
if (!$result->isAllowed()) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$tokenScopes = $result->getAttributes()['oauth.scopes'] ?? [];
|
||||
|
||||
if (!\is_array($tokenScopes)) {
|
||||
$tokenScopes = [];
|
||||
}
|
||||
|
||||
foreach ($requiredScopes as $required) {
|
||||
if (!\in_array($required, $tokenScopes, true)) {
|
||||
return AuthorizationResult::forbidden('insufficient_scope', \sprintf('Required scope: %s', $required), $requiredScopes);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Firebase\JWT\Key>
|
||||
*/
|
||||
private function getJwks(): array
|
||||
{
|
||||
$issuer = \is_array($this->issuer) ? $this->issuer[0] : $this->issuer;
|
||||
$jwksData = $this->jwksProvider->getJwks($issuer, $this->jwksUri);
|
||||
|
||||
/* @var array<string, \Firebase\JWT\Key> */
|
||||
return JWK::parseKeySet($jwksData, $this->algorithms[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function validateAudience(array $claims): bool
|
||||
{
|
||||
if (!isset($claims['aud'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokenAudiences = \is_array($claims['aud']) ? $claims['aud'] : [$claims['aud']];
|
||||
$expectedAudiences = \is_array($this->audience) ? $this->audience : [$this->audience];
|
||||
|
||||
foreach ($expectedAudiences as $expected) {
|
||||
if (\in_array($expected, $tokenAudiences, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function validateIssuer(array $claims): bool
|
||||
{
|
||||
if (!isset($claims['iss'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expectedIssuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer];
|
||||
|
||||
return \in_array($claims['iss'], $expectedIssuers, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractScopes(array $claims): array
|
||||
{
|
||||
if (!isset($claims[$this->scopeClaim])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$scopeValue = $claims[$this->scopeClaim];
|
||||
|
||||
if (\is_array($scopeValue)) {
|
||||
return array_values(array_filter($scopeValue, 'is_string'));
|
||||
}
|
||||
|
||||
if (\is_string($scopeValue)) {
|
||||
return array_values(array_filter(explode(' ', $scopeValue)));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
236
src/Server/Transport/Http/OAuth/OidcDiscovery.php
Normal file
236
src/Server/Transport/Http/OAuth/OidcDiscovery.php
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\OAuth;
|
||||
|
||||
use Http\Discovery\Psr17FactoryDiscovery;
|
||||
use Http\Discovery\Psr18ClientDiscovery;
|
||||
use Mcp\Exception\RuntimeException;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Http\Message\RequestFactoryInterface;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
|
||||
/**
|
||||
* Discovers OAuth 2.0 / OpenID Connect authorization server metadata.
|
||||
*
|
||||
* Supports:
|
||||
* - OAuth 2.0 Authorization Server Metadata (RFC 8414)
|
||||
* - OpenID Connect Discovery 1.0
|
||||
*
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc8414
|
||||
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
class OidcDiscovery implements OidcDiscoveryInterface
|
||||
{
|
||||
private const CACHE_KEY_PREFIX = 'mcp_oidc_discovery_';
|
||||
|
||||
private ClientInterface $httpClient;
|
||||
private RequestFactoryInterface $requestFactory;
|
||||
private OidcDiscoveryMetadataPolicyInterface $metadataPolicy;
|
||||
|
||||
/**
|
||||
* @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null)
|
||||
* @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null)
|
||||
* @param CacheInterface|null $cache PSR-16 cache for metadata (optional)
|
||||
* @param int $cacheTtl Cache TTL in seconds (default: 1 hour)
|
||||
* @param OidcDiscoveryMetadataPolicyInterface|null $metadataPolicy Metadata validation policy
|
||||
*/
|
||||
public function __construct(
|
||||
?ClientInterface $httpClient = null,
|
||||
?RequestFactoryInterface $requestFactory = null,
|
||||
private readonly ?CacheInterface $cache = null,
|
||||
private readonly int $cacheTtl = 3600,
|
||||
?OidcDiscoveryMetadataPolicyInterface $metadataPolicy = null,
|
||||
) {
|
||||
$this->httpClient = $httpClient ?? Psr18ClientDiscovery::find();
|
||||
$this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory();
|
||||
$this->metadataPolicy = $metadataPolicy ?? new StrictOidcDiscoveryMetadataPolicy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the JWKS URI from the authorization server metadata.
|
||||
*
|
||||
* @param string $issuer The issuer URL
|
||||
*
|
||||
* @return string The JWKS URI
|
||||
*
|
||||
* @throws RuntimeException If discover fails
|
||||
*/
|
||||
public function getJwksUri(string $issuer): string
|
||||
{
|
||||
$metadata = $this->discover($issuer);
|
||||
|
||||
return $metadata['jwks_uri'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the token endpoint from the authorization server metadata.
|
||||
*
|
||||
* @param string $issuer The issuer URL
|
||||
*
|
||||
* @return string The token endpoint URL
|
||||
*
|
||||
* @throws RuntimeException If discover fails
|
||||
*/
|
||||
public function getTokenEndpoint(string $issuer): string
|
||||
{
|
||||
$metadata = $this->discover($issuer);
|
||||
|
||||
return $metadata['token_endpoint'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the authorization endpoint from the authorization server metadata.
|
||||
*
|
||||
* @param string $issuer The issuer URL
|
||||
*
|
||||
* @return string The authorization endpoint URL
|
||||
*
|
||||
* @throws RuntimeException If discover fails
|
||||
*/
|
||||
public function getAuthorizationEndpoint(string $issuer): string
|
||||
{
|
||||
$metadata = $this->discover($issuer);
|
||||
|
||||
return $metadata['authorization_endpoint'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers authorization server metadata from the issuer URL.
|
||||
*
|
||||
* Tries endpoints in priority order per RFC 8414 and OpenID Connect Discovery:
|
||||
* 1. OAuth 2.0 path insertion: /.well-known/oauth-authorization-server/{path}
|
||||
* 2. OIDC path insertion: /.well-known/openid-configuration/{path}
|
||||
* 3. OIDC path appending: {path}/.well-known/openid-configuration
|
||||
*
|
||||
* @param string $issuer The issuer URL (e.g., "https://auth.example.com/realms/mcp")
|
||||
*
|
||||
* @return array<string, mixed> The authorization server metadata
|
||||
*
|
||||
* @throws RuntimeException If discovery fails
|
||||
*/
|
||||
public function discover(string $issuer): array
|
||||
{
|
||||
$cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $issuer);
|
||||
|
||||
if (null !== $this->cache) {
|
||||
$cached = $this->cache->get($cacheKey);
|
||||
if (\is_array($cached)) {
|
||||
/* @var array<string, mixed> $cached */
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = $this->fetchMetadata($issuer);
|
||||
|
||||
if (null !== $this->cache) {
|
||||
$this->cache->set($cacheKey, $metadata, $this->cacheTtl);
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function fetchMetadata(string $issuer): array
|
||||
{
|
||||
$issuer = rtrim($issuer, '/');
|
||||
$parsed = parse_url($issuer);
|
||||
|
||||
if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) {
|
||||
throw new RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer));
|
||||
}
|
||||
|
||||
$scheme = $parsed['scheme'];
|
||||
$host = $parsed['host'];
|
||||
$port = isset($parsed['port']) ? ':'.$parsed['port'] : '';
|
||||
$path = $parsed['path'] ?? '';
|
||||
|
||||
$baseUrl = $scheme.'://'.$host.$port;
|
||||
|
||||
// Build discovery URLs in priority order per RFC 8414 Section 3.1
|
||||
$discoveryUrls = [];
|
||||
|
||||
if ('' !== $path && '/' !== $path) {
|
||||
// For issuer URLs with path components
|
||||
// 1. OAuth 2.0 path insertion
|
||||
$discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'.$path;
|
||||
// 2. OIDC path insertion
|
||||
$discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path;
|
||||
// 3. OIDC path appending
|
||||
$discoveryUrls[] = $issuer.'/.well-known/openid-configuration';
|
||||
} else {
|
||||
// For issuer URLs without path components
|
||||
$discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server';
|
||||
$discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration';
|
||||
}
|
||||
|
||||
$lastException = null;
|
||||
|
||||
foreach ($discoveryUrls as $url) {
|
||||
try {
|
||||
$metadata = $this->fetchJson($url);
|
||||
if (!$this->metadataPolicy->isValid($metadata)) {
|
||||
throw new RuntimeException(\sprintf('OIDC discovery response from %s has invalid format.', $url));
|
||||
}
|
||||
|
||||
if (!isset($metadata['issuer']) || !\is_string($metadata['issuer'])) {
|
||||
throw new RuntimeException(\sprintf('OIDC discovery response from %s is missing required "issuer" field.', $url));
|
||||
}
|
||||
if ($metadata['issuer'] !== $issuer) {
|
||||
throw new RuntimeException(\sprintf('OIDC discovery issuer mismatch for %s: expected %s, got %s.', $url, $issuer, $metadata['issuer']));
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
} catch (RuntimeException $e) {
|
||||
$lastException = $e;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException(\sprintf('Failed to discover authorization server metadata for issuer: %s', $issuer), 0, $lastException);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function fetchJson(string $url): array
|
||||
{
|
||||
$request = $this->requestFactory->createRequest('GET', $url)
|
||||
->withHeader('Accept', 'application/json');
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->sendRequest($request);
|
||||
} catch (ClientExceptionInterface $e) {
|
||||
throw new RuntimeException(\sprintf('HTTP request to %s failed: %s', $url, $e->getMessage()), 0, $e);
|
||||
}
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new RuntimeException(\sprintf('HTTP request to %s failed with status %d', $url, $response->getStatusCode()));
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
throw new RuntimeException(\sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e);
|
||||
}
|
||||
|
||||
if (!\is_array($data)) {
|
||||
throw new RuntimeException(\sprintf('Expected JSON object from %s, got %s', $url, \gettype($data)));
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
31
src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php
Normal file
31
src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\OAuth;
|
||||
|
||||
/**
|
||||
* Contract for resolving OAuth/OIDC endpoint metadata from an issuer.
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
interface OidcDiscoveryInterface
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function discover(string $issuer): array;
|
||||
|
||||
public function getAuthorizationEndpoint(string $issuer): string;
|
||||
|
||||
public function getTokenEndpoint(string $issuer): string;
|
||||
|
||||
public function getJwksUri(string $issuer): string;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\OAuth;
|
||||
|
||||
/**
|
||||
* Validation policy for OIDC discovery metadata.
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
interface OidcDiscoveryMetadataPolicyInterface
|
||||
{
|
||||
public function isValid(mixed $metadata): bool;
|
||||
}
|
||||
245
src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php
Normal file
245
src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\OAuth;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* OAuth 2.0 Protected Resource Metadata (RFC 9728).
|
||||
*
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc9728
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
final class ProtectedResourceMetadata implements \JsonSerializable
|
||||
{
|
||||
public const DEFAULT_METADATA_PATH = '/.well-known/oauth-protected-resource';
|
||||
|
||||
private const LOCALIZED_HUMAN_READABLE_FIELD_PATTERN = '/^(resource_name|resource_documentation|resource_policy_uri|resource_tos_uri)#[A-Za-z0-9-]+$/';
|
||||
|
||||
/** @var list<string> */
|
||||
private array $authorizationServers;
|
||||
|
||||
/** @var list<string>|null */
|
||||
private ?array $scopesSupported;
|
||||
|
||||
/** @var list<string> */
|
||||
private array $metadataPaths;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private array $localizedHumanReadable;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $extra;
|
||||
|
||||
private ?string $resource;
|
||||
private ?string $resourceName;
|
||||
private ?string $resourceDocumentation;
|
||||
private ?string $resourcePolicyUri;
|
||||
private ?string $resourceTosUri;
|
||||
|
||||
/**
|
||||
* @param list<string> $authorizationServers
|
||||
* @param list<string>|null $scopesSupported
|
||||
* @param array<string, string> $localizedHumanReadable Locale-specific values, e.g. resource_name#en => "My Resource"
|
||||
* @param array<string, mixed> $extra Additional RFC 9728 metadata fields
|
||||
* @param list<string> $metadataPaths
|
||||
*/
|
||||
public function __construct(
|
||||
array $authorizationServers,
|
||||
?array $scopesSupported = null,
|
||||
?string $resource = null,
|
||||
?string $resourceName = null,
|
||||
?string $resourceDocumentation = null,
|
||||
?string $resourcePolicyUri = null,
|
||||
?string $resourceTosUri = null,
|
||||
array $localizedHumanReadable = [],
|
||||
array $extra = [],
|
||||
array $metadataPaths = [self::DEFAULT_METADATA_PATH],
|
||||
) {
|
||||
$this->authorizationServers = $this->normalizeStringList($authorizationServers, 'authorizationServers');
|
||||
if ([] === $this->authorizationServers) {
|
||||
throw new InvalidArgumentException('Protected resource metadata requires at least one authorization server.');
|
||||
}
|
||||
|
||||
$normalizedScopes = $this->normalizeStringList($scopesSupported ?? [], 'scopesSupported');
|
||||
$this->scopesSupported = [] === $normalizedScopes ? null : $normalizedScopes;
|
||||
|
||||
$this->resource = $this->normalizeNullableString($resource);
|
||||
$this->resourceName = $this->normalizeNullableString($resourceName);
|
||||
$this->resourceDocumentation = $this->normalizeNullableString($resourceDocumentation);
|
||||
$this->resourcePolicyUri = $this->normalizeNullableString($resourcePolicyUri);
|
||||
$this->resourceTosUri = $this->normalizeNullableString($resourceTosUri);
|
||||
$this->localizedHumanReadable = $this->normalizeLocalizedHumanReadable($localizedHumanReadable);
|
||||
$this->extra = $extra;
|
||||
|
||||
$this->metadataPaths = $this->normalizePaths($metadataPaths);
|
||||
if ([] === $this->metadataPaths) {
|
||||
throw new InvalidArgumentException('Protected resource metadata requires at least one metadata path.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getMetadataPaths(): array
|
||||
{
|
||||
return $this->metadataPaths;
|
||||
}
|
||||
|
||||
public function getPrimaryMetadataPath(): string
|
||||
{
|
||||
return $this->metadataPaths[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null
|
||||
*/
|
||||
public function getScopesSupported(): ?array
|
||||
{
|
||||
return $this->scopesSupported;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
$data = [
|
||||
'authorization_servers' => $this->authorizationServers,
|
||||
];
|
||||
|
||||
if (null !== $this->scopesSupported) {
|
||||
$data['scopes_supported'] = $this->scopesSupported;
|
||||
}
|
||||
|
||||
if (null !== $this->resource) {
|
||||
$data['resource'] = $this->resource;
|
||||
}
|
||||
|
||||
if (null !== $this->resourceName) {
|
||||
$data['resource_name'] = $this->resourceName;
|
||||
}
|
||||
|
||||
if (null !== $this->resourceDocumentation) {
|
||||
$data['resource_documentation'] = $this->resourceDocumentation;
|
||||
}
|
||||
|
||||
if (null !== $this->resourcePolicyUri) {
|
||||
$data['resource_policy_uri'] = $this->resourcePolicyUri;
|
||||
}
|
||||
|
||||
if (null !== $this->resourceTosUri) {
|
||||
$data['resource_tos_uri'] = $this->resourceTosUri;
|
||||
}
|
||||
|
||||
foreach ($this->localizedHumanReadable as $key => $value) {
|
||||
$data[$key] = $value;
|
||||
}
|
||||
|
||||
return array_merge($this->extra, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $values
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeStringList(array $values, string $parameterName): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($values as $value) {
|
||||
if (!\is_string($value)) {
|
||||
throw new InvalidArgumentException(\sprintf('Protected resource metadata parameter "%s" must contain strings.', $parameterName));
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
if ('' === $value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $value;
|
||||
}
|
||||
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
|
||||
private function normalizeNullableString(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $paths
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizePaths(array $paths): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (!\is_string($path)) {
|
||||
throw new InvalidArgumentException('Protected resource metadata paths must be strings.');
|
||||
}
|
||||
|
||||
$path = trim($path);
|
||||
if ('' === $path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('/' !== $path[0]) {
|
||||
$path = '/'.$path;
|
||||
}
|
||||
|
||||
$normalized[] = $path;
|
||||
}
|
||||
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $localizedHumanReadable
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function normalizeLocalizedHumanReadable(array $localizedHumanReadable): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($localizedHumanReadable as $field => $value) {
|
||||
if (!\is_string($field) || !preg_match(self::LOCALIZED_HUMAN_READABLE_FIELD_PATTERN, $field)) {
|
||||
throw new InvalidArgumentException(\sprintf('Invalid localized human-readable field: "%s".', (string) $field));
|
||||
}
|
||||
|
||||
if (!\is_string($value)) {
|
||||
throw new InvalidArgumentException(\sprintf('Localized human-readable value for "%s" must be a string.', $field));
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
if ('' === $value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[$field] = $value;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server\Transport\Http\OAuth;
|
||||
|
||||
/**
|
||||
* Default strict policy for OIDC discovery metadata validation.
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
final class StrictOidcDiscoveryMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface
|
||||
{
|
||||
public function isValid(mixed $metadata): bool
|
||||
{
|
||||
if (!\is_array($metadata)
|
||||
|| !isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri'])
|
||||
|| !\is_string($metadata['authorization_endpoint'])
|
||||
|| '' === trim($metadata['authorization_endpoint'])
|
||||
|| !\is_string($metadata['token_endpoint'])
|
||||
|| '' === trim($metadata['token_endpoint'])
|
||||
|| !\is_string($metadata['jwks_uri'])
|
||||
|| '' === trim($metadata['jwks_uri'])
|
||||
|| !isset($metadata['code_challenge_methods_supported'])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!\is_array($metadata['code_challenge_methods_supported']) || [] === $metadata['code_challenge_methods_supported']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($metadata['code_challenge_methods_supported'] as $method) {
|
||||
if (!\is_string($method) || '' === trim($method)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue