diff --git a/CHANGELOG.md b/CHANGELOG.md index 22ad2b37..e77f0621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] Remove the `providerClass` argument of `#[CompletionProvider]`. Use `provider:`, which takes the same class-string and is now the first positional argument. * Add `HttpTransport::getSessionId()` to read the server-minted `Mcp-Session-Id`: a request-scoped caller can persist it and pass it back through the constructor's `$headers` on a later transport. Always `null` on `2026-07-28`, which removed protocol-level sessions. +* Fix OIDC discovery rejecting issuers with a trailing slash (e.g. Authentik, Auth0). 0.8.0 ----- diff --git a/src/Server/Transport/Http/OAuth/OidcDiscovery.php b/src/Server/Transport/Http/OAuth/OidcDiscovery.php index 2f246852..a0c0cd61 100644 --- a/src/Server/Transport/Http/OAuth/OidcDiscovery.php +++ b/src/Server/Transport/Http/OAuth/OidcDiscovery.php @@ -146,8 +146,10 @@ public function discover(string $issuer): array */ private function fetchMetadata(string $issuer): array { - $issuer = rtrim($issuer, '/'); - $parsed = parse_url($issuer); + // The trailing slash is dropped to build discovery URLs (RFC 8414 §3.1), + // but the issuer is matched verbatim (RFC 8414 §3.3, OIDC Discovery §4.3). + $discoveryIssuer = rtrim($issuer, '/'); + $parsed = parse_url($discoveryIssuer); if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { throw new RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer)); @@ -170,7 +172,7 @@ private function fetchMetadata(string $issuer): array // 2. OIDC path insertion $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path; // 3. OIDC path appending - $discoveryUrls[] = $issuer.'/.well-known/openid-configuration'; + $discoveryUrls[] = $discoveryIssuer.'/.well-known/openid-configuration'; } else { // For issuer URLs without path components $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'; diff --git a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php index 29c86d6f..fd86d02e 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php @@ -293,6 +293,88 @@ public function testIssuerWithoutPathUsesStandardWellKnownEndpoints(): void $this->assertSame('https://auth.example.com/.well-known/openid-configuration', $requestedUrls[1]); } + #[TestDox('issuer with path and trailing slash is discovered and matched verbatim')] + public function testIssuerWithPathAndTrailingSlash(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + $issuer = 'https://auth.example.com/application/o/mcp/'; + $validMetadata = [ + 'issuer' => $issuer, + 'authorization_endpoint' => 'https://auth.example.com/application/o/authorize/', + 'token_endpoint' => 'https://auth.example.com/application/o/token/', + 'jwks_uri' => 'https://auth.example.com/application/o/mcp/jwks/', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(3)) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + if (3 !== \count($requestedUrls)) { + return $factory->createResponse(404); + } + + return $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($validMetadata, \JSON_THROW_ON_ERROR)), + ); + }); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $metadata = $discovery->discover($issuer); + + $this->assertSame($issuer, $metadata['issuer']); + $this->assertSame('https://auth.example.com/.well-known/oauth-authorization-server/application/o/mcp', $requestedUrls[0]); + $this->assertSame('https://auth.example.com/.well-known/openid-configuration/application/o/mcp', $requestedUrls[1]); + $this->assertSame('https://auth.example.com/application/o/mcp/.well-known/openid-configuration', $requestedUrls[2]); + } + + #[TestDox('issuer without path but with trailing slash is discovered and matched verbatim')] + public function testIssuerWithoutPathWithTrailingSlash(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + $issuer = 'https://auth.example.com/'; + $validMetadata = [ + 'issuer' => $issuer, + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth/token', + 'jwks_uri' => 'https://auth.example.com/.well-known/jwks.json', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + return $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($validMetadata, \JSON_THROW_ON_ERROR)), + ); + }); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $metadata = $discovery->discover($issuer); + + $this->assertSame($issuer, $metadata['issuer']); + $this->assertSame(['https://auth.example.com/.well-known/oauth-authorization-server'], $requestedUrls); + } + private function skipIfPsrHttpClientIsMissing(): void { if (!interface_exists(ClientInterface::class)) {