From a94a9fc8bb251310fe612efdca20a7d0904663bd Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Tue, 31 Mar 2026 08:46:46 +0200 Subject: [PATCH 1/5] 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 --- .gitignore | 1 + CHANGELOG.md | 2 + .../MicrosoftOidcMetadataPolicy.php | 38 -- examples/server/oauth-microsoft/README.md | 8 +- examples/server/oauth-microsoft/server.php | 4 +- .../Unit/MicrosoftOidcMetadataPolicyTest.php | 64 ---- src/Exception/ClientRegistrationException.php | 16 + .../ClientRegistrationMiddleware.php | 156 +++++++++ .../Http/OAuth/ClientRegistrarInterface.php | 42 +++ .../LenientOidcDiscoveryMetadataPolicy.php | 54 +++ .../ClientRegistrationMiddlewareTest.php | 330 ++++++++++++++++++ ...LenientOidcDiscoveryMetadataPolicyTest.php | 125 +++++++ .../Http/OAuth/OidcDiscoveryTest.php | 38 +- 13 files changed, 769 insertions(+), 109 deletions(-) delete mode 100644 examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php delete mode 100644 examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php create mode 100644 src/Exception/ClientRegistrationException.php create mode 100644 src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php create mode 100644 src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php create mode 100644 src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php diff --git a/.gitignore b/.gitignore index 6dc5d9ec..e1012acc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ tests/Conformance/logs/*.log # phpDocumentor .phpdoc/build/ .phpdoc/cache/ + diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c882ab7..42711674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add client component for building MCP clients * Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators) * Add elicitation enum schema types per SEP-1330: `TitledEnumSchemaDefinition`, `MultiSelectEnumSchemaDefinition`, `TitledMultiSelectEnumSchemaDefinition` +* 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 ----- diff --git a/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php b/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php deleted file mode 100644 index fb16cda8..00000000 --- a/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ -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']); - } -} diff --git a/examples/server/oauth-microsoft/README.md b/examples/server/oauth-microsoft/README.md index 74239498..21e87706 100644 --- a/examples/server/oauth-microsoft/README.md +++ b/examples/server/oauth-microsoft/README.md @@ -150,7 +150,7 @@ curl -X POST http://localhost:8000/mcp \ - `env.example` - Environment variables template - `server.php` - MCP server with OAuth middleware - `MicrosoftJwtTokenValidator.php` - Example-specific validator for Graph/non-Graph tokens -- `MicrosoftOidcMetadataPolicy.php` - Lenient metadata validation policy +- Uses built-in `LenientOidcDiscoveryMetadataPolicy` for metadata validation - `McpElements.php` - MCP tools including Graph API integration ## Environment Variables @@ -198,8 +198,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 diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php index de16ab5c..ad827e0b 100644 --- a/examples/server/oauth-microsoft/server.php +++ b/examples/server/oauth-microsoft/server.php @@ -16,7 +16,6 @@ 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\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 @@ $localBaseUrl = 'http://localhost:8000'; $discovery = new OidcDiscovery( - metadataPolicy: new MicrosoftOidcMetadataPolicy(), + metadataPolicy: new LenientOidcDiscoveryMetadataPolicy(), ); $jwtTokenValidator = new JwtTokenValidator( diff --git a/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php b/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php deleted file mode 100644 index 218e0fbd..00000000 --- a/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php +++ /dev/null @@ -1,64 +0,0 @@ - - */ -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)); - } -} diff --git a/src/Exception/ClientRegistrationException.php b/src/Exception/ClientRegistrationException.php new file mode 100644 index 00000000..636006b0 --- /dev/null +++ b/src/Exception/ClientRegistrationException.php @@ -0,0 +1,16 @@ +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 + { + $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.', + ]); + } + + if (!$decoded instanceof \stdClass) { + return $this->jsonResponse(400, [ + 'error' => 'invalid_client_metadata', + 'error_description' => 'Request body must be a JSON object.', + ]); + } + + /** @var array $data */ + $data = (array) $decoded; + + try { + $result = $this->registrar->register($data); + } catch (ClientRegistrationException $e) { + return $this->jsonResponse(400, [ + 'error' => 'invalid_client_metadata', + 'error_description' => $e->getMessage(), + ]); + } + + 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(); + } + + $metadata = json_decode($stream->__toString(), true); + + if (!\is_array($metadata)) { + 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 $data + * @param array $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; + } +} diff --git a/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php b/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php new file mode 100644 index 00000000..349d611b --- /dev/null +++ b/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php @@ -0,0 +1,42 @@ + $registrationRequest Client metadata from the registration request body + * + * @return array Registration response including client_id and optional client_secret + * + * @throws ClientRegistrationException If registration fails (e.g. invalid metadata, storage error) + */ + public function register(array $registrationRequest): array; +} diff --git a/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php b/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php new file mode 100644 index 00000000..2a8c507b --- /dev/null +++ b/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php @@ -0,0 +1,54 @@ + + */ +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; + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php new file mode 100644 index 00000000..cdf9ba22 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php @@ -0,0 +1,330 @@ +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') + ->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') + ->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') + ->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') + ->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 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') + ->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('redirect_uris is required', $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 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 $data + * @param array $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 $data + * @param array $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; + } + }; + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php b/tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php new file mode 100644 index 00000000..2b67facb --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php @@ -0,0 +1,125 @@ + + */ +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')); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php index 14eb54f1..29c86d6f 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php @@ -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 @@ public function testDiscoverRejectsMetadataWithoutCodeChallengeMethodsSupported( $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 @@ public function testDiscoverRejectsMetadataWithoutCodeChallengeMethodsSupported( $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 @@ public function testDiscoverRejectsMetadataWithoutCodeChallengeMethodsSupported( $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 { From 769324c782ba880d32bc89de4dd1d4d9573d61f2 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Tue, 31 Mar 2026 09:03:26 +0200 Subject: [PATCH 2/5] 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. --- examples/server/oauth-microsoft/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/server/oauth-microsoft/README.md b/examples/server/oauth-microsoft/README.md index 21e87706..66d51d8e 100644 --- a/examples/server/oauth-microsoft/README.md +++ b/examples/server/oauth-microsoft/README.md @@ -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 -- Uses built-in `LenientOidcDiscoveryMetadataPolicy` for metadata validation - `McpElements.php` - MCP tools including Graph API integration ## Environment Variables From 46e159516a6850ca7c721b704ca256dbc414cd7a Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Tue, 31 Mar 2026 09:14:24 +0200 Subject: [PATCH 3/5] 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 --- .../ClientRegistrationMiddleware.php | 12 ++- .../ClientRegistrationMiddlewareTest.php | 78 +++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php b/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php index d1fec5e1..c1850154 100644 --- a/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php @@ -77,18 +77,18 @@ private function handleRegistration(ServerRequestInterface $request): ResponseIn 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']); } /** @var array $data */ - $data = (array) $decoded; + $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); try { $result = $this->registrar->register($data); @@ -96,7 +96,7 @@ private function handleRegistration(ServerRequestInterface $request): ResponseIn return $this->jsonResponse(400, [ 'error' => 'invalid_client_metadata', 'error_description' => $e->getMessage(), - ]); + ], ['Cache-Control' => 'no-store']); } return $this->jsonResponse(201, $result, [ @@ -119,6 +119,10 @@ private function enrichAuthServerMetadata(ResponseInterface $response): Response $metadata = json_decode($stream->__toString(), true); if (!\is_array($metadata)) { + if ($stream->isSeekable()) { + $stream->rewind(); + } + return $response; } diff --git a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php index cdf9ba22..4e22cb84 100644 --- a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php @@ -114,6 +114,56 @@ public function testRegistrationWithEmptyJsonArrayReturns400(): void $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') + ->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') + ->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') + ->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 { @@ -195,6 +245,34 @@ public function testMetadataEnrichmentRemovesContentLength(): void $this->assertFalse($response->hasHeader('Content-Length')); } + #[TestDox('GET /.well-known/oauth-authorization-server with non-JSON body rewinds stream before returning')] + public function testMetadataEnrichmentRewindsStreamOnNonJsonBody(): void + { + $registrar = $this->createStub(ClientRegistrarInterface::class); + $middleware = $this->createMiddleware($registrar); + + $request = $this->factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); + + // Create a handler that returns a 200 with non-JSON body + $handler = new class($this->factory) implements RequestHandlerInterface { + public function __construct(private readonly Psr17Factory $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200) + ->withHeader('Content-Type', 'text/plain') + ->withBody($this->factory->createStream('not json')); + } + }; + + $response = $middleware->process($request, $handler); + + // Stream should be rewound so getContents() returns the full body + $this->assertSame('not json', $response->getBody()->getContents()); + } + #[TestDox('GET /.well-known/oauth-authorization-server with non-200 status passes through unchanged')] public function testMetadataNon200PassesThrough(): void { From 8d4c8e63edf864b7c1e23f5eeecb051c0b7ca160 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Tue, 31 Mar 2026 09:22:05 +0200 Subject: [PATCH 4/5] 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 --- .gitignore | 1 - .../ClientRegistrationMiddleware.php | 11 +++- .../ClientRegistrationMiddlewareTest.php | 54 +++++++++++++------ 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index e1012acc..6dc5d9ec 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,3 @@ tests/Conformance/logs/*.log # phpDocumentor .phpdoc/build/ .phpdoc/cache/ - diff --git a/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php b/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php index c1850154..abb5d4e7 100644 --- a/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php @@ -87,6 +87,7 @@ private function handleRegistration(ServerRequestInterface $request): ResponseIn ], ['Cache-Control' => 'no-store']); } + // Re-decode with assoc=true so nested objects become arrays (safe — already validated above) /** @var array $data */ $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); @@ -116,7 +117,15 @@ private function enrichAuthServerMetadata(ResponseInterface $response): Response $stream->rewind(); } - $metadata = json_decode($stream->__toString(), true); + 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)) { if ($stream->isSeekable()) { diff --git a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php index 4e22cb84..77edf585 100644 --- a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php @@ -180,6 +180,7 @@ public function testRegistrationWithRegistrarException(): void $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']); @@ -245,32 +246,32 @@ public function testMetadataEnrichmentRemovesContentLength(): void $this->assertFalse($response->hasHeader('Content-Length')); } - #[TestDox('GET /.well-known/oauth-authorization-server with non-JSON body rewinds stream before returning')] - public function testMetadataEnrichmentRewindsStreamOnNonJsonBody(): void + #[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'); - // Create a handler that returns a 200 with non-JSON body - $handler = new class($this->factory) implements RequestHandlerInterface { - public function __construct(private readonly Psr17Factory $factory) - { - } + $response = $middleware->process($request, $handler); - public function handle(ServerRequestInterface $request): ResponseInterface - { - return $this->factory->createResponse(200) - ->withHeader('Content-Type', 'text/plain') - ->withBody($this->factory->createStream('not json')); - } - }; + $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); - // Stream should be rewound so getContents() returns the full body - $this->assertSame('not json', $response->getBody()->getContents()); + $this->assertSame('"just a string"', $response->getBody()->getContents()); } #[TestDox('GET /.well-known/oauth-authorization-server with non-200 status passes through unchanged')] @@ -405,4 +406,25 @@ public function handle(ServerRequestInterface $request): ResponseInterface } }; } + + 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)); + } + }; + } } From e29e9bd9d6f3e0e2e718ba6cb9e53aa1f70cc7cf Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Tue, 31 Mar 2026 09:39:09 +0200 Subject: [PATCH 5/5] 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. --- .../Middleware/ClientRegistrationMiddleware.php | 2 +- .../ClientRegistrationMiddlewareTest.php | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php b/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php index abb5d4e7..b4a0a9b4 100644 --- a/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php @@ -127,7 +127,7 @@ private function enrichAuthServerMetadata(ResponseInterface $response): Response return $response; } - if (!\is_array($metadata)) { + if (!\is_array($metadata) || ([] !== $metadata && array_is_list($metadata))) { if ($stream->isSeekable()) { $stream->rewind(); } diff --git a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php index 77edf585..a5574279 100644 --- a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php @@ -274,6 +274,20 @@ public function testMetadataEnrichmentRewindsStreamOnNonObjectJsonBody(): void $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 {