[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:
parent
5d57b685a2
commit
863cfc1e78
12 changed files with 963 additions and 110 deletions
|
|
@ -10,6 +10,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
|
|||
* Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators)
|
||||
* Add elicitation enum schema types per SEP-1330: `TitledEnumSchemaDefinition`, `MultiSelectEnumSchemaDefinition`, `TitledMultiSelectEnumSchemaDefinition`
|
||||
* [BC break] Make Symfony Finder component optional. Users would need to install `symfony/finder` now themselves
|
||||
* Add `LenientOidcDiscoveryMetadataPolicy` for identity providers that omit `code_challenge_methods_supported` (e.g. FusionAuth, Microsoft Entra ID)
|
||||
* Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591)
|
||||
|
||||
0.4.0
|
||||
-----
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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\Example\Server\OAuthMicrosoft;
|
||||
|
||||
use Mcp\Server\Transport\Http\OAuth\OidcDiscoveryMetadataPolicyInterface;
|
||||
|
||||
/**
|
||||
* Lenient metadata policy for Microsoft discovery quirks.
|
||||
*
|
||||
* `code_challenge_methods_supported` is optional in this policy.
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
final class MicrosoftOidcMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface
|
||||
{
|
||||
public function isValid(mixed $metadata): bool
|
||||
{
|
||||
return \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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -148,9 +148,8 @@ curl -X POST http://localhost:8000/mcp \
|
|||
- `Dockerfile` - PHP-FPM container
|
||||
- `nginx/default.conf` - Nginx configuration
|
||||
- `env.example` - Environment variables template
|
||||
- `server.php` - MCP server with OAuth middleware
|
||||
- `server.php` - MCP server with OAuth middleware (uses built-in `LenientOidcDiscoveryMetadataPolicy` for metadata validation)
|
||||
- `MicrosoftJwtTokenValidator.php` - Example-specific validator for Graph/non-Graph tokens
|
||||
- `MicrosoftOidcMetadataPolicy.php` - Lenient metadata validation policy
|
||||
- `McpElements.php` - MCP tools including Graph API integration
|
||||
|
||||
## Environment Variables
|
||||
|
|
@ -198,8 +197,10 @@ Microsoft's JWKS endpoint is public. Ensure your container can reach:
|
|||
|
||||
### `code_challenge_methods_supported` missing in discovery metadata
|
||||
|
||||
This example configures `OidcDiscovery` with `MicrosoftOidcMetadataPolicy`, so this
|
||||
field can be missing or malformed and will not fail discovery.
|
||||
The default `StrictOidcDiscoveryMetadataPolicy` requires `code_challenge_methods_supported`.
|
||||
Microsoft Entra ID omits this field despite supporting PKCE with S256.
|
||||
This example uses the built-in `LenientOidcDiscoveryMetadataPolicy` which accepts missing
|
||||
`code_challenge_methods_supported` (defaults to S256 downstream).
|
||||
|
||||
### Graph API errors
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ require_once dirname(__DIR__).'/bootstrap.php';
|
|||
use Http\Discovery\Psr17Factory;
|
||||
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
|
||||
use Mcp\Example\Server\OAuthMicrosoft\MicrosoftJwtTokenValidator;
|
||||
use Mcp\Example\Server\OAuthMicrosoft\MicrosoftOidcMetadataPolicy;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\Session\FileSessionStore;
|
||||
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
|
||||
|
|
@ -25,6 +24,7 @@ use Mcp\Server\Transport\Http\Middleware\OAuthRequestMetaMiddleware;
|
|||
use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware;
|
||||
use Mcp\Server\Transport\Http\OAuth\JwksProvider;
|
||||
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
|
||||
use Mcp\Server\Transport\Http\OAuth\LenientOidcDiscoveryMetadataPolicy;
|
||||
use Mcp\Server\Transport\Http\OAuth\OidcDiscovery;
|
||||
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
|
||||
use Mcp\Server\Transport\StreamableHttpTransport;
|
||||
|
|
@ -37,7 +37,7 @@ $issuerV1 = "https://sts.windows.net/{$tenantId}/";
|
|||
$localBaseUrl = 'http://localhost:8000';
|
||||
|
||||
$discovery = new OidcDiscovery(
|
||||
metadataPolicy: new MicrosoftOidcMetadataPolicy(),
|
||||
metadataPolicy: new LenientOidcDiscoveryMetadataPolicy(),
|
||||
);
|
||||
|
||||
$jwtTokenValidator = new JwtTokenValidator(
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
<?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\Example\Server\OAuthMicrosoft\Tests\Unit;
|
||||
|
||||
use Mcp\Example\Server\OAuthMicrosoft\MicrosoftOidcMetadataPolicy;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests MicrosoftOidcMetadataPolicy validation behavior.
|
||||
*
|
||||
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
||||
*/
|
||||
class MicrosoftOidcMetadataPolicyTest extends TestCase
|
||||
{
|
||||
#[TestDox('metadata without code challenge methods is accepted')]
|
||||
public function testMissingCodeChallengeMethodsIsAccepted(): void
|
||||
{
|
||||
$policy = new MicrosoftOidcMetadataPolicy();
|
||||
$metadata = [
|
||||
'authorization_endpoint' => 'https://auth.example.com/authorize',
|
||||
'token_endpoint' => 'https://auth.example.com/token',
|
||||
'jwks_uri' => 'https://auth.example.com/jwks',
|
||||
];
|
||||
|
||||
$this->assertTrue($policy->isValid($metadata));
|
||||
}
|
||||
|
||||
#[TestDox('malformed code challenge methods are ignored for validity')]
|
||||
public function testMalformedCodeChallengeMethodsSupportedIsAccepted(): void
|
||||
{
|
||||
$policy = new MicrosoftOidcMetadataPolicy();
|
||||
$metadata = [
|
||||
'authorization_endpoint' => 'https://auth.example.com/authorize',
|
||||
'token_endpoint' => 'https://auth.example.com/token',
|
||||
'jwks_uri' => 'https://auth.example.com/jwks',
|
||||
'code_challenge_methods_supported' => 'S256',
|
||||
];
|
||||
|
||||
$this->assertTrue($policy->isValid($metadata));
|
||||
}
|
||||
|
||||
#[TestDox('required endpoint fields still enforce validity')]
|
||||
public function testIsValidRequiresCoreEndpoints(): void
|
||||
{
|
||||
$policy = new MicrosoftOidcMetadataPolicy();
|
||||
$metadata = [
|
||||
'authorization_endpoint' => 'https://auth.example.com/authorize',
|
||||
// token_endpoint missing
|
||||
'jwks_uri' => 'https://auth.example.com/jwks',
|
||||
];
|
||||
|
||||
$this->assertFalse($policy->isValid($metadata));
|
||||
}
|
||||
}
|
||||
23
src/Exception/ClientRegistrationException.php
Normal file
23
src/Exception/ClientRegistrationException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
44
src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php
Normal file
44
src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,495 @@
|
|||
<?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\Tests\Unit\Server\Transport\Http\Middleware;
|
||||
|
||||
use Mcp\Exception\ClientRegistrationException;
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Server\Transport\Http\Middleware\ClientRegistrationMiddleware;
|
||||
use Mcp\Server\Transport\Http\OAuth\ClientRegistrarInterface;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class ClientRegistrationMiddlewareTest extends TestCase
|
||||
{
|
||||
private Psr17Factory $factory;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->factory = new Psr17Factory();
|
||||
}
|
||||
|
||||
#[TestDox('POST /register with valid JSON delegates to registrar and returns 201')]
|
||||
public function testRegistrationSuccess(): void
|
||||
{
|
||||
$registrar = $this->createMock(ClientRegistrarInterface::class);
|
||||
$registrar->expects($this->once())
|
||||
->method('register')
|
||||
->with(['redirect_uris' => ['https://example.com/callback']])
|
||||
->willReturn(['client_id' => 'new-client', 'client_secret' => 's3cret']);
|
||||
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->factory->createStream(json_encode(['redirect_uris' => ['https://example.com/callback']])));
|
||||
|
||||
$response = $middleware->process($request, $this->createPassthroughHandler(404));
|
||||
|
||||
$this->assertSame(201, $response->getStatusCode());
|
||||
$this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
|
||||
$this->assertSame('no-store', $response->getHeaderLine('Cache-Control'));
|
||||
|
||||
$payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('new-client', $payload['client_id']);
|
||||
$this->assertSame('s3cret', $payload['client_secret']);
|
||||
}
|
||||
|
||||
#[TestDox('POST /register with invalid JSON returns 400')]
|
||||
public function testRegistrationWithInvalidJson(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->factory->createStream('not json'));
|
||||
|
||||
$response = $middleware->process($request, $this->createPassthroughHandler(404));
|
||||
|
||||
$this->assertSame(400, $response->getStatusCode());
|
||||
|
||||
$payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('invalid_client_metadata', $payload['error']);
|
||||
$this->assertSame('Request body must be valid JSON.', $payload['error_description']);
|
||||
}
|
||||
|
||||
#[TestDox('POST /register with JSON array instead of object returns 400')]
|
||||
public function testRegistrationWithJsonArrayReturns400(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->factory->createStream('["not","an","object"]'));
|
||||
|
||||
$response = $middleware->process($request, $this->createPassthroughHandler(404));
|
||||
|
||||
$this->assertSame(400, $response->getStatusCode());
|
||||
|
||||
$payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('invalid_client_metadata', $payload['error']);
|
||||
$this->assertSame('Request body must be a JSON object.', $payload['error_description']);
|
||||
}
|
||||
|
||||
#[TestDox('POST /register with empty JSON array returns 400')]
|
||||
public function testRegistrationWithEmptyJsonArrayReturns400(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->factory->createStream('[]'));
|
||||
|
||||
$response = $middleware->process($request, $this->createPassthroughHandler(404));
|
||||
|
||||
$this->assertSame(400, $response->getStatusCode());
|
||||
|
||||
$payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('invalid_client_metadata', $payload['error']);
|
||||
$this->assertSame('Request body must be a JSON object.', $payload['error_description']);
|
||||
}
|
||||
|
||||
#[TestDox('POST /register with nested JSON objects passes associative arrays to registrar')]
|
||||
public function testRegistrationWithNestedObjectsPassesAssociativeArrays(): void
|
||||
{
|
||||
$registrar = $this->createMock(ClientRegistrarInterface::class);
|
||||
$registrar->expects($this->once())
|
||||
->method('register')
|
||||
->with($this->callback(function (array $data): bool {
|
||||
// Nested objects must be associative arrays, not stdClass
|
||||
$this->assertIsArray($data['jwks']);
|
||||
$this->assertIsArray($data['jwks']['keys'][0]);
|
||||
$this->assertSame('RSA', $data['jwks']['keys'][0]['kty']);
|
||||
|
||||
return true;
|
||||
}))
|
||||
->willReturn(['client_id' => 'nested-client']);
|
||||
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$body = json_encode([
|
||||
'redirect_uris' => ['https://example.com/callback'],
|
||||
'jwks' => ['keys' => [['kty' => 'RSA', 'n' => 'abc', 'e' => 'AQAB']]],
|
||||
]);
|
||||
|
||||
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->factory->createStream($body));
|
||||
|
||||
$response = $middleware->process($request, $this->createPassthroughHandler(404));
|
||||
|
||||
$this->assertSame(201, $response->getStatusCode());
|
||||
}
|
||||
|
||||
#[TestDox('POST /register error responses include Cache-Control: no-store')]
|
||||
public function testRegistrationErrorResponsesIncludeCacheControl(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
// Invalid JSON
|
||||
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->factory->createStream('not json'));
|
||||
$response = $middleware->process($request, $this->createPassthroughHandler(404));
|
||||
$this->assertSame('no-store', $response->getHeaderLine('Cache-Control'));
|
||||
|
||||
// JSON array (not object)
|
||||
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->factory->createStream('["array"]'));
|
||||
$response = $middleware->process($request, $this->createPassthroughHandler(404));
|
||||
$this->assertSame('no-store', $response->getHeaderLine('Cache-Control'));
|
||||
}
|
||||
|
||||
#[TestDox('POST /register returns 400 when registrar throws ClientRegistrationException')]
|
||||
public function testRegistrationWithRegistrarException(): void
|
||||
{
|
||||
$registrar = $this->createMock(ClientRegistrarInterface::class);
|
||||
$registrar->expects($this->once())
|
||||
->method('register')
|
||||
->willThrowException(new ClientRegistrationException('redirect_uris is required'));
|
||||
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->factory->createStream('{}'));
|
||||
|
||||
$response = $middleware->process($request, $this->createPassthroughHandler(404));
|
||||
|
||||
$this->assertSame(400, $response->getStatusCode());
|
||||
$this->assertSame('no-store', $response->getHeaderLine('Cache-Control'));
|
||||
|
||||
$payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('invalid_client_metadata', $payload['error']);
|
||||
$this->assertSame('redirect_uris is required', $payload['error_description']);
|
||||
}
|
||||
|
||||
#[TestDox('POST /register without application/json Content-Type returns 400')]
|
||||
public function testRegistrationRejectsNonJsonContentType(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
|
||||
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
|
||||
->withBody($this->factory->createStream('key=value'));
|
||||
|
||||
$response = $middleware->process($request, $this->createPassthroughHandler(404));
|
||||
|
||||
$this->assertSame(400, $response->getStatusCode());
|
||||
$this->assertSame('no-store', $response->getHeaderLine('Cache-Control'));
|
||||
|
||||
$payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('invalid_client_metadata', $payload['error']);
|
||||
$this->assertSame('Content-Type must be application/json.', $payload['error_description']);
|
||||
}
|
||||
|
||||
#[TestDox('POST /register uses error code from ClientRegistrationException')]
|
||||
public function testRegistrationUsesCustomErrorCode(): void
|
||||
{
|
||||
$registrar = $this->createMock(ClientRegistrarInterface::class);
|
||||
$registrar->expects($this->once())
|
||||
->method('register')
|
||||
->willThrowException(new ClientRegistrationException('Invalid redirect URI', 'invalid_redirect_uri'));
|
||||
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->factory->createStream('{}'));
|
||||
|
||||
$response = $middleware->process($request, $this->createPassthroughHandler(404));
|
||||
|
||||
$this->assertSame(400, $response->getStatusCode());
|
||||
|
||||
$payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('invalid_redirect_uri', $payload['error']);
|
||||
$this->assertSame('Invalid redirect URI', $payload['error_description']);
|
||||
}
|
||||
|
||||
#[TestDox('GET /.well-known/oauth-authorization-server enriches response with registration_endpoint')]
|
||||
public function testMetadataEnrichment(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$upstreamMetadata = [
|
||||
'issuer' => 'http://localhost:8000',
|
||||
'authorization_endpoint' => 'http://localhost:8000/authorize',
|
||||
'token_endpoint' => 'http://localhost:8000/token',
|
||||
];
|
||||
|
||||
$request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server');
|
||||
$handler = $this->createJsonHandler(200, $upstreamMetadata);
|
||||
|
||||
$response = $middleware->process($request, $handler);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
|
||||
$payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('http://localhost:8000/register', $payload['registration_endpoint']);
|
||||
$this->assertSame('http://localhost:8000/authorize', $payload['authorization_endpoint']);
|
||||
}
|
||||
|
||||
#[TestDox('GET /.well-known/oauth-authorization-server preserves original response headers')]
|
||||
public function testMetadataEnrichmentPreservesOriginalHeaders(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server');
|
||||
$handler = $this->createJsonHandler(200, ['issuer' => 'http://localhost:8000'], 'max-age=3600', [
|
||||
'X-Custom' => 'preserved',
|
||||
'Vary' => 'Origin',
|
||||
]);
|
||||
|
||||
$response = $middleware->process($request, $handler);
|
||||
|
||||
$this->assertSame('max-age=3600', $response->getHeaderLine('Cache-Control'));
|
||||
$this->assertSame('preserved', $response->getHeaderLine('X-Custom'));
|
||||
$this->assertSame('Origin', $response->getHeaderLine('Vary'));
|
||||
}
|
||||
|
||||
#[TestDox('GET /.well-known/oauth-authorization-server removes stale Content-Length after body mutation')]
|
||||
public function testMetadataEnrichmentRemovesContentLength(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server');
|
||||
$handler = $this->createJsonHandler(200, ['issuer' => 'http://localhost:8000'], '', [
|
||||
'Content-Length' => '42',
|
||||
]);
|
||||
|
||||
$response = $middleware->process($request, $handler);
|
||||
|
||||
$this->assertFalse($response->hasHeader('Content-Length'));
|
||||
}
|
||||
|
||||
#[TestDox('GET /.well-known/oauth-authorization-server with invalid JSON body rewinds stream before returning')]
|
||||
public function testMetadataEnrichmentRewindsStreamOnInvalidJsonBody(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server');
|
||||
$handler = $this->createPlainTextHandler(200, 'not json');
|
||||
|
||||
$response = $middleware->process($request, $handler);
|
||||
|
||||
$this->assertSame('not json', $response->getBody()->getContents());
|
||||
}
|
||||
|
||||
#[TestDox('GET /.well-known/oauth-authorization-server with non-object JSON body rewinds stream before returning')]
|
||||
public function testMetadataEnrichmentRewindsStreamOnNonObjectJsonBody(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server');
|
||||
$handler = $this->createPlainTextHandler(200, '"just a string"');
|
||||
|
||||
$response = $middleware->process($request, $handler);
|
||||
|
||||
$this->assertSame('"just a string"', $response->getBody()->getContents());
|
||||
}
|
||||
|
||||
#[TestDox('GET /.well-known/oauth-authorization-server with JSON array body passes through unchanged')]
|
||||
public function testMetadataEnrichmentPassesThroughJsonArrayBody(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server');
|
||||
$handler = $this->createPlainTextHandler(200, '["not","an","object"]');
|
||||
|
||||
$response = $middleware->process($request, $handler);
|
||||
|
||||
$this->assertSame('["not","an","object"]', $response->getBody()->getContents());
|
||||
}
|
||||
|
||||
#[TestDox('GET /.well-known/oauth-authorization-server with non-200 status passes through unchanged')]
|
||||
public function testMetadataNon200PassesThrough(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server');
|
||||
$handler = $this->createPassthroughHandler(500);
|
||||
|
||||
$response = $middleware->process($request, $handler);
|
||||
|
||||
$this->assertSame(500, $response->getStatusCode());
|
||||
}
|
||||
|
||||
#[TestDox('non-matching routes pass through to next handler')]
|
||||
public function testNonMatchingRoutePassesThrough(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
$middleware = $this->createMiddleware($registrar);
|
||||
|
||||
$request = $this->factory->createServerRequest('GET', 'http://localhost:8000/mcp');
|
||||
$handler = $this->createPassthroughHandler(204);
|
||||
|
||||
$response = $middleware->process($request, $handler);
|
||||
|
||||
$this->assertSame(204, $response->getStatusCode());
|
||||
}
|
||||
|
||||
#[TestDox('constructor rejects empty localBaseUrl')]
|
||||
public function testConstructorRejectsEmptyBaseUrl(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new ClientRegistrationMiddleware(
|
||||
$this->createStub(ClientRegistrarInterface::class),
|
||||
'',
|
||||
$this->factory,
|
||||
$this->factory,
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('localBaseUrl trailing slash is normalized in registration_endpoint')]
|
||||
public function testTrailingSlashNormalization(): void
|
||||
{
|
||||
$registrar = $this->createStub(ClientRegistrarInterface::class);
|
||||
|
||||
$middleware = new ClientRegistrationMiddleware(
|
||||
$registrar,
|
||||
'http://localhost:8000/',
|
||||
$this->factory,
|
||||
$this->factory,
|
||||
);
|
||||
|
||||
$request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server');
|
||||
$handler = $this->createJsonHandler(200, ['issuer' => 'http://localhost:8000']);
|
||||
|
||||
$response = $middleware->process($request, $handler);
|
||||
|
||||
$payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('http://localhost:8000/register', $payload['registration_endpoint']);
|
||||
}
|
||||
|
||||
private function createMiddleware(ClientRegistrarInterface $registrar): ClientRegistrationMiddleware
|
||||
{
|
||||
return new ClientRegistrationMiddleware(
|
||||
$registrar,
|
||||
'http://localhost:8000',
|
||||
$this->factory,
|
||||
$this->factory,
|
||||
);
|
||||
}
|
||||
|
||||
private function createPassthroughHandler(int $status): RequestHandlerInterface
|
||||
{
|
||||
$factory = $this->factory;
|
||||
|
||||
return new class($factory, $status) implements RequestHandlerInterface {
|
||||
public function __construct(
|
||||
private readonly ResponseFactoryInterface $factory,
|
||||
private readonly int $status,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
return $this->factory->createResponse($this->status);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, string> $extraHeaders
|
||||
*/
|
||||
private function createJsonHandler(int $status, array $data, string $cacheControl = '', array $extraHeaders = []): RequestHandlerInterface
|
||||
{
|
||||
$factory = $this->factory;
|
||||
|
||||
return new class($factory, $status, $data, $cacheControl, $extraHeaders) implements RequestHandlerInterface {
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, string> $extraHeaders
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ResponseFactoryInterface $factory,
|
||||
private readonly int $status,
|
||||
private readonly array $data,
|
||||
private readonly string $cacheControl,
|
||||
private readonly array $extraHeaders,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$response = $this->factory->createResponse($this->status)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody((new Psr17Factory())->createStream(
|
||||
json_encode($this->data, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES),
|
||||
));
|
||||
|
||||
if ('' !== $this->cacheControl) {
|
||||
$response = $response->withHeader('Cache-Control', $this->cacheControl);
|
||||
}
|
||||
|
||||
foreach ($this->extraHeaders as $name => $value) {
|
||||
$response = $response->withHeader($name, $value);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function createPlainTextHandler(int $status, string $body): RequestHandlerInterface
|
||||
{
|
||||
$factory = $this->factory;
|
||||
|
||||
return new class($factory, $status, $body) implements RequestHandlerInterface {
|
||||
public function __construct(
|
||||
private readonly ResponseFactoryInterface $factory,
|
||||
private readonly int $status,
|
||||
private readonly string $body,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
return $this->factory->createResponse($this->status)
|
||||
->withHeader('Content-Type', 'text/plain')
|
||||
->withBody((new Psr17Factory())->createStream($this->body));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<?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\Tests\Unit\Server\Transport\Http\OAuth;
|
||||
|
||||
use Mcp\Server\Transport\Http\OAuth\LenientOidcDiscoveryMetadataPolicy;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests LenientOidcDiscoveryMetadataPolicy validation behavior.
|
||||
*
|
||||
* @author Simon Chrzanowski <simon.chrzanowski@quentic.com>
|
||||
*/
|
||||
class LenientOidcDiscoveryMetadataPolicyTest extends TestCase
|
||||
{
|
||||
#[TestDox('metadata without code challenge methods is valid (defaults to S256 downstream)')]
|
||||
public function testMissingCodeChallengeMethodsIsValid(): void
|
||||
{
|
||||
$policy = new LenientOidcDiscoveryMetadataPolicy();
|
||||
$metadata = [
|
||||
'authorization_endpoint' => 'https://auth.example.com/authorize',
|
||||
'token_endpoint' => 'https://auth.example.com/token',
|
||||
'jwks_uri' => 'https://auth.example.com/jwks',
|
||||
];
|
||||
|
||||
$this->assertTrue($policy->isValid($metadata));
|
||||
}
|
||||
|
||||
#[TestDox('valid code challenge methods list is accepted')]
|
||||
public function testValidCodeChallengeMethodsIsAccepted(): void
|
||||
{
|
||||
$policy = new LenientOidcDiscoveryMetadataPolicy();
|
||||
$metadata = [
|
||||
'authorization_endpoint' => 'https://auth.example.com/authorize',
|
||||
'token_endpoint' => 'https://auth.example.com/token',
|
||||
'jwks_uri' => 'https://auth.example.com/jwks',
|
||||
'code_challenge_methods_supported' => ['S256'],
|
||||
];
|
||||
|
||||
$this->assertTrue($policy->isValid($metadata));
|
||||
}
|
||||
|
||||
#[TestDox('empty code challenge methods list is invalid')]
|
||||
public function testEmptyCodeChallengeMethodsIsInvalid(): void
|
||||
{
|
||||
$policy = new LenientOidcDiscoveryMetadataPolicy();
|
||||
$metadata = [
|
||||
'authorization_endpoint' => 'https://auth.example.com/authorize',
|
||||
'token_endpoint' => 'https://auth.example.com/token',
|
||||
'jwks_uri' => 'https://auth.example.com/jwks',
|
||||
'code_challenge_methods_supported' => [],
|
||||
];
|
||||
|
||||
$this->assertFalse($policy->isValid($metadata));
|
||||
}
|
||||
|
||||
#[TestDox('non string code challenge method is invalid')]
|
||||
public function testNonStringCodeChallengeMethodIsInvalid(): void
|
||||
{
|
||||
$policy = new LenientOidcDiscoveryMetadataPolicy();
|
||||
$metadata = [
|
||||
'authorization_endpoint' => 'https://auth.example.com/authorize',
|
||||
'token_endpoint' => 'https://auth.example.com/token',
|
||||
'jwks_uri' => 'https://auth.example.com/jwks',
|
||||
'code_challenge_methods_supported' => ['S256', 123],
|
||||
];
|
||||
|
||||
$this->assertFalse($policy->isValid($metadata));
|
||||
}
|
||||
|
||||
#[TestDox('missing required fields is invalid')]
|
||||
public function testMissingRequiredFieldsIsInvalid(): void
|
||||
{
|
||||
$policy = new LenientOidcDiscoveryMetadataPolicy();
|
||||
|
||||
$this->assertFalse($policy->isValid([
|
||||
'authorization_endpoint' => 'https://auth.example.com/authorize',
|
||||
'token_endpoint' => 'https://auth.example.com/token',
|
||||
// missing jwks_uri
|
||||
]));
|
||||
}
|
||||
|
||||
#[TestDox('empty string endpoint is invalid')]
|
||||
public function testEmptyStringEndpointIsInvalid(): void
|
||||
{
|
||||
$policy = new LenientOidcDiscoveryMetadataPolicy();
|
||||
|
||||
$this->assertFalse($policy->isValid([
|
||||
'authorization_endpoint' => '',
|
||||
'token_endpoint' => 'https://auth.example.com/token',
|
||||
'jwks_uri' => 'https://auth.example.com/jwks',
|
||||
]));
|
||||
}
|
||||
|
||||
#[TestDox('null code challenge methods is invalid')]
|
||||
public function testNullCodeChallengeMethodsIsInvalid(): void
|
||||
{
|
||||
$policy = new LenientOidcDiscoveryMetadataPolicy();
|
||||
$metadata = [
|
||||
'authorization_endpoint' => 'https://auth.example.com/authorize',
|
||||
'token_endpoint' => 'https://auth.example.com/token',
|
||||
'jwks_uri' => 'https://auth.example.com/jwks',
|
||||
'code_challenge_methods_supported' => null,
|
||||
];
|
||||
|
||||
$this->assertFalse($policy->isValid($metadata));
|
||||
}
|
||||
|
||||
#[TestDox('non-array input is invalid')]
|
||||
public function testNonArrayInputIsInvalid(): void
|
||||
{
|
||||
$policy = new LenientOidcDiscoveryMetadataPolicy();
|
||||
|
||||
$this->assertFalse($policy->isValid('not an array'));
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
namespace Mcp\Tests\Unit\Server\Transport\Http\OAuth;
|
||||
|
||||
use Mcp\Exception\RuntimeException;
|
||||
use Mcp\Server\Transport\Http\OAuth\LenientOidcDiscoveryMetadataPolicy;
|
||||
use Mcp\Server\Transport\Http\OAuth\OidcDiscovery;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
|
|
@ -51,7 +52,7 @@ class OidcDiscoveryTest extends TestCase
|
|||
|
||||
$factory = new Psr17Factory();
|
||||
$issuer = 'https://auth.example.com';
|
||||
$metadataWithoutCodeChallengeMethods = [
|
||||
$metadata = [
|
||||
'issuer' => $issuer,
|
||||
'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize',
|
||||
'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token',
|
||||
|
|
@ -62,7 +63,7 @@ class OidcDiscoveryTest extends TestCase
|
|||
$httpClient->expects($this->exactly(2))
|
||||
->method('sendRequest')
|
||||
->willReturn($factory->createResponse(200)->withBody(
|
||||
$factory->createStream(json_encode($metadataWithoutCodeChallengeMethods, \JSON_THROW_ON_ERROR)),
|
||||
$factory->createStream(json_encode($metadata, \JSON_THROW_ON_ERROR)),
|
||||
));
|
||||
|
||||
$discovery = new OidcDiscovery(
|
||||
|
|
@ -75,6 +76,39 @@ class OidcDiscoveryTest extends TestCase
|
|||
$discovery->discover($issuer);
|
||||
}
|
||||
|
||||
#[TestDox('lenient discovery accepts metadata without code challenge methods')]
|
||||
public function testDiscoverAcceptsMetadataWithoutCodeChallengeMethodsUsingLenientPolicy(): void
|
||||
{
|
||||
$this->skipIfPsrHttpClientIsMissing();
|
||||
|
||||
$factory = new Psr17Factory();
|
||||
$issuer = 'https://auth.example.com';
|
||||
$metadata = [
|
||||
'issuer' => $issuer,
|
||||
'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize',
|
||||
'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token',
|
||||
'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys',
|
||||
];
|
||||
|
||||
$httpClient = $this->createMock(ClientInterface::class);
|
||||
$httpClient->expects($this->once())
|
||||
->method('sendRequest')
|
||||
->willReturn($factory->createResponse(200)->withBody(
|
||||
$factory->createStream(json_encode($metadata, \JSON_THROW_ON_ERROR)),
|
||||
));
|
||||
|
||||
$discovery = new OidcDiscovery(
|
||||
httpClient: $httpClient,
|
||||
requestFactory: $factory,
|
||||
metadataPolicy: new LenientOidcDiscoveryMetadataPolicy(),
|
||||
);
|
||||
|
||||
$result = $discovery->discover($issuer);
|
||||
|
||||
$this->assertSame($metadata['authorization_endpoint'], $result['authorization_endpoint']);
|
||||
$this->assertArrayNotHasKey('code_challenge_methods_supported', $result);
|
||||
}
|
||||
|
||||
#[TestDox('discover falls back to the next metadata URL when first response is invalid')]
|
||||
public function testDiscoverFallsBackOnInvalidMetadataResponse(): void
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue