[Server] feat: relax StrictOidcDiscoveryMetadataPolicy and add Dynamic Client Registration middleware (RFC 7591) (#269)

* feat: relax OIDC discovery policy and add Dynamic Client Registration middleware (RFC 7591)

- Add LenientOidcDiscoveryMetadataPolicy for providers that omit
  code_challenge_methods_supported (e.g. FusionAuth, Microsoft Entra ID)
- Keep StrictOidcDiscoveryMetadataPolicy RFC-aligned
- Add ClientRegistrationMiddleware handling POST /register and enriching
  /.well-known/oauth-authorization-server with registration_endpoint
- Update Microsoft example to use built-in LenientOidcDiscoveryMetadataPolicy

* fix: move metadata policy note under server.php in Files section

Address review comment: the LenientOidcDiscoveryMetadataPolicy bullet
was a behavioral note, not a file entry. Inline it into the server.php
description to keep the Files list consistent.

* fix: address Copilot review comments on ClientRegistrationMiddleware

- Decode JSON with assoc=true for registrar data so nested objects
  are associative arrays, not stdClass instances
- Add Cache-Control: no-store to all error responses (400), not just
  the success response (201)
- Rewind response body stream before returning unmodified response
  when metadata is not valid JSON, preventing empty body on read

* fix: harden ClientRegistrationMiddleware and improve test coverage

- Revert spurious .gitignore trailing newline
- Use JSON_THROW_ON_ERROR consistently in enrichAuthServerMetadata()
- Add comment clarifying the second json_decode in handleRegistration()
- Assert Cache-Control: no-store on registrar exception error response
- Add test for non-object JSON body in metadata enrichment path
- Extract createPlainTextHandler helper for non-JSON response tests

* fix: reject JSON array bodies in metadata enrichment

Treat JSON list arrays (e.g. [] or ["..."]) as invalid metadata in
enrichAuthServerMetadata() to prevent corrupting the response shape
by adding registration_endpoint to a non-object.

* fix: add RFC 7591 error codes, Content-Type validation, and docblock safety warning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
simon chrzanowski 2026-04-11 17:27:27 +02:00 • committed by GitHub
commit 863cfc1e78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 963 additions and 110 deletions

View file

@ -0,0 +1,23 @@
<?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\Exception;
final class ClientRegistrationException extends \RuntimeException implements ExceptionInterface
{
public function __construct(
string $message,
public readonly string $errorCode = 'invalid_client_metadata',
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}

View file

@ -0,0 +1,177 @@
<?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\ClientRegistrationException;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Server\Transport\Http\OAuth\ClientRegistrarInterface;
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;
/**
* OAuth 2.0 Dynamic Client Registration (RFC 7591) middleware.
*
* Handles POST /register requests by delegating to a ClientRegistrarInterface
* and enriches /.well-known/oauth-authorization-server responses with the
* registration_endpoint.
*/
final class ClientRegistrationMiddleware implements MiddlewareInterface
{
private const REGISTRATION_PATH = '/register';
private ResponseFactoryInterface $responseFactory;
private StreamFactoryInterface $streamFactory;
public function __construct(
private readonly ClientRegistrarInterface $registrar,
private readonly string $localBaseUrl,
?ResponseFactoryInterface $responseFactory = null,
?StreamFactoryInterface $streamFactory = null,
) {
if ('' === trim($localBaseUrl)) {
throw new InvalidArgumentException('The $localBaseUrl must not be empty.');
}
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$path = $request->getUri()->getPath();
if ('POST' === $request->getMethod() && self::REGISTRATION_PATH === $path) {
return $this->handleRegistration($request);
}
$response = $handler->handle($request);
if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) {
return $this->enrichAuthServerMetadata($response);
}
return $response;
}
private function handleRegistration(ServerRequestInterface $request): ResponseInterface
{
$contentType = $request->getHeaderLine('Content-Type');
if (!str_starts_with($contentType, 'application/json')) {
return $this->jsonResponse(400, [
'error' => 'invalid_client_metadata',
'error_description' => 'Content-Type must be application/json.',
], ['Cache-Control' => 'no-store']);
}
$body = $request->getBody()->__toString();
try {
$decoded = json_decode($body, false, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return $this->jsonResponse(400, [
'error' => 'invalid_client_metadata',
'error_description' => 'Request body must be valid JSON.',
], ['Cache-Control' => 'no-store']);
}
if (!$decoded instanceof \stdClass) {
return $this->jsonResponse(400, [
'error' => 'invalid_client_metadata',
'error_description' => 'Request body must be a JSON object.',
], ['Cache-Control' => 'no-store']);
}
// Re-decode with assoc=true so nested objects become arrays (safe — already validated above)
/** @var array<string, mixed> $data */
$data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR);
try {
$result = $this->registrar->register($data);
} catch (ClientRegistrationException $e) {
return $this->jsonResponse(400, [
'error' => $e->errorCode,
'error_description' => $e->getMessage(),
], ['Cache-Control' => 'no-store']);
}
return $this->jsonResponse(201, $result, [
'Cache-Control' => 'no-store',
]);
}
private function enrichAuthServerMetadata(ResponseInterface $response): ResponseInterface
{
if (200 !== $response->getStatusCode()) {
return $response;
}
$stream = $response->getBody();
if ($stream->isSeekable()) {
$stream->rewind();
}
try {
$metadata = json_decode($stream->__toString(), true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
if ($stream->isSeekable()) {
$stream->rewind();
}
return $response;
}
if (!\is_array($metadata) || ([] !== $metadata && array_is_list($metadata))) {
if ($stream->isSeekable()) {
$stream->rewind();
}
return $response;
}
$metadata['registration_endpoint'] = rtrim($this->localBaseUrl, '/').self::REGISTRATION_PATH;
return $response
->withBody($this->streamFactory->createStream(
json_encode($metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES),
))
->withHeader('Content-Type', 'application/json')
->withoutHeader('Content-Length');
}
/**
* @param array<string, mixed> $data
* @param array<string, string> $extraHeaders
*/
private function jsonResponse(int $status, array $data, array $extraHeaders = []): ResponseInterface
{
$response = $this->responseFactory
->createResponse($status)
->withHeader('Content-Type', 'application/json')
->withBody($this->streamFactory->createStream(
json_encode($data, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES),
));
foreach ($extraHeaders as $name => $value) {
if ('' !== $value) {
$response = $response->withHeader($name, $value);
}
}
return $response;
}
}

View file

@ -0,0 +1,44 @@
<?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\ClientRegistrationException;
/**
* Interface for OAuth 2.0 Dynamic Client Registration (RFC 7591).
*
* Implementations are responsible for persisting client credentials and
* returning a registration response as defined in RFC 7591 Section 3.2.
*
* @see https://datatracker.ietf.org/doc/html/rfc7591
*/
interface ClientRegistrarInterface
{
/**
* Registers a new OAuth 2.0 client.
*
* The registration request contains metadata fields as defined in RFC 7591
* Section 2 (e.g. redirect_uris, client_name, token_endpoint_auth_method).
*
* The returned array MUST include at least "client_id" and should include
* "client_secret" when the token endpoint auth method requires one.
*
* @param array<string, mixed> $registrationRequest Client metadata from the registration request body
*
* @return array<string, mixed> Registration response including client_id and optional client_secret
*
* @throws ClientRegistrationException If registration fails (e.g. invalid metadata, storage error).
* The exception message is returned to the client as error_description —
* do not include internal details (database errors, stack traces, etc.).
*/
public function register(array $registrationRequest): array;
}

View file

@ -0,0 +1,54 @@
<?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;
/**
* Lenient metadata policy for identity providers that omit
* code_challenge_methods_supported from their OIDC discovery response
* despite supporting PKCE (e.g. FusionAuth, Microsoft Entra ID).
*
* If code_challenge_methods_supported is present, it is still validated.
* If absent, the downstream OAuthProxyMiddleware defaults to ['S256'].
*
* @author Simon Chrzanowski <simon.chrzanowski@quentic.com>
*/
final class LenientOidcDiscoveryMetadataPolicy 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'])
) {
return false;
}
if (\array_key_exists('code_challenge_methods_supported', $metadata)) {
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;
}
}