Skip to content

Commit 863cfc1

Browse files
simonchrzchr-hertelclaude
authored
[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>
1 parent 5d57b68 commit 863cfc1

File tree

12 files changed

+963
-110
lines changed

12 files changed

+963
-110
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
1010
* Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators)
1111
* Add elicitation enum schema types per SEP-1330: `TitledEnumSchemaDefinition`, `MultiSelectEnumSchemaDefinition`, `TitledMultiSelectEnumSchemaDefinition`
1212
* [BC break] Make Symfony Finder component optional. Users would need to install `symfony/finder` now themselves
13+
* Add `LenientOidcDiscoveryMetadataPolicy` for identity providers that omit `code_challenge_methods_supported` (e.g. FusionAuth, Microsoft Entra ID)
14+
* Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591)
1315

1416
0.4.0
1517
-----

examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php

Lines changed: 0 additions & 38 deletions
This file was deleted.

examples/server/oauth-microsoft/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,8 @@ curl -X POST http://localhost:8000/mcp \
148148
- `Dockerfile` - PHP-FPM container
149149
- `nginx/default.conf` - Nginx configuration
150150
- `env.example` - Environment variables template
151-
- `server.php` - MCP server with OAuth middleware
151+
- `server.php` - MCP server with OAuth middleware (uses built-in `LenientOidcDiscoveryMetadataPolicy` for metadata validation)
152152
- `MicrosoftJwtTokenValidator.php` - Example-specific validator for Graph/non-Graph tokens
153-
- `MicrosoftOidcMetadataPolicy.php` - Lenient metadata validation policy
154153
- `McpElements.php` - MCP tools including Graph API integration
155154

156155
## Environment Variables
@@ -198,8 +197,10 @@ Microsoft's JWKS endpoint is public. Ensure your container can reach:
198197

199198
### `code_challenge_methods_supported` missing in discovery metadata
200199

201-
This example configures `OidcDiscovery` with `MicrosoftOidcMetadataPolicy`, so this
202-
field can be missing or malformed and will not fail discovery.
200+
The default `StrictOidcDiscoveryMetadataPolicy` requires `code_challenge_methods_supported`.
201+
Microsoft Entra ID omits this field despite supporting PKCE with S256.
202+
This example uses the built-in `LenientOidcDiscoveryMetadataPolicy` which accepts missing
203+
`code_challenge_methods_supported` (defaults to S256 downstream).
203204

204205
### Graph API errors
205206

examples/server/oauth-microsoft/server.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
use Http\Discovery\Psr17Factory;
1717
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
1818
use Mcp\Example\Server\OAuthMicrosoft\MicrosoftJwtTokenValidator;
19-
use Mcp\Example\Server\OAuthMicrosoft\MicrosoftOidcMetadataPolicy;
2019
use Mcp\Server;
2120
use Mcp\Server\Session\FileSessionStore;
2221
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
@@ -25,6 +24,7 @@
2524
use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware;
2625
use Mcp\Server\Transport\Http\OAuth\JwksProvider;
2726
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
27+
use Mcp\Server\Transport\Http\OAuth\LenientOidcDiscoveryMetadataPolicy;
2828
use Mcp\Server\Transport\Http\OAuth\OidcDiscovery;
2929
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
3030
use Mcp\Server\Transport\StreamableHttpTransport;
@@ -37,7 +37,7 @@
3737
$localBaseUrl = 'http://localhost:8000';
3838

3939
$discovery = new OidcDiscovery(
40-
metadataPolicy: new MicrosoftOidcMetadataPolicy(),
40+
metadataPolicy: new LenientOidcDiscoveryMetadataPolicy(),
4141
);
4242

