From 8026dea0cb1c7dbf30ced5e0defa6102c18af24c Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Tue, 17 Mar 2026 16:08:26 +0100 Subject: [PATCH 1/2] feat: add LenientOidcDiscoveryMetadataPolicy for IdPs without code_challenge_methods_supported Some identity providers (e.g. FusionAuth, Microsoft Entra ID) omit code_challenge_methods_supported from their OIDC discovery response despite supporting PKCE with S256. This policy relaxes the validation to only require authorization_endpoint, token_endpoint, and jwks_uri. --- .../LenientOidcDiscoveryMetadataPolicy.php | 38 +++++++ ...LenientOidcDiscoveryMetadataPolicyTest.php | 98 +++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php create mode 100644 tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php diff --git a/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php b/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php new file mode 100644 index 00000000..0a54aeb3 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php @@ -0,0 +1,38 @@ +assertTrue($policy->isValid($metadata)); + } + + /** + * @return iterable + */ + public static function provideValidMetadata(): iterable + { + $base = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + ]; + + yield 'without code_challenge_methods_supported' => [$base, 'without code_challenge_methods_supported']; + + yield 'with code_challenge_methods_supported' => [ + $base + ['code_challenge_methods_supported' => ['S256']], + 'with code_challenge_methods_supported', + ]; + } + + #[DataProvider('provideInvalidMetadata')] + #[TestDox('invalid metadata: $description')] + public function testInvalidMetadata(mixed $metadata, string $description): void + { + $policy = new LenientOidcDiscoveryMetadataPolicy(); + + $this->assertFalse($policy->isValid($metadata)); + } + + /** + * @return iterable + */ + public static function provideInvalidMetadata(): iterable + { + yield 'missing authorization_endpoint' => [ + [ + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + ], + 'missing authorization_endpoint', + ]; + + yield 'missing token_endpoint' => [ + [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'jwks_uri' => 'https://auth.example.com/jwks', + ], + 'missing token_endpoint', + ]; + + yield 'missing jwks_uri' => [ + [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + ], + 'missing jwks_uri', + ]; + + yield 'empty endpoint string' => [ + [ + 'authorization_endpoint' => '', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + ], + 'empty endpoint string', + ]; + + yield 'non-array metadata' => ['not an array', 'non-array metadata']; + } +} From 61ebe89cde3702a46b927112021d710bcfe9aca5 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Tue, 17 Mar 2026 16:08:34 +0100 Subject: [PATCH 2/2] feat: add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591) PSR-15 middleware that handles POST /register by delegating to a ClientRegistrarInterface and enriches /.well-known/oauth-authorization-server responses with the registration_endpoint. --- CHANGELOG.md | 2 + src/Exception/ClientRegistrationException.php | 16 ++ .../ClientRegistrationMiddleware.php | 139 ++++++++++ .../Http/OAuth/ClientRegistrarInterface.php | 29 ++ .../ClientRegistrationMiddlewareTest.php | 262 ++++++++++++++++++ 5 files changed, 448 insertions(+) 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 tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index def81eab..b30fd864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add built-in authentication middleware for HTTP transport using OAuth * Add client component for building MCP clients * Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators) +* Add `LenientOidcDiscoveryMetadataPolicy` for identity providers that omit `code_challenge_methods_supported` in OIDC discovery +* Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591) 0.4.0 ----- 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(); + $data = json_decode($body, true); + + if (!\is_array($data)) { + return $this->jsonResponse(400, [ + 'error' => 'invalid_client_metadata', + 'error_description' => 'Request body must be valid JSON.', + ]); + } + + 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); + } + + 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 $this->jsonResponse(200, $metadata, [ + 'Cache-Control' => $response->getHeaderLine('Cache-Control'), + ]); + } + + /** + * @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..d6ff6ef4 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php @@ -0,0 +1,29 @@ + $registrationRequest + * + * @return array + * + * @throws ClientRegistrationException + */ + public function register(array $registrationRequest): array; +} 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..1d99131b --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php @@ -0,0 +1,262 @@ +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')); + + $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 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 Cache-Control header')] + public function testMetadataEnrichmentPreservesCacheControl(): 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'); + + $response = $middleware->process($request, $handler); + + $this->assertSame('max-age=3600', $response->getHeaderLine('Cache-Control')); + } + + #[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 + */ + private function createJsonHandler(int $status, array $data, string $cacheControl = ''): RequestHandlerInterface + { + $factory = $this->factory; + + return new class($factory, $status, $data, $cacheControl) implements RequestHandlerInterface { + /** + * @param array $data + */ + public function __construct( + private readonly ResponseFactoryInterface $factory, + private readonly int $status, + private readonly array $data, + private readonly string $cacheControl, + ) { + } + + 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); + } + + return $response; + } + }; + } +}