[Server] fix OIDC discovery for issuers with trailing slash (#506)

* fix OIDC discovery for issuers with trailing slash

* add trailing-slash issuer tests and changelog entry

---------

Co-authored-by: Dmitry Vorotyntsev <d.vorotyntsev@dats.team>
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
This commit is contained in:
Дмитрий Воротынцев 2026-09-15 01:14:48 +03:00 • committed by GitHub
commit ef6bee5531
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 88 additions and 3 deletions

View file

@ -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. * [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. * 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 0.8.0
----- -----

View file

@ -146,8 +146,10 @@ class OidcDiscovery implements OidcDiscoveryInterface
*/ */
private function fetchMetadata(string $issuer): array private function fetchMetadata(string $issuer): array
{ {
$issuer = rtrim($issuer, '/'); // The trailing slash is dropped to build discovery URLs (RFC 8414 §3.1),
$parsed = parse_url($issuer); // 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'])) { if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) {
throw new RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer)); throw new RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer));
@ -170,7 +172,7 @@ class OidcDiscovery implements OidcDiscoveryInterface
// 2. OIDC path insertion // 2. OIDC path insertion
$discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path; $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path;
// 3. OIDC path appending // 3. OIDC path appending
$discoveryUrls[] = $issuer.'/.well-known/openid-configuration'; $discoveryUrls[] = $discoveryIssuer.'/.well-known/openid-configuration';
} else { } else {
// For issuer URLs without path components // For issuer URLs without path components
$discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'; $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server';

View file

@ -293,6 +293,88 @@ class OidcDiscoveryTest extends TestCase
$this->assertSame('https://auth.example.com/.well-known/openid-configuration', $requestedUrls[1]); $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 private function skipIfPsrHttpClientIsMissing(): void
{ {
if (!interface_exists(ClientInterface::class)) { if (!interface_exists(ClientInterface::class)) {