From 986dbd3bb8744e72f3e5a52498de0cb5e2d9199a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 4 Nov 2026 20:35:40 +0100 Subject: [PATCH 1/5] [Client] Add an `auth` parameter to `HttpTransport` with `BearerToken` The HTTP client gets decorated, so the transport keeps speaking plain PSR-18: AuthenticatingHttpClient authenticates every request and retries the ones the authenticator can act on. BearerToken sends a token you already have. A 401 or 403 now fails with an AuthorizationException that names the challenge, which connect() wraps in a ConnectionException without a retry. --- CHANGELOG.md | 2 + docs/client/errors.md | 19 +- docs/client/transports.md | 23 +- src/Client.php | 20 +- src/Client/Auth/AuthenticatingHttpClient.php | 90 ++++++ src/Client/Auth/AuthenticatorInterface.php | 47 +++ src/Client/Auth/BearerToken.php | 48 +++ src/Client/Transport/HttpTransport.php | 56 +++- src/Exception/AuthorizationException.php | 21 ++ .../Auth/AuthenticatingHttpClientTest.php | 301 ++++++++++++++++++ .../Client/Transport/HttpTransportTest.php | 165 +++++++++- 11 files changed, 778 insertions(+), 14 deletions(-) create mode 100644 src/Client/Auth/AuthenticatingHttpClient.php create mode 100644 src/Client/Auth/AuthenticatorInterface.php create mode 100644 src/Client/Auth/BearerToken.php create mode 100644 src/Exception/AuthorizationException.php create mode 100644 tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 232dacb1..42f2c9ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ All notable changes to `mcp/sdk` will be documented in this file. ### Client +* Add the `auth` parameter of `HttpTransport` and `AuthenticatorInterface`, to authenticate every request and retry the ones the server refuses. `BearerToken` sends a token you already have, and `AuthenticatingHttpClient` offers the same behavior as a PSR-18 decorator. +* [BC Break] A `401` or `403` answer to a request, a notification or the resumption of an event stream fails with an `AuthorizationException` that contains the `WWW-Authenticate` header, instead of a `RequestException`. `Client::connect()` wraps it in a `ConnectionException`, without a retry. * Fix `Client::connect()` leaving the previous transport open when called again: it closes the current transport first, so no stdio process or HTTP session is left behind. 0.9.0 diff --git a/docs/client/errors.md b/docs/client/errors.md index 80b63eb1..2ecc4fa1 100644 --- a/docs/client/errors.md +++ b/docs/client/errors.md @@ -6,7 +6,8 @@ all client errors in one place. All classes below live in the `Mcp\Exception` na | Exception | Thrown when | | --- | --- | | `ConnectionException` | `connect()` fails on every attempt, an HTTP request fails on the network level, or you call a method before `connect()` | -| `RequestException` | the server answers with an error, the request reaches the default request timeout (code `-32603`, message `Request timed out`), the server answers with an HTTP error status, or the STDIO server process exits while you wait for an answer | +| `RequestException` | the server answers with an error, the request reaches the default request timeout (code `-32603`, message `Request timed out`), the server answers with an HTTP error status other than `401` or `403`, or the STDIO server process exits while you wait for an answer | +| `AuthorizationException` | the server answers a request, a notification or the resumption of an event stream with the HTTP status `401` or `403` | | `TimeoutException` | the `timeoutSeconds` deadline of a `callTool()` call expires | | `RequestCancelledException` | the cancellation token of a `callTool()` call is canceled, see [Cancellation and deadlines](transports.md#cancellation-and-deadlines) | | `InvalidArgumentException` | `callTool()` gets a `timeoutSeconds` that is not finite and positive, `readResource()` gets an empty URI, the builder gets an invalid timeout, retry count or fallback revision, the request can't be encoded as JSON (for example invalid UTF-8 in tool arguments), or the server returns a malformed result (also from `connect()`) | @@ -27,6 +28,22 @@ try { } ``` +## AuthorizationException + +The message contains the `WWW-Authenticate` header of the response, if the server sends one: + +```php +use Mcp\Exception\AuthorizationException; + +try { + $result = $client->callTool('get_weather', ['city' => 'Paris']); +} catch (AuthorizationException $e) { + echo "Not authorized: {$e->getMessage()}\n"; +} +``` + +During `connect()`, the client doesn't retry the request. It throws a `ConnectionException`, and `$e->getPrevious()` returns the `AuthorizationException`. + ## RequestException ```php diff --git a/docs/client/transports.md b/docs/client/transports.md index 3c0d9f84..63bbb620 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -38,7 +38,7 @@ use Mcp\Client\Transport\HttpTransport; $transport = new HttpTransport( endpoint: 'http://localhost:8000', - headers: ['Authorization' => 'Bearer token'], + headers: ['X-Tenant' => 'acme'], ); ``` @@ -54,6 +54,7 @@ $transport = new HttpTransport( - `listen` (bool): Open the server's listening stream after the handshake (see below) - `reconnectPolicy` (ReconnectPolicy|null): How an interrupted SSE stream is resumed (default: 5 attempts, 1–10 s backoff) - `clock` (ClockInterface|null): PSR-20 clock used to schedule reconnects (default: a monotonic clock) +- `auth` (AuthenticatorInterface|null): Credentials for a server that requires authorization, see [Sending an access token](#sending-an-access-token) When an SSE connection closes before its JSON-RPC response arrives, the transport resumes it with a GET request carrying the latest `Last-Event-ID`. A server-provided SSE `retry` value controls the delay; otherwise the fallback delay doubles from 1 second up to 10 seconds. Completed requests and explicitly closed transports are not reconnected. A server-provided `retry` value is not capped, so a long one leaves the request waiting until its timeout. A reconnect answered with a redirect or a client error (e.g. `404` for an expired session or `405` without GET support) fails the request at once; only `408`, `429`, server errors and a successful answer that is not an event stream are retried. Protocol version `2026-07-28` removed stream resumption, so there an interrupted stream fails the request at once. @@ -86,7 +87,7 @@ composer require php-http/guzzle7-adapter **Error status codes:** -If the server answers with an HTTP error status, the request fails at once with a `RequestException`. +If the server answers with an HTTP error status, the request fails at once with a `RequestException`. The status `401` or `403` fails with an [`AuthorizationException`](errors.md#authorizationexception). **Session ID:** @@ -120,6 +121,24 @@ $transport = new HttpTransport('http://localhost:8000', listen: true); - A server without a listening stream answers `405`, and the connection carries on without one. - On `2026-07-28`, which has no standalone stream, the option does nothing. +### Sending an access token + +If you already have an access token (e.g. from a secrets manager), pass it with the `auth` parameter: + +```php +use Mcp\Client\Auth\BearerToken; +use Mcp\Client\Transport\HttpTransport; + +$transport = new HttpTransport( + 'https://mcp.example.com/mcp', + auth: new BearerToken($accessToken), +); +``` + +The transport sends the token in the `Authorization` header of every request. This value replaces an `Authorization` header that you pass with `headers`. + +To get credentials in another way, implement `Mcp\Client\Auth\AuthenticatorInterface`. The transport calls `authenticate()` for every request. It calls `handleChallenge()` when the server answers with `401` or `403`. Return `true` to send the request again, at most 3 times, or `false` to give up. + ## Cancellation and deadlines `callTool()` accepts optional `cancellation: ?CancellationTokenInterface` and diff --git a/src/Client.php b/src/Client.php index 993511de..9b32825d 100644 --- a/src/Client.php +++ b/src/Client.php @@ -16,6 +16,7 @@ use Mcp\Client\Configuration; use Mcp\Client\Protocol; use Mcp\Client\Transport\TransportInterface; +use Mcp\Exception\AuthorizationException; use Mcp\Exception\ConnectionException; use Mcp\Exception\ExceptionInterface; use Mcp\Exception\InvalidArgumentException; @@ -227,9 +228,9 @@ public function listTools(?string $cursor = null): ListToolsResult * @param CancellationTokenInterface|null $cancellation Non-blocking cancellation signal * @param float|null $timeoutSeconds Finite positive timeout replacing the default for this call * - * @throws RequestCancelledException When cancellation is observed - * @throws Exception\TimeoutException When the request timeout is observed to have expired - * @throws RequestException|ConnectionException|InvalidArgumentException|RuntimeException + * @throws RequestCancelledException When cancellation is observed + * @throws Exception\TimeoutException When the request timeout is observed to have expired + * @throws AuthorizationException|RequestException|ConnectionException|InvalidArgumentException|RuntimeException */ public function callTool(string $name, array $arguments = [], ?callable $onProgress = null, ?CancellationTokenInterface $cancellation = null, ?float $timeoutSeconds = null): CallToolResult { @@ -288,7 +289,7 @@ public function listResourceTemplates(?string $cursor = null): ListResourceTempl * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress * Optional callback for progress updates * - * @throws RequestException|ConnectionException|InvalidArgumentException + * @throws AuthorizationException|RequestException|ConnectionException|InvalidArgumentException */ public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult { @@ -323,7 +324,7 @@ public function listPrompts(?string $cursor = null): ListPromptsResult * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress * Optional callback for progress updates * - * @throws RequestException|ConnectionException|InvalidArgumentException + * @throws AuthorizationException|RequestException|ConnectionException|InvalidArgumentException */ public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult { @@ -340,7 +341,7 @@ public function getPrompt(string $name, array $arguments = [], ?callable $onProg * @param PromptReference|ResourceReference $ref The prompt or resource reference * @param array{name: string, value: string} $argument The argument to complete * - * @throws RequestException|ConnectionException|InvalidArgumentException + * @throws AuthorizationException|RequestException|ConnectionException|InvalidArgumentException */ public function complete(PromptReference|ResourceReference $ref, array $argument): CompletionCompleteResult { @@ -376,8 +377,9 @@ public function setLoggingLevel(LoggingLevel $level): void * * The server should react by requesting an updated list via roots/list. * - * @throws RuntimeException if the client did not advertise the `roots.listChanged` capability - * @throws ConnectionException if the client is not connected + * @throws RuntimeException if the client did not advertise the `roots.listChanged` capability + * @throws ConnectionException if the client is not connected + * @throws AuthorizationException if the server answers with HTTP 401 or 403 */ public function sendRootsListChanged(): void { @@ -405,7 +407,7 @@ public function sendRootsListChanged(): void * * @return Response * - * @throws RequestException|ConnectionException|InvalidArgumentException + * @throws AuthorizationException|RequestException|ConnectionException|InvalidArgumentException */ private function sendRequest(Request $request, ?callable $onProgress = null, ?CancellationTokenInterface $cancellation = null, ?float $timeoutSeconds = null): Response { diff --git a/src/Client/Auth/AuthenticatingHttpClient.php b/src/Client/Auth/AuthenticatingHttpClient.php new file mode 100644 index 00000000..40a02930 --- /dev/null +++ b/src/Client/Auth/AuthenticatingHttpClient.php @@ -0,0 +1,90 @@ + + */ +final class AuthenticatingHttpClient implements ClientInterface +{ + /** + * Authorization attempts allowed while serving a single request. + * + * A server that keeps answering "insufficient scope" no matter what is asking for + * an infinite loop; the specification asks clients to cap the escalation instead. + */ + public const DEFAULT_MAX_AUTHORIZATION_ATTEMPTS = 3; + + public function __construct( + private readonly ClientInterface $client, + private readonly AuthenticatorInterface $authenticator, + private readonly int $maxAuthorizationAttempts = self::DEFAULT_MAX_AUTHORIZATION_ATTEMPTS, + ) { + if ($maxAuthorizationAttempts < 1) { + throw new InvalidArgumentException(\sprintf('The number of authorization attempts must be at least 1, got %d.', $maxAuthorizationAttempts)); + } + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $body = $request->getBody(); + $position = $body->isSeekable() ? $body->tell() : null; + + $sent = $this->authenticator->authenticate($request); + $response = $this->client->sendRequest($sent); + + for ($attempt = 0; $attempt < $this->maxAuthorizationAttempts; ++$attempt) { + if (!\in_array($response->getStatusCode(), [401, 403], true)) { + return $response; + } + + // The request as it actually went out, credentials and all: the + // authenticator needs to see which of them the server just rejected. + try { + $retry = $this->authenticator->handleChallenge($sent, $response); + } catch (\Throwable $e) { + $response->getBody()->close(); + + throw $e; + } + + if (!$retry) { + return $response; + } + + // A consumed body that cannot be replayed would go out empty or truncated. + if (null === $position) { + return $response; + } + + $response->getBody()->close(); + $body->seek($position); + + $sent = $this->authenticator->authenticate($request); + $response = $this->client->sendRequest($sent); + } + + return $response; + } +} diff --git a/src/Client/Auth/AuthenticatorInterface.php b/src/Client/Auth/AuthenticatorInterface.php new file mode 100644 index 00000000..237a303c --- /dev/null +++ b/src/Client/Auth/AuthenticatorInterface.php @@ -0,0 +1,47 @@ + + */ +interface AuthenticatorInterface +{ + /** + * Return the request carrying whatever credentials are currently available. + * + * Implementations return the request unchanged when they hold none: the resource + * server answering with a challenge is what starts the flow. + */ + public function authenticate(RequestInterface $request): RequestInterface; + + /** + * React to a 401 or 403 answer from the resource server. + * + * @param RequestInterface $request the request that was rejected + * @param ResponseInterface $response the rejecting response, carrying the challenge + * + * @return bool true when credentials were obtained and the request should be retried, + * false when the authenticator cannot make progress + */ + public function handleChallenge(RequestInterface $request, ResponseInterface $response): bool; +} diff --git a/src/Client/Auth/BearerToken.php b/src/Client/Auth/BearerToken.php new file mode 100644 index 00000000..dbb27605 --- /dev/null +++ b/src/Client/Auth/BearerToken.php @@ -0,0 +1,48 @@ + + */ +final class BearerToken implements AuthenticatorInterface +{ + /** + * @param string $token the raw access token, without the "Bearer " prefix + */ + public function __construct(private readonly string $token) + { + if ('' === trim($token)) { + throw new InvalidArgumentException('The bearer token must not be empty.'); + } + } + + public function authenticate(RequestInterface $request): RequestInterface + { + return $request->withHeader('Authorization', 'Bearer '.$this->token); + } + + public function handleChallenge(RequestInterface $request, ResponseInterface $response): bool + { + return false; + } +} diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 0dc467a2..964e92a3 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -13,7 +13,10 @@ use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; +use Mcp\Client\Auth\AuthenticatingHttpClient; +use Mcp\Client\Auth\AuthenticatorInterface; use Mcp\Client\MonotonicClock; +use Mcp\Exception\AuthorizationException; use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RequestCancelledException; @@ -24,6 +27,7 @@ use Psr\Clock\ClockInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\StreamInterface; use Psr\Log\LoggerInterface; @@ -113,6 +117,11 @@ class HttpTransport extends BaseTransport implements HeaderAwareTransportInterfa * @param ReconnectPolicy|null $reconnectPolicy How an SSE stream that closed before its response * arrived is resumed (default: 5 attempts, 1-10 s backoff) * @param ClockInterface|null $clock Clock for scheduling reconnects (default: a monotonic clock) + * @param AuthenticatorInterface|null $auth Credentials for a server that requires authorization, + * e.g. {@see \Mcp\Client\Auth\BearerToken} for a token + * that is already at hand. Every request the transport + * makes is authenticated, and one the server challenges + * is retried once credentials are obtained. */ public function __construct( private readonly string $endpoint, @@ -125,6 +134,7 @@ public function __construct( private readonly bool $listen = false, ?ReconnectPolicy $reconnectPolicy = null, ?ClockInterface $clock = null, + ?AuthenticatorInterface $auth = null, ) { parent::__construct($logger); @@ -132,7 +142,8 @@ public function __construct( $this->listenParser = new SseParser($maxSseBufferBytes); $this->reconnectPolicy = $reconnectPolicy ?? new ReconnectPolicy(); $this->clock = $clock ?? new MonotonicClock(); - $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $client = $httpClient ?? Psr18ClientDiscovery::find(); + $this->httpClient = null === $auth ? $client : new AuthenticatingHttpClient($client, $auth); $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } @@ -224,6 +235,11 @@ public function send(string $data): void try { $response = $this->httpClient->sendRequest($request); + } catch (AuthorizationException $e) { + // Not a connection problem, and not one a second attempt would solve: the + // authenticator already did everything it could. + $this->handleError($e); + throw $e; } catch (\Throwable $e) { $this->handleError($e); throw new ConnectionException('HTTP request failed: '.$e->getMessage(), 0, $e); @@ -243,6 +259,10 @@ public function send(string $data): void $this->logger->debug('Received session ID', ['session_id' => $this->sessionId]); } + if (\in_array($response->getStatusCode(), [401, 403], true)) { + throw $this->authorizationFailure($response); + } + // A notification carries no id, so nobody is waiting for this body: it is // discarded rather than read as the answer to some other request. if (self::isNotification($data)) { @@ -765,6 +785,11 @@ private function processSseReconnect(): void try { $response = $this->httpClient->sendRequest($request); + } catch (AuthorizationException $e) { + $this->handleError($e); + $this->failSseResumption($e); + + return; } catch (\Throwable $e) { $this->handleError($e); $this->scheduleSseReconnect(); @@ -774,6 +799,12 @@ private function processSseReconnect(): void $status = $response->getStatusCode(); + if (\in_array($status, [401, 403], true)) { + $this->failSseResumption($this->authorizationFailure($response)); + + return; + } + if (404 === $status && $request->hasHeader('Mcp-Session-Id')) { $response->getBody()->close(); $this->expireSession(); @@ -809,6 +840,29 @@ private function processSseReconnect(): void $this->sseParser->reset(); } + /** + * Fails the waiting request with the exception itself, as a JSON-RPC error would lose its type. + */ + private function failSseResumption(AuthorizationException $failure): void + { + if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) { + $this->failSseStream($failure->getMessage()); + + return; + } + + $this->activeSuspend = $this->activeFiber->throw($failure); + $this->processSends(); + } + + private function authorizationFailure(ResponseInterface $response): AuthorizationException + { + $challenge = $response->getHeaderLine('WWW-Authenticate'); + $response->getBody()->close(); + + return new AuthorizationException(\sprintf('The server at "%s" rejected the request with %d %s. %s', $this->endpoint, $response->getStatusCode(), $response->getReasonPhrase(), '' !== $challenge ? $challenge : 'No challenge was provided.')); + } + /** * Arrange the next GET resumption without blocking request timeout handling. */ diff --git a/src/Exception/AuthorizationException.php b/src/Exception/AuthorizationException.php new file mode 100644 index 00000000..7cbc90c3 --- /dev/null +++ b/src/Exception/AuthorizationException.php @@ -0,0 +1,21 @@ + + */ +class AuthorizationException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php b/tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php new file mode 100644 index 00000000..915c4825 --- /dev/null +++ b/tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php @@ -0,0 +1,301 @@ +seen[] = $request->getHeaderLine('Authorization'); + + return new Response(200); + } + }; + + $client = new AuthenticatingHttpClient($inner, new BearerToken('secret')); + $client->sendRequest((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/mcp')); + + $this->assertSame(['Bearer secret'], $inner->seen); + } + + #[TestDox('a challenged request is retried once the authenticator makes progress')] + public function testRetriesAfterAChallenge(): void + { + $inner = new RejectingClient(1); + $client = new AuthenticatingHttpClient($inner, new CountingAuthenticator()); + + $response = $client->sendRequest((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/mcp')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(2, $inner->calls); + } + + #[TestDox('a server that keeps rejecting is given up on rather than looped against')] + public function testStopsAfterTheAttemptLimit(): void + { + $inner = new RejectingClient(\PHP_INT_MAX); + $authenticator = new CountingAuthenticator(); + $client = new AuthenticatingHttpClient($inner, $authenticator, 3); + + $response = $client->sendRequest((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/mcp')); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame(3, $authenticator->challenges); + $this->assertSame(4, $inner->calls); + $this->assertTrue($response->getBody()->isReadable()); + } + + #[TestDox('an authenticator that cannot make progress ends the exchange immediately')] + public function testStopsWhenTheAuthenticatorDeclines(): void + { + $inner = new RejectingClient(\PHP_INT_MAX); + $client = new AuthenticatingHttpClient($inner, new BearerToken('secret')); + + $response = $client->sendRequest((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/mcp')); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame(1, $inner->calls); + $this->assertTrue($response->getBody()->isReadable()); + } + + #[TestDox('a rejected response stays readable for the challenge and is closed before the retry')] + public function testClosesTheRejectedResponseBeforeTheRetry(): void + { + $inner = new class implements ClientInterface { + /** @var ResponseInterface[] */ + public array $responses = []; + + /** @var bool[] */ + public array $previousStillReadable = []; + + public function sendRequest(RequestInterface $request): ResponseInterface + { + if ([] !== $this->responses) { + $this->previousStillReadable[] = $this->responses[0]->getBody()->isReadable(); + } + + return $this->responses[] = new Response([] === $this->responses ? 401 : 200, [], 'body'); + } + }; + $authenticator = new CountingAuthenticator(); + + $response = (new AuthenticatingHttpClient($inner, $authenticator)) + ->sendRequest((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/mcp')); + + $this->assertSame([true], $authenticator->readableBodies); + $this->assertSame([false], $inner->previousStillReadable); + $this->assertTrue($response->getBody()->isReadable()); + } + + #[TestDox('a challenge handler that throws leaves no rejected response open')] + public function testClosesTheRejectedResponseWhenTheChallengeThrows(): void + { + $rejected = new Response(401, [], 'body'); + $inner = $this->createStub(ClientInterface::class); + $inner->method('sendRequest')->willReturn($rejected); + + $authenticator = $this->createStub(AuthenticatorInterface::class); + $authenticator->method('authenticate')->willReturnArgument(0); + $authenticator->method('handleChallenge')->willThrowException(new AuthorizationException('Sign-in failed.')); + + try { + (new AuthenticatingHttpClient($inner, $authenticator)) + ->sendRequest((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/mcp')); + $this->fail('The challenge failure should have reached the caller.'); + } catch (AuthorizationException $e) { + $this->assertSame('Sign-in failed.', $e->getMessage()); + } + + $this->assertFalse($rejected->getBody()->isReadable()); + } + + #[TestDox('a body that fails to seek leaves no rejected response open')] + public function testClosesTheRejectedResponseWhenTheBodyCannotSeek(): void + { + $rejected = new Response(401, [], 'body'); + $inner = $this->createStub(ClientInterface::class); + $inner->method('sendRequest')->willReturn($rejected); + + $body = $this->createStub(StreamInterface::class); + $body->method('isSeekable')->willReturn(true); + $body->method('tell')->willReturn(0); + $body->method('seek')->willThrowException(new \RuntimeException('Unable to seek.')); + + try { + (new AuthenticatingHttpClient($inner, new CountingAuthenticator())) + ->sendRequest((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/mcp')->withBody($body)); + $this->fail('The seek failure should have reached the caller.'); + } catch (\RuntimeException $e) { + $this->assertSame('Unable to seek.', $e->getMessage()); + } + + $this->assertFalse($rejected->getBody()->isReadable()); + } + + #[TestDox('a body that cannot be replayed ends the exchange after the challenge')] + public function testDoesNotRetryANonSeekableBody(): void + { + $body = $this->createMock(StreamInterface::class); + $body->method('isSeekable')->willReturn(false); + $body->expects($this->never())->method('rewind'); + + $inner = new RejectingClient(1); + $authenticator = new CountingAuthenticator(); + + $response = (new AuthenticatingHttpClient($inner, $authenticator)) + ->sendRequest((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/mcp')->withBody($body)); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertTrue($response->getBody()->isReadable()); + $this->assertSame(1, $inner->calls); + $this->assertSame(1, $authenticator->challenges); + } + + #[TestDox('the request body is replayed rather than sent empty on the retry')] + public function testRewindsTheBody(): void + { + $factory = new Psr17Factory(); + $inner = new class implements ClientInterface { + /** @var string[] */ + public array $bodies = []; + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->bodies[] = $request->getBody()->getContents(); + + return new Response(1 === \count($this->bodies) ? 401 : 200); + } + }; + + $request = $factory->createRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream('{"jsonrpc":"2.0"}')); + + (new AuthenticatingHttpClient($inner, new CountingAuthenticator()))->sendRequest($request); + + $this->assertSame(['{"jsonrpc":"2.0"}', '{"jsonrpc":"2.0"}'], $inner->bodies); + } + + #[TestDox('a retry sends the body from the position the first attempt started at')] + public function testRestoresTheBodyPosition(): void + { + $factory = new Psr17Factory(); + $inner = new class implements ClientInterface { + /** @var string[] */ + public array $bodies = []; + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->bodies[] = $request->getBody()->getContents(); + + return new Response(1 === \count($this->bodies) ? 401 : 200); + } + }; + + $body = $factory->createStream('skipped{"jsonrpc":"2.0"}'); + $body->seek(7); + + (new AuthenticatingHttpClient($inner, new CountingAuthenticator())) + ->sendRequest($factory->createRequest('POST', 'https://mcp.example.com/mcp')->withBody($body)); + + $this->assertSame(['{"jsonrpc":"2.0"}', '{"jsonrpc":"2.0"}'], $inner->bodies); + } + + #[TestDox('the challenge handler sees the credentials the rejected request carried')] + public function testChallengeSeesTheSentRequest(): void + { + $authenticator = new CountingAuthenticator(); + (new AuthenticatingHttpClient(new RejectingClient(1), $authenticator)) + ->sendRequest((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/mcp')); + + $this->assertSame(['Bearer token-0'], $authenticator->challenged); + } + + #[TestDox('an attempt limit below one is rejected')] + public function testRejectsAnImpossibleAttemptLimit(): void + { + $this->expectException(InvalidArgumentException::class); + + new AuthenticatingHttpClient(new RejectingClient(0), new BearerToken('secret'), 0); + } +} + +/** + * Answers 401 for the first call, 403 from then on, until the quota of rejections runs out. + */ +final class RejectingClient implements ClientInterface +{ + public int $calls = 0; + + public function __construct(private readonly int $rejections) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + ++$this->calls; + + if ($this->calls > $this->rejections) { + return new Response(200); + } + + return new Response(1 === $this->calls ? 401 : 403, ['WWW-Authenticate' => 'Bearer scope="mcp:admin"']); + } +} + +/** + * Always claims it can make progress, and counts how often it was asked to. + */ +final class CountingAuthenticator implements AuthenticatorInterface +{ + public int $challenges = 0; + + /** @var string[] The credentials each rejected request went out with. */ + public array $challenged = []; + + /** @var bool[] Whether each rejecting response could still be read during its challenge. */ + public array $readableBodies = []; + + public function authenticate(RequestInterface $request): RequestInterface + { + return $request->withHeader('Authorization', 'Bearer token-'.$this->challenges); + } + + public function handleChallenge(RequestInterface $request, ResponseInterface $response): bool + { + $this->challenged[] = $request->getHeaderLine('Authorization'); + $this->readableBodies[] = $response->getBody()->isReadable(); + ++$this->challenges; + + return true; + } +} diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index d5ac9f6c..e16f9bc6 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -12,12 +12,14 @@ namespace Mcp\Tests\Unit\Client\Transport; use Mcp\Client; +use Mcp\Client\Auth\BearerToken; use Mcp\Client\CancellationTokenInterface; use Mcp\Client\Configuration; use Mcp\Client\Handler\Notification\LoggingNotificationHandler; use Mcp\Client\Protocol; use Mcp\Client\State\ClientState; use Mcp\Client\Transport\HttpTransport; +use Mcp\Exception\AuthorizationException; use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RequestCancelledException; @@ -110,6 +112,168 @@ public function sendRequest(RequestInterface $request): ResponseInterface $this->assertSame('test-server', $client->getServerInfo()?->name); } + #[TestDox('every request carries the credentials passed as auth')] + public function testAuthAddsCredentialsToEveryRequest(): void + { + $httpClient = new RecordingHttpClient(); + + $client = Client::builder() + ->setClientInfo('test-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2025_11_25) + ->setInitTimeout(1) + ->build(); + + $client->connect(new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory, auth: new BearerToken('secret'))); + $client->disconnect(); + + $this->assertNotEmpty($httpClient->requests); + + foreach ($httpClient->requests as $request) { + $this->assertSame('Bearer secret', $request->getHeaderLine('Authorization')); + } + } + + /** + * @return iterable + */ + public static function refusedStatusProvider(): iterable + { + yield 'unauthorized' => [401]; + yield 'forbidden' => [403]; + } + + #[DataProvider('refusedStatusProvider')] + #[TestDox('connect() does not retry a refused request and names the challenge ($_dataName)')] + public function testConnectReportsARefusal(int $status): void + { + $httpClient = RecordingHttpClient::queued([new Response($status, ['WWW-Authenticate' => 'Bearer realm="mcp"'])]); + + $client = Client::builder() + ->setClientInfo('test-client', '1.0.0') + ->setInitTimeout(1) + ->setMaxRetries(2) + ->build(); + + try { + $client->connect(new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory, auth: new BearerToken('secret'))); + $this->fail('The client should not have connected.'); + } catch (ConnectionException $e) { + $this->assertInstanceOf(AuthorizationException::class, $e->getPrevious()); + $this->assertStringContainsString('Bearer realm="mcp"', $e->getMessage()); + } + + $this->assertCount(1, $httpClient->requests); + } + + #[DataProvider('refusedStatusProvider')] + #[TestDox('a refused request after connecting fails with an AuthorizationException ($_dataName)')] + public function testRequestReportsARefusal(int $status): void + { + $httpClient = new RecordingHttpClient(static fn (array $message): ?ResponseInterface => 'tools/call' === ($message['method'] ?? null) ? new Response($status) : null); + + $client = Client::builder() + ->setClientInfo('test-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2025_11_25) + ->setInitTimeout(1) + ->build(); + + $client->connect(new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory)); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('No challenge was provided.'); + + $client->callTool('fast'); + } + + #[DataProvider('refusedStatusProvider')] + #[TestDox('connect() fails when the server refuses the initialized notification ($_dataName)')] + public function testConnectReportsARefusedInitializedNotification(int $status): void + { + $httpClient = new RecordingHttpClient(static fn (array $message): ?ResponseInterface => 'notifications/initialized' === ($message['method'] ?? null) ? new Response($status) : null); + + $client = Client::builder() + ->setClientInfo('test-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2025_11_25) + ->setInitTimeout(1) + ->setMaxRetries(2) + ->build(); + + try { + $client->connect(new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory)); + $this->fail('The client should not have connected.'); + } catch (ConnectionException $e) { + $this->assertInstanceOf(AuthorizationException::class, $e->getPrevious()); + } + + $this->assertFalse($client->isConnected()); + $this->assertCount(2, $httpClient->messages); + } + + #[DataProvider('refusedStatusProvider')] + #[TestDox('a refused stream resumption fails the request with the challenge ($_dataName)')] + public function testRefusedResumptionReportsTheChallenge(int $status): void + { + $clock = new SteppingClock(); + $httpClient = RecordingHttpClient::queued([ + new Response(200, ['Content-Type' => 'text/event-stream'], "id: event-0\ndata:\n\n"), + new Response($status, ['WWW-Authenticate' => 'Bearer error="invalid_token"']), + ], $clock); + + try { + $this->runRequest($this->createTransport(httpClient: $httpClient, clock: $clock), 1); + $this->fail('The request should have failed.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('Bearer error="invalid_token"', $e->getMessage()); + } + + $this->assertCount(2, $httpClient->requests); + } + + #[TestDox('a refused stream resumption during connect() is not retried')] + public function testConnectDoesNotRetryARefusedResumption(): void + { + $clock = new SteppingClock(); + $httpClient = RecordingHttpClient::queued([ + new Response(200, ['Content-Type' => 'text/event-stream'], "id: event-0\ndata:\n\n"), + new Response(401, ['WWW-Authenticate' => 'Bearer realm="mcp"']), + ], $clock); + + $client = Client::builder() + ->setClientInfo('test-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2025_11_25) + ->setInitTimeout(5) + ->setMaxRetries(2) + ->build(); + + try { + $client->connect($this->createTransport(httpClient: $httpClient, clock: $clock)); + $this->fail('The client should not have connected.'); + } catch (ConnectionException $e) { + $this->assertInstanceOf(AuthorizationException::class, $e->getPrevious()); + } + + $this->assertCount(2, $httpClient->requests); + } + + #[TestDox('an authenticator that refuses a stream resumption fails the request without another attempt')] + public function testAuthorizationFailureDuringResumptionIsNotRetried(): void + { + $clock = new SteppingClock(); + $httpClient = RecordingHttpClient::queued([ + new Response(200, ['Content-Type' => 'text/event-stream'], "id: event-0\ndata:\n\n"), + new AuthorizationException('Sign-in failed.'), + ], $clock); + + try { + $this->runRequest($this->createTransport(httpClient: $httpClient, clock: $clock), 1); + $this->fail('The request should have failed.'); + } catch (AuthorizationException $e) { + $this->assertSame('Sign-in failed.', $e->getMessage()); + } + + $this->assertCount(2, $httpClient->requests); + } + #[TestDox('the server-minted session ID is exposed while connected and cleared on close')] public function testSessionIdIsExposedAndClearedOnClose(): void { @@ -491,7 +655,6 @@ public static function reconnectStatusProvider(): iterable yield 'redirect' => [307, 2]; yield 'session expired' => [404, 2]; yield 'no GET stream offered' => [405, 2]; - yield 'unauthorized' => [401, 2]; yield 'request timeout' => [408, 6]; yield 'rate limited' => [429, 6]; yield 'server error' => [503, 6]; From 9751821cace1559a4a5a54a7d4bf0a9b21a6150a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 4 Nov 2026 23:03:07 +0100 Subject: [PATCH 2/5] [Client] Add OAuth metadata discovery and challenge parsing Finds and validates the documents that say who guards an MCP server: protected resource metadata (RFC 9728) and authorization server metadata (RFC 8414 and OpenID Connect), plus the WWW-Authenticate challenge that points at them. All of it is @internal, since nothing uses it until the authenticator lands. Endpoints have to pass SecureUrl, and a document naming another issuer than it was fetched for is discarded. --- src/Client/Auth/AuthorizationChallenge.php | 99 +++++ .../Auth/AuthorizationServerMetadata.php | 145 +++++++ src/Client/Auth/MetadataDiscovery.php | 187 +++++++++ src/Client/Auth/ProtectedResourceMetadata.php | 126 ++++++ .../Auth/AuthorizationChallengeTest.php | 184 +++++++++ .../Auth/AuthorizationServerMetadataTest.php | 209 ++++++++++ .../Client/Auth/MetadataDiscoveryTest.php | 365 ++++++++++++++++++ .../Auth/ProtectedResourceMetadataTest.php | 93 +++++ 8 files changed, 1408 insertions(+) create mode 100644 src/Client/Auth/AuthorizationChallenge.php create mode 100644 src/Client/Auth/AuthorizationServerMetadata.php create mode 100644 src/Client/Auth/MetadataDiscovery.php create mode 100644 src/Client/Auth/ProtectedResourceMetadata.php create mode 100644 tests/Unit/Client/Auth/AuthorizationChallengeTest.php create mode 100644 tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php create mode 100644 tests/Unit/Client/Auth/MetadataDiscoveryTest.php create mode 100644 tests/Unit/Client/Auth/ProtectedResourceMetadataTest.php diff --git a/src/Client/Auth/AuthorizationChallenge.php b/src/Client/Auth/AuthorizationChallenge.php new file mode 100644 index 00000000..9cca35d7 --- /dev/null +++ b/src/Client/Auth/AuthorizationChallenge.php @@ -0,0 +1,99 @@ + + */ +final class AuthorizationChallenge +{ + /** + * @param array $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 + */ + 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 ?? []; + } +} diff --git a/src/Client/Auth/AuthorizationServerMetadata.php b/src/Client/Auth/AuthorizationServerMetadata.php new file mode 100644 index 00000000..16ce6620 --- /dev/null +++ b/src/Client/Auth/AuthorizationServerMetadata.php @@ -0,0 +1,145 @@ + + */ +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 + * @param string[] $tokenEndpointAuthMethodsSupported client authentication methods the token endpoint accepts + * @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 = [], + public readonly array $tokenEndpointAuthMethodsSupported = [], + public readonly array $codeChallengeMethodsSupported = [], + public readonly bool $clientIdMetadataDocumentSupported = false, + public readonly bool $issuerParameterSupported = false, + ) { + if ('' === trim($issuer)) { + throw new InvalidArgumentException('The authorization server metadata must name an issuer.'); + } + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $grantTypes = 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 ([] === $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) && !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])); + } + } + + /* @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 + */ + 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 [] === $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')) : []; + } +} diff --git a/src/Client/Auth/MetadataDiscovery.php b/src/Client/Auth/MetadataDiscovery.php new file mode 100644 index 00000000..aa454f94 --- /dev/null +++ b/src/Client/Auth/MetadataDiscovery.php @@ -0,0 +1,187 @@ + + */ +final class MetadataDiscovery +{ + public function __construct( + private readonly ClientInterface $httpClient, + private readonly RequestFactoryInterface $requestFactory, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Fetch the protected resource metadata for an MCP endpoint. + * + * @param string $resourceUrl the MCP endpoint being called + * @param ?string $metadataUrl the location the challenge named, which wins over any guess + */ + public function discoverProtectedResource(string $resourceUrl, ?string $metadataUrl = null): ?ProtectedResourceMetadata + { + foreach (null !== $metadataUrl ? [$metadataUrl] : self::protectedResourceCandidates($resourceUrl) as $candidate) { + $document = $this->fetch($candidate); + + if (null === $document) { + continue; + } + + try { + return ProtectedResourceMetadata::fromArray($document); + } catch (InvalidArgumentException $e) { + $this->logger->warning('Ignoring malformed protected resource metadata', ['url' => $candidate, 'exception' => $e]); + } + } + + return null; + } + + /** + * Fetch an authorization server's metadata, discarding it if its issuer differs (RFC 8414 section 3.3). + * + * @param string $issuer the issuer identifier, as advertised by the resource + */ + public function discoverAuthorizationServer(string $issuer): ?AuthorizationServerMetadata + { + if (!AuthorizationServerMetadata::isTransportSecure($issuer)) { + $this->logger->error('Refusing authorization server discovery: the issuer is neither an HTTPS URL nor a loopback address.', ['issuer' => $issuer]); + + return null; + } + + foreach (self::authorizationServerCandidates($issuer) as $candidate) { + $document = $this->fetch($candidate); + + if (null === $document) { + continue; + } + + try { + $metadata = AuthorizationServerMetadata::fromArray($document); + } catch (InvalidArgumentException $e) { + $this->logger->warning('Ignoring malformed authorization server metadata', ['url' => $candidate, 'exception' => $e]); + + continue; + } + + if ($metadata->issuer !== $issuer) { + $this->logger->warning('Discarding authorization server metadata: the document\'s issuer does not match the issuer it was requested for.', [ + 'expected_issuer' => $issuer, + 'metadata_issuer' => $metadata->issuer, + 'url' => $candidate, + ]); + + continue; + } + + return $metadata; + } + + return null; + } + + /** + * @return string[] + */ + public static function protectedResourceCandidates(string $resourceUrl): array + { + $parts = parse_url($resourceUrl); + + if (!\is_array($parts)) { + return []; + } + + $root = self::origin($parts).'/.well-known/oauth-protected-resource'; + $path = $parts['path'] ?? ''; + $suffix = ('/' === $path ? '' : $path).(isset($parts['query']) ? '?'.$parts['query'] : ''); + + return '' === $suffix ? [$root] : [$root.$suffix, $root]; + } + + /** + * @return string[] + */ + public static function authorizationServerCandidates(string $issuer): array + { + $parts = parse_url($issuer); + + if (!\is_array($parts)) { + return []; + } + + $origin = self::origin($parts); + $path = substr(rtrim($parts['path'] ?? '', '/'), 1); + + if ('' === $path) { + return [ + $origin.'/.well-known/oauth-authorization-server', + $origin.'/.well-known/openid-configuration', + ]; + } + + return [ + $origin.'/.well-known/oauth-authorization-server/'.$path, + $origin.'/.well-known/openid-configuration/'.$path, + $origin.'/'.$path.'/.well-known/openid-configuration', + ]; + } + + /** + * @return array|null + */ + private function fetch(string $url): ?array + { + $request = $this->requestFactory->createRequest('GET', $url)->withHeader('Accept', 'application/json'); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface $e) { + $this->logger->debug('Metadata request failed', ['url' => $url, 'exception' => $e]); + + return null; + } + + if (200 !== $response->getStatusCode()) { + $this->logger->debug('Metadata not available', ['url' => $url, 'status' => $response->getStatusCode()]); + + return null; + } + + $decoded = json_decode((string) $response->getBody(), true); + + return \is_array($decoded) ? $decoded : null; + } + + /** + * @param array $parts + */ + private static function origin(array $parts): string + { + $origin = ($parts['scheme'] ?? 'https').'://'.($parts['host'] ?? ''); + + return isset($parts['port']) ? $origin.':'.$parts['port'] : $origin; + } +} diff --git a/src/Client/Auth/ProtectedResourceMetadata.php b/src/Client/Auth/ProtectedResourceMetadata.php new file mode 100644 index 00000000..ce2cb0a7 --- /dev/null +++ b/src/Client/Auth/ProtectedResourceMetadata.php @@ -0,0 +1,126 @@ + + */ +final class ProtectedResourceMetadata +{ + /** + * @param string $resource the canonical URI the resource identifies itself by + * @param string[] $authorizationServers issuer identifiers of the servers that can mint tokens for it + * @param ?string[] $scopesSupported scopes the resource understands, or null when it does not say + */ + public function __construct( + public readonly string $resource, + public readonly array $authorizationServers = [], + public readonly ?array $scopesSupported = null, + ) { + if ('' === trim($resource)) { + throw new InvalidArgumentException('The protected resource metadata must name a resource.'); + } + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['resource']) || !\is_string($data['resource'])) { + throw new InvalidArgumentException('The protected resource metadata is missing a string "resource".'); + } + + $scopes = $data['scopes_supported'] ?? null; + + return new self( + $data['resource'], + array_values(array_filter((array) ($data['authorization_servers'] ?? []), 'is_string')), + \is_array($scopes) ? array_values(array_filter($scopes, 'is_string')) : null, + ); + } + + /** + * Whether a token minted for this resource may be sent to the given URL. + */ + public function covers(string $url): bool + { + return self::isWithin($url, $this->resource); + } + + /** + * Whether $url sits at or below the resource identifier $resource. + */ + public static function isWithin(string $url, string $resource): bool + { + $resource = parse_url($resource); + $target = parse_url($url); + + if (!\is_array($resource) || !\is_array($target)) { + return false; + } + + if (self::origin($resource) !== self::origin($target)) { + return false; + } + + if (isset($resource['query']) && ($target['query'] ?? null) !== $resource['query']) { + return false; + } + + $resourcePath = $resource['path'] ?? ''; + + if (str_ends_with($resourcePath, '/')) { + $resourcePath = substr($resourcePath, 0, -1); + } + + $targetPath = $target['path'] ?? ''; + + if (self::hasDotSegment($resourcePath) || self::hasDotSegment($targetPath)) { + return false; + } + + return '' === $resourcePath + || $targetPath === $resourcePath + || str_starts_with($targetPath, $resourcePath.'/'); + } + + /** + * @param array{scheme?: string, host?: string, port?: int} $parts + * + * @return array{string, string, ?int} + */ + private static function origin(array $parts): array + { + $scheme = strtolower($parts['scheme'] ?? ''); + + return [$scheme, strtolower($parts['host'] ?? ''), $parts['port'] ?? ['https' => 443, 'http' => 80][$scheme] ?? null]; + } + + private static function hasDotSegment(string $path): bool + { + // Rejected rather than resolved, since the server may resolve them differently than we would. + foreach (explode('/', rawurldecode($path)) as $segment) { + if ('.' === $segment || '..' === $segment) { + return true; + } + } + + return false; + } +} diff --git a/tests/Unit/Client/Auth/AuthorizationChallengeTest.php b/tests/Unit/Client/Auth/AuthorizationChallengeTest.php new file mode 100644 index 00000000..11105105 --- /dev/null +++ b/tests/Unit/Client/Auth/AuthorizationChallengeTest.php @@ -0,0 +1,184 @@ + 'Bearer scope="mcp:read mcp:write", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", error="insufficient_scope"', + ])); + + $this->assertSame('https://mcp.example.com/.well-known/oauth-protected-resource/mcp', $challenge->getResourceMetadataUrl()); + $this->assertSame(['mcp:read', 'mcp:write'], $challenge->getScopes()); + $this->assertSame('insufficient_scope', $challenge->getError()); + } + + #[TestDox('a challenge without parameters yields nothing rather than failing')] + public function testParsesBareChallenge(): void + { + $challenge = AuthorizationChallenge::fromResponse(new Response(401, ['WWW-Authenticate' => 'Bearer'])); + + $this->assertNull($challenge->getResourceMetadataUrl()); + $this->assertSame([], $challenge->getScopes()); + $this->assertNull($challenge->getError()); + } + + #[TestDox('a response with no challenge header at all yields nothing')] + public function testHandlesMissingHeader(): void + { + $challenge = AuthorizationChallenge::fromResponse(new Response(401)); + + $this->assertSame([], $challenge->parameters); + } + + #[TestDox('unquoted parameter values are accepted')] + public function testParsesUnquotedValues(): void + { + $challenge = AuthorizationChallenge::fromResponse(new Response(401, [ + 'WWW-Authenticate' => 'Bearer error=invalid_token, scope=mcp:read', + ])); + + $this->assertSame('invalid_token', $challenge->getError()); + $this->assertSame(['mcp:read'], $challenge->getScopes()); + } + + #[TestDox('a quoted backslash unescapes one character, not a C escape sequence')] + public function testUnescapesTheHttpWay(): void + { + $challenge = AuthorizationChallenge::fromResponse(new Response(401, [ + 'WWW-Authenticate' => 'Bearer error="say \\"hi\\" now", scope="a\\nb"', + ])); + + $this->assertSame('say "hi" now', $challenge->getError()); + $this->assertSame(['anb'], $challenge->getScopes()); + } + + #[TestDox('a Basic challenge alongside Bearer does not shadow it')] + public function testIgnoresOtherSchemes(): void + { + $response = (new Response(401)) + ->withAddedHeader('WWW-Authenticate', 'Basic realm="internal"') + ->withAddedHeader('WWW-Authenticate', 'Bearer scope="mcp:read"'); + + $this->assertSame(['mcp:read'], AuthorizationChallenge::fromResponse($response)->getScopes()); + } + + /** + * @return iterable}> + */ + public static function splitHeaderProvider(): iterable + { + yield 'one challenge across two lines' => [['Bearer scope="read"', 'resource_metadata="https://good"'], ['scope' => 'read', 'resource_metadata' => 'https://good']]; + yield 'a bare scheme on its own line' => [['Negotiate', 'Bearer scope="a"'], ['scope' => 'a']]; + yield 'a later line with another scheme' => [['Bearer scope="a"', 'Digest scope="evil"'], ['scope' => 'a']]; + yield 'an unterminated quote swallows the next line' => [['Basic realm="x', 'Bearer scope="a"'], []]; + } + + /** + * @param string[] $lines + * @param array $expected + */ + #[DataProvider('splitHeaderProvider')] + #[TestDox('field lines read as one comma-joined value for: $_dataName')] + public function testCombinesFieldLines(array $lines, array $expected): void + { + $challenge = AuthorizationChallenge::fromResponse(new Response(401, ['WWW-Authenticate' => $lines])); + + $this->assertSame($expected, $challenge->parameters); + } + + /** + * @return iterable}> + */ + public static function combinedHeaderProvider(): iterable + { + yield 'Bearer after another scheme' => ['Basic realm="internal", Bearer resource_metadata="https://good"', ['resource_metadata' => 'https://good']]; + yield 'Bearer after a token68 scheme' => ['Negotiate abc==, Bearer scope="a"', ['scope' => 'a']]; + yield 'Bearer after a bare scheme' => ['Negotiate, Bearer scope="a"', ['scope' => 'a']]; + yield 'Bearer before a token68 scheme' => ['Bearer scope="a", Negotiate abc==', ['scope' => 'a']]; + yield 'parameters of a later scheme do not override' => ['Bearer resource_metadata="https://good", scope="a", Digest realm="x", scope="evil", resource_metadata="https://evil"', ['resource_metadata' => 'https://good', 'scope' => 'a']]; + yield 'parameters of an earlier scheme are not picked up' => ['Basic realm="simple", Newauth realm="apps", type=1, title="Login to \\"apps\\"", Bearer scope="a"', ['scope' => 'a']]; + yield 'a scheme inside a quoted value is no scheme' => ['Basic realm="a, Bearer scope=\\"evil\\""', []]; + yield 'a comma inside a quoted value does not split' => ['Bearer error="use Basic realm=x, scope=evil", scope="a"', ['error' => 'use Basic realm=x, scope=evil', 'scope' => 'a']]; + yield 'an empty list member is skipped' => ['Bearer scope="a",, error="b"', ['scope' => 'a', 'error' => 'b']]; + yield 'several spaces before the equals sign' => ['Bearer scope="read", error ="invalid_token", resource_metadata="https://good"', ['scope' => 'read', 'error' => 'invalid_token', 'resource_metadata' => 'https://good']]; + yield 'several tabs before the equals sign' => ["Bearer scope=\"read\", error\t\t=\"t\"", ['scope' => 'read', 'error' => 't']]; + yield 'several spaces around the equals sign' => ['Bearer scope="read", error = "t"', ['scope' => 'read', 'error' => 't']]; + yield 'the scheme is case-insensitive' => ['bEARER scope="a"', ['scope' => 'a']]; + yield 'a scheme merely starting with Bearer' => ['Bearer-x scope="a"', []]; + } + + /** + * @param array $expected + */ + #[DataProvider('combinedHeaderProvider')] + #[TestDox('the Bearer challenge is isolated for: $_dataName')] + public function testIsolatesTheBearerChallenge(string $header, array $expected): void + { + $challenge = AuthorizationChallenge::fromResponse(new Response(401, ['WWW-Authenticate' => $header])); + + $this->assertSame($expected, $challenge->parameters); + } + + /** + * @return iterable + */ + public static function scopeProvider(): iterable + { + yield 'zero among others' => ['Bearer scope="0 mcp:read"', ['0', 'mcp:read']]; + yield 'bare zero' => ['Bearer scope=0', ['0']]; + yield 'surrounding and repeated whitespace' => ['Bearer scope=" a 0 b "', ['a', '0', 'b']]; + yield 'empty' => ['Bearer scope=""', []]; + yield 'only whitespace' => ['Bearer scope=" "', []]; + } + + /** + * @param string[] $expected + */ + #[DataProvider('scopeProvider')] + #[TestDox('the scopes are split for: $_dataName')] + public function testSplitsScopes(string $header, array $expected): void + { + $challenge = AuthorizationChallenge::fromResponse(new Response(401, ['WWW-Authenticate' => $header])); + + $this->assertSame($expected, $challenge->getScopes()); + } + + /** + * @return iterable + */ + public static function quotedPairProvider(): iterable + { + yield 'an escaped zero is a zero' => ['"a\\0b"', 'a0b']; + yield 'an escaped backslash stays one backslash' => ['"a\\\\0b"', 'a\\0b']; + yield 'an escaped quote' => ['"a\\"b"', 'a"b']; + yield 'a bare value is left alone' => ['a\\0b', 'a\\0b']; + } + + #[DataProvider('quotedPairProvider')] + #[TestDox('the value is decoded for: $_dataName')] + public function testDecodesQuotedPairs(string $value, string $expected): void + { + $challenge = AuthorizationChallenge::fromResponse(new Response(401, ['WWW-Authenticate' => 'Bearer error='.$value])); + + $this->assertSame($expected, $challenge->getError()); + } +} diff --git a/tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php b/tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php new file mode 100644 index 00000000..4ade0858 --- /dev/null +++ b/tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php @@ -0,0 +1,209 @@ + 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'registration_endpoint' => 'https://auth.example.com/register', + 'scopes_supported' => ['mcp:read', 'offline_access'], + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'token_endpoint_auth_methods_supported' => ['client_secret_basic'], + 'client_id_metadata_document_supported' => true, + 'authorization_response_iss_parameter_supported' => true, + ]); + + $this->assertSame('https://auth.example.com/register', $metadata->registrationEndpoint); + $this->assertTrue($metadata->clientIdMetadataDocumentSupported); + $this->assertTrue($metadata->issuerParameterSupported); + $this->assertTrue($metadata->supportsScope('offline_access')); + $this->assertFalse($metadata->supportsScope('mcp:write')); + } + + #[TestDox('a document missing an endpoint the client needs is rejected')] + public function testRequiresEndpoints(): void + { + $this->expectException(InvalidArgumentException::class); + + AuthorizationServerMetadata::fromArray([ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + ]); + } + + #[TestDox('the authorization endpoint may be absent when no supported grant uses it')] + public function testAuthorizationEndpointIsOptionalWithoutARedirectGrant(): void + { + $metadata = AuthorizationServerMetadata::fromArray([ + 'issuer' => 'https://auth.example.com', + 'token_endpoint' => 'https://auth.example.com/token', + 'grant_types_supported' => ['client_credentials'], + ]); + + $this->assertNull($metadata->authorizationEndpoint); + $this->assertTrue($metadata->supportsGrant('client_credentials')); + } + + /** + * @return iterable}> + */ + public static function authorizationEndpointRequiredProvider(): iterable + { + yield 'no grant list' => [[]]; + yield 'an empty grant list' => [['grant_types_supported' => []]]; + yield 'the authorization code grant' => [['grant_types_supported' => ['client_credentials', 'authorization_code']]]; + yield 'the implicit grant' => [['grant_types_supported' => ['implicit']]]; + } + + /** + * @param array $document + */ + #[DataProvider('authorizationEndpointRequiredProvider')] + #[TestDox('the authorization endpoint is required for: $_dataName')] + public function testAuthorizationEndpointIsRequiredForARedirectGrant(array $document): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('authorization_endpoint'); + + AuthorizationServerMetadata::fromArray($document + [ + 'issuer' => 'https://auth.example.com', + 'token_endpoint' => 'https://auth.example.com/token', + ]); + } + + #[TestDox('an insecure authorization endpoint is refused even when no grant uses it')] + public function testRejectsAnInsecureUnusedAuthorizationEndpoint(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('neither an HTTPS URL nor a loopback address'); + + AuthorizationServerMetadata::fromArray([ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'http://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'grant_types_supported' => ['client_credentials'], + ]); + } + + #[TestDox('an unstated grant list means the authorization code grant, and nothing else')] + public function testDefaultGrantTypes(): void + { + $metadata = AuthorizationServerMetadata::fromArray([ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + ]); + + $this->assertTrue($metadata->supportsGrant('authorization_code')); + $this->assertFalse($metadata->supportsGrant('refresh_token')); + } + + #[TestDox('endpoints that are not HTTPS are refused')] + public function testRejectsInsecureEndpoints(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('neither an HTTPS URL nor a loopback address'); + + AuthorizationServerMetadata::fromArray([ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'http://auth.example.com/token', + ]); + } + + #[DataProvider('loopbackProvider')] + #[TestDox('plain HTTP is allowed on a loopback address: $_dataName')] + public function testAllowsLoopbackOverHttp(string $endpoint): void + { + $metadata = AuthorizationServerMetadata::fromArray([ + 'issuer' => 'http://localhost:8080', + 'authorization_endpoint' => $endpoint.'/authorize', + 'token_endpoint' => $endpoint.'/token', + ]); + + $this->assertSame($endpoint.'/token', $metadata->tokenEndpoint); + } + + /** + * @return iterable + */ + public static function loopbackProvider(): iterable + { + yield 'localhost' => ['http://localhost:8080']; + yield 'the loopback address' => ['http://127.0.0.1:8080']; + yield 'anywhere in 127/8' => ['http://127.13.2.9:8080']; + yield 'IPv6 loopback' => ['http://[::1]:8080']; + yield 'a name under .localhost' => ['http://mcp.localhost:8080']; + } + + /** + * @return iterable + */ + public static function lookalikeProvider(): iterable + { + yield 'localhost as a subdomain' => ['http://localhost.evil.example.com']; + yield 'not an IP address in 127/8' => ['http://127.999.1.1']; + } + + #[DataProvider('lookalikeProvider')] + #[TestDox('a host that merely looks like loopback is still refused: $_dataName')] + public function testRejectsLookalikeHosts(string $endpoint): void + { + $this->expectException(InvalidArgumentException::class); + + AuthorizationServerMetadata::fromArray([ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => $endpoint.'/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + ]); + } + + #[TestDox('legacy endpoints are not built on an insecure issuer')] + public function testRefusesAnInsecureLegacyIssuer(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('neither an HTTPS URL nor a loopback address'); + + AuthorizationServerMetadata::forLegacyServer('http://mcp.example.com'); + } + + #[TestDox('legacy endpoints may use plain HTTP on a loopback address')] + public function testAllowsALoopbackLegacyIssuer(): void + { + $metadata = AuthorizationServerMetadata::forLegacyServer('http://localhost:8080'); + + $this->assertSame('http://localhost:8080/token', $metadata->tokenEndpoint); + } + + #[TestDox('a server that publishes nothing gets the endpoint names its era prescribed')] + public function testLegacyEndpoints(): void + { + $metadata = AuthorizationServerMetadata::forLegacyServer('https://mcp.example.com'); + + $this->assertSame('https://mcp.example.com/authorize', $metadata->authorizationEndpoint); + $this->assertSame('https://mcp.example.com/token', $metadata->tokenEndpoint); + $this->assertSame('https://mcp.example.com/register', $metadata->registrationEndpoint); + $this->assertFalse($metadata->issuerParameterSupported); + } +} diff --git a/tests/Unit/Client/Auth/MetadataDiscoveryTest.php b/tests/Unit/Client/Auth/MetadataDiscoveryTest.php new file mode 100644 index 00000000..0b3e05e7 --- /dev/null +++ b/tests/Unit/Client/Auth/MetadataDiscoveryTest.php @@ -0,0 +1,365 @@ + + */ + public static function protectedResourceProvider(): iterable + { + yield 'a path is tried before the host root' => [ + 'https://mcp.example.com/mcp', + [ + 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp', + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ], + ]; + + yield 'a root endpoint has only one location' => [ + 'https://mcp.example.com/', + ['https://mcp.example.com/.well-known/oauth-protected-resource'], + ]; + + yield 'no path has only one location' => [ + 'https://mcp.example.com', + ['https://mcp.example.com/.well-known/oauth-protected-resource'], + ]; + + yield 'a trailing slash is kept' => [ + 'https://mcp.example.com/mcp/', + [ + 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp/', + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ], + ]; + + yield 'a query is kept' => [ + 'https://mcp.example.com/mcp?x=1', + [ + 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp?x=1', + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ], + ]; + + yield 'a trailing slash and a query are kept' => [ + 'https://mcp.example.com/mcp/?tenant=a', + [ + 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp/?tenant=a', + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ], + ]; + + yield 'a query on the root endpoint' => [ + 'https://mcp.example.com/?x=1', + [ + 'https://mcp.example.com/.well-known/oauth-protected-resource?x=1', + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ], + ]; + + yield 'the port is part of the origin' => [ + 'http://localhost:8000/api/mcp', + [ + 'http://localhost:8000/.well-known/oauth-protected-resource/api/mcp', + 'http://localhost:8000/.well-known/oauth-protected-resource', + ], + ]; + } + + #[DataProvider('protectedResourceProvider')] + #[TestDox('protected resource metadata is looked for at: $_dataName')] + public function testProtectedResourceCandidates(string $url, array $expected): void + { + $this->assertSame($expected, MetadataDiscovery::protectedResourceCandidates($url)); + } + + /** + * @return iterable + */ + public static function authorizationServerProvider(): iterable + { + yield 'an issuer without a path' => [ + 'https://auth.example.com', + [ + 'https://auth.example.com/.well-known/oauth-authorization-server', + 'https://auth.example.com/.well-known/openid-configuration', + ], + ]; + + // RFC 8414 inserts the path into the well-known segment, OpenID Connect appends it. + yield 'a terminating slash on an issuer without a path' => [ + 'https://auth.example.com/', + [ + 'https://auth.example.com/.well-known/oauth-authorization-server', + 'https://auth.example.com/.well-known/openid-configuration', + ], + ]; + + yield 'a terminating slash on an issuer path' => [ + 'https://auth.example.com/tenant1/', + [ + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1', + 'https://auth.example.com/.well-known/openid-configuration/tenant1', + 'https://auth.example.com/tenant1/.well-known/openid-configuration', + ], + ]; + + yield 'an empty leading segment in an issuer path' => [ + 'https://auth.example.com//tenant1', + [ + 'https://auth.example.com/.well-known/oauth-authorization-server//tenant1', + 'https://auth.example.com/.well-known/openid-configuration//tenant1', + 'https://auth.example.com//tenant1/.well-known/openid-configuration', + ], + ]; + + yield 'an issuer with a path' => [ + 'https://auth.example.com/tenant1', + [ + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1', + 'https://auth.example.com/.well-known/openid-configuration/tenant1', + 'https://auth.example.com/tenant1/.well-known/openid-configuration', + ], + ]; + } + + #[DataProvider('authorizationServerProvider')] + #[TestDox('authorization server metadata is looked for at: $_dataName')] + public function testAuthorizationServerCandidates(string $issuer, array $expected): void + { + $this->assertSame($expected, MetadataDiscovery::authorizationServerCandidates($issuer)); + } + + #[TestDox('the first location that answers wins, and later ones are not requested')] + public function testStopsAtTheFirstDocument(): void + { + $client = new RecordingClient([ + 'https://auth.example.com/.well-known/oauth-authorization-server' => new Response(404), + 'https://auth.example.com/.well-known/openid-configuration' => self::json([ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + ]), + ]); + + $metadata = $this->discovery($client)->discoverAuthorizationServer('https://auth.example.com'); + + $this->assertNotNull($metadata); + $this->assertSame('https://auth.example.com/token', $metadata->tokenEndpoint); + $this->assertCount(2, $client->requested); + } + + #[TestDox('metadata declaring a different issuer than it was fetched for is discarded')] + public function testRejectsIssuerMismatch(): void + { + $client = new RecordingClient([ + 'https://auth.example.com/.well-known/oauth-authorization-server' => self::json([ + 'issuer' => 'https://attacker.example.com', + 'authorization_endpoint' => 'https://attacker.example.com/authorize', + 'token_endpoint' => 'https://attacker.example.com/token', + ]), + ]); + + $this->assertNull($this->discovery($client)->discoverAuthorizationServer('https://auth.example.com')); + } + + /** + * @return iterable + */ + public static function issuerMismatchFallbackProvider(): iterable + { + yield 'an issuer without a path' => [ + 'https://auth.example.com', + 'https://auth.example.com/.well-known/oauth-authorization-server', + 'https://auth.example.com/.well-known/openid-configuration', + ]; + + yield 'an issuer with a path' => [ + 'https://auth.example.com/tenant1', + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1', + 'https://auth.example.com/.well-known/openid-configuration/tenant1', + ]; + } + + #[DataProvider('issuerMismatchFallbackProvider')] + #[TestDox('a document naming another issuer does not stop a later location: $_dataName')] + public function testContinuesAfterAnIssuerMismatch(string $issuer, string $mismatched, string $matching): void + { + $client = new RecordingClient([ + $mismatched => self::json([ + 'issuer' => 'https://attacker.example.com', + 'authorization_endpoint' => 'https://attacker.example.com/authorize', + 'token_endpoint' => 'https://attacker.example.com/token', + ]), + $matching => self::json([ + 'issuer' => $issuer, + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + ]), + ]); + + $metadata = $this->discovery($client)->discoverAuthorizationServer($issuer); + + $this->assertNotNull($metadata); + $this->assertSame('https://auth.example.com/token', $metadata->tokenEndpoint); + $this->assertSame([$mismatched, $matching], $client->requested); + } + + #[TestDox('every location is tried before giving up on a mismatching issuer')] + public function testTriesEveryLocationOnIssuerMismatch(): void + { + $attacker = self::json([ + 'issuer' => 'https://attacker.example.com', + 'authorization_endpoint' => 'https://attacker.example.com/authorize', + 'token_endpoint' => 'https://attacker.example.com/token', + ]); + $client = new RecordingClient([ + 'https://auth.example.com/.well-known/oauth-authorization-server' => $attacker, + 'https://auth.example.com/.well-known/openid-configuration' => $attacker, + ]); + + $this->assertNull($this->discovery($client)->discoverAuthorizationServer('https://auth.example.com')); + $this->assertCount(2, $client->requested); + } + + #[TestDox('an issuer that is neither https nor loopback is not even requested')] + public function testRefusesAnInsecureIssuer(): void + { + $client = new RecordingClient([ + 'http://auth.example.com/.well-known/oauth-authorization-server' => self::json([ + 'issuer' => 'http://auth.example.com', + 'authorization_endpoint' => 'https://attacker.example.com/authorize', + 'token_endpoint' => 'https://attacker.example.com/token', + ]), + ]); + + $this->assertNull($this->discovery($client)->discoverAuthorizationServer('http://auth.example.com')); + $this->assertSame([], $client->requested); + } + + #[TestDox('a loopback issuer may use plain http')] + public function testAllowsALoopbackIssuer(): void + { + $client = new RecordingClient([ + 'http://localhost:8080/.well-known/oauth-authorization-server' => self::json([ + 'issuer' => 'http://localhost:8080', + 'authorization_endpoint' => 'http://localhost:8080/authorize', + 'token_endpoint' => 'http://localhost:8080/token', + ]), + ]); + + $this->assertNotNull($this->discovery($client)->discoverAuthorizationServer('http://localhost:8080')); + } + + #[TestDox('metadata without an authorization endpoint is usable when no grant needs it')] + public function testAcceptsMetadataWithoutAnAuthorizationEndpoint(): void + { + $client = new RecordingClient([ + 'https://auth.example.com/.well-known/oauth-authorization-server' => self::json([ + 'issuer' => 'https://auth.example.com', + 'token_endpoint' => 'https://auth.example.com/token', + 'grant_types_supported' => ['client_credentials'], + ]), + ]); + + $metadata = $this->discovery($client)->discoverAuthorizationServer('https://auth.example.com'); + + $this->assertNotNull($metadata); + $this->assertNull($metadata->authorizationEndpoint); + } + + #[TestDox('a metadata URL named by the challenge is used instead of guessing')] + public function testHonoursTheChallengeMetadataUrl(): void + { + $client = new RecordingClient([ + 'https://mcp.example.com/custom/location.json' => self::json([ + 'resource' => 'https://mcp.example.com/mcp', + 'authorization_servers' => ['https://auth.example.com'], + ]), + ]); + + $metadata = $this->discovery($client)->discoverProtectedResource('https://mcp.example.com/mcp', 'https://mcp.example.com/custom/location.json'); + + $this->assertNotNull($metadata); + $this->assertSame(['https://auth.example.com'], $metadata->authorizationServers); + $this->assertSame(['https://mcp.example.com/custom/location.json'], $client->requested); + } + + #[TestDox('a transport failure is not fatal, the next location is still tried')] + public function testSurvivesATransportFailure(): void + { + $client = new RecordingClient([ + 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp' => new class('connection refused') extends \RuntimeException implements ClientExceptionInterface {}, + 'https://mcp.example.com/.well-known/oauth-protected-resource' => self::json([ + 'resource' => 'https://mcp.example.com', + 'authorization_servers' => ['https://auth.example.com'], + ]), + ]); + + $this->assertNotNull($this->discovery($client)->discoverProtectedResource('https://mcp.example.com/mcp')); + } + + private function discovery(ClientInterface $client): MetadataDiscovery + { + return new MetadataDiscovery($client, new Psr17Factory()); + } + + /** + * @param array $payload + */ + private static function json(array $payload): Response + { + return new Response(200, ['Content-Type' => 'application/json'], (string) json_encode($payload)); + } +} + +/** + * A PSR-18 client that answers from a table and remembers what it was asked for. + */ +final class RecordingClient implements ClientInterface +{ + /** @var string[] */ + public array $requested = []; + + /** + * @param array $responses + */ + public function __construct(private readonly array $responses) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $url = (string) $request->getUri(); + $this->requested[] = $url; + $response = $this->responses[$url] ?? new Response(404); + + if ($response instanceof \Throwable) { + throw $response; + } + + return $response; + } +} diff --git a/tests/Unit/Client/Auth/ProtectedResourceMetadataTest.php b/tests/Unit/Client/Auth/ProtectedResourceMetadataTest.php new file mode 100644 index 00000000..b97df5e4 --- /dev/null +++ b/tests/Unit/Client/Auth/ProtectedResourceMetadataTest.php @@ -0,0 +1,93 @@ + + */ + public static function coverageProvider(): iterable + { + yield 'the same URL' => ['https://mcp.example.com/mcp', 'https://mcp.example.com/mcp', true]; + yield 'a resource at the host root' => ['https://mcp.example.com', 'https://mcp.example.com/mcp', true]; + yield 'an endpoint below the resource' => ['https://mcp.example.com/api', 'https://mcp.example.com/api/mcp', true]; + yield 'a sibling path' => ['https://mcp.example.com/other', 'https://mcp.example.com/mcp', false]; + yield 'a path that merely shares a prefix' => ['https://mcp.example.com/api', 'https://mcp.example.com/api-v2/mcp', false]; + yield 'a different host' => ['https://evil.example.com/mcp', 'https://mcp.example.com/mcp', false]; + yield 'a different scheme' => ['http://mcp.example.com/mcp', 'https://mcp.example.com/mcp', false]; + yield 'a different port' => ['http://localhost:9000/mcp', 'http://localhost:8000/mcp', false]; + + yield 'the same query' => ['https://mcp.example.com/mcp?tenant=a', 'https://mcp.example.com/mcp?tenant=a', true]; + yield 'the same query below the resource' => ['https://mcp.example.com/mcp?tenant=a', 'https://mcp.example.com/mcp/sub?tenant=a', true]; + yield 'an endpoint query on a resource without one' => ['https://mcp.example.com/mcp', 'https://mcp.example.com/mcp?tenant=a', true]; + yield 'a different query' => ['https://mcp.example.com/mcp?tenant=a', 'https://mcp.example.com/mcp?tenant=b', false]; + yield 'a missing query' => ['https://mcp.example.com/mcp?tenant=a', 'https://mcp.example.com/mcp', false]; + + yield 'a dot segment leaving the resource' => ['https://mcp.example.com/api', 'https://mcp.example.com/api/../other', false]; + yield 'a nested dot segment leaving the resource' => ['https://mcp.example.com/api', 'https://mcp.example.com/api/x/../../other', false]; + yield 'an encoded dot segment' => ['https://mcp.example.com/api', 'https://mcp.example.com/api/%2e%2e/other', false]; + yield 'a half encoded dot segment' => ['https://mcp.example.com/api', 'https://mcp.example.com/api/.%2e/other', false]; + yield 'a dot segment with an encoded slash' => ['https://mcp.example.com/api', 'https://mcp.example.com/api/..%2fother', false]; + yield 'a single dot segment' => ['https://mcp.example.com/api', 'https://mcp.example.com/api/./mcp', false]; + yield 'a dot segment in the resource' => ['https://mcp.example.com/api/..', 'https://mcp.example.com/api/../mcp', false]; + yield 'dots that are no dot segment' => ['https://mcp.example.com/api', 'https://mcp.example.com/api/a..b/...', true]; + yield 'dots in the query' => ['https://mcp.example.com/api', 'https://mcp.example.com/api/x?y=/../z', true]; + + yield 'a terminating slash on the root resource' => ['https://mcp.example.com/', 'https://mcp.example.com/mcp', true]; + yield 'a terminating slash on the resource, endpoint without it' => ['https://mcp.example.com/api/', 'https://mcp.example.com/api', true]; + yield 'a terminating slash on the resource, endpoint below it' => ['https://mcp.example.com/api/', 'https://mcp.example.com/api/x', true]; + yield 'a terminating slash on the resource, sharing a prefix' => ['https://mcp.example.com/api/', 'https://mcp.example.com/api-v2', false]; + yield 'an empty last segment, endpoint below it' => ['https://mcp.example.com/api//', 'https://mcp.example.com/api//x', true]; + yield 'an empty last segment does not cover the parent' => ['https://mcp.example.com/api//', 'https://mcp.example.com/api', false]; + yield 'an empty last segment does not cover a sibling' => ['https://mcp.example.com/api//', 'https://mcp.example.com/api/x', false]; + yield 'only slashes is not the root' => ['https://mcp.example.com//', 'https://mcp.example.com/mcp', false]; + + yield 'an explicit default https port on the endpoint' => ['https://mcp.example.com/api', 'https://mcp.example.com:443/api', true]; + yield 'an explicit default https port on the resource' => ['https://mcp.example.com:443/api', 'https://mcp.example.com/api', true]; + yield 'an explicit default http port' => ['http://localhost/api', 'http://localhost:80/api', true]; + yield 'an uppercase host' => ['https://mcp.example.com/api', 'https://MCP.example.com/api', true]; + yield 'an uppercase scheme' => ['https://mcp.example.com/api', 'HTTPS://mcp.example.com/api', true]; + yield 'the https port with the http scheme' => ['https://mcp.example.com/api', 'http://mcp.example.com:443/api', false]; + yield 'the http port with the https scheme' => ['https://mcp.example.com/api', 'https://mcp.example.com:80/api', false]; + yield 'a non-default port' => ['https://mcp.example.com/api', 'https://mcp.example.com:8443/api', false]; + yield 'a path differing in case' => ['https://mcp.example.com/api', 'https://mcp.example.com/API', false]; + } + + #[DataProvider('coverageProvider')] + #[TestDox('a token is allowed at the endpoint for: $_dataName')] + public function testCovers(string $resource, string $endpoint, bool $expected): void + { + $this->assertSame($expected, (new ProtectedResourceMetadata($resource))->covers($endpoint)); + } + + #[TestDox('scopes_supported is kept apart from an absent list')] + public function testDistinguishesEmptyAndAbsentScopes(): void + { + $this->assertNull(ProtectedResourceMetadata::fromArray(['resource' => 'https://mcp.example.com'])->scopesSupported); + $this->assertSame([], ProtectedResourceMetadata::fromArray(['resource' => 'https://mcp.example.com', 'scopes_supported' => []])->scopesSupported); + } + + #[TestDox('a document without a resource is rejected')] + public function testRequiresResource(): void + { + $this->expectException(InvalidArgumentException::class); + + ProtectedResourceMetadata::fromArray(['authorization_servers' => ['https://auth.example.com']]); + } +} From 5868b01f9daada5a06b5124e7e3e84108f26a9e8 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 4 Nov 2026 23:18:25 +0100 Subject: [PATCH 3/5] [Client] Add OAuth authorization with the authorization code flow Pass OAuth::forApplication('My App')->build() as `auth` and a client answers a 401 with discovery, client registration, the authorization code flow with PKCE and a retry. It refreshes tokens, handles step-up challenges and refuses what a hostile server could send. Client id metadata documents, the console handler, client credentials, private_key_jwt and cross-app access follow in their own changes, so their conformance scenarios stay in the baselines. --- .github/workflows/pipeline.yaml | 26 + .gitignore | 6 +- CHANGELOG.md | 2 + Makefile | 7 +- docs/client/authorization.md | 165 ++++ docs/client/errors.md | 4 +- docs/client/index.md | 2 + docs/client/transports.md | 4 +- docs/examples.md | 1 + examples/client/README.md | 13 + examples/client/oauth_client.php | 71 ++ mkdocs.yml | 1 + src/Client/Auth/AccessToken.php | 158 ++++ src/Client/Auth/AuthenticatorInterface.php | 5 + .../Auth/AuthorizationHandlerInterface.php | 40 + src/Client/Auth/ClientMetadata.php | 81 ++ src/Client/Auth/ClientRegistrar.php | 78 ++ src/Client/Auth/ClientRegistration.php | 108 +++ .../Auth/CredentialStorageInterface.php | 46 ++ src/Client/Auth/FileCredentialStorage.php | 158 ++++ src/Client/Auth/Grant.php | 28 + .../Auth/HeadlessAuthorizationHandler.php | 123 +++ src/Client/Auth/InMemoryCredentialStorage.php | 62 ++ .../Auth/LoopbackAuthorizationHandler.php | 167 ++++ src/Client/Auth/OAuth.php | 267 +++++++ src/Client/Auth/OAuthAuthenticator.php | 508 +++++++++++++ src/Client/Auth/OAuthConfiguration.php | 50 ++ src/Client/Auth/Pkce.php | 52 ++ src/Client/Auth/TokenEndpoint.php | 126 ++++ src/Client/Auth/TokenEndpointAuthMethod.php | 59 ++ src/Client/Transport/HttpTransport.php | 11 +- tests/Conformance/client.php | 56 +- .../conformance-baseline-2025-11-25.yml | 18 +- .../conformance-baseline-2026-07-28.yml | 24 - tests/E2E/BrowserAuthorizationHandler.php | 134 ++++ tests/E2E/README.md | 75 ++ tests/E2E/client.php | 108 +++ tests/E2E/docker-compose.yml | 70 ++ tests/E2E/keycloak/realm.json | 130 ++++ tests/E2E/nginx.conf | 17 + tests/E2E/run.sh | 57 ++ tests/E2E/server.php | 78 ++ tests/Integration/ClientOAuthTest.php | 284 +++++++ .../Fixture/oauth/authorization-server.php | 162 ++++ .../Fixture/oauth/resource-server.php | 73 ++ tests/Unit/Client/Auth/AccessTokenTest.php | 94 +++ .../Client/Auth/AuthorizationHandlerTest.php | 101 +++ .../Client/Auth/ClientRegistrationTest.php | 134 ++++ .../Client/Auth/FileCredentialStorageTest.php | 128 ++++ .../Client/Auth/OAuthAuthenticatorTest.php | 710 ++++++++++++++++++ tests/Unit/Client/Auth/OAuthTest.php | 88 +++ tests/Unit/Client/Auth/PkceTest.php | 53 ++ .../Auth/TokenEndpointAuthMethodTest.php | 59 ++ tests/Unit/Client/Auth/TokenEndpointTest.php | 173 +++++ 54 files changed, 5200 insertions(+), 55 deletions(-) create mode 100644 docs/client/authorization.md create mode 100644 examples/client/oauth_client.php create mode 100644 src/Client/Auth/AccessToken.php create mode 100644 src/Client/Auth/AuthorizationHandlerInterface.php create mode 100644 src/Client/Auth/ClientMetadata.php create mode 100644 src/Client/Auth/ClientRegistrar.php create mode 100644 src/Client/Auth/ClientRegistration.php create mode 100644 src/Client/Auth/CredentialStorageInterface.php create mode 100644 src/Client/Auth/FileCredentialStorage.php create mode 100644 src/Client/Auth/Grant.php create mode 100644 src/Client/Auth/HeadlessAuthorizationHandler.php create mode 100644 src/Client/Auth/InMemoryCredentialStorage.php create mode 100644 src/Client/Auth/LoopbackAuthorizationHandler.php create mode 100644 src/Client/Auth/OAuth.php create mode 100644 src/Client/Auth/OAuthAuthenticator.php create mode 100644 src/Client/Auth/OAuthConfiguration.php create mode 100644 src/Client/Auth/Pkce.php create mode 100644 src/Client/Auth/TokenEndpoint.php create mode 100644 src/Client/Auth/TokenEndpointAuthMethod.php create mode 100644 tests/E2E/BrowserAuthorizationHandler.php create mode 100644 tests/E2E/README.md create mode 100644 tests/E2E/client.php create mode 100644 tests/E2E/docker-compose.yml create mode 100644 tests/E2E/keycloak/realm.json create mode 100644 tests/E2E/nginx.conf create mode 100755 tests/E2E/run.sh create mode 100644 tests/E2E/server.php create mode 100644 tests/Integration/ClientOAuthTest.php create mode 100644 tests/Integration/Fixture/oauth/authorization-server.php create mode 100644 tests/Integration/Fixture/oauth/resource-server.php create mode 100644 tests/Unit/Client/Auth/AccessTokenTest.php create mode 100644 tests/Unit/Client/Auth/AuthorizationHandlerTest.php create mode 100644 tests/Unit/Client/Auth/ClientRegistrationTest.php create mode 100644 tests/Unit/Client/Auth/FileCredentialStorageTest.php create mode 100644 tests/Unit/Client/Auth/OAuthAuthenticatorTest.php create mode 100644 tests/Unit/Client/Auth/OAuthTest.php create mode 100644 tests/Unit/Client/Auth/PkceTest.php create mode 100644 tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php create mode 100644 tests/Unit/Client/Auth/TokenEndpointTest.php diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 3618ce4f..b4b41951 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -239,6 +239,32 @@ jobs: echo "" done || echo "No results found" + e2e: + name: end-to-end / client authorization + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: "none" + + - name: Install Composer + uses: "ramsey/composer-install@v4" + + # Keycloak, a real MCP server, and a client that has to authorize its way in. + # See tests/E2E/README.md. + - name: Run the authorization end-to-end suite + # Kept up so the next step can read the containers' logs when it fails. + run: E2E_KEEP=1 tests/E2E/run.sh + + - name: Show logs on failure + if: failure() + run: docker compose -f tests/E2E/docker-compose.yml logs + qa: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 72196aa5..b1321b10 100644 --- a/.gitignore +++ b/.gitignore @@ -6,11 +6,13 @@ vendor examples/**/dev.log examples/**/cache examples/**/sessions -tests/Conformance/client-conformance.json -tests/Conformance/server-conformance.json +examples/client/.oauth-credentials.json +tests/Conformance/client-conformance*.json +tests/Conformance/server-conformance*.json tests/Conformance/results* tests/Conformance/sessions tests/Conformance/logs/*.log +tests/E2E/sessions # phpDocumentor .phpdoc/build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f2c9ec..b7451360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ All notable changes to `mcp/sdk` will be documented in this file. ### Client +* Add OAuth authorization: pass `OAuth::forApplication('My App')->build()` as `auth` and the client answers a `401` with discovery, dynamic or pre-registered client identity, the authorization code flow with PKCE and a retry. It refreshes tokens, handles step-up challenges and treats the server's metadata as untrusted input. See [Authorization](docs/client/authorization.md). +* Add `LoopbackAuthorizationHandler` and `HeadlessAuthorizationHandler` behind `AuthorizationHandlerInterface`, and `InMemoryCredentialStorage` and `FileCredentialStorage` behind `CredentialStorageInterface`. * Add the `auth` parameter of `HttpTransport` and `AuthenticatorInterface`, to authenticate every request and retry the ones the server refuses. `BearerToken` sends a token you already have, and `AuthenticatingHttpClient` offers the same behavior as a PSR-18 decorator. * [BC Break] A `401` or `403` answer to a request, a notification or the resumption of an event stream fails with an `AuthorizationException` that contains the `WWW-Authenticate` header, instead of a `RequestException`. `Client::connect()` wraps it in a `ConnectionException`, without a retry. * Fix `Client::connect()` leaving the previous transport open when called again: it closes the current transport first, so no stdio process or HTTP session is left behind. diff --git a/Makefile b/Makefile index c2dd80f3..8dc5f8fb 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests interop-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs docs-guides docs-api docs-serve +.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests interop-tests e2e-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs docs-guides docs-api docs-serve # The 2026-07-28 scenarios ship on the `alpha` dist-tag; `latest` (0.1.x) has # none of them. Pinned to the same version CI runs (see @@ -36,6 +36,11 @@ interop-tests: npm ci --prefix tests/Interop --no-audit --no-fund vendor/bin/phpunit --testsuite=interop +# Client authorization against a real Keycloak and a real MCP server, over Docker. +# See tests/E2E/README.md; not part of `make ci`, since it needs Docker. +e2e-tests: + tests/E2E/run.sh + conformance-tests: conformance-server conformance-client conformance-server: diff --git a/docs/client/authorization.md b/docs/client/authorization.md new file mode 100644 index 00000000..6d1f36ed --- /dev/null +++ b/docs/client/authorization.md @@ -0,0 +1,165 @@ +# Authorization + +A remote MCP server can require an OAuth access token. It answers a request without a token with `401 Unauthorized`. Pass an authenticator to the HTTP transport and the client gets a token and sends the request again: + +```php +use Mcp\Client; +use Mcp\Client\Auth\OAuth; +use Mcp\Client\Transport\HttpTransport; + +$client = Client::builder()->setClientInfo('My App', '1.0.0')->build(); + +$client->connect(new HttpTransport( + 'https://mcp.example.com/mcp', + auth: OAuth::forApplication('My App')->build(), +)); +``` + +The client does nothing until a server answers with `401` or `403`. It talks to a server that doesn't require a token in the same way as a client without `auth`. + +If you already have a token, see [Sending an access token](transports.md#sending-an-access-token). + +## What the client does + +When the server refuses a request, the client takes these steps: + +| Step | Specification | +| --- | --- | +| Read the metadata URL and the required scopes from `WWW-Authenticate` | [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) | +| Fetch the protected resource metadata and check that it describes this server | [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) | +| Fetch the metadata of the authorization server and check its issuer | [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) | +| Register the client, unless you configured a client or the storage has one | [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) | +| Run the authorization code flow with PKCE | [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749), [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) | +| Check `state` and `iss` in the authorization response | [RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207) | +| Send the `resource` parameter in the authorization and token requests | [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) | +| Send the token, and refresh it or authorize again when it stops working | [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) | + +## Sending the User to the Browser + +An authorization handler sends the user to the authorization server and returns the answer. Pass one with `setAuthorizationHandler()`: + +```php +use Mcp\Client\Auth\HeadlessAuthorizationHandler; +use Mcp\Client\Auth\OAuth; + +$auth = OAuth::forApplication('My App') + ->setAuthorizationHandler(new HeadlessAuthorizationHandler()) + ->build(); +``` + +The SDK contains two handlers: + +- `LoopbackAuthorizationHandler` is the default. It opens the system browser and listens on the port of the redirect URI until the browser returns. Use it for desktop and command line applications. +- `HeadlessAuthorizationHandler` requests the authorization URL itself and reads the code from the redirect. It only works if the authorization server grants access without asking the user (e.g. a test server). + +For anything else, implement `AuthorizationHandlerInterface`. It has one method: + +```php +use Mcp\Client\Auth\AuthorizationHandlerInterface; + +final class AcmeAuthorizationHandler implements AuthorizationHandlerInterface +{ + public function authorize(string $authorizationUrl, string $redirectUri): array + { + // send the user to $authorizationUrl and wait for the redirect to $redirectUri + + // return its query parameters, the SDK checks them + return ['code' => '...', 'state' => '...', 'iss' => '...']; + } +} +``` + +The default redirect URI is `http://127.0.0.1:8765/callback`. Change it with `setRedirectUri()` if the port is in use or if the authorization server expects another one. The loopback handler listens on the host and port of this URI. + +## Storing Credentials + +By default, the client keeps its tokens in memory. A command line application then sends the user to the browser on every run. Use `FileCredentialStorage` to keep them between runs: + +```php +use Mcp\Client\Auth\FileCredentialStorage; +use Mcp\Client\Auth\OAuth; + +$auth = OAuth::forApplication('My App') + ->setCredentialStorage(new FileCredentialStorage($_SERVER['HOME'].'/.config/my-app/credentials.json')) + ->build(); +``` + +The file contains tokens and client secrets. The SDK creates it with the mode `0600` in a directory with the mode `0700`, and doesn't encrypt it. To use a keychain or a secrets manager, implement `CredentialStorageInterface`. + +The storage keys everything by the issuer of the authorization server. If a server moves to another authorization server, the client registers again and doesn't send its old client ID to the new one. + +## Using a Pre-registered Client + +If an administrator created a client for you, or if the authorization server doesn't support dynamic registration, pass the client ID: + +```php +// the secret is optional +OAuth::forApplication('My App')->setClientCredentials('my-client-id', 'my-client-secret'); +``` + +The client reads from the metadata of the authorization server how to send the secret. + +## Choosing Scopes + +By default, the client requests the scopes of the challenge. If there are none, it requests all scopes in `scopes_supported` of the resource. If there are none either, it sends no `scope` parameter. Use `setScopes()` to request your own: + +```php +OAuth::forApplication('My App')->setScopes('mcp:read', 'mcp:write'); +``` + +If the server answers with `insufficient_scope`, the client adds the scopes of this challenge and authorizes again. + +If the authorization server supports `offline_access`, the client requests it to get a refresh token. If the server refuses this scope, the client tries again without it. Use `setOfflineAccess(false)` if you don't want a refresh token. + +## Connecting to Older Servers + +Since the `2025-06-18` revision, a protected server must publish protected resource metadata. By default, the client refuses a server without it. To connect to a server built for the `2025-03-26` revision, call `setLegacyDiscovery(true)`. + +## Authenticating Other Requests + +To send other requests to the same server with the same credentials, wrap your PSR-18 client: + +```php +use Mcp\Client\Auth\AuthenticatingHttpClient; + +$httpClient = new AuthenticatingHttpClient($yourPsr18Client, $auth); +``` + +It authorizes at most 3 times per request. If the request body isn't seekable, it doesn't send the request again and returns the rejected response. + +## What the Client Refuses + +The client treats the MCP server and its metadata as untrusted input. It stops with an `AuthorizationException` in these cases: + +| Input | Reason | +| --- | --- | +| The protected resource metadata names a `resource` that doesn't cover the endpoint | The token would be issued for another server | +| The authorization server metadata names another `issuer` than the one it was fetched for | The document describes another server | +| The MCP server URL, an authorization server URL or an endpoint doesn't use `https` on a host that isn't loopback | Credentials would travel without encryption | +| The authorization response has no `state`, or another `state` | It doesn't answer the request of this client | +| `iss` doesn't match the issuer, or is missing although the server announced it | The response may come from another server | +| The server has no protected resource metadata, without `setLegacyDiscovery(true)` | There is nothing to check the authorization server against | + +The client ignores a `resource_metadata` URL on another origin than the MCP server and looks at the well-known URLs instead. It only sends a token to the resource it was issued for. + +!!! warning + The client can't protect a user who approves a hostile authorization server. A hostile MCP server can name one, and the user sees its address in the browser. Tell your users to check it before they sign in. + +## Handling Errors + +`connect()` reports a failed authorization as a `ConnectionException`. The cause is an `AuthorizationException`: + +```php +use Mcp\Exception\AuthorizationException; +use Mcp\Exception\ConnectionException; + +try { + $client->connect($transport); +} catch (ConnectionException $e) { + if ($e->getPrevious() instanceof AuthorizationException) { + echo "Could not authorize: {$e->getPrevious()->getMessage()}\n"; + } +} +``` + +After `connect()`, requests throw the `AuthorizationException` directly. The client doesn't retry a failed authorization. Pass a PSR-3 logger with `setLogger()` to see each step. diff --git a/docs/client/errors.md b/docs/client/errors.md index 2ecc4fa1..8fcf843b 100644 --- a/docs/client/errors.md +++ b/docs/client/errors.md @@ -7,7 +7,7 @@ all client errors in one place. All classes below live in the `Mcp\Exception` na | --- | --- | | `ConnectionException` | `connect()` fails on every attempt, an HTTP request fails on the network level, or you call a method before `connect()` | | `RequestException` | the server answers with an error, the request reaches the default request timeout (code `-32603`, message `Request timed out`), the server answers with an HTTP error status other than `401` or `403`, or the STDIO server process exits while you wait for an answer | -| `AuthorizationException` | the server answers a request, a notification or the resumption of an event stream with the HTTP status `401` or `403` | +| `AuthorizationException` | the server answers a request, a notification or the resumption of an event stream with the HTTP status `401` or `403`, or the [OAuth flow](authorization.md) fails | | `TimeoutException` | the `timeoutSeconds` deadline of a `callTool()` call expires | | `RequestCancelledException` | the cancellation token of a `callTool()` call is canceled, see [Cancellation and deadlines](transports.md#cancellation-and-deadlines) | | `InvalidArgumentException` | `callTool()` gets a `timeoutSeconds` that is not finite and positive, `readResource()` gets an empty URI, the builder gets an invalid timeout, retry count or fallback revision, the request can't be encoded as JSON (for example invalid UTF-8 in tool arguments), or the server returns a malformed result (also from `connect()`) | @@ -30,7 +30,7 @@ try { ## AuthorizationException -The message contains the `WWW-Authenticate` header of the response, if the server sends one: +This exception also reports a failed [authorization](authorization.md) flow. The message of a refused request contains the `WWW-Authenticate` header of the response, if the server sends one: ```php use Mcp\Exception\AuthorizationException; diff --git a/docs/client/index.md b/docs/client/index.md index b0080acc..4f9a5664 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -31,6 +31,8 @@ $client->disconnect(); and what the server told you about itself when connecting. * **[Transports](transports.md)** — launching a local server process (STDIO) or talking to a remote one (HTTP). +* **[Authorization](authorization.md)** — connecting to a server that wants an OAuth + token, and how the client goes and gets one. * **[Tools, resources & prompts](capabilities.md)** — listing and calling everything a server exposes, including progress callbacks and completions. * **[Server-initiated requests](server-requests.md)** — the other direction: log diff --git a/docs/client/transports.md b/docs/client/transports.md index 63bbb620..d9807816 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -54,7 +54,7 @@ $transport = new HttpTransport( - `listen` (bool): Open the server's listening stream after the handshake (see below) - `reconnectPolicy` (ReconnectPolicy|null): How an interrupted SSE stream is resumed (default: 5 attempts, 1–10 s backoff) - `clock` (ClockInterface|null): PSR-20 clock used to schedule reconnects (default: a monotonic clock) -- `auth` (AuthenticatorInterface|null): Credentials for a server that requires authorization, see [Sending an access token](#sending-an-access-token) +- `auth` (AuthenticatorInterface|null): Credentials for a server that requires authorization, see [Authorization](authorization.md) When an SSE connection closes before its JSON-RPC response arrives, the transport resumes it with a GET request carrying the latest `Last-Event-ID`. A server-provided SSE `retry` value controls the delay; otherwise the fallback delay doubles from 1 second up to 10 seconds. Completed requests and explicitly closed transports are not reconnected. A server-provided `retry` value is not capped, so a long one leaves the request waiting until its timeout. A reconnect answered with a redirect or a client error (e.g. `404` for an expired session or `405` without GET support) fails the request at once; only `408`, `429`, server errors and a successful answer that is not an event stream are retried. Protocol version `2026-07-28` removed stream resumption, so there an interrupted stream fails the request at once. @@ -123,7 +123,7 @@ $transport = new HttpTransport('http://localhost:8000', listen: true); ### Sending an access token -If you already have an access token (e.g. from a secrets manager), pass it with the `auth` parameter: +If you already have an access token (e.g. from a secrets manager), pass it with the `auth` parameter. To get a token through OAuth, see [Authorization](authorization.md). ```php use Mcp\Client\Auth\BearerToken; diff --git a/docs/examples.md b/docs/examples.md index 05af4865..622ce401 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -47,6 +47,7 @@ npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/ser | [`http_client_communication.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/http_client_communication.php) | The same handlers over the Streamable HTTP transport | [Server-initiated requests](client/server-requests.md) | | [`stdio_elicitation.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_elicitation.php) | Answering elicitation requests from an interactive prompt | [Server-initiated requests](client/server-requests.md) | | [`stdio_roots.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_roots.php) | Exposing workspace roots and signalling `roots/list_changed` | [Server-initiated requests](client/server-requests.md) | +| [`oauth_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/oauth_client.php) | Authorizing against a protected server, with discovery, PKCE and the browser | [Authorization](client/authorization.md) | | [`stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) | A client that only connects on revision `2026-07-28`, see [Modern-era client](#modern-era-client) | [Connecting to a server](client/connecting.md) | Client examples run directly: diff --git a/examples/client/README.md b/examples/client/README.md index c56abb3b..2b9c8d1b 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -22,6 +22,19 @@ php -S localhost:8000 examples/server/discovery-calculator/server.php php examples/client/http_discovery_calculator.php ``` +## OAuth client + +Connects to a server that requires authorization. The client starts with nothing: the +server's `401` is what triggers discovery, registration, the browser, and the token. + +```bash +# First, start the Keycloak-protected server example +cd examples/server/oauth-keycloak && docker compose up -d + +# Then run the client, and sign in as demo / demo123 when the browser opens +php examples/client/oauth_client.php +``` + ## Modern-era client (2026-07-28) Connects only on revision `2026-07-28`, without falling back to the `initialize` handshake. Every diff --git a/examples/client/oauth_client.php b/examples/client/oauth_client.php new file mode 100644 index 00000000..ecc2367f --- /dev/null +++ b/examples/client/oauth_client.php @@ -0,0 +1,71 @@ +setClientInfo('OAuth Example Client', '1.0.0') + ->setInitTimeout(120) + ->setRequestTimeout(60) + ->build(); + +$auth = OAuth::forApplication('MCP PHP SDK Example') + // The realm this example ships pre-registers a public client, so there is nothing + // to register. Drop this line against a server that offers dynamic registration. + ->setClientCredentials('mcp-client') + ->setCredentialStorage(new FileCredentialStorage(__DIR__.'/.oauth-credentials.json')) + ->build(); + +try { + echo "Connecting to {$endpoint}...\n"; + echo "A browser window will open for you to sign in, unless a stored token is still good.\n\n"; + + $client->connect(new HttpTransport($endpoint, auth: $auth)); + + echo "Connected as an authorized client.\n\n"; + + echo "Available tools:\n"; + foreach ($client->listTools()->tools as $tool) { + echo " - {$tool->name}: {$tool->description}\n"; + } + + $client->disconnect(); +} catch (AuthorizationException $e) { + echo "Could not authorize: {$e->getMessage()}\n"; + + exit(1); +} diff --git a/mkdocs.yml b/mkdocs.yml index d5e45434..6a822063 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - client/index.md - Connecting to a server: client/connecting.md - Transports: client/transports.md + - Authorization: client/authorization.md - "Tools, resources & prompts": client/capabilities.md - Server-initiated requests: client/server-requests.md - Error handling: client/errors.md diff --git a/src/Client/Auth/AccessToken.php b/src/Client/Auth/AccessToken.php new file mode 100644 index 00000000..6a12dafc --- /dev/null +++ b/src/Client/Auth/AccessToken.php @@ -0,0 +1,158 @@ + + */ +final class AccessToken implements \JsonSerializable +{ + /** + * Seconds of headroom before the nominal expiry at which the token is treated as stale. + * + * Without it a token that expires while the request is in flight would be sent anyway, + * costing a round trip and a challenge to learn what was already known. + */ + private const EXPIRY_LEEWAY_SECONDS = 30; + + /** + * @param string $accessToken the token to present to the resource server + * @param string $tokenType the token type, "Bearer" for everything MCP defines + * @param ?int $expiresAt unix timestamp the token expires at, null when the server did not say + * @param ?string $refreshToken the refresh token, when the grant produced one + * @param string[] $scopes the scopes the authorization server actually granted + */ + public function __construct( + public readonly string $accessToken, + public readonly string $tokenType = 'Bearer', + public readonly ?int $expiresAt = null, + public readonly ?string $refreshToken = null, + public readonly array $scopes = [], + ) { + if ('' === trim($accessToken)) { + throw new InvalidArgumentException('The access token must not be empty.'); + } + } + + /** + * Build a token from a parsed token endpoint response (RFC 6749 section 5.1). + * + * @param array $response + */ + public static function fromResponse(array $response, ?int $now = null): self + { + if (!isset($response['access_token']) || !\is_string($response['access_token'])) { + throw new InvalidArgumentException('The token response is missing a string "access_token".'); + } + + $expiresIn = $response['expires_in'] ?? null; + + return new self( + $response['access_token'], + \is_string($response['token_type'] ?? null) ? $response['token_type'] : 'Bearer', + is_numeric($expiresIn) ? ($now ?? time()) + (int) $expiresIn : null, + \is_string($response['refresh_token'] ?? null) ? $response['refresh_token'] : null, + \is_string($response['scope'] ?? null) ? self::splitScopes($response['scope']) : [], + ); + } + + public function isExpired(?int $now = null): bool + { + if (null === $this->expiresAt) { + return false; + } + + return ($now ?? time()) + self::EXPIRY_LEEWAY_SECONDS >= $this->expiresAt; + } + + /** + * Whether every one of the given scopes was granted. + * + * Authorization servers may narrow what was asked for, so "we requested it" and + * "we hold it" are different questions. + * + * @param string[] $scopes + */ + public function covers(array $scopes): bool + { + if ([] === $this->scopes) { + // The server did not report a scope, which per RFC 6749 means it granted + // exactly what was requested. Nothing can be ruled out from here. + return true; + } + + return [] === array_diff($scopes, $this->scopes); + } + + /** + * Record what was granted when the authorization server did not say. + * + * RFC 6749 section 5.1 makes the scope field optional precisely when the grant + * matches the request, so the requested set is the granted set -- writing it down + * is what lets a later challenge be told apart from a token that is simply stale. + * + * @param string[] $scopes + */ + public function withGrantedScopes(array $scopes): self + { + if ([] !== $this->scopes || [] === $scopes) { + return $this; + } + + return new self($this->accessToken, $this->tokenType, $this->expiresAt, $this->refreshToken, $scopes); + } + + public function getAuthorizationHeader(): string + { + return $this->tokenType.' '.$this->accessToken; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return array_filter([ + 'access_token' => $this->accessToken, + 'token_type' => $this->tokenType, + 'expires_at' => $this->expiresAt, + 'refresh_token' => $this->refreshToken, + 'scopes' => $this->scopes, + ], static fn (mixed $value): bool => null !== $value && [] !== $value); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + \is_string($data['access_token'] ?? null) ? $data['access_token'] : '', + \is_string($data['token_type'] ?? null) ? $data['token_type'] : 'Bearer', + isset($data['expires_at']) && is_numeric($data['expires_at']) ? (int) $data['expires_at'] : null, + \is_string($data['refresh_token'] ?? null) ? $data['refresh_token'] : null, + array_values(array_filter((array) ($data['scopes'] ?? []), 'is_string')), + ); + } + + /** + * @return string[] + */ + private static function splitScopes(string $scope): array + { + return array_values(array_filter(preg_split('/\s+/', trim($scope)) ?: [])); + } +} diff --git a/src/Client/Auth/AuthenticatorInterface.php b/src/Client/Auth/AuthenticatorInterface.php index 237a303c..ddfe456f 100644 --- a/src/Client/Auth/AuthenticatorInterface.php +++ b/src/Client/Auth/AuthenticatorInterface.php @@ -11,6 +11,7 @@ namespace Mcp\Client\Auth; +use Mcp\Exception\AuthorizationException; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -31,6 +32,8 @@ interface AuthenticatorInterface * * Implementations return the request unchanged when they hold none: the resource * server answering with a challenge is what starts the flow. + * + * @throws AuthorizationException when the credentials cannot be attached */ public function authenticate(RequestInterface $request): RequestInterface; @@ -42,6 +45,8 @@ public function authenticate(RequestInterface $request): RequestInterface; * * @return bool true when credentials were obtained and the request should be retried, * false when the authenticator cannot make progress + * + * @throws AuthorizationException when obtaining credentials fails */ public function handleChallenge(RequestInterface $request, ResponseInterface $response): bool; } diff --git a/src/Client/Auth/AuthorizationHandlerInterface.php b/src/Client/Auth/AuthorizationHandlerInterface.php new file mode 100644 index 00000000..639d6139 --- /dev/null +++ b/src/Client/Auth/AuthorizationHandlerInterface.php @@ -0,0 +1,40 @@ + + */ +interface AuthorizationHandlerInterface +{ + /** + * Send the user to $authorizationUrl and return the query parameters the + * authorization server redirected back to $redirectUri with. + * + * The returned array is passed through untouched -- `code`, `state`, `iss` and any + * `error` are all validated by the caller, so an implementation only has to deliver + * what it saw. + * + * @return array the callback's query parameters + * + * @throws \Mcp\Exception\AuthorizationException when the user could not be sent, or did not come back + */ + public function authorize(string $authorizationUrl, string $redirectUri): array; +} diff --git a/src/Client/Auth/ClientMetadata.php b/src/Client/Auth/ClientMetadata.php new file mode 100644 index 00000000..4d56f173 --- /dev/null +++ b/src/Client/Auth/ClientMetadata.php @@ -0,0 +1,81 @@ + + */ +final class ClientMetadata implements \JsonSerializable +{ + /** + * @param string $clientName the name shown to the user on the consent screen + * @param string[] $redirectUris where the authorization server may send the user back + * @param string[] $grantTypes the grants the client intends to use + * @param string $applicationType "native" for anything running on the user's machine, + * "web" for a server-side application (SEP-837) + * @param ?string $clientUri a page describing the client + * @param ?string $softwareId a stable identifier for the client software + * @param ?string $softwareVersion the version of that software + */ + public function __construct( + public readonly string $clientName, + public readonly array $redirectUris = [], + public readonly array $grantTypes = ['authorization_code', 'refresh_token'], + public readonly string $applicationType = 'native', + public readonly ?string $clientUri = null, + public readonly ?string $softwareId = null, + public readonly ?string $softwareVersion = null, + ) { + } + + /** + * @return array + */ + public function toArray(TokenEndpointAuthMethod $authMethod, ?string $scope = null): array + { + $usesAuthorizationCode = \in_array('authorization_code', $this->grantTypes, true); + + return array_filter([ + 'client_name' => $this->clientName, + 'redirect_uris' => $usesAuthorizationCode ? $this->redirectUris : [], + 'grant_types' => $this->grantTypes, + 'response_types' => $usesAuthorizationCode ? ['code'] : [], + 'token_endpoint_auth_method' => $authMethod->value, + 'application_type' => $this->applicationType, + 'client_uri' => $this->clientUri, + 'software_id' => $this->softwareId, + 'software_version' => $this->softwareVersion, + 'scope' => $scope, + ], static fn (mixed $value): bool => null !== $value && [] !== $value); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(TokenEndpointAuthMethod::None); + } + + public function withGrantTypes(string ...$grantTypes): self + { + return new self($this->clientName, $this->redirectUris, $grantTypes, $this->applicationType, $this->clientUri, $this->softwareId, $this->softwareVersion); + } +} diff --git a/src/Client/Auth/ClientRegistrar.php b/src/Client/Auth/ClientRegistrar.php new file mode 100644 index 00000000..ff70c918 --- /dev/null +++ b/src/Client/Auth/ClientRegistrar.php @@ -0,0 +1,78 @@ + + */ +final class ClientRegistrar +{ + public function __construct( + private readonly ClientInterface $httpClient, + private readonly RequestFactoryInterface $requestFactory, + private readonly StreamFactoryInterface $streamFactory, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function register( + AuthorizationServerMetadata $metadata, + ClientMetadata $clientMetadata, + TokenEndpointAuthMethod $preferredAuthMethod, + ?string $scope = null, + ): ClientRegistration { + if (null === $metadata->registrationEndpoint) { + throw new AuthorizationException(\sprintf('The authorization server "%s" does not support dynamic client registration, and no client id was configured for it.', $metadata->issuer)); + } + + $body = $clientMetadata->toArray($preferredAuthMethod, $scope); + + $this->logger->debug('Registering with the authorization server', [ + 'endpoint' => $metadata->registrationEndpoint, + 'client_name' => $clientMetadata->clientName, + ]); + + $request = $this->requestFactory->createRequest('POST', $metadata->registrationEndpoint) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json') + ->withBody($this->streamFactory->createStream((string) json_encode($body))); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (\Throwable $e) { + throw new AuthorizationException(\sprintf('The client registration request to "%s" failed: %s', $metadata->registrationEndpoint, $e->getMessage()), 0, $e); + } + + $decoded = json_decode((string) $response->getBody(), true); + + if (!\in_array($response->getStatusCode(), [200, 201], true) || !\is_array($decoded)) { + throw new AuthorizationException(\sprintf('The authorization server refused the client registration with %d.', $response->getStatusCode())); + } + + return ClientRegistration::fromResponse($decoded, $preferredAuthMethod); + } +} diff --git a/src/Client/Auth/ClientRegistration.php b/src/Client/Auth/ClientRegistration.php new file mode 100644 index 00000000..95615b33 --- /dev/null +++ b/src/Client/Auth/ClientRegistration.php @@ -0,0 +1,108 @@ + + */ +final class ClientRegistration implements \JsonSerializable +{ + /** + * @param string $clientId the client identifier + * @param ?string $clientSecret the client secret, when the server issued one + * @param TokenEndpointAuthMethod $tokenEndpointAuthMethod how the client authenticates at the token endpoint + * @param bool $dynamic whether this came from dynamic client registration, + * and may therefore be re-created at will + * @param ?int $clientSecretExpiresAt unix timestamp the secret expires at, 0/null when it does not + */ + public function __construct( + public readonly string $clientId, + public readonly ?string $clientSecret = null, + public readonly TokenEndpointAuthMethod $tokenEndpointAuthMethod = TokenEndpointAuthMethod::None, + public readonly bool $dynamic = false, + public readonly ?int $clientSecretExpiresAt = null, + ) { + if ('' === trim($clientId)) { + throw new InvalidArgumentException('The client id must not be empty.'); + } + } + + /** + * Build a registration from a parsed RFC 7591 registration response. + * + * @param array $response the parsed registration response + * @param TokenEndpointAuthMethod $fallback the method to assume when the server does not echo one back + */ + public static function fromResponse(array $response, TokenEndpointAuthMethod $fallback = TokenEndpointAuthMethod::None): self + { + if (!isset($response['client_id']) || !\is_string($response['client_id'])) { + throw new InvalidArgumentException('The client registration response is missing a string "client_id".'); + } + + $secret = \is_string($response['client_secret'] ?? null) ? $response['client_secret'] : null; + + // A server that echoes back a method has the final say; one that stays silent + // accepted what was asked for, which is what $fallback carries. + $method = TokenEndpointAuthMethod::tryFrom((string) ($response['token_endpoint_auth_method'] ?? '')) ?? $fallback; + + $expiresAt = $response['client_secret_expires_at'] ?? null; + + return new self( + $response['client_id'], + $secret, + $method, + true, + is_numeric($expiresAt) && 0 !== (int) $expiresAt ? (int) $expiresAt : null, + ); + } + + public function isExpired(?int $now = null): bool + { + return null !== $this->clientSecretExpiresAt && ($now ?? time()) >= $this->clientSecretExpiresAt; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return array_filter([ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'token_endpoint_auth_method' => $this->tokenEndpointAuthMethod->value, + 'dynamic' => $this->dynamic, + 'client_secret_expires_at' => $this->clientSecretExpiresAt, + ], static fn (mixed $value): bool => null !== $value); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + \is_string($data['client_id'] ?? null) ? $data['client_id'] : '', + \is_string($data['client_secret'] ?? null) ? $data['client_secret'] : null, + TokenEndpointAuthMethod::tryFrom((string) ($data['token_endpoint_auth_method'] ?? '')) ?? TokenEndpointAuthMethod::None, + (bool) ($data['dynamic'] ?? false), + isset($data['client_secret_expires_at']) && is_numeric($data['client_secret_expires_at']) ? (int) $data['client_secret_expires_at'] : null, + ); + } +} diff --git a/src/Client/Auth/CredentialStorageInterface.php b/src/Client/Auth/CredentialStorageInterface.php new file mode 100644 index 00000000..8e7cbec4 --- /dev/null +++ b/src/Client/Auth/CredentialStorageInterface.php @@ -0,0 +1,46 @@ + + */ +interface CredentialStorageInterface +{ + /** + * @param string $issuer the authorization server's issuer identifier + */ + public function getClientRegistration(string $issuer): ?ClientRegistration; + + public function saveClientRegistration(string $issuer, ClientRegistration $registration): void; + + /** + * @param string $issuer the authorization server's issuer identifier + * @param string $resource the canonical URI of the protected resource the token is for + */ + public function getToken(string $issuer, string $resource): ?AccessToken; + + public function saveToken(string $issuer, string $resource, AccessToken $token): void; + + /** + * Drop everything held for one authorization server, or the whole store when null. + */ + public function forget(?string $issuer = null): void; +} diff --git a/src/Client/Auth/FileCredentialStorage.php b/src/Client/Auth/FileCredentialStorage.php new file mode 100644 index 00000000..6f9c6da4 --- /dev/null +++ b/src/Client/Auth/FileCredentialStorage.php @@ -0,0 +1,158 @@ + + */ +final class FileCredentialStorage implements CredentialStorageInterface +{ + /** @var array{registrations: array>, tokens: array>>}|null */ + private ?array $data = null; + + public function __construct(private readonly string $path) + { + } + + public function getClientRegistration(string $issuer): ?ClientRegistration + { + $registration = $this->read()['registrations'][$issuer] ?? null; + + if (null === $registration) { + return null; + } + + try { + return ClientRegistration::fromArray($registration); + } catch (InvalidArgumentException) { + // Half-written or hand-edited: registering again costs one request. + return null; + } + } + + public function saveClientRegistration(string $issuer, ClientRegistration $registration): void + { + $data = $this->read(); + $data['registrations'][$issuer] = $registration->jsonSerialize(); + + $this->write($data); + } + + public function getToken(string $issuer, string $resource): ?AccessToken + { + $token = $this->read()['tokens'][$issuer][$resource] ?? null; + + if (null === $token) { + return null; + } + + try { + return AccessToken::fromArray($token); + } catch (InvalidArgumentException) { + return null; + } + } + + public function saveToken(string $issuer, string $resource, AccessToken $token): void + { + $data = $this->read(); + $data['tokens'][$issuer][$resource] = $token->jsonSerialize(); + + $this->write($data); + } + + public function forget(?string $issuer = null): void + { + if (null === $issuer) { + $this->write(['registrations' => [], 'tokens' => []]); + + return; + } + + $data = $this->read(); + unset($data['registrations'][$issuer], $data['tokens'][$issuer]); + + $this->write($data); + } + + /** + * @return array{registrations: array>, tokens: array>>} + */ + private function read(): array + { + if (null !== $this->data) { + return $this->data; + } + + if (!is_file($this->path)) { + return $this->data = ['registrations' => [], 'tokens' => []]; + } + + $contents = file_get_contents($this->path); + $decoded = false === $contents ? null : json_decode($contents, true); + + if (!\is_array($decoded)) { + // A truncated or hand-edited file is not worth failing a connection over: + // the worst case is one more trip through the authorization flow. + return $this->data = ['registrations' => [], 'tokens' => []]; + } + + return $this->data = [ + 'registrations' => \is_array($decoded['registrations'] ?? null) ? $decoded['registrations'] : [], + 'tokens' => \is_array($decoded['tokens'] ?? null) ? $decoded['tokens'] : [], + ]; + } + + /** + * @param array{registrations: array>, tokens: array>>} $data + */ + private function write(array $data): void + { + $this->data = $data; + $directory = \dirname($this->path); + + if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) { + throw new RuntimeException(\sprintf('Could not create the credential directory "%s".', $directory)); + } + + $encoded = json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES); + + if (false === $encoded) { + throw new RuntimeException(\sprintf('Could not encode the credentials for "%s".', $this->path)); + } + + // Written to a private temporary file and moved into place, rather than written + // in place and then chmod'ed: the file exists for a moment either way, and this + // way it never exists world-readable, and never exists half-written. + $temporary = @tempnam($directory, '.credentials'); + + if (false === $temporary) { + throw new RuntimeException(\sprintf('Could not create a temporary file next to "%s".', $this->path)); + } + + if (false === @file_put_contents($temporary, $encoded) || !@chmod($temporary, 0600) || !@rename($temporary, $this->path)) { + @unlink($temporary); + + throw new RuntimeException(\sprintf('Could not write the credential file "%s".', $this->path)); + } + } +} diff --git a/src/Client/Auth/Grant.php b/src/Client/Auth/Grant.php new file mode 100644 index 00000000..aa4536a0 --- /dev/null +++ b/src/Client/Auth/Grant.php @@ -0,0 +1,28 @@ + + */ +enum Grant: string +{ + /** A user approves the request in a browser; the default for anything with a human behind it. */ + case AuthorizationCode = 'authorization_code'; + + /** Trades a still-valid refresh token for a fresh access token. */ + case RefreshToken = 'refresh_token'; +} diff --git a/src/Client/Auth/HeadlessAuthorizationHandler.php b/src/Client/Auth/HeadlessAuthorizationHandler.php new file mode 100644 index 00000000..a9b00299 --- /dev/null +++ b/src/Client/Auth/HeadlessAuthorizationHandler.php @@ -0,0 +1,123 @@ + + */ +final class HeadlessAuthorizationHandler implements AuthorizationHandlerInterface +{ + public function __construct( + private readonly float $timeout = 10.0, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function authorize(string $authorizationUrl, string $redirectUri): array + { + $location = $this->fetchRedirect($authorizationUrl); + $query = parse_url($location, \PHP_URL_QUERY); + + if (!\is_string($query) || '' === $query) { + throw new AuthorizationException(\sprintf('The authorization server redirected to "%s", which carries no query parameters.', $location)); + } + + parse_str($query, $parameters); + + $callback = []; + + foreach ($parameters as $name => $value) { + if (\is_string($name) && \is_scalar($value)) { + $callback[$name] = (string) $value; + } + } + + // Only the parameter names: the query carries the authorization code, and a + // single-use credential in a log file is still a credential in a log file. + $this->logger->debug('Authorization endpoint redirected', ['parameters' => array_keys($callback)]); + + return $callback; + } + + /** + * Issue a single GET and return the `Location` header without following it. + */ + private function fetchRedirect(string $url): string + { + $parts = parse_url($url); + + if (!\is_array($parts) || !isset($parts['host'])) { + throw new AuthorizationException(\sprintf('The authorization URL "%s" cannot be parsed.', $url)); + } + + $secure = 'https' === ($parts['scheme'] ?? 'https'); + $port = $parts['port'] ?? ($secure ? 443 : 80); + $target = ($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : ''); + + $socket = @stream_socket_client( + \sprintf('%s://%s:%d', $secure ? 'ssl' : 'tcp', $parts['host'], $port), + $errorCode, + $errorMessage, + $this->timeout, + ); + + if (false === $socket) { + throw new AuthorizationException(\sprintf('Could not reach the authorization endpoint at "%s": %s (%d).', $url, $errorMessage, $errorCode)); + } + + try { + stream_set_timeout($socket, (int) $this->timeout); + fwrite($socket, \sprintf( + "GET %s HTTP/1.1\r\nHost: %s\r\nAccept: */*\r\nConnection: close\r\n\r\n", + $target, + $parts['host'].(\in_array($port, [80, 443], true) ? '' : ':'.$port), + )); + + $headers = ''; + + while (!feof($socket) && !str_contains($headers, "\r\n\r\n")) { + $chunk = fread($socket, 4096); + + if (false === $chunk || '' === $chunk) { + break; + } + + $headers .= $chunk; + } + } finally { + fclose($socket); + } + + if (preg_match('/^Location:\s*(\S+)\s*$/im', $headers, $matches)) { + return $matches[1]; + } + + throw new AuthorizationException(\sprintf('The authorization endpoint "%s" did not answer with a redirect. It may need a user to approve the request, in which case an interactive authorization handler is required.', $url)); + } +} diff --git a/src/Client/Auth/InMemoryCredentialStorage.php b/src/Client/Auth/InMemoryCredentialStorage.php new file mode 100644 index 00000000..bce30a71 --- /dev/null +++ b/src/Client/Auth/InMemoryCredentialStorage.php @@ -0,0 +1,62 @@ + + */ +final class InMemoryCredentialStorage implements CredentialStorageInterface +{ + /** @var array */ + private array $registrations = []; + + /** @var array> */ + private array $tokens = []; + + public function getClientRegistration(string $issuer): ?ClientRegistration + { + return $this->registrations[$issuer] ?? null; + } + + public function saveClientRegistration(string $issuer, ClientRegistration $registration): void + { + $this->registrations[$issuer] = $registration; + } + + public function getToken(string $issuer, string $resource): ?AccessToken + { + return $this->tokens[$issuer][$resource] ?? null; + } + + public function saveToken(string $issuer, string $resource, AccessToken $token): void + { + $this->tokens[$issuer][$resource] = $token; + } + + public function forget(?string $issuer = null): void + { + if (null === $issuer) { + $this->registrations = []; + $this->tokens = []; + + return; + } + + unset($this->registrations[$issuer], $this->tokens[$issuer]); + } +} diff --git a/src/Client/Auth/LoopbackAuthorizationHandler.php b/src/Client/Auth/LoopbackAuthorizationHandler.php new file mode 100644 index 00000000..2329fd1d --- /dev/null +++ b/src/Client/Auth/LoopbackAuthorizationHandler.php @@ -0,0 +1,167 @@ +/...`, the handler listens there for exactly as long as the + * authorization takes, shows the user a "you can close this tab" page, and hands the + * query parameters back. Loopback redirects are what OAuth 2.1 recommends for native + * applications, and unlike a pasted code they keep the authorization code off the + * user's clipboard and out of their shell history. + * + * @author Christopher Hertel + */ +final class LoopbackAuthorizationHandler implements AuthorizationHandlerInterface +{ + private const SUCCESS_PAGE = 'Authorized' + .'' + .'

You are signed in.

You can close this tab and return to the application.

'; + + /** + * @param ?callable(string $url): void $openBrowser how to put the URL in front of the user, + * defaulting to the platform's URL opener + * @param int $timeout seconds to wait for the user to come back + */ + public function __construct( + private readonly mixed $openBrowser = null, + private readonly int $timeout = 300, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function authorize(string $authorizationUrl, string $redirectUri): array + { + $parts = parse_url($redirectUri); + $host = $parts['host'] ?? '127.0.0.1'; + $port = $parts['port'] ?? ('https' === ($parts['scheme'] ?? 'http') ? 443 : 80); + + $server = @stream_socket_server(\sprintf('tcp://%s:%d', $host, $port), $errorCode, $errorMessage); + + if (false === $server) { + throw new AuthorizationException(\sprintf('Could not listen on "%s:%d" for the authorization redirect: %s (%d).', $host, $port, $errorMessage, $errorCode)); + } + + try { + $this->open($authorizationUrl); + + $deadline = microtime(true) + $this->timeout; + + while (microtime(true) < $deadline) { + $connection = @stream_socket_accept($server, max(1, (int) ($deadline - microtime(true)))); + + if (false === $connection) { + continue; + } + + $parameters = $this->serve($connection); + + if (null !== $parameters) { + return $parameters; + } + } + } finally { + fclose($server); + } + + throw new AuthorizationException(\sprintf('No authorization redirect arrived at "%s" within %d seconds.', $redirectUri, $this->timeout)); + } + + /** + * Answer one connection, returning its query parameters when it looks like the callback. + * + * Browsers ask for `/favicon.ico` alongside the page, so a request without any + * parameters is answered and ignored rather than mistaken for the redirect. + * + * @param resource $connection + * + * @return array|null + */ + private function serve($connection): ?array + { + stream_set_timeout($connection, 5); + $requestLine = fgets($connection, 8192); + + if (false === $requestLine || !preg_match('#^GET\s+(\S+)\s+HTTP/#i', $requestLine, $matches)) { + fclose($connection); + + return null; + } + + $query = parse_url($matches[1], \PHP_URL_QUERY); + parse_str(\is_string($query) ? $query : '', $parameters); + + $body = self::SUCCESS_PAGE; + fwrite($connection, \sprintf( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", + \strlen($body), + $body, + )); + fclose($connection); + + if ([] === $parameters) { + return null; + } + + $callback = []; + + foreach ($parameters as $name => $value) { + if (\is_string($name) && \is_scalar($value)) { + $callback[$name] = (string) $value; + } + } + + return $callback; + } + + private function open(string $url): void + { + if (\is_callable($this->openBrowser)) { + ($this->openBrowser)($url); + + return; + } + + // The SDK only ever passes an endpoint it has already validated, but this string + // reaches the desktop's protocol-handler dispatcher, so it is worth not relying + // on that staying true. + if (!AuthorizationServerMetadata::isTransportSecure($url)) { + throw new AuthorizationException(\sprintf('Refusing to open "%s": an authorization URL must be HTTPS, or HTTP on a loopback address.', $url)); + } + + $this->logger->info('Opening the authorization page in the browser', ['url' => $url]); + + // Backgrounded, because the launcher may not return until the browser exits. + $command = match (\PHP_OS_FAMILY) { + 'Darwin' => \sprintf('open %s > /dev/null 2>&1 &', escapeshellarg($url)), + 'Windows' => \sprintf('start "" %s', escapeshellarg($url)), + default => \sprintf('xdg-open %s > /dev/null 2>&1 &', escapeshellarg($url)), + }; + + if (\function_exists('exec')) { + @exec($command, $output, $status); + + if (0 === $status) { + return; + } + } + + // No browser to open, or opening it failed: the user can still follow the link. + fwrite(\STDERR, \sprintf("\nOpen this URL to authorize the application:\n\n %s\n\n", $url)); + } +} diff --git a/src/Client/Auth/OAuth.php b/src/Client/Auth/OAuth.php new file mode 100644 index 00000000..8721c4c6 --- /dev/null +++ b/src/Client/Auth/OAuth.php @@ -0,0 +1,267 @@ +build()); + * + * Everything else is a deviation from that: a pre-registered client id, credentials + * that should outlive the process. + * + * @author Christopher Hertel + */ +final class OAuth +{ + /** + * Loopback redirect target used unless the application names its own. + * + * A fixed port because many authorization servers require redirect URIs to be + * registered up front, and a random one could never be. + */ + public const DEFAULT_REDIRECT_URI = 'http://127.0.0.1:8765/callback'; + + private string $redirectUri = self::DEFAULT_REDIRECT_URI; + private ?string $clientId = null; + private ?string $clientSecret = null; + private string $applicationType = 'native'; + private ?string $clientUri = null; + private ?string $softwareId = null; + private ?string $softwareVersion = null; + + /** @var string[] */ + private array $scopes = []; + private bool $offlineAccess = true; + private bool $legacyDiscovery = false; + private ?string $resource = null; + + private ?CredentialStorageInterface $storage = null; + private ?AuthorizationHandlerInterface $authorizationHandler = null; + private ?ClientInterface $httpClient = null; + private ?RequestFactoryInterface $requestFactory = null; + private ?StreamFactoryInterface $streamFactory = null; + private LoggerInterface $logger; + + private function __construct(private readonly string $clientName) + { + if ('' === trim($clientName)) { + throw new InvalidArgumentException('The client name must not be empty; it is what the user sees on the consent screen.'); + } + + $this->logger = new NullLogger(); + } + + /** + * An application acting for a user, who approves the request in a browser. + * + * @param string $clientName the name shown to the user while they approve + */ + public static function forApplication(string $clientName): self + { + return new self($clientName); + } + + /** + * Where the authorization server sends the user back to. + * + * Must match what {@see LoopbackAuthorizationHandler} listens on, and -- for a + * pre-registered client -- what the authorization server has on file. + */ + public function setRedirectUri(string $redirectUri): self + { + $this->redirectUri = $redirectUri; + + return $this; + } + + /** + * Use a client id issued out of band instead of registering dynamically. + */ + public function setClientCredentials(string $clientId, ?string $clientSecret = null): self + { + $this->clientId = $clientId; + $this->clientSecret = $clientSecret; + + return $this; + } + + /** + * Ask for these scopes, rather than whatever the server asks for. + * + * A step-up challenge still adds the scopes it names, since the server already + * refused a token that has only these. + */ + public function setScopes(string ...$scopes): self + { + $this->scopes = array_values($scopes); + + return $this; + } + + /** + * Whether to ask for a refresh token where the authorization server offers one. + * + * On by default: without it every expiry sends the user back to the browser. + */ + public function setOfflineAccess(bool $offlineAccess): self + { + $this->offlineAccess = $offlineAccess; + + return $this; + } + + /** + * Whether to fall back to pre-2025-06-18 discovery for servers that publish no + * protected resource metadata. + * + * Off by default. Every revision of the specification since 2025-06-18 requires a + * protected resource server to publish that document, so a server that does not is + * either much older than this SDK or is not the server it appears to be -- and + * without the document there is nothing to check the authorization server against. + * Turn it on knowingly, to reach a server built against 2025-03-26. + */ + public function setLegacyDiscovery(bool $legacyDiscovery): self + { + $this->legacyDiscovery = $legacyDiscovery; + + return $this; + } + + /** + * Request tokens for this resource identifier instead of the endpoint being called. + */ + public function setResource(string $resource): self + { + $this->resource = $resource; + + return $this; + } + + /** + * Whether the client runs on the user's machine ("native") or on a server ("web"). + */ + public function setApplicationType(string $applicationType): self + { + if (!\in_array($applicationType, ['native', 'web'], true)) { + throw new InvalidArgumentException(\sprintf('The application type must be "native" or "web", got "%s".', $applicationType)); + } + + $this->applicationType = $applicationType; + + return $this; + } + + /** + * Extra identifying information shown to users and administrators. + */ + public function setSoftwareInfo(?string $clientUri = null, ?string $softwareId = null, ?string $softwareVersion = null): self + { + $this->clientUri = $clientUri; + $this->softwareId = $softwareId; + $this->softwareVersion = $softwareVersion; + + return $this; + } + + /** + * Where tokens and registrations live between requests, and between runs. + */ + public function setCredentialStorage(CredentialStorageInterface $storage): self + { + $this->storage = $storage; + + return $this; + } + + /** + * How the user gets to the authorization endpoint and back. + */ + public function setAuthorizationHandler(AuthorizationHandlerInterface $handler): self + { + $this->authorizationHandler = $handler; + + return $this; + } + + public function setHttpClient( + ClientInterface $httpClient, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ): self { + $this->httpClient = $httpClient; + $this->requestFactory = $requestFactory; + $this->streamFactory = $streamFactory; + + return $this; + } + + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function build(): OAuthAuthenticator + { + $httpClient = $this->httpClient ?? Psr18ClientDiscovery::find(); + $requestFactory = $this->requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $streamFactory = $this->streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + + $grantTypes = $this->offlineAccess + ? [Grant::AuthorizationCode->value, Grant::RefreshToken->value] + : [Grant::AuthorizationCode->value]; + + $configuration = new OAuthConfiguration( + new ClientMetadata( + $this->clientName, + [$this->redirectUri], + $grantTypes, + $this->applicationType, + $this->clientUri, + $this->softwareId, + $this->softwareVersion, + ), + $this->redirectUri, + $this->clientId, + $this->clientSecret, + $this->scopes, + $this->offlineAccess, + $this->legacyDiscovery, + $this->resource, + ); + + return new OAuthAuthenticator( + $configuration, + $this->storage ?? new InMemoryCredentialStorage(), + $this->authorizationHandler ?? new LoopbackAuthorizationHandler(logger: $this->logger), + new MetadataDiscovery($httpClient, $requestFactory, $this->logger), + new ClientRegistrar($httpClient, $requestFactory, $streamFactory, $this->logger), + new TokenEndpoint($httpClient, $requestFactory, $streamFactory, $this->logger), + $this->logger, + ); + } +} diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php new file mode 100644 index 00000000..366d6da5 --- /dev/null +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -0,0 +1,508 @@ + + */ +final class OAuthAuthenticator implements AuthenticatorInterface +{ + private ?string $issuer = null; + private ?string $resource = null; + private ?AccessToken $token = null; + + /** + * The resource the current token may be sent to. + * + * Distinct from $resource, which is what was asked for: an application that + * overrides the RFC 8707 resource parameter is choosing which token to obtain, not + * granting itself permission to spend it somewhere else. + */ + private ?string $audience = null; + + /** @var string[] The scopes the last authorization asked for, which a step-up challenge adds to. */ + private array $requestedScopes = []; + + private bool $offlineAccessRefused = false; + + public function __construct( + private readonly OAuthConfiguration $configuration, + private readonly CredentialStorageInterface $storage, + private readonly AuthorizationHandlerInterface $authorizationHandler, + private readonly MetadataDiscovery $discovery, + private readonly ClientRegistrar $registrar, + private readonly TokenEndpoint $tokenEndpoint, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function authenticate(RequestInterface $request): RequestInterface + { + if (null === $this->token || $this->token->isExpired()) { + return $request; + } + + // A token is minted for one resource and means nothing at another, so the + // request has to be aimed at the resource this one was obtained for. Normally it + // is -- the transport builds one authenticator per endpoint -- but an + // authenticator is an ordinary object a caller may reuse, and the cost of one + // mistake there is handing a hostile server somebody else's token. + if (null === $this->audience || !ProtectedResourceMetadata::isWithin(self::canonicalize((string) $request->getUri()), $this->audience)) { + return $request; + } + + return $request->withHeader('Authorization', $this->token->getAuthorizationHeader()); + } + + public function handleChallenge(RequestInterface $request, ResponseInterface $response): bool + { + $challenge = AuthorizationChallenge::fromResponse($response); + $endpoint = self::canonicalize((string) $request->getUri()); + + // Metadata fetched over plain http can be forged and the token would travel in the clear. + if (!AuthorizationServerMetadata::isTransportSecure($endpoint)) { + throw new AuthorizationException(\sprintf('Refusing to authorize the server at "%s": it is neither an HTTPS URL nor a loopback address.', $endpoint)); + } + + [$resource, $audience, $metadata, $scopesSupported] = $this->discover($endpoint, $challenge); + + if ($metadata->issuer !== $this->issuer) { + // A different authorization server is a different world: its predecessor's + // client id and tokens are not merely stale there, presenting them would + // hand one server credentials that belong to another. + $this->logger->info('Authorization server changed', ['from' => $this->issuer, 'to' => $metadata->issuer]); + $this->token = null; + $this->requestedScopes = []; + $this->offlineAccessRefused = false; + } + + $this->issuer = $metadata->issuer; + $this->resource = $resource; + $this->audience = $audience; + + $scopes = $this->selectScopes($challenge, $scopesSupported, $metadata); + // The resource never requires offline_access, which only gets a refresh token. + $needed = array_values(array_diff($scopes, ['offline_access'])); + $stored = $this->storage->getToken($metadata->issuer, $resource); + + if (null !== $stored && $this->isUsable($stored, $needed, $request)) { + $this->logger->debug('Reusing a stored access token', ['issuer' => $metadata->issuer, 'resource' => $resource]); + $this->token = $stored; + + return true; + } + + $client = $this->resolveClient($metadata, $scopes); + $token = $this->refresh($stored, $metadata, $client, $needed); + + if (null === $token) { + [$token, $scopes] = $this->grantWithFallback($metadata, $client, $scopes, $resource); + } + + $this->token = $token->withGrantedScopes($scopes); + $this->requestedScopes = $scopes; + $this->storage->saveToken($metadata->issuer, $resource, $this->token); + + return true; + } + + /** + * Work out which resource is being protected, and by whom. + * + * Two resource identifiers come out of this, and they are not always the same one. + * The first is what goes in the RFC 8707 `resource` parameter, which an application + * may override. The second is the resource this server actually is, and it is what + * the resulting token may be sent to -- an override says which token to ask for, not + * where it is safe to spend. + * + * @return array{string, string, AuthorizationServerMetadata, ?string[]} + */ + private function discover(string $endpoint, AuthorizationChallenge $challenge): array + { + $metadata = $this->discovery->discoverProtectedResource($endpoint, self::trustedMetadataUrl($endpoint, $challenge)); + + if (null === $metadata) { + return [$this->configuration->resource ?? $endpoint, $endpoint, $this->discoverLegacyServer($endpoint), null]; + } + + if (!$metadata->covers($endpoint)) { + // The document claims tokens for somewhere else. Following it would mean + // asking an authorization server for a token bound to a resource the client + // is not talking to, and then sending it to one that is -- the exact shape + // of a confused-deputy attack. + throw new AuthorizationException(\sprintf('The protected resource metadata for "%s" declares the unrelated resource "%s"; refusing to authorize against it.', $endpoint, $metadata->resource)); + } + + $issuer = $metadata->authorizationServers[0] ?? null; + + if (null === $issuer) { + throw new AuthorizationException(\sprintf('The protected resource metadata for "%s" names no authorization server.', $endpoint)); + } + + // Checked before the document is fetched, not after: this identifier is a string + // the server chose, and building well-known URLs out of it and requesting them is + // already a request made on the server's behalf, from wherever the client runs. + if (!AuthorizationServerMetadata::isTransportSecure($issuer)) { + throw new AuthorizationException(\sprintf('The protected resource metadata for "%s" names "%s" as its authorization server, which is neither an HTTPS URL nor a loopback address.', $endpoint, $issuer)); + } + + $server = $this->discovery->discoverAuthorizationServer($issuer); + + if (null === $server) { + throw new AuthorizationException(\sprintf('No usable metadata was found for the authorization server "%s".', $issuer)); + } + + return [$this->configuration->resource ?? $metadata->resource, $metadata->resource, $server, $metadata->scopesSupported]; + } + + /** + * The metadata location named by the challenge, but only when it belongs to the + * server that issued the challenge. + * + * RFC 9728 derives the location from the resource identifier, so a document that + * describes this resource lives on this resource. A challenge pointing anywhere else + * is asking the client to make a request on the server's behalf -- to a host the + * server picked, from wherever the client happens to run, which may be inside a + * network the server cannot reach itself. Ignoring it costs nothing: the well-known + * locations are probed instead, exactly as for a challenge that named none. + */ + private static function trustedMetadataUrl(string $endpoint, AuthorizationChallenge $challenge): ?string + { + $url = $challenge->getResourceMetadataUrl(); + + if (null === $url) { + return null; + } + + if (self::origin($url) === self::origin($endpoint)) { + return $url; + } + + return null; + } + + /** + * Fall back to how authorization was discovered before protected resource metadata existed. + * + * Servers built against the 2025-03-26 revision act as their own authorization + * server, either publishing metadata at their root or exposing nothing at all and + * relying on the fixed endpoint names that revision spelled out. + */ + private function discoverLegacyServer(string $endpoint): AuthorizationServerMetadata + { + if (!$this->configuration->legacyDiscovery) { + throw new AuthorizationException(\sprintf('The server at "%s" published no protected resource metadata, and discovery of pre-2025-06-18 servers is disabled.', $endpoint)); + } + + $issuer = self::origin($endpoint); + $this->logger->info('No protected resource metadata; falling back to treating the server as its own authorization server.', ['issuer' => $issuer]); + + return $this->discovery->discoverAuthorizationServer($issuer) ?? AuthorizationServerMetadata::forLegacyServer($issuer); + } + + /** + * Decide what to ask for. + * + * The challenge is the most specific thing available and wins; failing that the + * resource's own list is asked for in full; failing that the parameter is left off + * entirely, because inventing scopes only produces a rejection. Whatever comes out + * is unioned with what was previously granted, so a step-up challenge naming only + * the missing scope does not quietly drop the ones already held. + * + * Configured scopes replace that choice. An insufficient_scope challenge still adds + * the scopes it names, or the new token would be refused again. + * + * @param ?string[] $scopesSupported + * + * @return string[] + */ + private function selectScopes(AuthorizationChallenge $challenge, ?array $scopesSupported, AuthorizationServerMetadata $metadata): array + { + $scopes = $this->configuration->scopes; + + if ([] === $scopes) { + $scopes = $challenge->getScopes() ?: ($scopesSupported ?? []); + } elseif ('insufficient_scope' === $challenge->getError()) { + $scopes = [...$scopes, ...$challenge->getScopes()]; + } + + $scopes = array_values(array_unique([...$this->requestedScopes, ...$scopes])); + + if ($this->offlineAccessRefused) { + return array_values(array_diff($scopes, ['offline_access'])); + } + + // A refresh token is only worth asking for where the authorization server says + // it understands the request; sending an unknown scope is an error, not a hint. + if ($this->configuration->offlineAccess + && $metadata->supportsScope('offline_access') + && $metadata->supportsGrant(Grant::RefreshToken->value) + && !\in_array('offline_access', $scopes, true) + ) { + $scopes[] = 'offline_access'; + } + + return $scopes; + } + + /** + * Whether a stored token is worth trying instead of authorizing again. + * + * @param string[] $scopes + */ + private function isUsable(AccessToken $token, array $scopes, RequestInterface $request): bool + { + // The request that was just rejected carried this very token, so the server has + // already given its answer about it. + if ($request->getHeaderLine('Authorization') === $token->getAuthorizationHeader()) { + return false; + } + + return !$token->isExpired() && $token->covers($scopes); + } + + /** + * Trade a refresh token for a new access token, when there is one and it is enough. + * + * A step-up challenge asks for scopes the refresh token was never granted, and RFC + * 6749 will not widen a grant on refresh, so that case falls through to a fresh + * authorization instead. + * + * @param string[] $scopes + */ + private function refresh(?AccessToken $stored, AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes): ?AccessToken + { + if (null === $stored || null === $stored->refreshToken || !$stored->covers($scopes) || !$metadata->supportsGrant(Grant::RefreshToken->value)) { + return null; + } + + try { + $token = $this->tokenEndpoint->request($metadata, $client, array_filter([ + 'grant_type' => Grant::RefreshToken->value, + 'refresh_token' => $stored->refreshToken, + 'resource' => $this->resource, + ])); + } catch (AuthorizationException $e) { + $this->logger->info('Refreshing the access token failed; falling back to a new authorization.', ['exception' => $e]); + + return null; + } + + // A server may rotate the refresh token, or leave the old one in place. + return null === $token->refreshToken + ? new AccessToken($token->accessToken, $token->tokenType, $token->expiresAt, $stored->refreshToken, $token->scopes) + : $token; + } + + /** + * Run the grant, giving up the optional refresh-token scope if that is what stood + * in the way. + * + * `offline_access` is asked for on the strength of the authorization server saying + * it understands the scope, but understanding it and being willing to grant it to + * this client for this user are different questions, and only the second one gets + * answered here. Everything else the client asked for was named by the resource or + * by the challenge, so there is nothing else to concede. + * + * The refusal comes from the authorization endpoint, as invalid_scope, or from the + * token endpoint when the code is exchanged. Keycloak does the latter, with + * not_allowed, for a user who may not have offline tokens. + * + * @param string[] $scopes + * + * @return array{AccessToken, string[]} + */ + private function grantWithFallback(AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes, string $resource): array + { + try { + return [$this->grant($metadata, $client, $scopes, $resource), $scopes]; + } catch (AuthorizationException $e) { + $reduced = array_values(array_diff($scopes, ['offline_access'])); + + if ($reduced === $scopes || (!str_contains($e->getMessage(), 'invalid_scope') && !str_contains($e->getMessage(), 'not_allowed'))) { + throw $e; + } + + $this->logger->info('The authorization server refused the offline_access scope; retrying without it, at the cost of a refresh token.', ['exception' => $e]); + $this->offlineAccessRefused = true; + + return [$this->grant($metadata, $client, $reduced, $resource), $reduced]; + } + } + + /** + * @param string[] $scopes + */ + private function grant(AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes, string $resource): AccessToken + { + $pkce = Pkce::generate(); + $state = rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '='); + + $parameters = array_filter([ + 'response_type' => 'code', + 'client_id' => $client->clientId, + 'redirect_uri' => $this->configuration->redirectUri, + 'state' => $state, + 'code_challenge' => $pkce->challenge, + 'code_challenge_method' => Pkce::METHOD, + 'resource' => $resource, + 'scope' => implode(' ', $scopes), + ]); + + if (null === $metadata->authorizationEndpoint) { + throw new AuthorizationException(\sprintf('The authorization server "%s" has no authorization endpoint.', $metadata->issuer)); + } + + $url = $metadata->authorizationEndpoint + .(str_contains($metadata->authorizationEndpoint, '?') ? '&' : '?') + .http_build_query($parameters, '', '&', \PHP_QUERY_RFC3986); + + $this->logger->info('Starting the authorization code flow', ['authorization_endpoint' => $metadata->authorizationEndpoint, 'scope' => $parameters['scope'] ?? null]); + + $callback = $this->authorizationHandler->authorize($url, $this->configuration->redirectUri); + + $this->verifyCallback($callback, $state, $metadata); + + return $this->tokenEndpoint->request($metadata, $client, array_filter([ + 'grant_type' => Grant::AuthorizationCode->value, + 'code' => $callback['code'], + 'redirect_uri' => $this->configuration->redirectUri, + 'code_verifier' => $pkce->verifier, + 'resource' => $resource, + ])); + } + + /** + * Check the authorization response before spending the code it carries. + * + * @param array $callback + * + * @phpstan-assert non-empty-array{code: string} $callback + */ + private function verifyCallback(array $callback, string $state, AuthorizationServerMetadata $metadata): void + { + if (isset($callback['error'])) { + throw new AuthorizationException(\sprintf('The authorization server denied the request: %s%s', $callback['error'], isset($callback['error_description']) ? ' ('.$callback['error_description'].')' : '')); + } + + // A response that did not carry back the state this client sent did not come from + // the request this client made (RFC 6749 section 10.12). Required, not merely + // checked when present: the loopback listener accepts a connection from any local + // process, and an absent state would let one of them feed the client a code. + if (!isset($callback['state']) || !hash_equals($state, $callback['state'])) { + throw new AuthorizationException('The authorization response did not carry back the state parameter this client sent.'); + } + + // RFC 9207: where the issuer is named it must be the one the flow was started + // with, compared as a string with no normalisation -- the whole point is to + // catch a response mixed in from a second, attacker-controlled server. Where the + // server said it would name one, its absence is just as suspicious. + if (isset($callback['iss'])) { + if ($callback['iss'] !== $metadata->issuer) { + throw new AuthorizationException(\sprintf('The authorization response names the issuer "%s", but the flow was started with "%s".', $callback['iss'], $metadata->issuer)); + } + } elseif ($metadata->issuerParameterSupported) { + throw new AuthorizationException(\sprintf('The authorization server "%s" advertises the iss parameter, but its response did not carry one.', $metadata->issuer)); + } + + if (!isset($callback['code']) || '' === $callback['code']) { + throw new AuthorizationException('The authorization response carried no authorization code.'); + } + } + + /** + * Work out which client identity to use with this authorization server. + * + * @param string[] $scopes + */ + private function resolveClient(AuthorizationServerMetadata $metadata, array $scopes): ClientRegistration + { + $configuration = $this->configuration; + + if (null !== $configuration->clientId) { + return new ClientRegistration( + $configuration->clientId, + $configuration->clientSecret, + TokenEndpointAuthMethod::negotiate( + $metadata->tokenEndpointAuthMethodsSupported, + null !== $configuration->clientSecret, + ), + ); + } + + $stored = $this->storage->getClientRegistration($metadata->issuer); + + if (null !== $stored && !$stored->isExpired()) { + return $stored; + } + + $registration = $this->registrar->register( + $metadata, + $configuration->clientMetadata, + TokenEndpointAuthMethod::negotiate($metadata->tokenEndpointAuthMethodsSupported, true), + [] === $scopes ? null : implode(' ', $scopes), + ); + + $this->storage->saveClientRegistration($metadata->issuer, $registration); + + return $registration; + } + + /** + * The canonical URI a token is requested for: no fragment, and nothing a server + * would not recognise as its own identity. + */ + private static function canonicalize(string $url): string + { + $parts = parse_url($url); + + if (!\is_array($parts)) { + return $url; + } + + return self::origin($url).($parts['path'] ?? '').(isset($parts['query']) ? '?'.$parts['query'] : ''); + } + + private static function origin(string $url): string + { + $parts = parse_url($url); + + if (!\is_array($parts)) { + return $url; + } + + $origin = strtolower(($parts['scheme'] ?? 'https').'://'.($parts['host'] ?? '')); + + return isset($parts['port']) ? $origin.':'.$parts['port'] : $origin; + } +} diff --git a/src/Client/Auth/OAuthConfiguration.php b/src/Client/Auth/OAuthConfiguration.php new file mode 100644 index 00000000..cd110d8a --- /dev/null +++ b/src/Client/Auth/OAuthConfiguration.php @@ -0,0 +1,50 @@ + + */ +final class OAuthConfiguration +{ + /** + * @param ClientMetadata $clientMetadata what the client says about itself when registering + * @param string $redirectUri where the authorization server sends the user back + * @param ?string $clientId a client id issued out of band, skipping registration + * @param ?string $clientSecret the matching secret, when there is one + * @param string[] $scopes scopes to request regardless of what is advertised + * @param bool $offlineAccess whether to ask for a refresh token where the server offers one + * @param bool $legacyDiscovery whether to fall back to pre-2025-06-18 discovery when a + * server publishes no protected resource metadata + * @param ?string $resource the canonical resource URI to request tokens for, when it + * should not be derived from the endpoint being called + */ + public function __construct( + public readonly ClientMetadata $clientMetadata, + public readonly string $redirectUri = OAuth::DEFAULT_REDIRECT_URI, + public readonly ?string $clientId = null, + public readonly ?string $clientSecret = null, + public readonly array $scopes = [], + public readonly bool $offlineAccess = true, + public readonly bool $legacyDiscovery = false, + public readonly ?string $resource = null, + ) { + } +} diff --git a/src/Client/Auth/Pkce.php b/src/Client/Auth/Pkce.php new file mode 100644 index 00000000..ea54481a --- /dev/null +++ b/src/Client/Auth/Pkce.php @@ -0,0 +1,52 @@ + + */ +final class Pkce +{ + public const METHOD = 'S256'; + + private function __construct( + public readonly string $verifier, + public readonly string $challenge, + ) { + } + + public static function generate(): self + { + $verifier = self::base64Url(random_bytes(32)); + + return new self($verifier, self::base64Url(hash('sha256', $verifier, true))); + } + + public static function fromVerifier(string $verifier): self + { + return new self($verifier, self::base64Url(hash('sha256', $verifier, true))); + } + + private static function base64Url(string $bytes): string + { + return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + } +} diff --git a/src/Client/Auth/TokenEndpoint.php b/src/Client/Auth/TokenEndpoint.php new file mode 100644 index 00000000..66dd6832 --- /dev/null +++ b/src/Client/Auth/TokenEndpoint.php @@ -0,0 +1,126 @@ + + */ +final class TokenEndpoint +{ + public function __construct( + private readonly ClientInterface $httpClient, + private readonly RequestFactoryInterface $requestFactory, + private readonly StreamFactoryInterface $streamFactory, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * @param array $parameters grant-specific form fields + */ + public function request( + AuthorizationServerMetadata $metadata, + ClientRegistration $client, + array $parameters, + ): AccessToken { + $parameters['client_id'] = $client->clientId; + $authorization = null; + + switch ($client->tokenEndpointAuthMethod) { + case TokenEndpointAuthMethod::ClientSecretBasic: + // RFC 6749 section 2.3.1: both halves are form-urlencoded before being + // joined, so a secret containing a colon survives the round trip. + $authorization = 'Basic '.base64_encode(rawurlencode($client->clientId).':'.rawurlencode((string) $client->clientSecret)); + break; + + case TokenEndpointAuthMethod::ClientSecretPost: + $parameters['client_secret'] = (string) $client->clientSecret; + break; + + case TokenEndpointAuthMethod::None: + break; + } + + $this->logger->debug('Requesting an access token', [ + 'endpoint' => $metadata->tokenEndpoint, + 'grant_type' => $parameters['grant_type'] ?? null, + 'auth_method' => $client->tokenEndpointAuthMethod->value, + ]); + + return AccessToken::fromResponse($this->post($metadata->tokenEndpoint, $parameters, $authorization)); + } + + /** + * @param array $parameters + * + * @return array + */ + private function post(string $endpoint, array $parameters, ?string $authorization = null): array + { + $request = $this->requestFactory->createRequest('POST', $endpoint) + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withHeader('Accept', 'application/json') + ->withBody($this->streamFactory->createStream(http_build_query($parameters, '', '&', \PHP_QUERY_RFC1738))); + + if (null !== $authorization) { + $request = $request->withHeader('Authorization', $authorization); + } + + try { + $response = $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface $e) { + throw new AuthorizationException(\sprintf('The token request to "%s" failed: %s', $endpoint, $e->getMessage()), 0, $e); + } + + $body = (string) $response->getBody(); + $decoded = json_decode($body, true); + + if (200 !== $response->getStatusCode() || !\is_array($decoded)) { + // Only an error document is quoted back. A 200 that failed to decode is a + // successful token response in a shape this client cannot read, and putting + // its body in the message would put a live access token into every log the + // exception reaches. + throw new AuthorizationException(\sprintf('The authorization server answered the token request with %d%s.', $response->getStatusCode(), match (true) { + 200 === $response->getStatusCode() => ', and a body this client could not read as JSON', \is_array($decoded) => ': '.self::describeError($decoded), default => '', + })); + } + + return $decoded; + } + + /** + * @param array $response + */ + private static function describeError(array $response): string + { + $error = \is_string($response['error'] ?? null) ? $response['error'] : 'unknown_error'; + $description = \is_string($response['error_description'] ?? null) ? $response['error_description'] : null; + + return null === $description ? $error : $error.' ('.$description.')'; + } +} diff --git a/src/Client/Auth/TokenEndpointAuthMethod.php b/src/Client/Auth/TokenEndpointAuthMethod.php new file mode 100644 index 00000000..5cc7610f --- /dev/null +++ b/src/Client/Auth/TokenEndpointAuthMethod.php @@ -0,0 +1,59 @@ + + */ +enum TokenEndpointAuthMethod: string +{ + /** Public client: no client authentication at all, PKCE carries the security. */ + case None = 'none'; + + /** Client id and secret in the HTTP Basic authorization header (RFC 6749 section 2.3.1). */ + case ClientSecretBasic = 'client_secret_basic'; + + /** Client id and secret in the request body. */ + case ClientSecretPost = 'client_secret_post'; + + /** + * Pick the method to use against a server advertising the given list. + * + * Preference runs from strongest to weakest, but a client without a secret can only + * ever be public, and one that was handed a secret should use it. An authorization + * server that advertises nothing is treated as RFC 6749 does: Basic is the default. + * + * @param string[] $supported the server's token_endpoint_auth_methods_supported + */ + public static function negotiate(array $supported, bool $hasSecret): self + { + $candidates = $hasSecret + ? [self::ClientSecretBasic, self::ClientSecretPost, self::None] + : [self::None]; + + if ([] === $supported) { + return $candidates[0]; + } + + foreach ($candidates as $candidate) { + if (\in_array($candidate->value, $supported, true)) { + return $candidate; + } + } + + // Nothing the client can satisfy was advertised. Sending the strongest thing it + // has is more useful than refusing outright: servers under-report this field. + return $candidates[0]; + } +} diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 964e92a3..5753b760 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -117,11 +117,12 @@ class HttpTransport extends BaseTransport implements HeaderAwareTransportInterfa * @param ReconnectPolicy|null $reconnectPolicy How an SSE stream that closed before its response * arrived is resumed (default: 5 attempts, 1-10 s backoff) * @param ClockInterface|null $clock Clock for scheduling reconnects (default: a monotonic clock) - * @param AuthenticatorInterface|null $auth Credentials for a server that requires authorization, - * e.g. {@see \Mcp\Client\Auth\BearerToken} for a token - * that is already at hand. Every request the transport - * makes is authenticated, and one the server challenges - * is retried once credentials are obtained. + * @param AuthenticatorInterface|null $auth Credentials for a server that requires authorization. + * Pass {@see \Mcp\Client\Auth\OAuth} to negotiate them + * on demand, or {@see \Mcp\Client\Auth\BearerToken} + * when the token is already at hand. Every request the + * transport makes is authenticated, and one the server + * challenges is retried once credentials are obtained. */ public function __construct( private readonly string $endpoint, diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php index d71785df..7da7a14d 100644 --- a/tests/Conformance/client.php +++ b/tests/Conformance/client.php @@ -12,6 +12,9 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; use Mcp\Client; +use Mcp\Client\Auth\AuthenticatorInterface; +use Mcp\Client\Auth\HeadlessAuthorizationHandler; +use Mcp\Client\Auth\OAuth; use Mcp\Client\Handler\Request\RequestHandlerInterface; use Mcp\Client\Transport\HttpTransport; use Mcp\Schema\ClientCapabilities; @@ -36,7 +39,12 @@ $version = ProtocolVersion::tryFrom(getenv('MCP_CONFORMANCE_PROTOCOL_VERSION') ?: '') ?? ProtocolVersion::V2025_11_25; -// Scenario-specific data (tool arguments, credentials) the runner passes in. +// Scenario-specific data the runner passes in as JSON. Which keys are present depends +// on the scenario: +// +// toolCalls http-custom-headers: the exact calls to make +// client_id auth/pre-registration: credentials an administrator would +// client_secret have configured out of band $context = json_decode(getenv('MCP_CONFORMANCE_CONTEXT') ?: '[]', true); $context = is_array($context) ? $context : []; @@ -86,8 +94,44 @@ public function handle(Request $request): Response $builder->addRequestHandler($acceptElicitation); } +/** + * Wires up OAuth for the scenarios that need it. + * + * The authorization endpoint of the harness grants without asking a human, so the + * headless handler stands in for the browser. Everything else -- discovery, PKCE, + * registration, scope selection -- is the SDK doing what it would do in production. + */ +function buildAuthenticator(string $scenario, array $context, Psr\Log\LoggerInterface $logger): ?AuthenticatorInterface +{ + if (!str_starts_with($scenario, 'auth/')) { + return null; + } + + $handler = new HeadlessAuthorizationHandler(logger: $logger); + + $oauth = OAuth::forApplication('mcp-conformance-test-client') + ->setRedirectUri('http://localhost:3000/callback') + ->setAuthorizationHandler($handler) + ->setLogger($logger); + + // Pre-registration: the server does not offer dynamic registration, so the runner + // passes the credentials an administrator would have configured. + if (isset($context['client_id'])) { + $oauth->setClientCredentials($context['client_id'], $context['client_secret'] ?? null); + } + + // These two scenarios are 2025-03-26 servers: no protected resource metadata, and + // the authorization endpoints either at the server's root well-known location or at + // the fixed paths that revision named. Opt in, since the SDK does not by default. + if (str_starts_with($scenario, 'auth/2025-03-26-')) { + $oauth->setLegacyDiscovery(true); + } + + return $oauth->build(); +} + $client = $builder->build(); -$transport = new HttpTransport($url, logger: $logger); +$transport = new HttpTransport($url, logger: $logger, auth: buildAuthenticator($scenario, $context, $logger)); try { $client->connect($transport); @@ -188,6 +232,14 @@ public function handle(Request $request): Response break; default: + if (str_starts_with($scenario, 'auth/')) { + // Every auth scenario serves the same tool; calling it is what proves the + // token survived past the handshake, and drives the step-up challenge. + $client->callTool($toolsResult->tools[0]->name ?? 'test-tool', []); + $logger->info('Called the protected tool'); + break; + } + $logger->warning(sprintf('Unknown scenario: %s', $scenario)); break; } diff --git a/tests/Conformance/conformance-baseline-2025-11-25.yml b/tests/Conformance/conformance-baseline-2025-11-25.yml index 3c2fa853..699cafdd 100644 --- a/tests/Conformance/conformance-baseline-2025-11-25.yml +++ b/tests/Conformance/conformance-baseline-2025-11-25.yml @@ -1,23 +1,7 @@ client: + # Elicitation defaults (SEP-1034) are not filled in when accepting. - elicitation-sep1034-client-defaults - - auth/metadata-default - - auth/metadata-var1 - - auth/metadata-var2 - - auth/metadata-var3 - auth/basic-cimd - - auth/scope-from-www-authenticate - - auth/scope-from-scopes-supported - - auth/scope-omitted-when-undefined - - auth/scope-step-up - - auth/scope-retry-limit - - auth/token-endpoint-auth-basic - - auth/token-endpoint-auth-post - - auth/token-endpoint-auth-none - - auth/pre-registration - - auth/2025-03-26-oauth-metadata-backcompat - - auth/2025-03-26-oauth-endpoint-fallback - - auth/offline-access-scope - - auth/offline-access-not-supported - auth/client-credentials-jwt - auth/client-credentials-basic - auth/cross-app-access-complete-flow diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index c4abd37f..cf86e6a7 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -1,27 +1,3 @@ client: - http-invalid-tool-headers:sep-2243-invalid-tool-tools-list-gate - - auth/metadata-default - - auth/metadata-var1 - - auth/metadata-var2 - - auth/metadata-var3 - - auth/metadata-issuer-mismatch - - auth/resource-mismatch - auth/basic-cimd - - auth/pre-registration - - auth/scope-from-www-authenticate - - auth/scope-from-scopes-supported - - auth/scope-omitted-when-undefined - - auth/scope-step-up - - auth/scope-retry-limit - - auth/token-endpoint-auth-basic - - auth/token-endpoint-auth-post - - auth/token-endpoint-auth-none - - auth/offline-access-scope - - auth/offline-access-not-supported - - auth/authorization-server-migration:sep-2352-reregister-on-as-change - - auth/iss-supported - - auth/iss-not-advertised - - auth/iss-supported-missing - - auth/iss-wrong-issuer - - auth/iss-unexpected - - auth/iss-normalized diff --git a/tests/E2E/BrowserAuthorizationHandler.php b/tests/E2E/BrowserAuthorizationHandler.php new file mode 100644 index 00000000..ae4f7154 --- /dev/null +++ b/tests/E2E/BrowserAuthorizationHandler.php @@ -0,0 +1,134 @@ + + */ +final class BrowserAuthorizationHandler implements AuthorizationHandlerInterface +{ + private readonly HttpClientInterface $httpClient; + + /** @var array */ + private array $cookies = []; + + public function __construct( + private readonly string $username, + private readonly string $password, + ) { + // Redirects are the payload here, not a detour to be followed. + $this->httpClient = HttpClient::create(['max_redirects' => 0]); + } + + public function authorize(string $authorizationUrl, string $redirectUri): array + { + [$status, $headers, $body] = $this->get($authorizationUrl); + + if (302 === $status || 303 === $status) { + // Already signed in, or an authorization server that never asks. + return self::parametersOf($headers['location'][0] ?? ''); + } + + if (200 !== $status) { + throw new AuthorizationException(\sprintf('The authorization endpoint answered %d instead of a login page.', $status)); + } + + [$status, $headers] = $this->submitLoginForm($body); + + if (302 !== $status && 303 !== $status) { + throw new AuthorizationException(\sprintf('Signing in as "%s" did not produce a redirect (got %d). The credentials or the login form may have changed.', $this->username, $status)); + } + + return self::parametersOf($headers['location'][0] ?? ''); + } + + /** + * @return array{int, array} + */ + private function submitLoginForm(string $page): array + { + if (!preg_match('/]+action="([^"]+)"/i', $page, $matches)) { + throw new AuthorizationException('The authorization endpoint returned a page with no login form.'); + } + + $response = $this->httpClient->request('POST', html_entity_decode($matches[1], \ENT_QUOTES | \ENT_HTML5), [ + 'headers' => ['Cookie' => $this->cookieHeader()], + 'body' => ['username' => $this->username, 'password' => $this->password], + ]); + + return [$response->getStatusCode(), $response->getHeaders(false)]; + } + + /** + * @return array{int, array, string} + */ + private function get(string $url): array + { + $response = $this->httpClient->request('GET', $url, ['headers' => ['Cookie' => $this->cookieHeader()]]); + $headers = $response->getHeaders(false); + + foreach ($headers['set-cookie'] ?? [] as $cookie) { + [$pair] = explode(';', $cookie, 2); + [$name, $value] = array_pad(explode('=', $pair, 2), 2, ''); + $this->cookies[trim($name)] = trim($value); + } + + return [$response->getStatusCode(), $headers, $response->getContent(false)]; + } + + private function cookieHeader(): string + { + return implode('; ', array_map( + static fn (string $name, string $value): string => $name.'='.$value, + array_keys($this->cookies), + $this->cookies, + )); + } + + /** + * @return array + */ + private static function parametersOf(string $location): array + { + parse_str((string) parse_url($location, \PHP_URL_QUERY), $parameters); + + if ([] === $parameters) { + throw new AuthorizationException(\sprintf('The authorization server redirected to "%s", which carries nothing to continue with.', $location)); + } + + $callback = []; + + foreach ($parameters as $name => $value) { + if (\is_string($name) && \is_scalar($value)) { + $callback[$name] = (string) $value; + } + } + + return $callback; + } +} diff --git a/tests/E2E/README.md b/tests/E2E/README.md new file mode 100644 index 00000000..8a61a514 --- /dev/null +++ b/tests/E2E/README.md @@ -0,0 +1,75 @@ +# End-to-end authorization + +The conformance suite proves the client follows the specification. This proves it works. + +Everything here is real: [Keycloak](https://www.keycloak.org/) as the authorization +server, an MCP server built with this SDK validating Keycloak's own signed tokens behind +the SDK's authorization middleware, and a client that starts with no credentials at all +and has to find its way in from a bare `401`. Nothing is stubbed and nothing is +in-process — the client talks to Keycloak and to the MCP server over HTTP, the way an +application would. + +```bash +tests/E2E/run.sh +``` + +Needs Docker and a `composer install`. The first run pulls the Keycloak image, which +takes a minute; afterwards the whole thing is about twenty seconds. + +## What it checks + +``` + ok a client with no credentials is refused + ok the protected tool list is readable + ok the protected tool answers + ok the credentials were written to disk + ok the stored token still opens the server +``` + +Between the first and second line the client does all of this against a real identity +provider, unattended: + +1. Reads the `WWW-Authenticate` challenge and fetches the protected resource metadata. +2. Discovers Keycloak's OpenID Connect configuration from the issuer the metadata names, + and checks the document really describes that issuer. +3. Starts the authorization code flow with PKCE, naming the MCP server as the resource. +4. Signs `demo` in at Keycloak's login form (see `BrowserAuthorizationHandler`, which + plays the browser — it is a test fixture, not part of the SDK). +5. Verifies `state` and `iss` on the redirect, and exchanges the code for a token. +6. Presents the token; the MCP server verifies its signature against Keycloak's published + keys, and its audience against the mapper in the realm. +7. Writes the token to disk, so the second connection needs no browser. + +## Layout + +| File | What it is | +| --- | --- | +| `docker-compose.yml` | Keycloak plus the MCP server, on ports of their own so the OAuth server example can stay up alongside | +| `server.php` | The MCP server: SDK authorization middleware, SDK HTTP transport, real JWT validation | +| `client.php` | The run itself — the assertions are here | +| `BrowserAuthorizationHandler.php` | Signs the user in at a real login form, so no human is needed | +| `nginx.conf` | Routes `/mcp` and the well-known paths to `server.php` | +| `keycloak/realm.json` | The realm Keycloak imports: one public client, one demo user, the audience mapper | + +The Keycloak realm in `keycloak/realm.json` is this fixture's own, derived from the one +[`examples/server/oauth-keycloak`](../../examples/server/oauth-keycloak) ships: one public +client with loopback redirect URIs, one `demo` / `demo123` user, and a client scope whose +audience mapper stamps this server's resource identifier into the token. It is a copy +rather than a mount of the example's, so the example can change its port and audience +without breaking this run. + +## In continuous integration + +`run.sh` is the whole entry point, so the `e2e` job in +[`pipeline.yaml`](../../.github/workflows/pipeline.yaml) is one step. + +## Running it by hand + +```bash +E2E_KEEP=1 tests/E2E/run.sh # leave the containers up afterwards +E2E_KEYCLOAK_PORT=9181 tests/E2E/run.sh # move the ports if something holds the defaults +docker compose -f tests/E2E/docker-compose.yml logs # when something goes wrong +``` + +Keycloak's admin console is at (`admin` / `admin`) while +the environment is up. diff --git a/tests/E2E/client.php b/tests/E2E/client.php new file mode 100644 index 00000000..3b754acd --- /dev/null +++ b/tests/E2E/client.php @@ -0,0 +1,108 @@ +setClientInfo('mcp-client-auth-e2e', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(30) + ->build(); + + $client->connect(new HttpTransport($endpoint, auth: OAuth::forApplication('MCP PHP SDK end-to-end client') + // Keycloak's realm import registers this public client with loopback redirect + // URIs; a server offering dynamic registration would need none of this. + ->setClientCredentials('mcp-client') + ->setAuthorizationHandler(new BrowserAuthorizationHandler($username, $password)) + ->setCredentialStorage($storage) + ->build())); + + return $client; +}; + +@unlink($credentialsFile); + +fwrite(\STDOUT, "\nUnauthorized client\n"); + +try { + $bare = Client::builder()->setClientInfo('mcp-client-auth-e2e', '1.0.0')->setInitTimeout(15)->build(); + $bare->connect(new HttpTransport($endpoint)); + $check('a client with no credentials is refused', false, 'the server let it in'); +} catch (ConnectionException $e) { + // connect() wraps what it will not retry; the cause names the challenge. + $check( + 'a client with no credentials is refused', + $e->getPrevious() instanceof AuthorizationException && str_contains($e->getMessage(), '401'), + $e->getMessage(), + ); +} + +fwrite(\STDOUT, "\nFirst run, no stored credentials\n"); + +$storage = new FileCredentialStorage($credentialsFile); +$client = $connect($storage); + +$tools = array_map(static fn ($tool) => $tool->name, $client->listTools()->tools); +$check('the protected tool list is readable', ['whoami'] === $tools, implode(', ', $tools)); + +$result = json_encode($client->callTool('whoami', [])->content); +$check('the protected tool answers', str_contains((string) $result, 'authorized'), (string) $result); + +$client->disconnect(); + +$check('the credentials were written to disk', is_file($credentialsFile)); + +fwrite(\STDOUT, "\nSecond run, reusing what was stored\n"); + +$second = $connect(new FileCredentialStorage($credentialsFile)); +$check('the stored token still opens the server', str_contains((string) json_encode($second->callTool('whoami', [])->content), 'authorized')); +$second->disconnect(); + +fwrite(\STDOUT, "\n"); +fwrite(\STDOUT, 0 === $failures ? "End-to-end authorization succeeded.\n" : sprintf("%d end-to-end check(s) failed.\n", $failures)); + +exit($failures > 0 ? 1 : 0); diff --git a/tests/E2E/docker-compose.yml b/tests/E2E/docker-compose.yml new file mode 100644 index 00000000..bcc1f73a --- /dev/null +++ b/tests/E2E/docker-compose.yml @@ -0,0 +1,70 @@ +# The environment the client authorization end-to-end test runs against: a real +# authorization server, and a real MCP server built with this SDK sitting behind it. +# +# Ports and the project name are deliberately different from +# examples/server/oauth-keycloak, so this can run while that example is up. +name: mcp-client-auth-e2e + +services: + # Keycloak imports this fixture's own realm: one public client with loopback + # redirect URIs, one demo user, and a client scope whose audience mapper stamps + # this server's resource identifier into the token. Derived from the OAuth server + # example's realm, but owned here so the example's port and audience can change + # without breaking this run. + keycloak: + image: quay.io/keycloak/keycloak:24.0 + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + volumes: + - ./keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro + command: + - start-dev + - --import-realm + - --http-port=8181 + ports: + - "${E2E_KEYCLOAK_PORT:-8181}:8181" + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8181; echo -e 'GET /health/ready HTTP/1.1\r\nhost: localhost\r\nConnection: close\r\n\r\n' >&3 && head -1 <&3 | grep -q 200"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 20s + networks: + - e2e + + php-fpm: + image: php:8.4-fpm-alpine + volumes: + - ../..:/app:ro + - ./sessions:/app/tests/E2E/sessions + working_dir: /app + environment: + # Keycloak derives its issuer from the Host header, so the same realm has two + # names: the one the client sees from the host, and the one this container + # reaches over the compose network. Tokens carry the former and are verified + # against keys fetched over the latter. + MCP_E2E_ISSUER_EXTERNAL: http://localhost:${E2E_KEYCLOAK_PORT:-8181}/realms/mcp-e2e + MCP_E2E_ISSUER_INTERNAL: http://keycloak:8181/realms/mcp-e2e + MCP_E2E_RESOURCE: http://localhost:${E2E_MCP_PORT:-8001}/mcp + depends_on: + keycloak: + condition: service_healthy + networks: + - e2e + + nginx: + image: nginx:1.26-alpine + ports: + - "${E2E_MCP_PORT:-8001}:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ../..:/app:ro + depends_on: + - php-fpm + networks: + - e2e + +networks: + e2e: diff --git a/tests/E2E/keycloak/realm.json b/tests/E2E/keycloak/realm.json new file mode 100644 index 00000000..a983ff61 --- /dev/null +++ b/tests/E2E/keycloak/realm.json @@ -0,0 +1,130 @@ +{ + "realm": "mcp-e2e", + "enabled": true, + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "clients": [ + { + "clientId": "mcp-client", + "name": "MCP Client Application", + "description": "Public client for MCP client applications", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false, + "fullScopeAllowed": true, + "redirectUris": [ + "http://localhost:*", + "http://127.0.0.1:*" + ], + "webOrigins": [ + "http://localhost:*", + "http://127.0.0.1:*" + ], + "defaultClientScopes": [ + "openid", + "profile", + "email", + "mcp" + ], + "optionalClientScopes": [], + "attributes": { + "pkce.code.challenge.method": "S256" + } + }, + { + "clientId": "mcp-server", + "name": "MCP Server Resource", + "description": "Resource server representing the MCP server", + "enabled": true, + "publicClient": false, + "bearerOnly": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false + } + ], + "clientScopes": [ + { + "name": "mcp", + "description": "MCP access scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Access to MCP server resources" + }, + "protocolMappers": [ + { + "name": "mcp-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "http://localhost:8001/mcp", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "mcp-scopes", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "scope", + "claim.value": "mcp:read mcp:write", + "jsonType.label": "String", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false" + } + } + ] + } + ], + "users": [ + { + "username": "demo", + "email": "demo@example.com", + "emailVerified": true, + "enabled": true, + "firstName": "Demo", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "demo123", + "temporary": false + } + ], + "realmRoles": [ + "default-roles-mcp" + ] + } + ], + "defaultDefaultClientScopes": [ + "openid", + "profile", + "email" + ], + "roles": { + "realm": [ + { + "name": "default-roles-mcp", + "description": "Default roles for MCP realm", + "composite": false + } + ] + } +} diff --git a/tests/E2E/nginx.conf b/tests/E2E/nginx.conf new file mode 100644 index 00000000..9da5ab80 --- /dev/null +++ b/tests/E2E/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name localhost; + root /app/tests/E2E; + + # Both the MCP endpoint and the protected resource metadata are served by the + # same script, the way a real deployment mounts the SDK's middleware stack. + location / { + try_files $uri /server.php$is_args$args; + } + + location ~ \.php$ { + fastcgi_pass php-fpm:9000; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + } +} diff --git a/tests/E2E/run.sh b/tests/E2E/run.sh new file mode 100755 index 00000000..63fec627 --- /dev/null +++ b/tests/E2E/run.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env sh +# +# Brings the end-to-end environment up, runs the client against it, and tears it down. +# +# tests/E2E/run.sh full run +# E2E_KEEP=1 tests/E2E/run.sh leave the containers up afterwards, to poke at them +# +# Ports can be moved with E2E_KEYCLOAK_PORT and E2E_MCP_PORT if something already +# holds the defaults. + +set -eu + +directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +compose="docker compose -f $directory/docker-compose.yml" + +keycloak_port=${E2E_KEYCLOAK_PORT:-8181} +mcp_port=${E2E_MCP_PORT:-8001} + +if [ ! -d "$directory/../../vendor" ]; then + echo "Install the dependencies first: composer install" >&2 + exit 1 +fi + +cleanup() { + if [ "${E2E_KEEP:-0}" = "1" ]; then + echo "Leaving the environment up. Stop it with: $compose down -v" + return + fi + + $compose down -v >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +mkdir -p "$directory/sessions" +chmod 0777 "$directory/sessions" + +echo "Starting Keycloak and the MCP server..." +E2E_KEYCLOAK_PORT="$keycloak_port" E2E_MCP_PORT="$mcp_port" $compose up -d --wait + +# --wait covers Keycloak's health check; nginx and php-fpm have none, so the MCP +# endpoint is polled until it answers with the 401 that means it is serving. +echo "Waiting for the MCP endpoint..." +attempt=0 +until [ "$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://localhost:$mcp_port/mcp" || true)" = "401" ]; do + attempt=$((attempt + 1)) + if [ "$attempt" -gt 60 ]; then + echo "The MCP server never came up." >&2 + $compose logs + exit 1 + fi + sleep 1 +done + +echo "Running the client..." +MCP_E2E_ENDPOINT="http://localhost:$mcp_port/mcp" \ +MCP_E2E_CREDENTIALS="$directory/sessions/credentials.json" \ + php "$directory/client.php" diff --git a/tests/E2E/server.php b/tests/E2E/server.php new file mode 100644 index 00000000..63491e8e --- /dev/null +++ b/tests/E2E/server.php @@ -0,0 +1,78 @@ +setServerInfo('client-auth-e2e-server', '1.0.0') + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->addTool( + static fn (): string => 'authorized', + name: 'whoami', + description: 'Answers only when the caller presented a token this server trusts.', + ) + ->build(); + +$response = $server->run(new StreamableHttpTransport( + (new Psr17Factory())->createServerRequestFromGlobals(), + middleware: [ + ...StreamableHttpTransport::defaultMiddleware(), + new ProtectedResourceMetadataMiddleware($metadata), + new AuthorizationMiddleware( + // Keycloak derives its issuer from the Host header, so a token minted for + // the client carries the address the client used. Keys are fetched over the + // compose network, where this container can actually reach Keycloak. + new JwtTokenValidator( + issuer: $externalIssuer, + // RFC 8707: the token has to name this resource, not just any server. + audience: $resource, + keys: JWK::parseKeySet(json_decode((string) file_get_contents($internalIssuer.'/protocol/openid-connect/certs'), true)), + ), + $metadata, + ), + ], +)); + +(new SapiEmitter())->emit($response); diff --git a/tests/Integration/ClientOAuthTest.php b/tests/Integration/ClientOAuthTest.php new file mode 100644 index 00000000..079d76ea --- /dev/null +++ b/tests/Integration/ClientOAuthTest.php @@ -0,0 +1,284 @@ + + */ +final class ClientOAuthTest extends TestCase +{ + private Process $authorizationServer; + private Process $resourceServer; + private string $authorizationServerUrl; + private string $resourceServerUrl; + private string $workingDirectory; + + protected function setUp(): void + { + $this->workingDirectory = sys_get_temp_dir().'/mcp-oauth-integration-'.bin2hex(random_bytes(6)); + + if (!mkdir($this->workingDirectory, 0700, true) && !is_dir($this->workingDirectory)) { + $this->markTestSkipped('Could not create a working directory for the fixture servers.'); + } + + $environment = [ + 'MCP_OAUTH_STATE' => $this->workingDirectory.'/state.json', + 'MCP_OAUTH_SESSIONS' => $this->workingDirectory, + ]; + + [$this->authorizationServer, $this->authorizationServerUrl] = $this->serve('authorization-server.php', $environment); + + $environment['MCP_OAUTH_AUTHORIZATION_SERVER'] = $this->authorizationServerUrl; + + [$this->resourceServer, $this->resourceServerUrl] = $this->serve('resource-server.php', $environment); + } + + protected function tearDown(): void + { + $this->authorizationServer->stop(); + $this->resourceServer->stop(); + + foreach (glob($this->workingDirectory.'/*') ?: [] as $file) { + is_dir($file) ? $this->removeDirectory($file) : unlink($file); + } + + @rmdir($this->workingDirectory); + } + + #[TestDox('a client with no credentials at all ends up calling a protected tool')] + public function testAuthorizesFromNothing(): void + { + $client = $this->connect(); + + $this->assertSame(['whoami'], array_map(static fn ($tool) => $tool->name, $client->listTools()->tools)); + $this->assertStringContainsString('the token was accepted', (string) json_encode($client->callTool('whoami', [])->content)); + + $client->disconnect(); + } + + #[TestDox('the authorization request carries PKCE, the resource, and the challenged scope')] + public function testAuthorizationRequestContents(): void + { + $this->connect()->disconnect(); + + $query = $this->state()['authorization_query'] ?? []; + + $this->assertSame('code', $query['response_type'] ?? null); + $this->assertSame('S256', $query['code_challenge_method'] ?? null); + $this->assertNotEmpty($query['code_challenge'] ?? ''); + $this->assertNotEmpty($query['state'] ?? ''); + $this->assertSame($this->resourceServerUrl.'/mcp', $query['resource'] ?? null); + // "mcp:read" is what the challenge asked for; "offline_access" is added because + // the authorization server advertises it and a refresh token is worth having. + $this->assertSame('mcp:read offline_access', $query['scope'] ?? null); + } + + #[TestDox('the client registers itself, naming what it is and what it will do')] + public function testDynamicRegistration(): void + { + $this->connect()->disconnect(); + + $registration = $this->state()['registration'] ?? []; + + $this->assertSame('Integration Test Client', $registration['client_name'] ?? null); + $this->assertSame('native', $registration['application_type'] ?? null); + $this->assertContains('refresh_token', $registration['grant_types'] ?? []); + } + + #[TestDox('the token request authenticates the client the way the server asked it to')] + public function testTokenRequestUsesTheRegisteredAuthMethod(): void + { + $this->connect()->disconnect(); + $state = $this->state(); + + $this->assertSame('authorization_code', $state['token_request']['grant_type'] ?? null); + $this->assertSame($this->resourceServerUrl.'/mcp', $state['token_request']['resource'] ?? null); + $this->assertSame( + 'Basic '.base64_encode('dynamic-client:dynamic-secret'), + $state['token_authorization'] ?? null, + ); + } + + #[TestDox('a second run reuses the stored credentials instead of registering again')] + public function testStoredCredentialsAreReused(): void + { + $storage = new FileCredentialStorage($this->workingDirectory.'/credentials.json'); + + $this->connect($storage)->disconnect(); + $firstToken = $this->state()['issued_token'] ?? null; + + $this->connect($storage)->disconnect(); + + $this->assertNotNull($firstToken); + $this->assertSame($firstToken, $this->state()['issued_token'] ?? null, 'The second run should not have gone back to the token endpoint.'); + } + + #[TestDox('a failed authorization is reported once, not retried until the user gives up')] + public function testAFailedAuthorizationIsNotRetried(): void + { + file_put_contents($this->workingDirectory.'/state.json', json_encode(['fail_token' => true])); + + try { + $this->connect(); + $this->fail('The client should not have connected.'); + } catch (ConnectionException $e) { + $this->assertInstanceOf(AuthorizationException::class, $e->getPrevious()); + $this->assertStringContainsString('invalid_client', $e->getMessage()); + } + + // Connecting retries on a connection failure, and an authorization the server + // refused is not one: a second attempt would only send the user back to the + // browser for the same answer. + $this->assertSame(1, $this->state()['authorization_count'] ?? 0); + } + + #[TestDox('a client that cannot authorize fails loudly rather than hanging')] + public function testUnauthorizedClientFails(): void + { + $client = Client::builder()->setClientInfo('integration-client', '1.0.0')->setInitTimeout(5)->build(); + + try { + $client->connect(new HttpTransport($this->resourceServerUrl.'/mcp')); + $this->fail('The server should have refused an unauthenticated client.'); + } catch (ConnectionException $e) { + // connect() wraps what it will not retry; the cause names the challenge. + $this->assertInstanceOf(AuthorizationException::class, $e->getPrevious()); + $this->assertStringContainsString('rejected the request with 401', $e->getMessage()); + } + } + + #[TestDox('the headless handler reads the code out of a redirect without following it')] + public function testHeadlessHandlerAgainstARealEndpoint(): void + { + $url = $this->authorizationServerUrl.'/authorize?'.http_build_query([ + 'redirect_uri' => 'http://127.0.0.1:8765/callback', + 'state' => 'a-state', + ]); + + $parameters = (new HeadlessAuthorizationHandler())->authorize($url, 'http://127.0.0.1:8765/callback'); + + $this->assertSame('integration-authorization-code', $parameters['code'] ?? null); + $this->assertSame('a-state', $parameters['state'] ?? null); + $this->assertSame($this->authorizationServerUrl, $parameters['iss'] ?? null); + } + + #[TestDox('an endpoint that does not redirect is reported as needing a real user')] + public function testHeadlessHandlerNeedsARedirect(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('interactive authorization handler is required'); + + (new HeadlessAuthorizationHandler())->authorize($this->authorizationServerUrl.'/nothing-here', 'http://127.0.0.1:8765/callback'); + } + + private function connect(?FileCredentialStorage $storage = null): Client + { + $client = Client::builder() + ->setClientInfo('integration-client', '1.0.0') + ->setInitTimeout(10) + ->setRequestTimeout(10) + ->build(); + + $auth = OAuth::forApplication('Integration Test Client') + ->setAuthorizationHandler(new HeadlessAuthorizationHandler()) + ->setCredentialStorage($storage ?? new InMemoryCredentialStorage()) + ->build(); + + $client->connect(new HttpTransport($this->resourceServerUrl.'/mcp', auth: $auth)); + + return $client; + } + + /** + * @return array + */ + private function state(): array + { + $path = $this->workingDirectory.'/state.json'; + + return is_file($path) ? (json_decode((string) file_get_contents($path), true) ?: []) : []; + } + + /** + * Start one of the fixture routers under the built-in web server. + * + * @param array $environment + * + * @return array{Process, string} + */ + private function serve(string $fixture, array $environment): array + { + $port = self::freePort(); + $process = new Process( + [\PHP_BINARY, '-S', '127.0.0.1:'.$port, __DIR__.'/Fixture/oauth/'.$fixture], + __DIR__, + $environment + ['MCP_OAUTH_PORT' => (string) $port], + ); + $process->start(); + + $url = 'http://127.0.0.1:'.$port; + $deadline = microtime(true) + 10; + + while (microtime(true) < $deadline) { + $probe = @fsockopen('127.0.0.1', $port, $code, $message, 0.2); + + if (false !== $probe) { + fclose($probe); + + return [$process, $url]; + } + + usleep(50_000); + } + + $this->fail(\sprintf('The fixture server "%s" did not start: %s', $fixture, $process->getErrorOutput())); + } + + private static function freePort(): int + { + $socket = stream_socket_server('tcp://127.0.0.1:0'); + self::assertIsResource($socket); + $port = (int) explode(':', (string) stream_socket_get_name($socket, false))[1]; + fclose($socket); + + return $port; + } + + private function removeDirectory(string $directory): void + { + foreach (glob($directory.'/*') ?: [] as $file) { + is_dir($file) ? $this->removeDirectory($file) : unlink($file); + } + + @rmdir($directory); + } +} diff --git a/tests/Integration/Fixture/oauth/authorization-server.php b/tests/Integration/Fixture/oauth/authorization-server.php new file mode 100644 index 00000000..fd785a79 --- /dev/null +++ b/tests/Integration/Fixture/oauth/authorization-server.php @@ -0,0 +1,162 @@ + + */ + public function read(): array + { + $contents = is_file($this->path) ? file_get_contents($this->path) : false; + + return false === $contents ? [] : (json_decode($contents, true) ?: []); + } + + /** + * @param array $values + */ + public function merge(array $values): void + { + file_put_contents($this->path, json_encode(array_merge($this->read(), $values)), \LOCK_EX); + } +}; + +$issuer = 'http://'.($_SERVER['HTTP_HOST'] ?? '127.0.0.1'); +$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', \PHP_URL_PATH); + +$json = static function (array $payload, int $status = 200): void { + http_response_code($status); + header('Content-Type: application/json'); + echo json_encode($payload); +}; + +$body = static function (): array { + $raw = file_get_contents('php://input') ?: ''; + + if (str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'application/json')) { + return json_decode($raw, true) ?: []; + } + + parse_str($raw, $form); + + return $form; +}; + +switch ($path) { + case '/.well-known/oauth-authorization-server': + $json([ + 'issuer' => $issuer, + 'authorization_endpoint' => $issuer.'/authorize', + 'token_endpoint' => $issuer.'/token', + 'registration_endpoint' => $issuer.'/register', + 'response_types_supported' => ['code'], + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'code_challenge_methods_supported' => ['S256'], + 'token_endpoint_auth_methods_supported' => ['client_secret_basic', 'none'], + 'scopes_supported' => ['mcp:read', 'offline_access'], + 'authorization_response_iss_parameter_supported' => true, + ]); + break; + + case '/register': + $registration = $body(); + $state->merge([ + 'client_id' => 'dynamic-client', + 'client_secret' => 'dynamic-secret', + 'registration' => $registration, + ]); + + $json([ + 'client_id' => 'dynamic-client', + 'client_secret' => 'dynamic-secret', + 'client_name' => $registration['client_name'] ?? null, + 'redirect_uris' => $registration['redirect_uris'] ?? [], + 'token_endpoint_auth_method' => 'client_secret_basic', + ], 201); + break; + + case '/authorize': + $state->merge([ + 'code_challenge' => $_GET['code_challenge'] ?? null, + 'granted_scope' => $_GET['scope'] ?? '', + 'authorization_query' => $_GET, + // Counted so a test can tell one failed authorization from a client that + // quietly went round again. + 'authorization_count' => ($state->read()['authorization_count'] ?? 0) + 1, + ]); + + $redirect = $_GET['redirect_uri'].'?'.http_build_query(array_filter([ + 'code' => 'integration-authorization-code', + 'state' => $_GET['state'] ?? null, + 'iss' => $issuer, + ])); + + http_response_code(302); + header('Location: '.$redirect); + break; + + case '/token': + $form = $body(); + $stored = $state->read(); + + // Set by a test that wants to see what the client does when the exchange fails. + if ($stored['fail_token'] ?? false) { + $json(['error' => 'invalid_client', 'error_description' => 'Refused on purpose.'], 401); + break; + } + + if ('integration-authorization-code' !== ($form['code'] ?? null)) { + $json(['error' => 'invalid_grant'], 400); + break; + } + + $challenge = rtrim(strtr(base64_encode(hash('sha256', (string) ($form['code_verifier'] ?? ''), true)), '+/', '-_'), '='); + + if ($challenge !== ($stored['code_challenge'] ?? null)) { + $json(['error' => 'invalid_grant', 'error_description' => 'PKCE verification failed'], 400); + break; + } + + $token = 'integration-access-token-'.bin2hex(random_bytes(4)); + $state->merge([ + 'issued_token' => $token, + 'token_request' => $form, + 'token_authorization' => $_SERVER['HTTP_AUTHORIZATION'] ?? null, + ]); + + $json([ + 'access_token' => $token, + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'refresh_token' => 'integration-refresh-token', + 'scope' => $stored['granted_scope'] ?? '', + ]); + break; + + default: + $json(['error' => 'not_found'], 404); +} diff --git a/tests/Integration/Fixture/oauth/resource-server.php b/tests/Integration/Fixture/oauth/resource-server.php new file mode 100644 index 00000000..cfc119a6 --- /dev/null +++ b/tests/Integration/Fixture/oauth/resource-server.php @@ -0,0 +1,73 @@ + $self.'/mcp', + 'authorization_servers' => [$authorizationServer], + 'scopes_supported' => ['mcp:read'], + ]); + + return; +} + +$state = is_file($statePath) ? (json_decode((string) file_get_contents($statePath), true) ?: []) : []; +$presented = preg_match('/^Bearer\s+(.*)$/i', $_SERVER['HTTP_AUTHORIZATION'] ?? '', $matches) ? trim($matches[1]) : null; + +if (null === $presented || !isset($state['issued_token']) || !hash_equals((string) $state['issued_token'], $presented)) { + http_response_code(401); + header(sprintf('WWW-Authenticate: Bearer scope="mcp:read", resource_metadata="%s/.well-known/oauth-protected-resource/mcp"', $self)); + header('Content-Type: application/json'); + echo json_encode(['error' => 'invalid_token']); + + return; +} + +$server = Server::builder() + ->setServerInfo('oauth-integration-server', '1.0.0') + ->setSession(new FileSessionStore($sessionPath)) + ->addTool( + static fn (): string => 'the token was accepted', + name: 'whoami', + description: 'Reports that the request was authorized.', + ) + ->build(); + +$response = $server->run(new StreamableHttpTransport( + (new Psr17Factory())->createServerRequestFromGlobals(), +)); + +(new SapiEmitter())->emit($response); diff --git a/tests/Unit/Client/Auth/AccessTokenTest.php b/tests/Unit/Client/Auth/AccessTokenTest.php new file mode 100644 index 00000000..39ee8e4a --- /dev/null +++ b/tests/Unit/Client/Auth/AccessTokenTest.php @@ -0,0 +1,94 @@ + 'abc', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'refresh_token' => 'refresh', + 'scope' => 'mcp:read mcp:write', + ], 1000); + + $this->assertSame('abc', $token->accessToken); + $this->assertSame(4600, $token->expiresAt); + $this->assertSame('refresh', $token->refreshToken); + $this->assertSame(['mcp:read', 'mcp:write'], $token->scopes); + $this->assertSame('Bearer abc', $token->getAuthorizationHeader()); + } + + #[TestDox('a response without an access token is rejected')] + public function testRejectsResponseWithoutToken(): void + { + $this->expectException(InvalidArgumentException::class); + + AccessToken::fromResponse(['token_type' => 'Bearer']); + } + + #[TestDox('a token is treated as expired shortly before it actually is')] + public function testExpiresEarly(): void + { + $token = new AccessToken('abc', expiresAt: 1000); + + $this->assertFalse($token->isExpired(900)); + $this->assertTrue($token->isExpired(980)); + $this->assertTrue($token->isExpired(1001)); + } + + #[TestDox('a token without an expiry never goes stale on its own')] + public function testNeverExpiresWithoutExpiry(): void + { + $this->assertFalse((new AccessToken('abc'))->isExpired()); + } + + #[TestDox('scope coverage decides whether a challenge needs a new authorization')] + public function testCovers(): void + { + $token = new AccessToken('abc', scopes: ['mcp:read']); + + $this->assertTrue($token->covers(['mcp:read'])); + $this->assertTrue($token->covers([])); + $this->assertFalse($token->covers(['mcp:read', 'mcp:write'])); + } + + #[TestDox('a token whose grant was not reported covers whatever was asked for')] + public function testUnreportedScopeCoversEverything(): void + { + $this->assertTrue((new AccessToken('abc'))->covers(['mcp:write'])); + } + + #[TestDox('the requested scopes are recorded when the server did not report them')] + public function testWithGrantedScopes(): void + { + $this->assertSame(['mcp:read'], (new AccessToken('abc'))->withGrantedScopes(['mcp:read'])->scopes); + $this->assertSame(['mcp:read'], (new AccessToken('abc', scopes: ['mcp:read']))->withGrantedScopes(['mcp:write'])->scopes); + } + + #[TestDox('a token survives a round trip through storage')] + public function testRoundTrip(): void + { + $token = new AccessToken('abc', 'Bearer', 4600, 'refresh', ['mcp:read']); + $restored = AccessToken::fromArray($token->jsonSerialize()); + + $this->assertEquals($token, $restored); + } +} diff --git a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php new file mode 100644 index 00000000..26f6da3d --- /dev/null +++ b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php @@ -0,0 +1,101 @@ +authorize('https://auth.example.com/authorize', \sprintf('http://127.0.0.1:%d/callback', $port)); + + $this->assertSame(['code' => 'abc', 'state' => 'xyz'], $parameters); + } + + #[TestDox('the loopback handler ignores the browser also asking for a favicon')] + public function testLoopbackHandlerIgnoresUnrelatedRequests(): void + { + $port = self::freePort(); + + $browser = static function () use ($port): void { + foreach (['/favicon.ico', '/callback?code=abc'] as $target) { + $socket = stream_socket_client(\sprintf('tcp://127.0.0.1:%d', $port), $code, $message, 2); + self::assertIsResource($socket); + fwrite($socket, \sprintf("GET %s HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", $target)); + } + }; + + $this->assertSame(['code' => 'abc'], (new LoopbackAuthorizationHandler($browser, 5)) + ->authorize('https://auth.example.com/authorize', \sprintf('http://127.0.0.1:%d/callback', $port))); + } + + #[TestDox('a port that is already taken is reported instead of waited on')] + public function testLoopbackHandlerReportsABusyPort(): void + { + $server = stream_socket_server('tcp://127.0.0.1:0'); + $this->assertIsResource($server); + $port = (int) explode(':', (string) stream_socket_get_name($server, false))[1]; + + try { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Could not listen'); + + (new LoopbackAuthorizationHandler(static fn () => null, 1)) + ->authorize('https://auth.example.com/authorize', \sprintf('http://127.0.0.1:%d/callback', $port)); + } finally { + fclose($server); + } + } + + #[TestDox('the loopback handler gives up rather than waiting forever')] + public function testLoopbackHandlerTimesOut(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('No authorization redirect arrived'); + + (new LoopbackAuthorizationHandler(static fn () => null, 1)) + ->authorize('https://auth.example.com/authorize', \sprintf('http://127.0.0.1:%d/callback', self::freePort())); + } + + // A bare code carries neither state nor iss, so accepting one would silently skip + // both of the checks that tie the response to this request. + private static function freePort(): int + { + $socket = stream_socket_server('tcp://127.0.0.1:0'); + self::assertIsResource($socket); + $port = (int) explode(':', (string) stream_socket_get_name($socket, false))[1]; + fclose($socket); + + return $port; + } +} diff --git a/tests/Unit/Client/Auth/ClientRegistrationTest.php b/tests/Unit/Client/Auth/ClientRegistrationTest.php new file mode 100644 index 00000000..513210d7 --- /dev/null +++ b/tests/Unit/Client/Auth/ClientRegistrationTest.php @@ -0,0 +1,134 @@ + 'issued', + 'client_secret' => 'secret', + 'token_endpoint_auth_method' => 'client_secret_post', + ], TokenEndpointAuthMethod::ClientSecretBasic); + + $this->assertSame(TokenEndpointAuthMethod::ClientSecretPost, $registration->tokenEndpointAuthMethod); + $this->assertTrue($registration->dynamic); + } + + #[TestDox('a silent server is taken to have accepted what was asked for')] + public function testFallsBackToTheRequestedAuthMethod(): void + { + $registration = ClientRegistration::fromResponse(['client_id' => 'issued', 'client_secret' => 'secret'], TokenEndpointAuthMethod::None); + + $this->assertSame(TokenEndpointAuthMethod::None, $registration->tokenEndpointAuthMethod); + } + + #[TestDox('a response with no client id is rejected')] + public function testRequiresAClientId(): void + { + $this->expectException(InvalidArgumentException::class); + + ClientRegistration::fromResponse(['client_secret' => 'secret']); + } + + #[TestDox('a secret with an expiry is treated as expired once it passes')] + public function testSecretExpiry(): void + { + $registration = ClientRegistration::fromResponse(['client_id' => 'issued', 'client_secret_expires_at' => 1000]); + + $this->assertFalse($registration->isExpired(999)); + $this->assertTrue($registration->isExpired(1001)); + } + + #[TestDox('a secret that never expires is reported as 0, and is not an expiry')] + public function testNonExpiringSecret(): void + { + $this->assertFalse(ClientRegistration::fromResponse(['client_id' => 'issued', 'client_secret_expires_at' => 0])->isExpired(\PHP_INT_MAX)); + } + + #[TestDox('registration posts what the client is and what it intends to do')] + public function testRegistrationBody(): void + { + $server = new CapturingRegistrationServer(); + $factory = new Psr17Factory(); + + $registration = (new ClientRegistrar($server, $factory, $factory))->register( + $this->metadata('https://auth.example.com/register'), + new ClientMetadata('My App', ['http://127.0.0.1:8765/callback']), + TokenEndpointAuthMethod::ClientSecretBasic, + 'mcp:read', + ); + + $this->assertSame('My App', $server->body['client_name'] ?? null); + $this->assertSame(['http://127.0.0.1:8765/callback'], $server->body['redirect_uris'] ?? null); + $this->assertSame(['code'], $server->body['response_types'] ?? null); + $this->assertSame('native', $server->body['application_type'] ?? null); + $this->assertSame('client_secret_basic', $server->body['token_endpoint_auth_method'] ?? null); + $this->assertSame('mcp:read', $server->body['scope'] ?? null); + $this->assertSame('registered', $registration->clientId); + } + + #[TestDox('a server without a registration endpoint says so plainly')] + public function testMissingRegistrationEndpoint(): void + { + $factory = new Psr17Factory(); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('does not support dynamic client registration'); + + (new ClientRegistrar(new CapturingRegistrationServer(), $factory, $factory))->register( + $this->metadata(null), + new ClientMetadata('My App'), + TokenEndpointAuthMethod::None, + ); + } + + private function metadata(?string $registrationEndpoint): AuthorizationServerMetadata + { + return new AuthorizationServerMetadata( + 'https://auth.example.com', + 'https://auth.example.com/authorize', + 'https://auth.example.com/token', + $registrationEndpoint, + ); + } +} + +final class CapturingRegistrationServer implements ClientInterface +{ + /** @var array */ + public array $body = []; + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->body = json_decode((string) $request->getBody(), true) ?: []; + + return new Response(201, ['Content-Type' => 'application/json'], (string) json_encode(['client_id' => 'registered', 'client_secret' => 'issued-secret'])); + } +} diff --git a/tests/Unit/Client/Auth/FileCredentialStorageTest.php b/tests/Unit/Client/Auth/FileCredentialStorageTest.php new file mode 100644 index 00000000..0b283b99 --- /dev/null +++ b/tests/Unit/Client/Auth/FileCredentialStorageTest.php @@ -0,0 +1,128 @@ +path = sys_get_temp_dir().'/mcp-credentials-'.bin2hex(random_bytes(6)).'/credentials.json'; + } + + protected function tearDown(): void + { + if (is_file($this->path)) { + unlink($this->path); + rmdir(\dirname($this->path)); + } + } + + #[TestDox('credentials written by one run are found by the next')] + public function testSurvivesAcrossInstances(): void + { + $storage = new FileCredentialStorage($this->path); + $storage->saveClientRegistration('https://auth.example.com', new ClientRegistration('client', 'secret', TokenEndpointAuthMethod::ClientSecretBasic, true)); + $storage->saveToken('https://auth.example.com', 'https://mcp.example.com/mcp', new AccessToken('token', 'Bearer', 4600, 'refresh', ['mcp:read'])); + + $reopened = new FileCredentialStorage($this->path); + + $registration = $reopened->getClientRegistration('https://auth.example.com'); + $token = $reopened->getToken('https://auth.example.com', 'https://mcp.example.com/mcp'); + + $this->assertNotNull($registration); + $this->assertNotNull($token); + $this->assertSame('client', $registration->clientId); + $this->assertSame(TokenEndpointAuthMethod::ClientSecretBasic, $registration->tokenEndpointAuthMethod); + $this->assertSame('token', $token->accessToken); + $this->assertSame(['mcp:read'], $token->scopes); + } + + #[TestDox('tokens for one resource are not handed out for another')] + public function testKeysTokensByResource(): void + { + $storage = new FileCredentialStorage($this->path); + $storage->saveToken('https://auth.example.com', 'https://mcp.example.com/mcp', new AccessToken('token')); + + $this->assertNull($storage->getToken('https://auth.example.com', 'https://other.example.com/mcp')); + $this->assertNull($storage->getToken('https://other-auth.example.com', 'https://mcp.example.com/mcp')); + } + + #[TestDox('forgetting one authorization server leaves the others alone')] + public function testForgetsOneIssuer(): void + { + $storage = new FileCredentialStorage($this->path); + $storage->saveToken('https://one.example.com', 'https://mcp.example.com/mcp', new AccessToken('one')); + $storage->saveToken('https://two.example.com', 'https://mcp.example.com/mcp', new AccessToken('two')); + + $storage->forget('https://one.example.com'); + + $this->assertNull($storage->getToken('https://one.example.com', 'https://mcp.example.com/mcp')); + $this->assertSame('two', $storage->getToken('https://two.example.com', 'https://mcp.example.com/mcp')?->accessToken); + } + + #[TestDox('the file is not readable by anyone else')] + public function testFilePermissions(): void + { + $storage = new FileCredentialStorage($this->path); + $storage->saveToken('https://auth.example.com', 'https://mcp.example.com/mcp', new AccessToken('token')); + + $this->assertSame('0600', substr(\sprintf('%o', fileperms($this->path)), -4)); + } + + // The window between creating a file and chmod'ing it is a window in which another + // local user can open it, so the write happens in a private file that is then moved + // into place. + #[TestDox('the credentials are never world-readable, not even briefly')] + public function testFileIsNeverExposedWhileBeingWritten(): void + { + $storage = new FileCredentialStorage($this->path); + $storage->saveToken('https://auth.example.com', 'https://mcp.example.com/mcp', new AccessToken('first')); + + clearstatcache(); + $before = fileperms($this->path); + + $storage->saveToken('https://auth.example.com', 'https://mcp.example.com/mcp', new AccessToken('second')); + + clearstatcache(); + $this->assertSame($before, fileperms($this->path)); + $this->assertSame('0600', substr(\sprintf('%o', fileperms($this->path)), -4)); + $this->assertSame('second', $storage->getToken('https://auth.example.com', 'https://mcp.example.com/mcp')?->accessToken); + $this->assertCount(1, glob(\dirname($this->path).'/*') ?: [], 'The temporary file should not survive the write.'); + } + + #[TestDox('a corrupted file costs one authorization, not a crash')] + public function testToleratesACorruptedFile(): void + { + @mkdir(\dirname($this->path), 0700, true); + file_put_contents($this->path, 'not json at all'); + + $this->assertNull((new FileCredentialStorage($this->path))->getToken('https://auth.example.com', 'https://mcp.example.com/mcp')); + } + + #[TestDox('nothing is stored until there is something to store')] + public function testDoesNotTouchTheFileSystemWhenIdle(): void + { + $storage = new FileCredentialStorage($this->path); + + $this->assertNull($storage->getToken('https://auth.example.com', 'https://mcp.example.com/mcp')); + $this->assertFileDoesNotExist($this->path); + } +} diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php new file mode 100644 index 00000000..8700c06f --- /dev/null +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -0,0 +1,710 @@ +authenticator($server); + + $this->assertTrue($authenticator->handleChallenge($this->request(), $server->challenge())); + + $authenticated = $authenticator->authenticate($this->request()); + $this->assertSame('Bearer access-token-1', $authenticated->getHeaderLine('Authorization')); + + $this->assertSame([ + 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp', + 'https://auth.example.com/.well-known/oauth-authorization-server', + 'https://auth.example.com/register', + 'https://auth.example.com/token', + ], $server->requested); + } + + #[TestDox('the registration names the application type and the grants the client will use')] + public function testRegistrationBody(): void + { + $server = new FakeOAuthServer(); + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('native', $server->registration['application_type'] ?? null); + $this->assertSame(['authorization_code', 'refresh_token'], $server->registration['grant_types'] ?? null); + $this->assertSame(['http://127.0.0.1:8765/callback'], $server->registration['redirect_uris'] ?? null); + } + + #[TestDox('the code exchange proves possession of the verifier behind the challenge')] + public function testPkceVerifierMatchesTheChallenge(): void + { + $server = new FakeOAuthServer(); + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('S256', $server->authorization['code_challenge_method'] ?? null); + $this->assertSame( + $server->authorization['code_challenge'] ?? null, + Pkce::fromVerifier($server->token['code_verifier'] ?? '')->challenge, + ); + } + + #[TestDox('the resource the token is for is named consistently in both requests')] + public function testResourceParameter(): void + { + $server = new FakeOAuthServer(); + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame(self::ENDPOINT, $server->authorization['resource'] ?? null); + $this->assertSame(self::ENDPOINT, $server->token['resource'] ?? null); + } + + #[TestDox('the scope named by the challenge is what gets requested')] + public function testUsesTheChallengedScope(): void + { + $server = new FakeOAuthServer(); + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + + $this->assertSame('mcp:read', $server->authorization['scope'] ?? null); + } + + #[TestDox('with nothing challenged, the resource\'s own scopes are asked for in full')] + public function testFallsBackToTheResourceScopes(): void + { + $server = new FakeOAuthServer(resourceScopes: ['mcp:read', 'mcp:write']); + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); + } + + #[TestDox('with no scopes to be had, the parameter is left off entirely')] + public function testOmitsTheScopeParameter(): void + { + $server = new FakeOAuthServer(); + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge()); + + $this->assertArrayNotHasKey('scope', $server->authorization); + } + + #[TestDox('a step-up challenge keeps the scopes already granted alongside the new one')] + public function testUnionsScopesOnReAuthorization(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server); + + $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + $rejected = $authenticator->authenticate($this->request()); + $authenticator->handleChallenge($rejected, $server->challenge(['mcp:write'], 403)); + + $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); + } + + #[TestDox('a step-up challenge adds its scope to the configured ones instead of refreshing the token')] + public function testStepUpExtendsTheConfiguredScopes(): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test')->setScopes('mcp:read')); + + $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:admin'])); + $this->assertSame('mcp:read', $server->authorization['scope'] ?? null); + + $rejected = $authenticator->authenticate($this->request()); + $authenticator->handleChallenge($rejected, $server->challenge(['mcp:write'], 403, 'insufficient_scope')); + + $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); + $this->assertSame('authorization_code', $server->token['grant_type'] ?? null); + } + + #[TestDox('offline_access is requested only where the authorization server offers it')] + public function testRequestsOfflineAccessWhenSupported(): void + { + $server = new FakeOAuthServer(serverScopes: ['mcp:read', 'offline_access'], resourceScopes: ['mcp:read']); + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('mcp:read offline_access', $server->authorization['scope'] ?? null); + } + + #[TestDox('offline_access is not invented when the authorization server never mentioned it')] + public function testDoesNotRequestUnsupportedOfflineAccess(): void + { + $server = new FakeOAuthServer(serverScopes: ['mcp:read'], resourceScopes: ['mcp:read']); + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('mcp:read', $server->authorization['scope'] ?? null); + } + + #[TestDox('offline_access refused when the code is exchanged is given up, as when the authorization endpoint refuses it')] + public function testGivesUpOfflineAccessRefusedAtTheTokenEndpoint(): void + { + $server = new FakeOAuthServer(resourceScopes: ['mcp:read'], serverScopes: ['mcp:read', 'offline_access'], refuseOfflineAccess: true); + $authenticator = $this->authenticator($server); + + $this->assertTrue($authenticator->handleChallenge($this->request(), $server->challenge())); + + $this->assertSame('mcp:read', $server->authorization['scope'] ?? null); + $this->assertSame('Bearer access-token-1', $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); + } + + #[TestDox('offline_access refused once is not asked for again on step-up')] + public function testDoesNotAskAgainForARefusedOfflineAccess(): void + { + $server = new FakeOAuthServer(resourceScopes: ['mcp:read'], serverScopes: ['mcp:read', 'mcp:write', 'offline_access'], refuseOfflineAccess: true); + $authenticator = $this->authenticator($server); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + $rejected = $authenticator->authenticate($this->request()); + $authenticator->handleChallenge($rejected, $server->challenge(['mcp:write'], 403, 'insufficient_scope')); + + $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); + $this->assertCount(3, array_keys($server->requested, 'https://auth.example.com/token', true)); + } + + #[TestDox('offline_access refused once is not asked for again, even when configured')] + public function testDoesNotAskAgainForARefusedOfflineAccessThatIsConfigured(): void + { + $server = new FakeOAuthServer(serverScopes: ['mcp:read', 'mcp:write', 'offline_access'], refuseOfflineAccess: true); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test')->setScopes('mcp:read', 'offline_access')); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + $rejected = $authenticator->authenticate($this->request()); + $authenticator->handleChallenge($rejected, $server->challenge(['mcp:write'], 403, 'insufficient_scope')); + + $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); + $this->assertCount(3, array_keys($server->requested, 'https://auth.example.com/token', true)); + } + + #[TestDox('a code refused for another reason than offline_access fails the flow instead of starting another one')] + public function testKeepsOfflineAccessWhenTheCodeIsRefusedForAnotherReason(): void + { + $server = new FakeOAuthServer(resourceScopes: ['mcp:read'], serverScopes: ['mcp:read', 'offline_access'], codeError: 'invalid_grant'); + $authenticator = $this->authenticator($server); + + try { + $authenticator->handleChallenge($this->request(), $server->challenge()); + $this->fail('The refused code should have failed the flow.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('invalid_grant', $e->getMessage()); + } + + $this->assertSame('mcp:read offline_access', $server->authorization['scope'] ?? null); + $this->assertCount(1, array_keys($server->requested, 'https://auth.example.com/token', true)); + } + + #[TestDox('a stored token is reused even though offline_access was not granted with it')] + public function testReusesAStoredTokenWithoutOfflineAccess(): void + { + $server = new FakeOAuthServer(resourceScopes: ['mcp:read'], serverScopes: ['mcp:read', 'offline_access'], refreshTokens: true, refuseOfflineAccess: true); + $storage = new InMemoryCredentialStorage(); + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge()); + $requestsAfterFirstFlow = \count($server->requested); + + $second = $this->authenticator($server, storage: $storage); + $second->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('Bearer access-token-1', $second->authenticate($this->request())->getHeaderLine('Authorization')); + $this->assertSame($requestsAfterFirstFlow + 2, \count($server->requested)); + } + + #[TestDox('an expired token is refreshed even though offline_access was not granted with it')] + public function testRefreshesATokenWithoutOfflineAccess(): void + { + $server = new FakeOAuthServer(resourceScopes: ['mcp:read'], serverScopes: ['mcp:read', 'offline_access'], refreshTokens: true); + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('expired-token', 'Bearer', time() - 60, 'refresh-token', ['mcp:read'])); + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('refresh_token', $server->token['grant_type'] ?? null); + $this->assertSame([], $server->authorization); + } + + #[TestDox('metadata naming an unrelated resource aborts the flow before any token is asked for')] + public function testRejectsAResourceMismatch(): void + { + $server = new FakeOAuthServer(resource: 'https://evil.example.com/mcp'); + $authenticator = $this->authenticator($server); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('declares the unrelated resource'); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + } + + #[TestDox('an authorization response naming the wrong issuer is refused')] + public function testRejectsAMismatchedIssuer(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, new StubAuthorizationHandler($server, issuer: 'https://evil.example.com')); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('names the issuer'); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + } + + #[TestDox('a missing issuer is refused when the authorization server said it would send one')] + public function testRejectsAMissingIssuer(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, new StubAuthorizationHandler($server, issuer: null)); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('did not carry one'); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + } + + #[TestDox('a mismatched state is refused, however the code got back')] + public function testRejectsAMismatchedState(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, new StubAuthorizationHandler($server, state: 'not-the-state')); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('state parameter'); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + } + + #[TestDox('a stored token is reused instead of sending the user through the browser again')] + public function testReusesAStoredToken(): void + { + $server = new FakeOAuthServer(); + $storage = new InMemoryCredentialStorage(); + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge()); + $requestsAfterFirstFlow = \count($server->requested); + + $second = $this->authenticator($server, storage: $storage); + $second->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('Bearer access-token-1', $second->authenticate($this->request())->getHeaderLine('Authorization')); + // Only the two discovery documents; no registration, no authorization, no token. + $this->assertSame($requestsAfterFirstFlow + 2, \count($server->requested)); + } + + #[TestDox('a token the server has just rejected is not offered back to it')] + public function testDoesNotReplayARejectedToken(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + $rejected = $authenticator->authenticate($this->request()); + + // Same scopes, unexpired, and still in storage -- but the server answered 401 + // to this very token, so reusing it would only earn the same answer. + $authenticator->handleChallenge($rejected, $server->challenge()); + + $this->assertSame('Bearer access-token-2', $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); + } + + // The transport builds one authenticator per endpoint, but an authenticator is an + // ordinary object: sharing one across two servers must not carry the first server's + // token to the second. + #[TestDox('a token is not attached to a request aimed at a different server')] + public function testTokenIsBoundToItsResource(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $elsewhere = (new Psr17Factory())->createRequest('POST', 'https://other.example.com/mcp'); + + $this->assertSame('', $authenticator->authenticate($elsewhere)->getHeaderLine('Authorization')); + $this->assertSame('Bearer access-token-1', $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); + } + + #[TestDox('an authorization response with no state at all is refused')] + public function testRejectsAMissingState(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, new StubAuthorizationHandler($server, state: '')); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('state parameter'); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + } + + #[TestDox('an authorization server offering plain HTTP endpoints is not followed')] + public function testRejectsInsecureEndpoints(): void + { + $server = new FakeOAuthServer(insecureEndpoints: true); + $authenticator = $this->authenticator($server); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('No usable metadata'); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + } + + // A challenge pointing at somebody else's host is asking the client to fetch a URL + // on the server's behalf, from wherever the client happens to run. + #[TestDox('a metadata location on another host is ignored, not fetched')] + public function testIgnoresACrossOriginMetadataUrl(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server); + + $challenge = new Response(401, ['WWW-Authenticate' => 'Bearer resource_metadata="http://169.254.169.254/latest/meta-data/"']); + $authenticator->handleChallenge($this->request(), $challenge); + + $this->assertNotContains('http://169.254.169.254/latest/meta-data/', $server->requested); + // The well-known locations are probed instead, so the flow still completes. + $this->assertContains('https://mcp.example.com/.well-known/oauth-protected-resource/mcp', $server->requested); + } + + #[TestDox('a metadata location on the same server is followed')] + public function testFollowsASameOriginMetadataUrl(): void + { + $server = new FakeOAuthServer(); + $challenge = new Response(401, ['WWW-Authenticate' => 'Bearer resource_metadata="https://mcp.example.com/custom/location.json"']); + + $this->authenticator($server)->handleChallenge($this->request(), $challenge); + + $this->assertContains('https://mcp.example.com/custom/location.json', $server->requested); + } + + // Building the well-known URLs out of this identifier and requesting them is already + // a request made on the server's behalf, so the check has to precede the fetch. + #[TestDox('an authorization server on a plain-HTTP host is refused before it is contacted')] + public function testRefusesAnInsecureIssuerWithoutFetchingIt(): void + { + $server = new FakeOAuthServer(authorizationServer: 'http://10.0.0.5:8080/internal'); + $authenticator = $this->authenticator($server); + + try { + $authenticator->handleChallenge($this->request(), $server->challenge()); + $this->fail('The insecure authorization server should have been refused.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('neither an HTTPS URL nor a loopback address', $e->getMessage()); + } + + foreach ($server->requested as $url) { + $this->assertStringNotContainsString('10.0.0.5', $url, 'The issuer must not be contacted before it is validated.'); + } + } + + // An application overriding the RFC 8707 resource parameter is choosing which token + // to ask for, not where it may be spent -- the token still has to reach the server. + #[TestDox('an overridden resource parameter does not stop the token reaching the server')] + public function testOverriddenResourceStillAuthenticatesTheEndpoint(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test')->setResource('https://api.example.com/v1')); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('https://api.example.com/v1', $server->authorization['resource'] ?? null); + $this->assertSame('Bearer access-token-1', $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); + } + + #[TestDox('a client id configured up front is used instead of registering')] + public function testSkipsRegistrationForAKnownClient(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test') + ->setClientCredentials('known-client', 'known-secret')); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $this->assertNotContains('https://auth.example.com/register', $server->requested); + $this->assertSame('known-client', $server->authorization['client_id'] ?? null); + } + + #[TestDox('a server on plain http is refused before anything is fetched')] + public function testRefusesAnInsecureServer(): void + { + $server = new FakeOAuthServer(); + $request = (new Psr17Factory())->createRequest('POST', 'http://mcp.example.com/mcp'); + + try { + $this->authenticator($server)->handleChallenge($request, $server->challenge()); + $this->fail('The insecure server was not refused.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('http://mcp.example.com/mcp', $e->getMessage()); + } + + $this->assertSame([], $server->requested); + } + + #[TestDox('legacy discovery does not make a server on plain http acceptable')] + public function testLegacyDiscoveryRefusesAnInsecureServer(): void + { + $server = new FakeOAuthServer(); + $request = (new Psr17Factory())->createRequest('POST', 'http://mcp.example.com/mcp'); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test')->setLegacyDiscovery(true)); + + $this->expectException(AuthorizationException::class); + + $authenticator->handleChallenge($request, $server->challenge()); + } + + #[TestDox('a server without an authorization endpoint is refused before the browser opens')] + public function testRefusesAMissingAuthorizationEndpoint(): void + { + $server = new FakeOAuthServer(hasAuthorizationEndpoint: false); + + try { + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge()); + $this->fail('The missing authorization endpoint was not refused.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('no authorization endpoint', $e->getMessage()); + } + + $this->assertSame([], $server->authorization); + $this->assertNotContains('https://auth.example.com/token', $server->requested); + } + + private function request(): RequestInterface + { + return (new Psr17Factory())->createRequest('POST', self::ENDPOINT); + } + + private function authenticator( + FakeOAuthServer $server, + ?AuthorizationHandlerInterface $handler = null, + ?InMemoryCredentialStorage $storage = null, + ?OAuth $configuration = null, + ): OAuthAuthenticator { + $factory = new Psr17Factory(); + + return new OAuthAuthenticator( + $this->configuration($configuration), + $storage ?? new InMemoryCredentialStorage(), + $handler ?? new StubAuthorizationHandler($server), + new MetadataDiscovery($server, $factory), + new ClientRegistrar($server, $factory, $factory), + new TokenEndpoint($server, $factory, $factory), + ); + } + + /** + * Reach into the builder for the configuration it would hand the authenticator, + * so the test and the public API cannot drift apart. + */ + private function configuration(?OAuth $oauth): OAuthConfiguration + { + $oauth ??= OAuth::forApplication('test-client'); + $property = new \ReflectionProperty(OAuthAuthenticator::class, 'configuration'); + + return $property->getValue($oauth->setAuthorizationHandler(new StubAuthorizationHandler())->build()); + } +} + +/** + * The authorization endpoint's answer, as a well-behaved user agent would report it. + */ +final class StubAuthorizationHandler implements AuthorizationHandlerInterface +{ + public function __construct( + private readonly ?FakeOAuthServer $server = null, + private readonly ?string $issuer = 'https://auth.example.com', + private readonly ?string $state = null, + ) { + } + + public function authorize(string $authorizationUrl, string $redirectUri): array + { + $this->server?->recordAuthorization($authorizationUrl); + parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query); + + $echoed = \is_string($query['state'] ?? null) ? $query['state'] : null; + + return array_filter([ + 'code' => 'test-authorization-code', + // An empty configured state stands for a response that carries none at all. + 'state' => '' === $this->state ? null : ($this->state ?? $echoed), + 'iss' => $this->issuer, + ], static fn (?string $value): bool => null !== $value); + } +} + +/** + * A protected resource and its authorization server, answering over PSR-18. + */ +final class FakeOAuthServer implements ClientInterface +{ + /** @var string[] */ + public array $requested = []; + + /** @var array */ + public array $registration = []; + + /** @var array */ + public array $authorization = []; + + /** @var array */ + public array $token = []; + + private int $issued = 0; + + /** + * @param ?string[] $resourceScopes + * @param string[] $serverScopes + */ + public function __construct( + private readonly string $resource = 'https://mcp.example.com/mcp', + private readonly ?array $resourceScopes = null, + private readonly array $serverScopes = [], + private readonly bool $insecureEndpoints = false, + private readonly string $authorizationServer = 'https://auth.example.com', + private readonly bool $refreshTokens = false, + private readonly bool $refuseOfflineAccess = false, + private readonly ?string $codeError = null, + private readonly bool $hasAuthorizationEndpoint = true, + ) { + } + + /** + * @param string[] $scopes + */ + public function challenge(array $scopes = [], int $status = 401, ?string $error = null): ResponseInterface + { + $parameters = 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"'; + + if ([] !== $scopes) { + $parameters = \sprintf('scope="%s", ', implode(' ', $scopes)).$parameters; + } + + if (null !== $error) { + $parameters = \sprintf('error="%s", ', $error).$parameters; + } + + return new Response($status, ['WWW-Authenticate' => 'Bearer '.$parameters]); + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $url = (string) $request->getUri(); + $this->requested[] = $url; + parse_str((string) $request->getBody(), $parsed); + $form = []; + + foreach ($parsed as $name => $value) { + if (\is_string($name) && \is_scalar($value)) { + $form[$name] = (string) $value; + } + } + + return match ($url) { + 'https://mcp.example.com/custom/location.json', + 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp' => self::json(array_filter([ + 'resource' => $this->resource, + 'authorization_servers' => [$this->authorizationServer], + 'scopes_supported' => $this->resourceScopes, + ], static fn (mixed $value): bool => null !== $value)), + + 'https://auth.example.com/.well-known/oauth-authorization-server' => self::json([ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => $this->hasAuthorizationEndpoint ? ($this->insecureEndpoints ? 'http' : 'https').'://auth.example.com/authorize' : null, + 'token_endpoint' => ($this->insecureEndpoints ? 'http' : 'https').'://auth.example.com/token', + 'registration_endpoint' => ($this->insecureEndpoints ? 'http' : 'https').'://auth.example.com/register', + 'scopes_supported' => $this->serverScopes, + 'grant_types_supported' => $this->hasAuthorizationEndpoint ? ['authorization_code', 'refresh_token'] : ['client_credentials'], + 'code_challenge_methods_supported' => ['S256'], + 'authorization_response_iss_parameter_supported' => true, + ]), + + 'https://auth.example.com/register' => $this->register((string) $request->getBody()), + 'https://auth.example.com/token' => $this->issueToken($form), + + default => new Response(404), + }; + } + + /** + * Called by the stub handler's caller: the authorization request never leaves the + * SDK as an HTTP call, so its parameters are recorded from the URL instead. + */ + public function recordAuthorization(string $url): void + { + parse_str((string) parse_url($url, \PHP_URL_QUERY), $query); + $flat = []; + + foreach ($query as $name => $value) { + if (\is_string($name) && \is_scalar($value)) { + $flat[$name] = (string) $value; + } + } + $this->authorization = $flat; + } + + private function register(string $body): ResponseInterface + { + $this->registration = json_decode($body, true) ?: []; + + return self::json(['client_id' => 'registered-client', 'client_secret' => 'registered-secret'], 201); + } + + /** + * @param array $form + */ + private function issueToken(array $form): ResponseInterface + { + $flat = []; + + foreach ($form as $name => $value) { + if (\is_string($name) && \is_scalar($value)) { + $flat[$name] = (string) $value; + } + } + $this->token = $flat; + $scope = $this->authorization['scope'] ?? ''; + + // Keycloak accepts offline_access at the authorization endpoint, and only refuses it + // here, for a user who may not have offline tokens. + if ($this->refuseOfflineAccess && 'authorization_code' === $form['grant_type'] && \in_array('offline_access', explode(' ', $scope), true)) { + return self::json(['error' => 'not_allowed', 'error_description' => 'Offline tokens not allowed for the user or client'], 400); + } + + if (null !== $this->codeError && 'authorization_code' === $form['grant_type']) { + return self::json(['error' => $this->codeError], 400); + } + + if (!$this->refreshTokens) { + return self::json(['access_token' => 'access-token-'.++$this->issued, 'token_type' => 'Bearer', 'expires_in' => 3600]); + } + + return self::json(['access_token' => 'access-token-'.++$this->issued, 'token_type' => 'Bearer', 'expires_in' => 3600, 'refresh_token' => 'refresh-token-'.$this->issued, 'scope' => $scope]); + } + + /** + * @param array $payload + */ + private static function json(array $payload, int $status = 200): ResponseInterface + { + return new Response($status, ['Content-Type' => 'application/json'], (string) json_encode($payload)); + } +} diff --git a/tests/Unit/Client/Auth/OAuthTest.php b/tests/Unit/Client/Auth/OAuthTest.php new file mode 100644 index 00000000..62b9f630 --- /dev/null +++ b/tests/Unit/Client/Auth/OAuthTest.php @@ -0,0 +1,88 @@ +configurationOf(OAuth::forApplication('My App')); + $this->assertSame(OAuth::DEFAULT_REDIRECT_URI, $configuration->redirectUri); + $this->assertSame('My App', $configuration->clientMetadata->clientName); + $this->assertSame(['http://127.0.0.1:8765/callback'], $configuration->clientMetadata->redirectUris); + $this->assertSame('native', $configuration->clientMetadata->applicationType); + $this->assertTrue($configuration->offlineAccess); + $this->assertNull($configuration->clientId); + } + + #[TestDox('the redirect URI the handler listens on is the one registered')] + public function testRedirectUriIsUsedForRegistration(): void + { + $configuration = $this->configurationOf(OAuth::forApplication('My App')->setRedirectUri('http://127.0.0.1:9999/cb')); + + $this->assertSame('http://127.0.0.1:9999/cb', $configuration->redirectUri); + $this->assertSame(['http://127.0.0.1:9999/cb'], $configuration->clientMetadata->redirectUris); + } + + #[TestDox('a client that wants no refresh token does not register for the grant either')] + public function testOfflineAccessDrivesTheRegisteredGrants(): void + { + $with = $this->configurationOf(OAuth::forApplication('My App')); + $without = $this->configurationOf(OAuth::forApplication('My App')->setOfflineAccess(false)); + + $this->assertSame(['authorization_code', 'refresh_token'], $with->clientMetadata->grantTypes); + $this->assertSame(['authorization_code'], $without->clientMetadata->grantTypes); + } + + #[TestDox('a nameless client is refused, because the user would see the name')] + public function testClientNameIsRequired(): void + { + $this->expectException(InvalidArgumentException::class); + + OAuth::forApplication(' '); + } + + #[TestDox('an application type outside the two OIDC values is refused')] + public function testApplicationTypeIsValidated(): void + { + $this->expectException(InvalidArgumentException::class); + + OAuth::forApplication('My App')->setApplicationType('mobile'); + } + + #[TestDox('registration metadata carries the software identity when given')] + public function testSoftwareInfo(): void + { + $metadata = $this->configurationOf(OAuth::forApplication('My App') + ->setSoftwareInfo('https://my-app.example.com', 'my-app', '2.1.0'))->clientMetadata; + + $body = $metadata->toArray(TokenEndpointAuthMethod::None); + + $this->assertSame('https://my-app.example.com', $body['client_uri']); + $this->assertSame('my-app', $body['software_id']); + $this->assertSame('2.1.0', $body['software_version']); + } + + private function configurationOf(OAuth $oauth): OAuthConfiguration + { + return (new \ReflectionProperty(OAuthAuthenticator::class, 'configuration'))->getValue($oauth->build()); + } +} diff --git a/tests/Unit/Client/Auth/PkceTest.php b/tests/Unit/Client/Auth/PkceTest.php new file mode 100644 index 00000000..9d1b6058 --- /dev/null +++ b/tests/Unit/Client/Auth/PkceTest.php @@ -0,0 +1,53 @@ +verifier, true)), '+/', '-_'), '='); + + $this->assertSame($expected, $pkce->challenge); + $this->assertSame('S256', Pkce::METHOD); + } + + #[TestDox('the verifier fits the length and alphabet RFC 7636 allows')] + public function testVerifierShape(): void + { + $verifier = Pkce::generate()->verifier; + + $this->assertGreaterThanOrEqual(43, \strlen($verifier)); + $this->assertLessThanOrEqual(128, \strlen($verifier)); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9\-._~]+$/', $verifier); + } + + #[TestDox('two pairs never come out the same')] + public function testEachPairIsFresh(): void + { + $this->assertNotSame(Pkce::generate()->verifier, Pkce::generate()->verifier); + } + + #[TestDox('a stored verifier reproduces the challenge it was sent with')] + public function testFromVerifier(): void + { + $pkce = Pkce::generate(); + + $this->assertSame($pkce->challenge, Pkce::fromVerifier($pkce->verifier)->challenge); + } +} diff --git a/tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php b/tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php new file mode 100644 index 00000000..2d99e81e --- /dev/null +++ b/tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php @@ -0,0 +1,59 @@ + + */ + public static function negotiationProvider(): iterable + { + yield 'a public client can only be public' => [ + ['client_secret_basic', 'none'], false, TokenEndpointAuthMethod::None, + ]; + + yield 'a client with a secret prefers the header over the body' => [ + ['client_secret_post', 'client_secret_basic'], true, TokenEndpointAuthMethod::ClientSecretBasic, + ]; + + yield 'a server offering only the body form gets it' => [ + ['client_secret_post'], true, TokenEndpointAuthMethod::ClientSecretPost, + ]; + + yield 'a server that only takes public clients gets one' => [ + ['none'], true, TokenEndpointAuthMethod::None, + ]; + + // RFC 6749 makes Basic the one method every server must accept, so it is the + // right guess for a server that documents nothing. + yield 'a silent server is assumed to take Basic' => [ + [], true, TokenEndpointAuthMethod::ClientSecretBasic, + ]; + + yield 'a silent server with a public client still gets none' => [ + [], false, TokenEndpointAuthMethod::None, + ]; + } + + #[DataProvider('negotiationProvider')] + #[TestDox('the token endpoint method is negotiated for: $_dataName')] + public function testNegotiate(array $supported, bool $hasSecret, TokenEndpointAuthMethod $expected): void + { + $this->assertSame($expected, TokenEndpointAuthMethod::negotiate($supported, $hasSecret)); + } +} diff --git a/tests/Unit/Client/Auth/TokenEndpointTest.php b/tests/Unit/Client/Auth/TokenEndpointTest.php new file mode 100644 index 00000000..7265fc86 --- /dev/null +++ b/tests/Unit/Client/Auth/TokenEndpointTest.php @@ -0,0 +1,173 @@ +}> + */ + public static function authenticationProvider(): iterable + { + yield 'a public client sends only its id' => [ + TokenEndpointAuthMethod::None, + null, + ['client_id' => 'a-client'], + ]; + + yield 'a secret in the header' => [ + TokenEndpointAuthMethod::ClientSecretBasic, + 'Basic '.base64_encode('a-client:a-secret'), + ['client_id' => 'a-client'], + ]; + + yield 'a secret in the body' => [ + TokenEndpointAuthMethod::ClientSecretPost, + null, + ['client_id' => 'a-client', 'client_secret' => 'a-secret'], + ]; + } + + #[DataProvider('authenticationProvider')] + #[TestDox('the client authenticates with: $_dataName')] + public function testClientAuthentication(TokenEndpointAuthMethod $method, ?string $authorization, array $expectedFields): void + { + $server = new CapturingTokenServer(); + $this->endpoint($server)->request( + $this->metadata(), + new ClientRegistration('a-client', 'a-secret', $method), + ['grant_type' => 'authorization_code', 'code' => 'a-code'], + ); + + $this->assertSame($authorization, $server->authorization); + + foreach ($expectedFields as $field => $value) { + $this->assertSame($value, $server->form[$field] ?? null, \sprintf('Expected "%s" in the request body.', $field)); + } + + if (TokenEndpointAuthMethod::ClientSecretPost !== $method) { + $this->assertArrayNotHasKey('client_secret', $server->form); + } + } + + // RFC 6749 section 2.3.1 form-urlencodes both halves before joining them, which is + // the difference between a secret containing a colon working and silently not. + #[TestDox('a Basic credential is form-encoded before it is joined')] + public function testBasicCredentialsAreEncoded(): void + { + $server = new CapturingTokenServer(); + $this->endpoint($server)->request( + $this->metadata(), + new ClientRegistration('a client', 'se:cret', TokenEndpointAuthMethod::ClientSecretBasic), + ['grant_type' => 'authorization_code'], + ); + + $this->assertSame('a%20client:se%3Acret', base64_decode(substr((string) $server->authorization, 6), true)); + } + + // Signing with the HMAC family would use the private key as a shared secret, which + // is `client_secret_jwt` wearing the wrong name -- a downgrade, not a signature. + #[TestDox('the authorization server\'s own error text survives into the exception')] + public function testErrorResponse(): void + { + $server = new CapturingTokenServer(new Response(400, [], (string) json_encode([ + 'error' => 'invalid_grant', + 'error_description' => 'The code has expired.', + ]))); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('invalid_grant (The code has expired.)'); + + $this->endpoint($server)->request($this->metadata(), new ClientRegistration('a-client'), ['grant_type' => 'authorization_code']); + } + + // A token endpoint answering 200 in a shape this client cannot read is still handing + // over a live token; quoting the body back would put it in every log downstream. + #[TestDox('an unreadable success body is never quoted into the exception')] + public function testDoesNotLeakAnUnreadableSuccessBody(): void + { + $server = new CapturingTokenServer(new Response(200, ['Content-Type' => 'application/x-www-form-urlencoded'], 'access_token=super-secret-token&token_type=bearer')); + + try { + $this->endpoint($server)->request($this->metadata(), new ClientRegistration('a-client'), ['grant_type' => 'authorization_code']); + $this->fail('An unreadable token response should not have been accepted.'); + } catch (AuthorizationException $e) { + $this->assertStringNotContainsString('super-secret-token', $e->getMessage()); + $this->assertStringContainsString('could not read as JSON', $e->getMessage()); + } + } + + private function endpoint(ClientInterface $client): TokenEndpoint + { + $factory = new Psr17Factory(); + + return new TokenEndpoint($client, $factory, $factory); + } + + private function metadata(): AuthorizationServerMetadata + { + return new AuthorizationServerMetadata( + 'https://auth.example.com', + 'https://auth.example.com/authorize', + 'https://auth.example.com/token', + ); + } +} + +/** + * Records the token request and answers with a canned response. + */ +final class CapturingTokenServer implements ClientInterface +{ + public ?string $authorization = null; + + /** @var array */ + public array $form = []; + + public function __construct(private readonly ?ResponseInterface $response = null) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->authorization = $request->hasHeader('Authorization') ? $request->getHeaderLine('Authorization') : null; + parse_str((string) $request->getBody(), $parsed); + $this->form = []; + + foreach ($parsed as $name => $value) { + if (\is_string($name) && \is_scalar($value)) { + $this->form[$name] = (string) $value; + } + } + + return $this->response ?? new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([ + 'access_token' => 'a-token', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + ])); + } +} From 8f432191792c5a40ecdf8bc11f99db6612cbb658 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 4 Nov 2026 23:34:00 +0100 Subject: [PATCH 4/5] [Client] Add client ID metadata documents and a console authorization handler setClientMetadataUrl() uses a published document as the client id where the authorization server supports it, and otherwise registers as before. The URL has to be https with a path. ConsoleAuthorizationHandler prints the authorization URL and reads back the redirected URL, for a shell without a browser. It refuses a bare code, which would skip the state and iss checks. --- CHANGELOG.md | 1 + docs/client/authorization.md | 13 +++- .../Auth/ConsoleAuthorizationHandler.php | 73 +++++++++++++++++++ .../Auth/HeadlessAuthorizationHandler.php | 2 +- src/Client/Auth/OAuth.php | 25 +++++++ src/Client/Auth/OAuthAuthenticator.php | 9 +++ src/Client/Auth/OAuthConfiguration.php | 23 +++--- tests/Conformance/client.php | 6 ++ .../conformance-baseline-2025-11-25.yml | 1 - .../conformance-baseline-2026-07-28.yml | 1 - .../Client/Auth/AuthorizationHandlerTest.php | 67 +++++++++++++++++ .../Client/Auth/OAuthAuthenticatorTest.php | 42 +++++++++++ tests/Unit/Client/Auth/OAuthTest.php | 21 ++++++ 13 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 src/Client/Auth/ConsoleAuthorizationHandler.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b7451360..f1d0d8ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to `mcp/sdk` will be documented in this file. ### Client * Add OAuth authorization: pass `OAuth::forApplication('My App')->build()` as `auth` and the client answers a `401` with discovery, dynamic or pre-registered client identity, the authorization code flow with PKCE and a retry. It refreshes tokens, handles step-up challenges and treats the server's metadata as untrusted input. See [Authorization](docs/client/authorization.md). +* Add `OAuth::setClientMetadataUrl()`, to use a client ID metadata document where the authorization server supports it, and `ConsoleAuthorizationHandler` for a shell without a browser. * Add `LoopbackAuthorizationHandler` and `HeadlessAuthorizationHandler` behind `AuthorizationHandlerInterface`, and `InMemoryCredentialStorage` and `FileCredentialStorage` behind `CredentialStorageInterface`. * Add the `auth` parameter of `HttpTransport` and `AuthenticatorInterface`, to authenticate every request and retry the ones the server refuses. `BearerToken` sends a token you already have, and `AuthenticatingHttpClient` offers the same behavior as a PSR-18 decorator. * [BC Break] A `401` or `403` answer to a request, a notification or the resumption of an event stream fails with an `AuthorizationException` that contains the `WWW-Authenticate` header, instead of a `RequestException`. `Client::connect()` wraps it in a `ConnectionException`, without a retry. diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 6d1f36ed..4db69403 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -47,9 +47,10 @@ $auth = OAuth::forApplication('My App') ->build(); ``` -The SDK contains two handlers: +The SDK contains three handlers: - `LoopbackAuthorizationHandler` is the default. It opens the system browser and listens on the port of the redirect URI until the browser returns. Use it for desktop and command line applications. +- `ConsoleAuthorizationHandler` prints the authorization URL and reads the URL of the redirect from the standard input. Use it in a remote shell or a container without a browser. The user must paste the whole URL, because the SDK checks its `state` and `iss`. - `HeadlessAuthorizationHandler` requests the authorization URL itself and reads the code from the redirect. It only works if the authorization server grants access without asking the user (e.g. a test server). For anything else, implement `AuthorizationHandlerInterface`. It has one method: @@ -99,6 +100,16 @@ OAuth::forApplication('My App')->setClientCredentials('my-client-id', 'my-client The client reads from the metadata of the authorization server how to send the secret. +## Using a URL as the Client ID + +If you publish a client ID metadata document, the authorization server can read your client's metadata from its URL. You then don't need a registration per server. The URL must use `https` and have a path: + +```php +OAuth::forApplication('My App')->setClientMetadataUrl('https://app.example.com/client-metadata.json'); +``` + +The client only uses this URL if the authorization server supports these documents. Otherwise, it registers. A client ID that you pass with `setClientCredentials()` takes precedence. + ## Choosing Scopes By default, the client requests the scopes of the challenge. If there are none, it requests all scopes in `scopes_supported` of the resource. If there are none either, it sends no `scope` parameter. Use `setScopes()` to request your own: diff --git a/src/Client/Auth/ConsoleAuthorizationHandler.php b/src/Client/Auth/ConsoleAuthorizationHandler.php new file mode 100644 index 00000000..711d92f3 --- /dev/null +++ b/src/Client/Auth/ConsoleAuthorizationHandler.php @@ -0,0 +1,73 @@ + + */ +final class ConsoleAuthorizationHandler implements AuthorizationHandlerInterface +{ + /** + * @param resource $input stream the redirected URL is read from + * @param resource $output stream the prompt is written to + */ + public function __construct( + private readonly mixed $input = \STDIN, + private readonly mixed $output = \STDERR, + ) { + } + + public function authorize(string $authorizationUrl, string $redirectUri): array + { + fwrite($this->output, \sprintf( + "\nOpen this URL to authorize the application:\n\n %s\n\nThen paste the full URL you were redirected to: ", + $authorizationUrl, + )); + + $answer = fgets($this->input); + + if (false === $answer || '' === trim($answer)) { + throw new AuthorizationException('No authorization response was provided.'); + } + + $answer = trim($answer); + $query = parse_url($answer, \PHP_URL_QUERY); + + if (!\is_string($query) || '' === $query) { + // A bare code cannot carry the state and issuer the response is checked + // against, so accepting one would quietly skip both checks. + throw new AuthorizationException('Paste the whole URL you were redirected to, not just the code: the rest of it is what proves the response belongs to this request.'); + } + + parse_str($query, $parameters); + + $callback = []; + + foreach ($parameters as $name => $value) { + if (\is_string($name) && \is_scalar($value)) { + $callback[$name] = (string) $value; + } + } + + return $callback; + } +} diff --git a/src/Client/Auth/HeadlessAuthorizationHandler.php b/src/Client/Auth/HeadlessAuthorizationHandler.php index a9b00299..a07261c1 100644 --- a/src/Client/Auth/HeadlessAuthorizationHandler.php +++ b/src/Client/Auth/HeadlessAuthorizationHandler.php @@ -23,7 +23,7 @@ * That only works against an authorization server that grants without an interactive * prompt -- a conformance harness, a test double, or an enterprise IdP that has already * consented on the user's behalf. Anywhere a human is supposed to approve something, - * use {@see LoopbackAuthorizationHandler}. + * use {@see LoopbackAuthorizationHandler} or {@see ConsoleAuthorizationHandler}. * * The request is made over a plain socket rather than the PSR-18 client on purpose: * PSR-18 leaves redirect following to the implementation, and this handler's whole job diff --git a/src/Client/Auth/OAuth.php b/src/Client/Auth/OAuth.php index 8721c4c6..1f280122 100644 --- a/src/Client/Auth/OAuth.php +++ b/src/Client/Auth/OAuth.php @@ -47,6 +47,7 @@ final class OAuth private string $redirectUri = self::DEFAULT_REDIRECT_URI; private ?string $clientId = null; private ?string $clientSecret = null; + private ?string $clientMetadataUrl = null; private string $applicationType = 'native'; private ?string $clientUri = null; private ?string $softwareId = null; @@ -108,6 +109,29 @@ public function setClientCredentials(string $clientId, ?string $clientSecret = n return $this; } + /** + * Publish the client's identity at a URL and use that URL as the client id. + * + * Servers that support client id metadata documents read the client's metadata from + * this URL, so one identity works everywhere instead of one registration per server. + * The document must be reachable by the authorization server. A client id set with + * {@see self::setClientCredentials()} takes precedence. + * + * @param string $url an https URL with a path, as client id metadata documents require + */ + public function setClientMetadataUrl(string $url): self + { + $parts = parse_url($url); + + if (!\is_array($parts) || 'https' !== strtolower($parts['scheme'] ?? '') || !isset($parts['host']) || '' === trim($parts['path'] ?? '', '/')) { + throw new InvalidArgumentException(\sprintf('The client metadata URL must be an https URL with a path, got "%s".', $url)); + } + + $this->clientMetadataUrl = $url; + + return $this; + } + /** * Ask for these scopes, rather than whatever the server asks for. * @@ -248,6 +272,7 @@ public function build(): OAuthAuthenticator $this->redirectUri, $this->clientId, $this->clientSecret, + $this->clientMetadataUrl, $this->scopes, $this->offlineAccess, $this->legacyDiscovery, diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 366d6da5..b6054aa3 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -460,6 +460,15 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco ); } + // A client that can publish its metadata at a URL does not need to register at + // all: the URL is the client id, and every server it meets reads the same + // document instead of minting another identity for it. + if ($metadata->clientIdMetadataDocumentSupported && null !== $configuration->clientMetadataUrl) { + $this->logger->debug('Using the client metadata document URL as the client id', ['client_id' => $configuration->clientMetadataUrl]); + + return new ClientRegistration($configuration->clientMetadataUrl, null, TokenEndpointAuthMethod::negotiate($metadata->tokenEndpointAuthMethodsSupported, false)); + } + $stored = $this->storage->getClientRegistration($metadata->issuer); if (null !== $stored && !$stored->isExpired()) { diff --git a/src/Client/Auth/OAuthConfiguration.php b/src/Client/Auth/OAuthConfiguration.php index cd110d8a..66753ce1 100644 --- a/src/Client/Auth/OAuthConfiguration.php +++ b/src/Client/Auth/OAuthConfiguration.php @@ -25,22 +25,25 @@ final class OAuthConfiguration { /** - * @param ClientMetadata $clientMetadata what the client says about itself when registering - * @param string $redirectUri where the authorization server sends the user back - * @param ?string $clientId a client id issued out of band, skipping registration - * @param ?string $clientSecret the matching secret, when there is one - * @param string[] $scopes scopes to request regardless of what is advertised - * @param bool $offlineAccess whether to ask for a refresh token where the server offers one - * @param bool $legacyDiscovery whether to fall back to pre-2025-06-18 discovery when a - * server publishes no protected resource metadata - * @param ?string $resource the canonical resource URI to request tokens for, when it - * should not be derived from the endpoint being called + * @param ClientMetadata $clientMetadata what the client says about itself when registering + * @param string $redirectUri where the authorization server sends the user back + * @param ?string $clientId a client id issued out of band, skipping registration + * @param ?string $clientSecret the matching secret, when there is one + * @param ?string $clientMetadataUrl a URL serving the client metadata document, usable as a + * client id against servers that accept one + * @param string[] $scopes scopes to request regardless of what is advertised + * @param bool $offlineAccess whether to ask for a refresh token where the server offers one + * @param bool $legacyDiscovery whether to fall back to pre-2025-06-18 discovery when a + * server publishes no protected resource metadata + * @param ?string $resource the canonical resource URI to request tokens for, when it + * should not be derived from the endpoint being called */ public function __construct( public readonly ClientMetadata $clientMetadata, public readonly string $redirectUri = OAuth::DEFAULT_REDIRECT_URI, public readonly ?string $clientId = null, public readonly ?string $clientSecret = null, + public readonly ?string $clientMetadataUrl = null, public readonly array $scopes = [], public readonly bool $offlineAccess = true, public readonly bool $legacyDiscovery = false, diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php index 7da7a14d..1dce1a97 100644 --- a/tests/Conformance/client.php +++ b/tests/Conformance/client.php @@ -127,6 +127,12 @@ function buildAuthenticator(string $scenario, array $context, Psr\Log\LoggerInte $oauth->setLegacyDiscovery(true); } + // The client metadata document only needs to be a stable URL the authorization + // server recognises; the harness never dereferences it. + if ('auth/basic-cimd' === $scenario) { + $oauth->setClientMetadataUrl('https://conformance-test.local/client-metadata.json'); + } + return $oauth->build(); } diff --git a/tests/Conformance/conformance-baseline-2025-11-25.yml b/tests/Conformance/conformance-baseline-2025-11-25.yml index 699cafdd..c53cc62c 100644 --- a/tests/Conformance/conformance-baseline-2025-11-25.yml +++ b/tests/Conformance/conformance-baseline-2025-11-25.yml @@ -1,7 +1,6 @@ client: # Elicitation defaults (SEP-1034) are not filled in when accepting. - elicitation-sep1034-client-defaults - - auth/basic-cimd - auth/client-credentials-jwt - auth/client-credentials-basic - auth/cross-app-access-complete-flow diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index cf86e6a7..8bd7b97e 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -1,3 +1,2 @@ client: - http-invalid-tool-headers:sep-2243-invalid-tool-tools-list-gate - - auth/basic-cimd diff --git a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php index 26f6da3d..21885978 100644 --- a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php +++ b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Client\Auth; +use Mcp\Client\Auth\ConsoleAuthorizationHandler; use Mcp\Client\Auth\LoopbackAuthorizationHandler; use Mcp\Exception\AuthorizationException; use PHPUnit\Framework\Attributes\TestDox; @@ -87,8 +88,74 @@ public function testLoopbackHandlerTimesOut(): void ->authorize('https://auth.example.com/authorize', \sprintf('http://127.0.0.1:%d/callback', self::freePort())); } + #[TestDox('the console handler accepts a pasted redirect URL, parameters and all')] + public function testConsoleHandlerParsesAPastedUrl(): void + { + $this->assertSame( + ['code' => 'abc', 'state' => 'xyz'], + $this->console("http://127.0.0.1:8765/callback?code=abc&state=xyz\n"), + ); + } + // A bare code carries neither state nor iss, so accepting one would silently skip // both of the checks that tie the response to this request. + #[TestDox('the console handler refuses a bare code, which would skip the response checks')] + public function testConsoleHandlerRefusesABareCode(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Paste the whole URL'); + + $this->console("abc\n"); + } + + #[TestDox('the console handler shows the URL it wants opened')] + public function testConsoleHandlerPrintsTheUrl(): void + { + $output = fopen('php://memory', 'r+'); + $this->assertIsResource($output); + + $this->console("http://127.0.0.1:8765/callback?code=abc\n", $output); + rewind($output); + + $this->assertStringContainsString('https://auth.example.com/authorize', (string) stream_get_contents($output)); + fclose($output); + } + + #[TestDox('the console handler gives up when nothing is pasted')] + public function testConsoleHandlerRequiresAnAnswer(): void + { + $this->expectException(AuthorizationException::class); + + $this->console("\n"); + } + + /** + * @param resource|null $output + * + * @return array + */ + private function console(string $answer, mixed $output = null): array + { + $input = fopen('php://memory', 'r+'); + $this->assertIsResource($input); + fwrite($input, $answer); + rewind($input); + + $sink = $output ?? fopen('php://memory', 'r+'); + $this->assertIsResource($sink); + + try { + return (new ConsoleAuthorizationHandler($input, $sink)) + ->authorize('https://auth.example.com/authorize', 'http://127.0.0.1:8765/callback'); + } finally { + fclose($input); + + if (null === $output) { + fclose($sink); + } + } + } + private static function freePort(): int { $socket = stream_socket_server('tcp://127.0.0.1:0'); diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 8700c06f..e1bad44b 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -487,6 +487,46 @@ public function testRefusesAMissingAuthorizationEndpoint(): void $this->assertNotContains('https://auth.example.com/token', $server->requested); } + #[TestDox('a client metadata URL is the client id where the server supports it')] + public function testUsesTheClientMetadataUrlAsClientId(): void + { + $server = new FakeOAuthServer(clientMetadataDocuments: true); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test') + ->setClientMetadataUrl('https://app.example.com/client.json')); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $this->assertNotContains('https://auth.example.com/register', $server->requested); + $this->assertSame('https://app.example.com/client.json', $server->authorization['client_id'] ?? null); + $this->assertSame('https://app.example.com/client.json', $server->token['client_id'] ?? null); + } + + #[TestDox('a client metadata URL is ignored where the server does not support it')] + public function testRegistersWithoutClientMetadataSupport(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test') + ->setClientMetadataUrl('https://app.example.com/client.json')); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $this->assertContains('https://auth.example.com/register', $server->requested); + $this->assertNotSame('https://app.example.com/client.json', $server->authorization['client_id'] ?? null); + } + + #[TestDox('a configured client id wins over a client metadata URL')] + public function testConfiguredClientIdWinsOverTheClientMetadataUrl(): void + { + $server = new FakeOAuthServer(clientMetadataDocuments: true); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test') + ->setClientCredentials('known-client') + ->setClientMetadataUrl('https://app.example.com/client.json')); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('known-client', $server->authorization['client_id'] ?? null); + } + private function request(): RequestInterface { return (new Psr17Factory())->createRequest('POST', self::ENDPOINT); @@ -584,6 +624,7 @@ public function __construct( private readonly bool $refuseOfflineAccess = false, private readonly ?string $codeError = null, private readonly bool $hasAuthorizationEndpoint = true, + private readonly bool $clientMetadataDocuments = false, ) { } @@ -631,6 +672,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface 'authorization_endpoint' => $this->hasAuthorizationEndpoint ? ($this->insecureEndpoints ? 'http' : 'https').'://auth.example.com/authorize' : null, 'token_endpoint' => ($this->insecureEndpoints ? 'http' : 'https').'://auth.example.com/token', 'registration_endpoint' => ($this->insecureEndpoints ? 'http' : 'https').'://auth.example.com/register', + 'client_id_metadata_document_supported' => $this->clientMetadataDocuments, 'scopes_supported' => $this->serverScopes, 'grant_types_supported' => $this->hasAuthorizationEndpoint ? ['authorization_code', 'refresh_token'] : ['client_credentials'], 'code_challenge_methods_supported' => ['S256'], diff --git a/tests/Unit/Client/Auth/OAuthTest.php b/tests/Unit/Client/Auth/OAuthTest.php index 62b9f630..48e9ff7a 100644 --- a/tests/Unit/Client/Auth/OAuthTest.php +++ b/tests/Unit/Client/Auth/OAuthTest.php @@ -16,6 +16,7 @@ use Mcp\Client\Auth\OAuthConfiguration; use Mcp\Client\Auth\TokenEndpointAuthMethod; use Mcp\Exception\InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -33,6 +34,26 @@ public function testApplicationDefaults(): void $this->assertNull($configuration->clientId); } + /** + * @return iterable + */ + public static function invalidClientMetadataUrlProvider(): iterable + { + yield 'plain http' => ['http://app.example.com/client.json']; + yield 'no path' => ['https://app.example.com']; + yield 'the root path' => ['https://app.example.com/']; + yield 'not a URL' => ['client.json']; + } + + #[DataProvider('invalidClientMetadataUrlProvider')] + #[TestDox('a client metadata URL needs https and a path: $_dataName')] + public function testRejectsAnInvalidClientMetadataUrl(string $url): void + { + $this->expectException(InvalidArgumentException::class); + + OAuth::forApplication('My App')->setClientMetadataUrl($url); + } + #[TestDox('the redirect URI the handler listens on is the one registered')] public function testRedirectUriIsUsedForRegistration(): void { From 6c78d1a12555fee21cf70e79bf7a8147c156d6e1 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 5 Nov 2026 02:22:27 +0100 Subject: [PATCH 5/5] [Client] Add client credentials, private_key_jwt and cross-app access forServiceAccount() runs without a user, setPrivateKeyJwt() replaces the shared secret with a signed assertion and setCrossAppAccess() exchanges an enterprise identity token for a grant. --- CHANGELOG.md | 1 + composer.json | 2 +- docs/client/authorization.md | 46 ++++++++ docs/get-started/installation.md | 1 + src/Client/Auth/CrossAppAccess.php | 50 +++++++++ src/Client/Auth/Grant.php | 9 ++ src/Client/Auth/OAuth.php | 69 +++++++++++- src/Client/Auth/OAuthAuthenticator.php | 54 ++++++++- src/Client/Auth/OAuthConfiguration.php | 46 ++++++-- src/Client/Auth/TokenEndpoint.php | 48 +++++++- src/Client/Auth/TokenEndpointAuthMethod.php | 13 ++- tests/Conformance/client.php | 41 ++++++- .../conformance-baseline-2025-11-25.yml | 3 - .../Client/Auth/OAuthAuthenticatorTest.php | 87 +++++++++++++++ tests/Unit/Client/Auth/OAuthTest.php | 66 +++++++++++ .../Auth/TokenEndpointAuthMethodTest.php | 30 +++-- tests/Unit/Client/Auth/TokenEndpointTest.php | 103 ++++++++++++++++++ 17 files changed, 631 insertions(+), 38 deletions(-) create mode 100644 src/Client/Auth/CrossAppAccess.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d0d8ac..5404949b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to `mcp/sdk` will be documented in this file. ### Client * Add OAuth authorization: pass `OAuth::forApplication('My App')->build()` as `auth` and the client answers a `401` with discovery, dynamic or pre-registered client identity, the authorization code flow with PKCE and a retry. It refreshes tokens, handles step-up challenges and treats the server's metadata as untrusted input. See [Authorization](docs/client/authorization.md). +* Add `OAuth::forServiceAccount()` for the client credentials grant, `OAuth::setPrivateKeyJwt()` for `private_key_jwt` client authentication and `OAuth::setCrossAppAccess()` to reach a server through an enterprise identity. * Add `OAuth::setClientMetadataUrl()`, to use a client ID metadata document where the authorization server supports it, and `ConsoleAuthorizationHandler` for a shell without a browser. * Add `LoopbackAuthorizationHandler` and `HeadlessAuthorizationHandler` behind `AuthorizationHandlerInterface`, and `InMemoryCredentialStorage` and `FileCredentialStorage` behind `CredentialStorageInterface`. * Add the `auth` parameter of `HttpTransport` and `AuthenticatorInterface`, to authenticate every request and retry the ones the server refuses. `BearerToken` sends a token you already have, and `AuthenticatingHttpClient` offers the same behavior as a PSR-18 decorator. diff --git a/composer.json b/composer.json index 4b1ea1e2..93a19888 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "suggest": { - "firebase/php-jwt": "Required for the JwtTokenValidator.", + "firebase/php-jwt": "Required for the JwtTokenValidator, and for private_key_jwt client authentication in the OAuth client.", "psr/cache-implementation": "Required for JwtTokenValidator::fromIssuer() to cache metadata and keys.", "symfony/finder": "Required for file-based discovery.", "symfony/lock": "Required for the SymfonyLockSessionLock." diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 4db69403..b93a5734 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -110,6 +110,52 @@ OAuth::forApplication('My App')->setClientMetadataUrl('https://app.example.com/c The client only uses this URL if the authorization server supports these documents. Otherwise, it registers. A client ID that you pass with `setClientCredentials()` takes precedence. +## Running Without a User + +A background job has no user who can approve a request. Use `forServiceAccount()` with a client ID and a secret that an administrator created for you: + +```php +use Mcp\Client\Auth\OAuth; + +$auth = OAuth::forServiceAccount('Acme Report Job', 'my-client-id', 'my-client-secret')->build(); +``` + +The client then authenticates as itself with the client credentials grant. It doesn't open a browser and doesn't register. If you pass no secret, you must call `setPrivateKeyJwt()`. + +## Authenticating With a Private Key + +An authorization server can accept a signed assertion in place of a shared secret. This needs the `firebase/php-jwt` package: + +```bash +composer require firebase/php-jwt +``` + +Pass your key with `setPrivateKeyJwt()`: + +```php +// the default algorithm is RS256 +OAuth::forServiceAccount('Acme Report Job', 'my-client-id') + ->setPrivateKeyJwt(file_get_contents('/etc/acme/private-key.pem'), 'ES256'); +``` + +The algorithm can be `RS256`, `RS384`, `RS512`, `PS256`, `ES256`, `ES256K`, `ES384` or `EdDSA`. Use one that the authorization server lists in `token_endpoint_auth_signing_alg_values_supported`. `PS256` also needs `phpseclib/phpseclib` and `EdDSA` needs the `sodium` extension. You can also use a key for a user-facing client, together with `setClientCredentials()`. + +## Using an Enterprise Identity + +If your users already signed in at a company identity provider, they don't need to approve each MCP server. With cross-app access, the client exchanges their identity token for a grant for one MCP server. It then gets the access token with this grant: + +```php +use Mcp\Client\Auth\CrossAppAccess; +use Mcp\Client\Auth\OAuth; + +$auth = OAuth::forApplication('My App') + ->setClientCredentials('my-client-id', 'my-client-secret') + ->setCrossAppAccess(new CrossAppAccess('https://idp.example.com/token', $identityToken)) + ->build(); +``` + +Your application gets `$identityToken` from its own sign-in. The token endpoint of the identity provider must use `https`, unless it runs on a loopback address. If the client has another ID at the identity provider, pass it as the `clientId` argument of `CrossAppAccess`. + ## Choosing Scopes By default, the client requests the scopes of the challenge. If there are none, it requests all scopes in `scopes_supported` of the resource. If there are none either, it sends no `scope` parameter. Use `setScopes()` to request your own: diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index 5dc7548d..770f803e 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -27,6 +27,7 @@ Which extras you need depends on what you build: | emit the response from a standalone HTTP entry point | `laminas/laminas-httphandlerrunner` | | [connect a client over HTTP](../client/transports.md) | any PSR-18 client, e.g. `symfony/http-client` | | [validate JWT access tokens](../run/authorization.md) | `firebase/php-jwt` | +| [authenticate a client with a private key](../client/authorization.md#authenticating-with-a-private-key) | `firebase/php-jwt` | | store sessions, cache discovery or share notifications through a PSR-6 or PSR-16 cache | `psr/cache` or `psr/simple-cache` implementation, e.g. `symfony/cache` | | [fetch JWT keys from an issuer](../run/authorization.md) with `JwtTokenValidator::fromIssuer()` | `psr/cache` (PSR-6) implementation, e.g. `symfony/cache` | diff --git a/src/Client/Auth/CrossAppAccess.php b/src/Client/Auth/CrossAppAccess.php new file mode 100644 index 00000000..292f9d35 --- /dev/null +++ b/src/Client/Auth/CrossAppAccess.php @@ -0,0 +1,50 @@ + + */ +final class CrossAppAccess +{ + public const AUTHORIZATION_GRANT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id-jag'; + + public const ID_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token'; + + /** + * @param string $tokenEndpoint the token endpoint of the identity provider + * @param string $identityToken the token identifying the user, usually an OIDC ID token + * @param string $identityTokenType the RFC 8693 type of that token + * @param ?string $clientId the client id at the identity provider, when it differs + * from the client id at the authorization server + * + * @throws InvalidArgumentException when the token endpoint is neither https nor loopback + */ + public function __construct( + public readonly string $tokenEndpoint, + public readonly string $identityToken, + public readonly string $identityTokenType = self::ID_TOKEN_TYPE, + public readonly ?string $clientId = null, + ) { + if (!AuthorizationServerMetadata::isTransportSecure($tokenEndpoint)) { + throw new InvalidArgumentException(\sprintf('The identity provider token endpoint "%s" is neither an HTTPS URL nor a loopback address.', $tokenEndpoint)); + } + } +} diff --git a/src/Client/Auth/Grant.php b/src/Client/Auth/Grant.php index aa4536a0..ddb1efaf 100644 --- a/src/Client/Auth/Grant.php +++ b/src/Client/Auth/Grant.php @@ -23,6 +23,15 @@ enum Grant: string /** A user approves the request in a browser; the default for anything with a human behind it. */ case AuthorizationCode = 'authorization_code'; + /** The client acts as itself, with no user involved -- daemons, cron jobs, service accounts. */ + case ClientCredentials = 'client_credentials'; + /** Trades a still-valid refresh token for a fresh access token. */ case RefreshToken = 'refresh_token'; + + /** Exchanges a token the client already holds for one scoped to this resource (RFC 8693). */ + case TokenExchange = 'urn:ietf:params:oauth:grant-type:token-exchange'; + + /** Presents an assertion signed by a party the authorization server trusts (RFC 7523). */ + case JwtBearer = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; } diff --git a/src/Client/Auth/OAuth.php b/src/Client/Auth/OAuth.php index 1f280122..3c45be39 100644 --- a/src/Client/Auth/OAuth.php +++ b/src/Client/Auth/OAuth.php @@ -29,8 +29,8 @@ * * $transport = new HttpTransport($url, auth: OAuth::forApplication('My App')->build()); * - * Everything else is a deviation from that: a pre-registered client id, credentials - * that should outlive the process. + * Everything else is a deviation from that: a pre-registered client id, a machine + * account with no user, credentials that should outlive the process. * * @author Christopher Hertel */ @@ -47,7 +47,11 @@ final class OAuth private string $redirectUri = self::DEFAULT_REDIRECT_URI; private ?string $clientId = null; private ?string $clientSecret = null; + private Grant $grant = Grant::AuthorizationCode; private ?string $clientMetadataUrl = null; + private ?string $privateKeyPem = null; + private string $signingAlgorithm = 'RS256'; + private ?CrossAppAccess $crossAppAccess = null; private string $applicationType = 'native'; private ?string $clientUri = null; private ?string $softwareId = null; @@ -85,6 +89,23 @@ public static function forApplication(string $clientName): self return new self($clientName); } + /** + * A client acting as itself, with no user and no browser (RFC 6749 section 4.4). + * + * The credentials have to be issued out of band: there is nobody to consent to a + * dynamic registration. + */ + public static function forServiceAccount(string $clientName, string $clientId, ?string $clientSecret = null): self + { + $oauth = new self($clientName); + $oauth->grant = Grant::ClientCredentials; + $oauth->clientId = $clientId; + $oauth->clientSecret = $clientSecret; + $oauth->offlineAccess = false; + + return $oauth; + } + /** * Where the authorization server sends the user back to. * @@ -132,6 +153,22 @@ public function setClientMetadataUrl(string $url): self return $this; } + /** + * Authenticate at the token endpoint with a signed assertion instead of a secret. + * + * Needs the firebase/php-jwt package. + * + * @param string $privateKeyPem a PEM-encoded private key + * @param string $algorithm one of RS256, RS384, RS512, PS256, ES256, ES256K, ES384 or EdDSA + */ + public function setPrivateKeyJwt(string $privateKeyPem, string $algorithm = 'RS256'): self + { + $this->privateKeyPem = $privateKeyPem; + $this->signingAlgorithm = $algorithm; + + return $this; + } + /** * Ask for these scopes, rather than whatever the server asks for. * @@ -184,6 +221,22 @@ public function setResource(string $resource): self return $this; } + /** + * Reach the server through an identity the user already holds at their identity + * provider, rather than sending them to a consent screen. + * + * Requires the client id and secret the authorization server knows this client by, + * set with {@see self::setClientCredentials()}. + */ + public function setCrossAppAccess(CrossAppAccess $crossAppAccess): self + { + $this->crossAppAccess = $crossAppAccess; + $this->grant = Grant::JwtBearer; + $this->offlineAccess = false; + + return $this; + } + /** * Whether the client runs on the user's machine ("native") or on a server ("web"). */ @@ -255,9 +308,11 @@ public function build(): OAuthAuthenticator $requestFactory = $this->requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); $streamFactory = $this->streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); - $grantTypes = $this->offlineAccess - ? [Grant::AuthorizationCode->value, Grant::RefreshToken->value] - : [Grant::AuthorizationCode->value]; + $grantTypes = match (true) { + Grant::AuthorizationCode !== $this->grant => [$this->grant->value], + $this->offlineAccess => [Grant::AuthorizationCode->value, Grant::RefreshToken->value], + default => [Grant::AuthorizationCode->value], + }; $configuration = new OAuthConfiguration( new ClientMetadata( @@ -270,13 +325,17 @@ public function build(): OAuthAuthenticator $this->softwareVersion, ), $this->redirectUri, + $this->grant, $this->clientId, $this->clientSecret, $this->clientMetadataUrl, + $this->privateKeyPem, + $this->signingAlgorithm, $this->scopes, $this->offlineAccess, $this->legacyDiscovery, $this->resource, + $this->crossAppAccess, ); return new OAuthAuthenticator( diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index b6054aa3..a3658474 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -311,7 +311,7 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta 'grant_type' => Grant::RefreshToken->value, 'refresh_token' => $stored->refreshToken, 'resource' => $this->resource, - ])); + ]), $this->configuration->privateKeyPem, $this->configuration->signingAlgorithm); } catch (AuthorizationException $e) { $this->logger->info('Refreshing the access token failed; falling back to a new authorization.', ['exception' => $e]); @@ -365,6 +365,23 @@ private function grantWithFallback(AuthorizationServerMetadata $metadata, Client */ private function grant(AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes, string $resource): AccessToken { + if (Grant::JwtBearer === $this->configuration->grant) { + return $this->tokenEndpoint->request($metadata, $client, array_filter([ + 'grant_type' => Grant::JwtBearer->value, + 'assertion' => $this->requestAuthorizationGrant($metadata, $resource), + 'scope' => implode(' ', $scopes), + 'resource' => $resource, + ]), $this->configuration->privateKeyPem, $this->configuration->signingAlgorithm); + } + + if (Grant::ClientCredentials === $this->configuration->grant) { + return $this->tokenEndpoint->request($metadata, $client, array_filter([ + 'grant_type' => Grant::ClientCredentials->value, + 'scope' => implode(' ', $scopes), + 'resource' => $resource, + ]), $this->configuration->privateKeyPem, $this->configuration->signingAlgorithm); + } + $pkce = Pkce::generate(); $state = rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '='); @@ -399,7 +416,41 @@ private function grant(AuthorizationServerMetadata $metadata, ClientRegistration 'redirect_uri' => $this->configuration->redirectUri, 'code_verifier' => $pkce->verifier, 'resource' => $resource, + ]), $this->configuration->privateKeyPem, $this->configuration->signingAlgorithm); + } + + /** + * Trades the identity token for a grant for this resource and authorization server. + */ + private function requestAuthorizationGrant(AuthorizationServerMetadata $metadata, string $resource): string + { + $crossAppAccess = $this->configuration->crossAppAccess; + + if (null === $crossAppAccess) { + throw new AuthorizationException('No cross-app access identity is configured.'); + } + + $this->logger->info('Exchanging the enterprise identity for an authorization grant', [ + 'identity_provider' => $crossAppAccess->tokenEndpoint, + 'audience' => $metadata->issuer, + 'resource' => $resource, + ]); + + $response = $this->tokenEndpoint->post($crossAppAccess->tokenEndpoint, array_filter([ + 'grant_type' => Grant::TokenExchange->value, + 'subject_token' => $crossAppAccess->identityToken, + 'subject_token_type' => $crossAppAccess->identityTokenType, + 'requested_token_type' => CrossAppAccess::AUTHORIZATION_GRANT_TOKEN_TYPE, + 'audience' => $metadata->issuer, + 'resource' => $resource, + 'client_id' => $crossAppAccess->clientId, ])); + + if (!\is_string($response['access_token'] ?? null)) { + throw new AuthorizationException(\sprintf('The identity provider at "%s" returned no authorization grant.', $crossAppAccess->tokenEndpoint)); + } + + return $response['access_token']; } /** @@ -456,6 +507,7 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco TokenEndpointAuthMethod::negotiate( $metadata->tokenEndpointAuthMethodsSupported, null !== $configuration->clientSecret, + null !== $configuration->privateKeyPem, ), ); } diff --git a/src/Client/Auth/OAuthConfiguration.php b/src/Client/Auth/OAuthConfiguration.php index 66753ce1..4162f3a3 100644 --- a/src/Client/Auth/OAuthConfiguration.php +++ b/src/Client/Auth/OAuthConfiguration.php @@ -11,6 +11,8 @@ namespace Mcp\Client\Auth; +use Mcp\Exception\InvalidArgumentException; + /** * Everything about how this client wants to authorize, decided before the first request. * @@ -25,29 +27,49 @@ final class OAuthConfiguration { /** - * @param ClientMetadata $clientMetadata what the client says about itself when registering - * @param string $redirectUri where the authorization server sends the user back - * @param ?string $clientId a client id issued out of band, skipping registration - * @param ?string $clientSecret the matching secret, when there is one - * @param ?string $clientMetadataUrl a URL serving the client metadata document, usable as a - * client id against servers that accept one - * @param string[] $scopes scopes to request regardless of what is advertised - * @param bool $offlineAccess whether to ask for a refresh token where the server offers one - * @param bool $legacyDiscovery whether to fall back to pre-2025-06-18 discovery when a - * server publishes no protected resource metadata - * @param ?string $resource the canonical resource URI to request tokens for, when it - * should not be derived from the endpoint being called + * @param ClientMetadata $clientMetadata what the client says about itself when registering + * @param string $redirectUri where the authorization server sends the user back + * @param Grant $grant the grant to drive + * @param ?string $clientId a client id issued out of band, skipping registration + * @param ?string $clientSecret the matching secret, when there is one + * @param ?string $clientMetadataUrl a URL serving the client metadata document, usable as a + * client id against servers that accept one + * @param ?string $privateKeyPem a PEM private key for `private_key_jwt` client authentication + * @param string $signingAlgorithm the algorithm that key is used with + * @param string[] $scopes scopes to request regardless of what is advertised + * @param bool $offlineAccess whether to ask for a refresh token where the server offers one + * @param bool $legacyDiscovery whether to fall back to pre-2025-06-18 discovery when a + * server publishes no protected resource metadata + * @param ?string $resource the canonical resource URI to request tokens for, when it + * should not be derived from the endpoint being called + * @param ?CrossAppAccess $crossAppAccess the enterprise identity to reach the server through, + * instead of asking the user to approve anything */ public function __construct( public readonly ClientMetadata $clientMetadata, public readonly string $redirectUri = OAuth::DEFAULT_REDIRECT_URI, + public readonly Grant $grant = Grant::AuthorizationCode, public readonly ?string $clientId = null, public readonly ?string $clientSecret = null, public readonly ?string $clientMetadataUrl = null, + public readonly ?string $privateKeyPem = null, + public readonly string $signingAlgorithm = 'RS256', public readonly array $scopes = [], public readonly bool $offlineAccess = true, public readonly bool $legacyDiscovery = false, public readonly ?string $resource = null, + public readonly ?CrossAppAccess $crossAppAccess = null, ) { + if (Grant::ClientCredentials === $grant && (null === $clientId || (null === $clientSecret && null === $privateKeyPem))) { + throw new InvalidArgumentException('The client credentials grant needs a client id and either a client secret or a private key.'); + } + + if (null !== $crossAppAccess && null === $clientId) { + throw new InvalidArgumentException('Cross-app access needs the client id the authorization server knows this client by; it is what the authorization grant is bound to.'); + } + + if (null !== $privateKeyPem && null === $clientId) { + throw new InvalidArgumentException('Private key JWT client authentication needs a client id to name in the assertion.'); + } } } diff --git a/src/Client/Auth/TokenEndpoint.php b/src/Client/Auth/TokenEndpoint.php index 66dd6832..892621ff 100644 --- a/src/Client/Auth/TokenEndpoint.php +++ b/src/Client/Auth/TokenEndpoint.php @@ -11,6 +11,7 @@ namespace Mcp\Client\Auth; +use Firebase\JWT\JWT; use Mcp\Exception\AuthorizationException; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; @@ -32,6 +33,9 @@ */ final class TokenEndpoint { + /** The asymmetric algorithms of RFC 7518, since the authorization server holds the public key. */ + private const ASSERTION_ALGORITHMS = ['RS256', 'RS384', 'RS512', 'PS256', 'ES256', 'ES256K', 'ES384', 'EdDSA']; + public function __construct( private readonly ClientInterface $httpClient, private readonly RequestFactoryInterface $requestFactory, @@ -47,6 +51,8 @@ public function request( AuthorizationServerMetadata $metadata, ClientRegistration $client, array $parameters, + ?string $privateKeyPem = null, + string $signingAlgorithm = 'RS256', ): AccessToken { $parameters['client_id'] = $client->clientId; $authorization = null; @@ -62,6 +68,15 @@ public function request( $parameters['client_secret'] = (string) $client->clientSecret; break; + case TokenEndpointAuthMethod::PrivateKeyJwt: + if (null === $privateKeyPem) { + throw new AuthorizationException('The client registration uses private_key_jwt client authentication, but no private key was configured. Call OAuth::setPrivateKeyJwt().'); + } + + $parameters['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; + $parameters['client_assertion'] = $this->createClientAssertion($client->clientId, $metadata->issuer, $privateKeyPem, $signingAlgorithm); + break; + case TokenEndpointAuthMethod::None: break; } @@ -76,11 +91,13 @@ public function request( } /** + * Post a form-encoded grant request, for an exchange that doesn't return an access token. + * * @param array $parameters * * @return array */ - private function post(string $endpoint, array $parameters, ?string $authorization = null): array + public function post(string $endpoint, array $parameters, ?string $authorization = null): array { $request = $this->requestFactory->createRequest('POST', $endpoint) ->withHeader('Content-Type', 'application/x-www-form-urlencoded') @@ -113,6 +130,35 @@ private function post(string $endpoint, array $parameters, ?string $authorizatio return $decoded; } + /** + * Builds the JWT a client signs to authenticate itself (RFC 7523 section 2.2). + */ + private function createClientAssertion(string $clientId, string $audience, string $privateKeyPem, string $algorithm): string + { + if (!class_exists(JWT::class)) { + throw new AuthorizationException('For using private_key_jwt client authentication, the firebase/php-jwt package is required. Try running "composer require firebase/php-jwt".'); + } + + if (!\in_array($algorithm, self::ASSERTION_ALGORITHMS, true)) { + throw new AuthorizationException(\sprintf('Unsupported client assertion algorithm "%s". Use one of %s.', $algorithm, implode(', ', self::ASSERTION_ALGORITHMS))); + } + + $now = time(); + + try { + return JWT::encode([ + 'iss' => $clientId, + 'sub' => $clientId, + 'aud' => $audience, + 'jti' => bin2hex(random_bytes(16)), + 'iat' => $now, + 'exp' => $now + 300, + ], $privateKeyPem, $algorithm); + } catch (\DomainException|\InvalidArgumentException|\UnexpectedValueException $e) { + throw new AuthorizationException(\sprintf('Signing the client assertion with %s failed: %s', $algorithm, $e->getMessage()), 0, $e); + } + } + /** * @param array $response */ diff --git a/src/Client/Auth/TokenEndpointAuthMethod.php b/src/Client/Auth/TokenEndpointAuthMethod.php index 5cc7610f..1c720f92 100644 --- a/src/Client/Auth/TokenEndpointAuthMethod.php +++ b/src/Client/Auth/TokenEndpointAuthMethod.php @@ -27,6 +27,9 @@ enum TokenEndpointAuthMethod: string /** Client id and secret in the request body. */ case ClientSecretPost = 'client_secret_post'; + /** A JWT assertion signed with the client's private key (RFC 7523). */ + case PrivateKeyJwt = 'private_key_jwt'; + /** * Pick the method to use against a server advertising the given list. * @@ -36,11 +39,13 @@ enum TokenEndpointAuthMethod: string * * @param string[] $supported the server's token_endpoint_auth_methods_supported */ - public static function negotiate(array $supported, bool $hasSecret): self + public static function negotiate(array $supported, bool $hasSecret, bool $hasPrivateKey = false): self { - $candidates = $hasSecret - ? [self::ClientSecretBasic, self::ClientSecretPost, self::None] - : [self::None]; + $candidates = [ + ...($hasPrivateKey ? [self::PrivateKeyJwt] : []), + ...($hasSecret ? [self::ClientSecretBasic, self::ClientSecretPost] : []), + self::None, + ]; if ([] === $supported) { return $candidates[0]; diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php index 1dce1a97..7e7055fa 100644 --- a/tests/Conformance/client.php +++ b/tests/Conformance/client.php @@ -13,6 +13,7 @@ use Mcp\Client; use Mcp\Client\Auth\AuthenticatorInterface; +use Mcp\Client\Auth\CrossAppAccess; use Mcp\Client\Auth\HeadlessAuthorizationHandler; use Mcp\Client\Auth\OAuth; use Mcp\Client\Handler\Request\RequestHandlerInterface; @@ -43,8 +44,14 @@ // on the scenario: // // toolCalls http-custom-headers: the exact calls to make -// client_id auth/pre-registration: credentials an administrator would -// client_secret have configured out of band +// client_id auth/pre-registration, auth/client-credentials-*, +// client_secret auth/enterprise-managed-authorization: credentials an +// administrator would have configured out of band +// private_key_pem auth/client-credentials-jwt: the key to sign the client +// signing_algorithm assertion with, and the algorithm to sign it with +// idp_token_endpoint auth/enterprise-managed-authorization: the identity provider +// idp_id_token the user is already signed in to, the token proving it, and +// idp_client_id this client's identifier there $context = json_decode(getenv('MCP_CONFORMANCE_CONTEXT') ?: '[]', true); $context = is_array($context) ? $context : []; @@ -109,6 +116,36 @@ function buildAuthenticator(string $scenario, array $context, Psr\Log\LoggerInte $handler = new HeadlessAuthorizationHandler(logger: $logger); + // A client authenticating as itself: the harness hands over the credentials the + // authorization server already knows about, since nobody can consent to a registration. + if (str_starts_with($scenario, 'auth/client-credentials')) { + $oauth = OAuth::forServiceAccount( + 'mcp-conformance-test-client', + $context['client_id'] ?? 'conformance-test-client', + $context['client_secret'] ?? null, + ); + + if (isset($context['private_key_pem'])) { + $oauth->setPrivateKeyJwt($context['private_key_pem'], $context['signing_algorithm'] ?? 'ES256'); + } + + return $oauth->setLogger($logger)->build(); + } + + // Enterprise-managed authorization: the user already signed in at the company + // identity provider, so the runner hands over that token instead of a browser. + if ('auth/enterprise-managed-authorization' === $scenario) { + return OAuth::forApplication('mcp-conformance-test-client') + ->setClientCredentials($context['client_id'], $context['client_secret']) + ->setCrossAppAccess(new CrossAppAccess( + $context['idp_token_endpoint'], + $context['idp_id_token'], + clientId: $context['idp_client_id'] ?? null, + )) + ->setLogger($logger) + ->build(); + } + $oauth = OAuth::forApplication('mcp-conformance-test-client') ->setRedirectUri('http://localhost:3000/callback') ->setAuthorizationHandler($handler) diff --git a/tests/Conformance/conformance-baseline-2025-11-25.yml b/tests/Conformance/conformance-baseline-2025-11-25.yml index c53cc62c..ac08dab2 100644 --- a/tests/Conformance/conformance-baseline-2025-11-25.yml +++ b/tests/Conformance/conformance-baseline-2025-11-25.yml @@ -1,6 +1,3 @@ client: # Elicitation defaults (SEP-1034) are not filled in when accepting. - elicitation-sep1034-client-defaults - - auth/client-credentials-jwt - - auth/client-credentials-basic - - auth/cross-app-access-complete-flow diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index e1bad44b..1ca8bad6 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -14,6 +14,7 @@ use Mcp\Client\Auth\AccessToken; use Mcp\Client\Auth\AuthorizationHandlerInterface; use Mcp\Client\Auth\ClientRegistrar; +use Mcp\Client\Auth\CrossAppAccess; use Mcp\Client\Auth\InMemoryCredentialStorage; use Mcp\Client\Auth\MetadataDiscovery; use Mcp\Client\Auth\OAuth; @@ -24,6 +25,7 @@ use Mcp\Exception\AuthorizationException; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientInterface; @@ -527,6 +529,76 @@ public function testConfiguredClientIdWinsOverTheClientMetadataUrl(): void $this->assertSame('known-client', $server->authorization['client_id'] ?? null); } + #[TestDox('a service account gets a token without registering or a browser')] + public function testClientCredentialsGrant(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: OAuth::forServiceAccount('job', 'job-client', 'job-secret')); + + $this->assertTrue($authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read']))); + + $this->assertSame('client_credentials', $server->token['grant_type'] ?? null); + $this->assertSame('mcp:read', $server->token['scope'] ?? null); + $this->assertSame(self::ENDPOINT, $server->token['resource'] ?? null); + $this->assertSame([], $server->authorization); + $this->assertNotContains('https://auth.example.com/register', $server->requested); + } + + #[TestDox('cross-app access exchanges the identity token and presents the grant as an assertion')] + public function testCrossAppAccessGrant(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: $this->crossAppAccess()); + + $this->assertTrue($authenticator->handleChallenge($this->request(), $server->challenge())); + + $this->assertSame('urn:ietf:params:oauth:grant-type:token-exchange', $server->exchange['grant_type'] ?? null); + $this->assertSame('an-id-token', $server->exchange['subject_token'] ?? null); + $this->assertSame(CrossAppAccess::AUTHORIZATION_GRANT_TOKEN_TYPE, $server->exchange['requested_token_type'] ?? null); + $this->assertSame('https://auth.example.com', $server->exchange['audience'] ?? null); + $this->assertSame(self::ENDPOINT, $server->exchange['resource'] ?? null); + $this->assertSame('urn:ietf:params:oauth:grant-type:jwt-bearer', $server->token['grant_type'] ?? null); + $this->assertSame('an-authorization-grant', $server->token['assertion'] ?? null); + $this->assertSame([], $server->authorization); + } + + #[TestDox('an identity provider that returns no grant ends the flow')] + public function testCrossAppAccessWithoutAGrant(): void + { + $server = new FakeOAuthServer(identityProviderGrants: false); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('returned no authorization grant'); + + $this->authenticator($server, configuration: $this->crossAppAccess())->handleChallenge($this->request(), $server->challenge()); + } + + #[TestDox('a configured private key signs the code exchange')] + #[RequiresPhpExtension('openssl')] + public function testPrivateKeySignsTheCodeExchange(): void + { + $key = openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + $this->assertNotFalse($key); + openssl_pkey_export($key, $pem); + + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test') + ->setClientCredentials('known-client') + ->setPrivateKeyJwt($pem, 'ES256')); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('urn:ietf:params:oauth:client-assertion-type:jwt-bearer', $server->token['client_assertion_type'] ?? null); + $this->assertNotEmpty($server->token['client_assertion'] ?? null); + } + + private function crossAppAccess(): OAuth + { + return OAuth::forApplication('test') + ->setClientCredentials('known-client', 'known-secret') + ->setCrossAppAccess(new CrossAppAccess('https://idp.example.com/token', 'an-id-token')); + } + private function request(): RequestInterface { return (new Psr17Factory())->createRequest('POST', self::ENDPOINT); @@ -608,6 +680,9 @@ final class FakeOAuthServer implements ClientInterface /** @var array */ public array $token = []; + /** @var array */ + public array $exchange = []; + private int $issued = 0; /** @@ -625,6 +700,7 @@ public function __construct( private readonly ?string $codeError = null, private readonly bool $hasAuthorizationEndpoint = true, private readonly bool $clientMetadataDocuments = false, + private readonly bool $identityProviderGrants = true, ) { } @@ -681,6 +757,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface 'https://auth.example.com/register' => $this->register((string) $request->getBody()), 'https://auth.example.com/token' => $this->issueToken($form), + 'https://idp.example.com/token' => $this->exchangeToken($form), default => new Response(404), }; @@ -713,6 +790,16 @@ private function register(string $body): ResponseInterface /** * @param array $form */ + private function exchangeToken(array $form): ResponseInterface + { + $this->exchange = $form; + + return self::json($this->identityProviderGrants ? ['access_token' => 'an-authorization-grant'] : []); + } + + /** + * @param array $form + */ private function issueToken(array $form): ResponseInterface { $flat = []; diff --git a/tests/Unit/Client/Auth/OAuthTest.php b/tests/Unit/Client/Auth/OAuthTest.php index 48e9ff7a..d514c351 100644 --- a/tests/Unit/Client/Auth/OAuthTest.php +++ b/tests/Unit/Client/Auth/OAuthTest.php @@ -11,6 +11,8 @@ namespace Mcp\Tests\Unit\Client\Auth; +use Mcp\Client\Auth\CrossAppAccess; +use Mcp\Client\Auth\Grant; use Mcp\Client\Auth\OAuth; use Mcp\Client\Auth\OAuthAuthenticator; use Mcp\Client\Auth\OAuthConfiguration; @@ -27,6 +29,7 @@ public function testApplicationDefaults(): void { $configuration = $this->configurationOf(OAuth::forApplication('My App')); $this->assertSame(OAuth::DEFAULT_REDIRECT_URI, $configuration->redirectUri); + $this->assertSame(Grant::AuthorizationCode, $configuration->grant); $this->assertSame('My App', $configuration->clientMetadata->clientName); $this->assertSame(['http://127.0.0.1:8765/callback'], $configuration->clientMetadata->redirectUris); $this->assertSame('native', $configuration->clientMetadata->applicationType); @@ -102,6 +105,69 @@ public function testSoftwareInfo(): void $this->assertSame('2.1.0', $body['software_version']); } + #[TestDox('a service account authenticates as itself, with no user and no refresh token')] + public function testServiceAccountDefaults(): void + { + $configuration = $this->configurationOf(OAuth::forServiceAccount('My Job', 'job-client', 'job-secret')); + + $this->assertSame(Grant::ClientCredentials, $configuration->grant); + $this->assertSame('job-client', $configuration->clientId); + $this->assertSame('job-secret', $configuration->clientSecret); + $this->assertFalse($configuration->offlineAccess); + $this->assertSame(['client_credentials'], $configuration->clientMetadata->grantTypes); + } + + #[TestDox('cross-app access switches to the assertion grant')] + public function testCrossAppAccessSwitchesGrant(): void + { + $configuration = $this->configurationOf(OAuth::forApplication('My App') + ->setClientCredentials('client', 'secret') + ->setCrossAppAccess(new CrossAppAccess('https://idp.example.com/token', 'an-id-token'))); + + $crossAppAccess = $configuration->crossAppAccess; + + $this->assertSame(Grant::JwtBearer, $configuration->grant); + $this->assertNotNull($crossAppAccess); + $this->assertSame('https://idp.example.com/token', $crossAppAccess->tokenEndpoint); + $this->assertSame(CrossAppAccess::ID_TOKEN_TYPE, $crossAppAccess->identityTokenType); + } + + #[TestDox('cross-app access without a client id is refused: the grant is bound to one')] + public function testCrossAppAccessNeedsAClientId(): void + { + $this->expectException(InvalidArgumentException::class); + + OAuth::forApplication('My App') + ->setCrossAppAccess(new CrossAppAccess('https://idp.example.com/token', 'an-id-token')) + ->build(); + } + + #[TestDox('a service account needs a client secret or a private key')] + public function testServiceAccountNeedsACredential(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('client secret or a private key'); + + OAuth::forServiceAccount('My Job', 'job-client')->build(); + } + + #[TestDox('a service account can authenticate with only a private key')] + public function testServiceAccountWithAPrivateKey(): void + { + $configuration = $this->configurationOf(OAuth::forServiceAccount('My Job', 'job-client')->setPrivateKeyJwt('a-pem', 'ES256')); + + $this->assertSame('a-pem', $configuration->privateKeyPem); + $this->assertSame('ES256', $configuration->signingAlgorithm); + } + + #[TestDox('the identity token is not sent to an insecure identity provider')] + public function testCrossAppAccessRefusesAnInsecureIdentityProvider(): void + { + $this->expectException(InvalidArgumentException::class); + + new CrossAppAccess('http://idp.example.com/token', 'an-id-token'); + } + private function configurationOf(OAuth $oauth): OAuthConfiguration { return (new \ReflectionProperty(OAuthAuthenticator::class, 'configuration'))->getValue($oauth->build()); diff --git a/tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php b/tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php index 2d99e81e..46d7eb3e 100644 --- a/tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php +++ b/tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php @@ -19,41 +19,53 @@ final class TokenEndpointAuthMethodTest extends TestCase { /** - * @return iterable + * @return iterable */ public static function negotiationProvider(): iterable { yield 'a public client can only be public' => [ - ['client_secret_basic', 'none'], false, TokenEndpointAuthMethod::None, + ['client_secret_basic', 'none'], false, false, TokenEndpointAuthMethod::None, ]; yield 'a client with a secret prefers the header over the body' => [ - ['client_secret_post', 'client_secret_basic'], true, TokenEndpointAuthMethod::ClientSecretBasic, + ['client_secret_post', 'client_secret_basic'], true, false, TokenEndpointAuthMethod::ClientSecretBasic, ]; yield 'a server offering only the body form gets it' => [ - ['client_secret_post'], true, TokenEndpointAuthMethod::ClientSecretPost, + ['client_secret_post'], true, false, TokenEndpointAuthMethod::ClientSecretPost, ]; yield 'a server that only takes public clients gets one' => [ - ['none'], true, TokenEndpointAuthMethod::None, + ['none'], true, false, TokenEndpointAuthMethod::None, + ]; + + yield 'a private key beats a shared secret' => [ + ['client_secret_basic', 'private_key_jwt'], true, true, TokenEndpointAuthMethod::PrivateKeyJwt, + ]; + + yield 'a private key without a secret never claims Basic' => [ + ['client_secret_basic', 'none'], false, true, TokenEndpointAuthMethod::None, + ]; + + yield 'a private key is used against a silent server' => [ + [], false, true, TokenEndpointAuthMethod::PrivateKeyJwt, ]; // RFC 6749 makes Basic the one method every server must accept, so it is the // right guess for a server that documents nothing. yield 'a silent server is assumed to take Basic' => [ - [], true, TokenEndpointAuthMethod::ClientSecretBasic, + [], true, false, TokenEndpointAuthMethod::ClientSecretBasic, ]; yield 'a silent server with a public client still gets none' => [ - [], false, TokenEndpointAuthMethod::None, + [], false, false, TokenEndpointAuthMethod::None, ]; } #[DataProvider('negotiationProvider')] #[TestDox('the token endpoint method is negotiated for: $_dataName')] - public function testNegotiate(array $supported, bool $hasSecret, TokenEndpointAuthMethod $expected): void + public function testNegotiate(array $supported, bool $hasSecret, bool $hasPrivateKey, TokenEndpointAuthMethod $expected): void { - $this->assertSame($expected, TokenEndpointAuthMethod::negotiate($supported, $hasSecret)); + $this->assertSame($expected, TokenEndpointAuthMethod::negotiate($supported, $hasSecret, $hasPrivateKey)); } } diff --git a/tests/Unit/Client/Auth/TokenEndpointTest.php b/tests/Unit/Client/Auth/TokenEndpointTest.php index 7265fc86..431a8cd7 100644 --- a/tests/Unit/Client/Auth/TokenEndpointTest.php +++ b/tests/Unit/Client/Auth/TokenEndpointTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Client\Auth; +use Firebase\JWT\JWT; use Firebase\JWT\Key; use Mcp\Client\Auth\AuthorizationServerMetadata; use Mcp\Client\Auth\ClientRegistration; @@ -20,6 +21,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientInterface; @@ -121,6 +123,107 @@ public function testDoesNotLeakAnUnreadableSuccessBody(): void } } + #[RequiresPhpExtension('openssl')] + #[TestDox('a private key assertion verifies against the public key, and names the client')] + public function testPrivateKeyJwtAssertion(): void + { + $key = openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + $this->assertNotFalse($key); + openssl_pkey_export($key, $privateKey); + + $server = new CapturingTokenServer(); + $this->endpoint($server)->request( + $this->metadata(), + new ClientRegistration('a-client', null, TokenEndpointAuthMethod::PrivateKeyJwt), + ['grant_type' => 'client_credentials'], + $privateKey, + 'ES256', + ); + + $this->assertSame('urn:ietf:params:oauth:client-assertion-type:jwt-bearer', $server->form['client_assertion_type'] ?? null); + + $details = openssl_pkey_get_details($key); + $this->assertIsArray($details); + + $claims = JWT::decode($server->form['client_assertion'], new Key($details['key'], 'ES256')); + + $this->assertSame('a-client', $claims->iss); + $this->assertSame('a-client', $claims->sub); + $this->assertSame('https://auth.example.com', $claims->aud); + $this->assertGreaterThan(time(), $claims->exp); + } + + #[RequiresPhpExtension('openssl')] + #[TestDox('an RSA assertion verifies against its public key too')] + public function testPrivateKeyJwtWithRsa(): void + { + $key = openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]); + $this->assertNotFalse($key); + openssl_pkey_export($key, $privateKey); + + $server = new CapturingTokenServer(); + $this->endpoint($server)->request( + $this->metadata(), + new ClientRegistration('a-client', null, TokenEndpointAuthMethod::PrivateKeyJwt), + ['grant_type' => 'client_credentials'], + $privateKey, + 'RS256', + ); + + $details = openssl_pkey_get_details($key); + $this->assertIsArray($details); + + $claims = JWT::decode($server->form['client_assertion'], new Key($details['key'], 'RS256')); + + $this->assertSame('a-client', $claims->iss); + $this->assertSame('https://auth.example.com', $claims->aud); + } + + // Signing with the HMAC family would use the private key as a shared secret, which + // is `client_secret_jwt` wearing the wrong name -- a downgrade, not a signature. + #[TestDox('a symmetric algorithm is refused rather than used on the private key')] + public function testRefusesASymmetricAlgorithm(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Unsupported client assertion algorithm "HS256"'); + + $this->endpoint(new CapturingTokenServer())->request( + $this->metadata(), + new ClientRegistration('a-client', null, TokenEndpointAuthMethod::PrivateKeyJwt), + ['grant_type' => 'client_credentials'], + '-----BEGIN PRIVATE KEY-----not-a-real-key-----END PRIVATE KEY-----', + 'HS256', + ); + } + + #[TestDox('an unreadable key is reported rather than producing a broken assertion')] + public function testReportsAnUnusableKey(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Signing the client assertion with ES256 failed'); + + $this->endpoint(new CapturingTokenServer())->request( + $this->metadata(), + new ClientRegistration('a-client', null, TokenEndpointAuthMethod::PrivateKeyJwt), + ['grant_type' => 'client_credentials'], + 'not a key at all', + 'ES256', + ); + } + + #[TestDox('an assertion is refused rather than skipped when no key was configured')] + public function testPrivateKeyJwtNeedsAKey(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('no private key was configured'); + + $this->endpoint(new CapturingTokenServer())->request( + $this->metadata(), + new ClientRegistration('a-client', null, TokenEndpointAuthMethod::PrivateKeyJwt), + ['grant_type' => 'client_credentials'], + ); + } + private function endpoint(ClientInterface $client): TokenEndpoint { $factory = new Psr17Factory();