4343
$jwtTokenValidator = new JwtTokenValidator(

examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php

Lines changed: 0 additions & 64 deletions
This file was deleted.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Exception;
13+
14+
final class ClientRegistrationException extends \RuntimeException implements ExceptionInterface
15+
{
16+
public function __construct(
17+
string $message,
18+
public readonly string $errorCode = 'invalid_client_metadata',
19+
?\Throwable $previous = null,
20+
) {
21+
parent::__construct($message, 0, $previous);
22+
}
23+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Transport\Http\Middleware;
13+
14+
use Http\Discovery\Psr17FactoryDiscovery;
15+
use Mcp\Exception\ClientRegistrationException;
16+
use Mcp\Exception\InvalidArgumentException;
17+
use Mcp\Server\Transport\Http\OAuth\ClientRegistrarInterface;
18+
use Psr\Http\Message\ResponseFactoryInterface;
19+
use Psr\Http\Message\ResponseInterface;
20+
use Psr\Http\Message\ServerRequestInterface;
21+
use Psr\Http\Message\StreamFactoryInterface;
22+
use Psr\Http\Server\MiddlewareInterface;
23+
use Psr\Http\Server\RequestHandlerInterface;
24+
25+
/**
26+
* OAuth 2.0 Dynamic Client Registration (RFC 7591) middleware.
27+
*
28+
* Handles POST /register requests by delegating to a ClientRegistrarInterface
29+
* and enriches /.well-known/oauth-authorization-server responses with the
30+
* registration_endpoint.
31+
*/
32+
final class ClientRegistrationMiddleware implements MiddlewareInterface
33+
{
34+
private const REGISTRATION_PATH = '/register';
35+
36+
private ResponseFactoryInterface $responseFactory;
37+
private StreamFactoryInterface $streamFactory;
38+
39+
public function __construct(
40+
private readonly ClientRegistrarInterface $registrar,
41+
private readonly string $localBaseUrl,
42+
?ResponseFactoryInterface $responseFactory = null,
43+
?StreamFactoryInterface $streamFactory = null,
44+
) {
45+
if ('' === trim($localBaseUrl)) {
46+
throw new InvalidArgumentException('The $localBaseUrl must not be empty.');
47+
}
48+
49+
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
50+
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
51+
}
52+
53+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
54+
{
55+
$path = $request->getUri()->getPath();
56+
57+
if ('POST' === $request->getMethod() && self::REGISTRATION_PATH === $path) {
58+
return $this->handleRegistration($request);
59+
}
60+
61+
$response = $handler->handle($request);
62+
63+
if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) {
64+
return $this->enrichAuthServerMetadata($response);
65+
}
66+
67+
return $response;
68+
}
69+
70+
private function handleRegistration(ServerRequestInterface $request): ResponseInterface
71+
{
72+
$contentType = $request->getHeaderLine('Content-Type');
73+
if (!str_starts_with($contentType, 'application/json')) {
74+
return $this->jsonResponse(400, [
75+
'error' => 'invalid_client_metadata',
76+
'error_description' => 'Content-Type must be application/json.',
77+
], ['Cache-Control' => 'no-store']);
78+
}
79+
80+
$body = $request->getBody()->__toString();
81+
82+
try {
83+
$decoded = json_decode($body, false, 512, \JSON_THROW_ON_ERROR);
84+
} catch (\JsonException) {
85+
return $this->jsonResponse(400, [
86+
'error' => 'invalid_client_metadata',
87+
'error_description' => 'Request body must be valid JSON.',
88+
], ['Cache-Control' => 'no-store']);
89+
}
90+
91+
if (!$decoded instanceof \stdClass) {
92+
return $this->jsonResponse(400, [
93+
'error' => 'invalid_client_metadata',
94+
'error_description' => 'Request body must be a JSON object.',
95+
], ['Cache-Control' => 'no-store']);
96+
}
97+
98+
// Re-decode with assoc=true so nested objects become arrays (safe — already validated above)
99+
/** @var array<string, mixed> $data */
100+
$data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR);
101+
102+
try {
103+
$result = $this->registrar->register($data);
104+
} catch (ClientRegistrationException $e) {
105+
return $this->jsonResponse(400, [
106+
'error' => $e->errorCode,
107+
'error_description' => $e->getMessage(),
108+
], ['Cache-Control' => 'no-store']);
109+
}
110+
111+
return $this->jsonResponse(201, $result, [
112+
'Cache-Control' => 'no-store',
113+
]);
114+
}
115+
116+
private function enrichAuthServerMetadata(ResponseInterface $response): ResponseInterface
117+
{
118+
if (200 !== $response->getStatusCode()) {
119+
return $response;
120+
}
121+
122+
$stream = $response->getBody();
123+
124+
if ($stream->isSeekable()) {
125+
$stream->rewind();
126+
}
127+
128+
try {
129+
$metadata = json_decode($stream->__toString(), true, 512, \JSON_THROW_ON_ERROR);
130+
} catch (\JsonException) {
131+
if ($stream->isSeekable()) {
132+
$stream->rewind();
133+
}
134+
135+
return $response;
136+
}
137+
138+
if (!\is_array($metadata) || ([] !== $metadata && array_is_list($metadata))) {
139+
if ($stream->isSeekable()) {
140+
$stream->rewind();
141+
}
142+
143+
return $response;
144+
}
145+
146+
$metadata['registration_endpoint'] = rtrim($this->localBaseUrl, '/').self::REGISTRATION_PATH;
147+
148+
return $response
149+
->withBody($this->streamFactory->createStream(
150+
json_encode($metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES),
151+
))
152+
->withHeader('Content-Type', 'application/json')
153+
->withoutHeader('Content-Length');
154+
}
155+
156+
/**
157+
* @param array<string, mixed> $data
158+
* @param array<string, string> $extraHeaders
159+
*/
160+
private function jsonResponse(int $status, array $data, array $extraHeaders = []): ResponseInterface
161+
{
162+
$response = $this->responseFactory
163+
->createResponse($status)
164+
->withHeader('Content-Type', 'application/json')
165+
->withBody($this->streamFactory->createStream(
166+
json_encode($data, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES),
167+
));
168+
169+
foreach ($extraHeaders as $name => $value) {
170+
if ('' !== $value) {
171+
$response = $response->withHeader($name, $value);
172+
}
173+
}
174+
175+
return $response;
176+
}
177+
}

0 commit comments

Comments
 (0)