Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8df8ae4
[Client] Add OAuth metadata discovery and challenge parsing
chr-hertel Nov 4, 2026
4a855ac
[Client] Isolate the Bearer challenge and decode its values per RFC 9110
chr-hertel Nov 4, 2026
d16c7dd
[Client] Tighten resource containment and keep slash and query in met…
chr-hertel Nov 4, 2026
350d591
[Client] Keep challenge parameters with several spaces before the equ…
chr-hertel Nov 4, 2026
04ce88a
[Client] Refuse insecure issuers and allow metadata without an author…
chr-hertel Nov 4, 2026
2b9c3a0
[Client] Read WWW-Authenticate field lines as one combined value
chr-hertel Nov 4, 2026
bd3dfd4
[Client] Try later metadata locations after an issuer mismatch and ke…
chr-hertel Nov 4, 2026
868e34f
[Client] Strip only the terminating slash of a resource path
chr-hertel Nov 5, 2026
f833b8c
[Client] Refuse legacy endpoints on an insecure issuer
chr-hertel Nov 5, 2026
eda969d
[Client] Refuse to approve a bearer token for an insecure URL
chr-hertel Nov 5, 2026
d36e2b4
[Client] Reject a resource identifier with a fragment or userinfo
chr-hertel Nov 8, 2026
217c260
[Client] Keep a stated grant list apart from an omitted one
chr-hertel Nov 8, 2026
752fffc
[Client] Reject an issuer with a query or fragment
chr-hertel Nov 8, 2026
a034ebe
[Client] Reject authorization server URLs with userinfo or endpoint f…
chr-hertel Nov 8, 2026
b711554
[Client] Refuse to fetch resource metadata over an insecure URL
chr-hertel Nov 8, 2026
514796a
[Client] Document what an empty token endpoint auth method list means
chr-hertel Nov 8, 2026
49327cf
[Client] Document that a resource covers its whole subtree
chr-hertel Nov 8, 2026
659c94b
[Client] Treat a backslash and path parameters as dot segment boundaries
chr-hertel Nov 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/Client/Auth/AuthorizationChallenge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Client\Auth;

use Psr\Http\Message\ResponseInterface;

/**
* The parsed `WWW-Authenticate` challenge a protected resource answers with.
*
* @internal
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class AuthorizationChallenge
{
/**
* @param array<string, string> $parameters the challenge's auth-param list, keys lowercased
*/
private function __construct(public readonly array $parameters)
{
}

public static function fromResponse(ResponseInterface $response): self
{
return new self(self::parseBearerParameters($response->getHeaderLine('WWW-Authenticate')));
}

/**
* The URL of the resource's metadata document, when the challenge named one.
*/
public function getResourceMetadataUrl(): ?string
{
return $this->parameters['resource_metadata'] ?? null;
}

/**
* The scopes the rejected request needed.
*
* @return string[]
*/
public function getScopes(): array
{
return preg_split('/\s+/', $this->parameters['scope'] ?? '', -1, \PREG_SPLIT_NO_EMPTY) ?: [];
}

public function getError(): ?string
{
return $this->parameters['error'] ?? null;
}

/**
* Read the Bearer challenge out of a field value that may list several challenges.
*
* @return array<string, string>
*/
private static function parseBearerParameters(string $header): array
{
if (!preg_match_all('/(?:[^",]|"(?:[^"\\\\]|\\\\.)*")+/s', $header, $members)) {
return [];
}

$parameters = null;

foreach ($members[0] as $member) {
if (preg_match('/^\s*([^\s=,"]+)(?:\s++(?!=)|\s*$)(.*)$/s', $member, $scheme)) {
if (null !== $parameters) {
break;
}

if (0 !== strcasecmp($scheme[1], 'Bearer')) {
continue;
}

$parameters = [];
$member = $scheme[2];
}

if (null === $parameters) {
continue;
}

if (preg_match('/^\s*([^\s=,"]+)\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|((?!=)[^,\s]*))\s*$/s', $member, $parameter)) {
// RFC 9110 quoted-pair: only the backslash goes, unlike stripslashes() which maps \0 to NUL.
$parameters[strtolower($parameter[1])] = $parameter[3] ?? (preg_replace('/\\\\(.)/s', '$1', $parameter[2] ?? '') ?? '');
}
}

return $parameters ?? [];
}
}
159 changes: 159 additions & 0 deletions src/Client/Auth/AuthorizationServerMetadata.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Client\Auth;

use Mcp\Exception\InvalidArgumentException;
use Mcp\Server\Transport\Http\OAuth\SecureUrl;

/**
* An authorization server's metadata document (RFC 8414 or OpenID Connect discovery).
*
* @internal
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class AuthorizationServerMetadata
{
/**
* @param string $issuer the issuer identifier, which must match how the document was found
* @param ?string $authorizationEndpoint where the user is sent to approve the request, when a supported grant uses it
* @param string $tokenEndpoint where codes and refresh tokens are exchanged
* @param ?string $registrationEndpoint where a client can register itself, when the server allows it
* @param string[] $scopesSupported scopes the server knows about
* @param ?string[] $grantTypesSupported grant types the server accepts, or null when it does not say
* @param string[] $tokenEndpointAuthMethodsSupported client authentication methods the token endpoint accepts, empty when unstated, which RFC 8414 section 2 reads as client_secret_basic
* @param string[] $codeChallengeMethodsSupported PKCE challenge methods the server accepts
* @param bool $clientIdMetadataDocumentSupported whether a URL may be used as the client id
* @param bool $issuerParameterSupported whether authorization responses carry an "iss" parameter
*/
public function __construct(
public readonly string $issuer,
public readonly ?string $authorizationEndpoint,
public readonly string $tokenEndpoint,
public readonly ?string $registrationEndpoint = null,
public readonly array $scopesSupported = [],
public readonly ?array $grantTypesSupported = null,
public readonly array $tokenEndpointAuthMethodsSupported = [],
public readonly array $codeChallengeMethodsSupported = [],
public readonly bool $clientIdMetadataDocumentSupported = false,
public readonly bool $issuerParameterSupported = false,
) {
$parts = SecureUrl::parse($issuer, 'authorization server issuer');

// RFC 8414 section 2 rules out a query and fragment; RFC 9110 section 4.2.4 treats untrusted userinfo as an error.
if (isset($parts['query']) || isset($parts['fragment']) || isset($parts['user']) || isset($parts['pass'])) {
throw new InvalidArgumentException(\sprintf('The authorization server issuer "%s" must not have a query, fragment or userinfo.', $issuer));
}
}

/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
$grantTypes = isset($data['grant_types_supported']) ? self::strings($data['grant_types_supported']) : null;
$requiredFields = ['issuer', 'token_endpoint'];

// RFC 8414 section 2 requires this endpoint only when a supported grant uses it.
if (null === $grantTypes || [] !== array_intersect($grantTypes, ['authorization_code', 'implicit'])) {
$requiredFields[] = 'authorization_endpoint';
}

foreach ($requiredFields as $required) {
if (!isset($data[$required]) || !\is_string($data[$required])) {
throw new InvalidArgumentException(\sprintf('The authorization server metadata is missing a string "%s".', $required));
}
}

foreach (['authorization_endpoint', 'token_endpoint', 'registration_endpoint'] as $endpoint) {
if (!\is_string($data[$endpoint] ?? null)) {
continue;
}

if (!self::isTransportSecure($data[$endpoint])) {
throw new InvalidArgumentException(\sprintf('The authorization server metadata declares "%s" as "%s", which is neither an HTTPS URL nor a loopback address.', $endpoint, $data[$endpoint]));
}

$parts = SecureUrl::parse($data[$endpoint], $endpoint);

// RFC 6749 sections 3.1 and 3.2 rule out fragments; RFC 9110 section 4.2.4 treats untrusted userinfo as an error.
if (isset($parts['fragment']) || isset($parts['user']) || isset($parts['pass'])) {
throw new InvalidArgumentException(\sprintf('The authorization server metadata declares "%s" as "%s", which must not have a fragment or userinfo.', $endpoint, $data[$endpoint]));
}
}

/* @var array{issuer: string, token_endpoint: string} $data */
return new self(
$data['issuer'],
\is_string($data['authorization_endpoint'] ?? null) ? $data['authorization_endpoint'] : null,
$data['token_endpoint'],
\is_string($data['registration_endpoint'] ?? null) ? $data['registration_endpoint'] : null,
self::strings($data['scopes_supported'] ?? null),
$grantTypes,
self::strings($data['token_endpoint_auth_methods_supported'] ?? null),
self::strings($data['code_challenge_methods_supported'] ?? null),
true === ($data['client_id_metadata_document_supported'] ?? null),
true === ($data['authorization_response_iss_parameter_supported'] ?? null),
);
}

/**
* The fixed endpoints a 2025-03-26 era server without metadata is assumed to expose.
*
* @throws InvalidArgumentException when the issuer is neither https nor loopback, or has a query, fragment or userinfo
*/
public static function forLegacyServer(string $issuer): self
{
if (!self::isTransportSecure($issuer)) {
throw new InvalidArgumentException(\sprintf('The legacy authorization server "%s" is neither an HTTPS URL nor a loopback address.', $issuer));
}

$base = rtrim($issuer, '/');

return new self($issuer, $base.'/authorize', $base.'/token', $base.'/register');
}

public function supportsGrant(string $grantType): bool
{
// RFC 8414 section 2 also defaults to implicit, which OAuth 2.1 removed.
return null === $this->grantTypesSupported
? 'authorization_code' === $grantType
: \in_array($grantType, $this->grantTypesSupported, true);
}

public function supportsScope(string $scope): bool
{
return \in_array($scope, $this->scopesSupported, true);
}

/**
* Whether a URL is https or loopback, the only endpoints credentials may go to.
*/
public static function isTransportSecure(string $url): bool
{
try {
SecureUrl::parse($url, 'URL');

return true;
} catch (InvalidArgumentException) {
return false;
}
}

/**
* @return string[]
*/
private static function strings(mixed $value): array
{
return \is_array($value) ? array_values(array_filter($value, 'is_string')) : [];
}
}
Loading
Loading