From 443af5c307f7d034410fff8a043c2e8554523d71 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Tue, 17 Mar 2026 16:08:26 +0100 Subject: [PATCH 1/4] 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 7974a2fa342181ffde012b6e696ebae760d31f09 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Tue, 17 Mar 2026 16:08:34 +0100 Subject: [PATCH 2/4] 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 9c882ab7..afdfceca 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` 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; + } + }; + } +} From 56523055bb9c82a69f440e9ae0b15bf6f155d9d7 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Wed, 18 Mar 2026 09:17:46 +0100 Subject: [PATCH 3/4] fix: address MR review comments for ClientRegistration - Use Mcp\Exception\InvalidArgumentException instead of global \InvalidArgumentException - Add interface documentation for ClientRegistrarInterface with RFC 7591 references --- .../ClientRegistrationMiddleware.php | 3 ++- .../Http/OAuth/ClientRegistrarInterface.php | 19 ++++++++++++++++--- .../ClientRegistrationMiddlewareTest.php | 3 ++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php b/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php index b68a5b14..36ac55ad 100644 --- a/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php @@ -13,6 +13,7 @@ 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; @@ -42,7 +43,7 @@ public function __construct( ?StreamFactoryInterface $streamFactory = null, ) { if ('' === trim($localBaseUrl)) { - throw new \InvalidArgumentException('The $localBaseUrl must not be empty.'); + throw new InvalidArgumentException('The $localBaseUrl must not be empty.'); } $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); diff --git a/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php b/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php index d6ff6ef4..349d611b 100644 --- a/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php +++ b/src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php @@ -15,15 +15,28 @@ /** * 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 { /** - * @param array $registrationRequest + * 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 $registrationRequest Client metadata from the registration request body * - * @return array + * @return array Registration response including client_id and optional client_secret * - * @throws ClientRegistrationException + * @throws ClientRegistrationException If registration fails (e.g. invalid metadata, storage error) */ 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 index 1d99131b..ada2ff7a 100644 --- a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php @@ -12,6 +12,7 @@ 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; @@ -165,7 +166,7 @@ public function testNonMatchingRoutePassesThrough(): void #[TestDox('constructor rejects empty localBaseUrl')] public function testConstructorRejectsEmptyBaseUrl(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); new ClientRegistrationMiddleware( $this->createStub(ClientRegistrarInterface::class), From bc3908b22aa208a1e34d0b65087df30a6b766236 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Wed, 18 Mar 2026 09:42:29 +0100 Subject: [PATCH 4/4] refactor: remove LenientOidcDiscoveryMetadataPolicy, relax Strict instead - StrictOidcDiscoveryMetadataPolicy now accepts missing code_challenge_methods_supported (defaults to S256 downstream) - If the field is present, it is still validated strictly - Remove LenientOidcDiscoveryMetadataPolicy (no longer needed) - Update tests and CHANGELOG accordingly --- CHANGELOG.md | 2 +- examples/server/oauth-microsoft/README.md | 5 +- .../LenientOidcDiscoveryMetadataPolicy.php | 38 ------- .../StrictOidcDiscoveryMetadataPolicy.php | 15 +-- ...LenientOidcDiscoveryMetadataPolicyTest.php | 98 ------------------- .../Http/OAuth/OidcDiscoveryTest.php | 17 ++-- .../StrictOidcDiscoveryMetadataPolicyTest.php | 6 +- 7 files changed, 24 insertions(+), 157 deletions(-) delete mode 100644 src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php delete mode 100644 tests/Unit/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicyTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index afdfceca..64eb61ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ 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` in OIDC discovery +* Allow `StrictOidcDiscoveryMetadataPolicy` to accept metadata without `code_challenge_methods_supported` (defaults to S256 downstream) * Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591) 0.4.0 diff --git a/examples/server/oauth-microsoft/README.md b/examples/server/oauth-microsoft/README.md index 74239498..aceda056 100644 --- a/examples/server/oauth-microsoft/README.md +++ b/examples/server/oauth-microsoft/README.md @@ -198,8 +198,9 @@ 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` accepts metadata without `code_challenge_methods_supported` +(defaults to S256 downstream). The `MicrosoftOidcMetadataPolicy` in this example demonstrates +how to implement a custom policy via `OidcDiscoveryMetadataPolicyInterface`. ### Graph API errors diff --git a/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php b/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php deleted file mode 100644 index 0a54aeb3..00000000 --- a/src/Server/Transport/Http/OAuth/LenientOidcDiscoveryMetadataPolicy.php +++ /dev/null @@ -1,38 +0,0 @@ -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']; - } -} diff --git a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php index 14eb54f1..3842822d 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php @@ -44,14 +44,14 @@ public function testInvalidIssuerUrlThrows(): void $discovery->discover('invalid-issuer'); } - #[TestDox('strict discovery rejects metadata without code challenge methods')] - public function testDiscoverRejectsMetadataWithoutCodeChallengeMethodsSupported(): void + #[TestDox('discovery accepts metadata without code_challenge_methods_supported')] + public function testDiscoverAcceptsMetadataWithoutCodeChallengeMethodsSupported(): void { $this->skipIfPsrHttpClientIsMissing(); $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', @@ -59,10 +59,10 @@ public function testDiscoverRejectsMetadataWithoutCodeChallengeMethodsSupported( ]; $httpClient = $this->createMock(ClientInterface::class); - $httpClient->expects($this->exactly(2)) + $httpClient->expects($this->once()) ->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( @@ -70,9 +70,10 @@ public function testDiscoverRejectsMetadataWithoutCodeChallengeMethodsSupported( requestFactory: $factory, ); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Failed to discover authorization server metadata'); - $discovery->discover($issuer); + $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')] diff --git a/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php b/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php index 3d1e8a7d..16b2898b 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php @@ -22,8 +22,8 @@ */ class StrictOidcDiscoveryMetadataPolicyTest extends TestCase { - #[TestDox('metadata without code challenge methods is invalid in strict mode')] - public function testMissingCodeChallengeMethodsIsInvalid(): void + #[TestDox('metadata without code challenge methods is valid (defaults to S256 downstream)')] + public function testMissingCodeChallengeMethodsIsValid(): void { $policy = new StrictOidcDiscoveryMetadataPolicy(); $metadata = [ @@ -32,7 +32,7 @@ public function testMissingCodeChallengeMethodsIsInvalid(): void 'jwks_uri' => 'https://auth.example.com/jwks', ]; - $this->assertFalse($policy->isValid($metadata)); + $this->assertTrue($policy->isValid($metadata)); } #[TestDox('valid code challenge methods list is accepted in strict mode')]