[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.
|
* [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
|
||||||
-----
|
-----
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue