[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:
parent
5a64bab1c7
commit
ef6bee5531
3 changed files with 88 additions and 3 deletions
|
|
@ -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
|
||||
-----
|
||||
|
|
|
|||
|
|
@ -146,8 +146,10 @@ class OidcDiscovery implements OidcDiscoveryInterface
|
|||
*/
|
||||
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 @@ class OidcDiscovery implements OidcDiscoveryInterface
|
|||
// 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';
|
||||
|
|
|
|||
|
|
@ -293,6 +293,88 @@ class OidcDiscoveryTest extends TestCase
|
|||
$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)) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue