[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:
Volodymyr Panivko 2026-03-10 17:10:07 +01:00 • committed by GitHub
commit e3f4b88768
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 6038 additions and 0 deletions

View 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);
}
}

View 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();
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View 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;
}
}

View file

@ -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;
}

View 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;
}
}

View 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;
}

View 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 [];
}
}

View 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;
}
}

View 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;
}

View file

@ -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;
}

View 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;
}
}

View file

@ -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;
}
}