Skip to content

Commit a94a9fc

Browse files
committed
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
1 parent e8f5a8b commit a94a9fc

13 files changed

Lines changed: 769 additions & 109 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ tests/Conformance/logs/*.log
1313
# phpDocumentor
1414
.phpdoc/build/
1515
.phpdoc/cache/
16+

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
99
* Add client component for building MCP clients
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`
12+
* Add `LenientOidcDiscoveryMetadataPolicy` for identity providers that omit `code_challenge_methods_supported` (e.g. FusionAuth, Microsoft Entra ID)
13+
* Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591)
1214

1315
0.4.0
1416
-----

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 & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ curl -X POST http://localhost:8000/mcp \
150150
- `env.example` - Environment variables template
151151
- `server.php` - MCP server with OAuth middleware
152152
- `MicrosoftJwtTokenValidator.php` - Example-specific validator for Graph/non-Graph tokens
153-
- `MicrosoftOidcMetadataPolicy.php` - Lenient metadata validation policy
153+
- Uses built-in `LenientOidcDiscoveryMetadataPolicy` for metadata validation
154154
- `McpElements.php` - MCP tools including Graph API integration
155155

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

199199
### `code_challenge_methods_supported` missing in discovery metadata
200200

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

204206
### Graph API errors
205207

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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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+
$body = $request->getBody()->__toString();
73+
74+
try {
75+
$decoded = json_decode($body, false, 512, \JSON_THROW_ON_ERROR);
76+
} catch (\JsonException) {
77+
return $this->jsonResponse(400, [
78+
'error' => 'invalid_client_metadata',
79+
'error_description' => 'Request body must be valid JSON.',
80+
]);
81+
}
82+
83+
if (!$decoded instanceof \stdClass) {
84+
return $this->jsonResponse(400, [
85+
'error' => 'invalid_client_metadata',
86+
'error_description' => 'Request body must be a JSON object.',
87+
]);
88+
}
89+
90+
/** @var array<string, mixed> $data */
91+
$data = (array) $decoded;
92+
93+
try {
94+
$result = $this->registrar->register($data);
95+
} catch (ClientRegistrationException $e) {
96+
return $this->jsonResponse(400, [
97+
'error' => 'invalid_client_metadata',
98+
'error_description' => $e->getMessage(),
99+
]);
100+
}
101+
102+
return $this->jsonResponse(201, $result, [
103+
'Cache-Control' => 'no-store',
104+
]);
105+
}
106+
107+
private function enrichAuthServerMetadata(ResponseInterface $response): ResponseInterface
108+
{
109+
if (200 !== $response->getStatusCode()) {
110+
return $response;
111+
}
112+
113+
$stream = $response->getBody();
114+
115+
if ($stream->isSeekable()) {
116+
$stream->rewind();
117+
}
118+
119+
$metadata = json_decode($stream->__toString(), true);
120+
121+
if (!\is_array($metadata)) {
122+
return $response;
123+
}
124+
125+
$metadata['registration_endpoint'] = rtrim($this->localBaseUrl, '/').self::REGISTRATION_PATH;
126+
127+
return $response
128+
->withBody($this->streamFactory->createStream(
129+
json_encode($metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES),
130+
))
131+
->withHeader('Content-Type', 'application/json')
132+
->withoutHeader('Content-Length');
133+
}
134+
135+
/**
136+
* @param array<string, mixed> $data
137+
* @param array<string, string> $extraHeaders
138+
*/
139+
private function jsonResponse(int $status, array $data, array $extraHeaders = []): ResponseInterface
140+
{
141+
$response = $this->responseFactory
142+
->createResponse($status)
143+
->withHeader('Content-Type', 'application/json')
144+
->withBody($this->streamFactory->createStream(
145+
json_encode($data, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES),
146+
));
147+
148+
foreach ($extraHeaders as $name => $value) {
149+
if ('' !== $value) {
150+
$response = $response->withHeader($name, $value);
151+
}
152+
}
153+
154+
return $response;
155+
}
156+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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\OAuth;
13+
14+
use Mcp\Exception\ClientRegistrationException;
15+
16+
/**
17+
* Interface for OAuth 2.0 Dynamic Client Registration (RFC 7591).
18+
*
19+
* Implementations are responsible for persisting client credentials and
20+
* returning a registration response as defined in RFC 7591 Section 3.2.
21+
*
22+
* @see https://datatracker.ietf.org/doc/html/rfc7591
23+
*/
24+
interface ClientRegistrarInterface
25+
{
26+
/**
27+
* Registers a new OAuth 2.0 client.
28+
*
29+
* The registration request contains metadata fields as defined in RFC 7591
30+
* Section 2 (e.g. redirect_uris, client_name, token_endpoint_auth_method).
31+
*
32+
* The returned array MUST include at least "client_id" and should include
33+
* "client_secret" when the token endpoint auth method requires one.
34+
*
35+
* @param array<string, mixed> $registrationRequest Client metadata from the registration request body
36+
*
37+
* @return array<string, mixed> Registration response including client_id and optional client_secret
38+
*
39+
* @throws ClientRegistrationException If registration fails (e.g. invalid metadata, storage error)
40+
*/
41+
public function register(array $registrationRequest): array;
42+
}

0 commit comments

Comments
 (0)