From 118922c23881160ed6a89b63ec8342dcec632da7 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 13:03:46 +0100 Subject: [PATCH 01/60] [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 | 7 +- CHANGELOG.md | 3 + Makefile | 7 +- docs/client/authorization.md | 171 ++++ 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 | 74 ++ mkdocs.yml | 1 + src/Client/Auth/AccessToken.php | 158 ++++ .../Auth/AuthorizationHandlerInterface.php | 40 + src/Client/Auth/ClientMetadata.php | 81 ++ src/Client/Auth/ClientRegistrar.php | 88 +++ src/Client/Auth/ClientRegistration.php | 108 +++ .../Auth/CredentialStorageInterface.php | 46 ++ src/Client/Auth/FileCredentialStorage.php | 180 +++++ 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 | 282 +++++++ src/Client/Auth/OAuthAuthenticator.php | 512 ++++++++++++ src/Client/Auth/OAuthConfiguration.php | 59 ++ src/Client/Auth/Pkce.php | 52 ++ src/Client/Auth/TokenEndpoint.php | 133 ++++ src/Client/Auth/TokenEndpointAuthMethod.php | 59 ++ src/Client/Transport/HttpTransport.php | 11 +- tests/Conformance/client.php | 80 +- .../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 | 71 ++ 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 | 102 +++ .../Client/Auth/AuthorizationHandlerTest.php | 120 +++ .../Client/Auth/ClientRegistrationTest.php | 185 +++++ .../Client/Auth/FileCredentialStorageTest.php | 176 +++++ .../Client/Auth/OAuthAuthenticatorTest.php | 744 ++++++++++++++++++ tests/Unit/Client/Auth/OAuthTest.php | 105 +++ tests/Unit/Client/Auth/PkceTest.php | 53 ++ .../Auth/TokenEndpointAuthMethodTest.php | 59 ++ tests/Unit/Client/Auth/TokenEndpointTest.php | 184 +++++ 53 files changed, 5486 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 0cdbe8a50..717ba09fe 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -281,6 +281,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 72196aa5f..d7bb6ac21 100644 --- a/.gitignore +++ b/.gitignore @@ -6,11 +6,14 @@ vendor examples/**/dev.log examples/**/cache examples/**/sessions -tests/Conformance/client-conformance.json -tests/Conformance/server-conformance.json +examples/client/.oauth-credentials.json +examples/client/.oauth-credentials.json.lock +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 e846a94d9..b21129d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,9 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `HttpTransport` turns off redirects on a discovered `symfony/http-client` client and treats every `3xx` answer as an error that names the status and target. An endpoint that is reached through a redirect stops working, so configure its final URL. A client you pass must not follow redirects to another origin. * Fix `HttpTransport` taking an `Mcp-Session-Id` from any response: it replaced the session mid-way and, on `2026-07-28`, made `getSessionId()` return a value and `close()` send a `DELETE`. Only the answer to `initialize` sets it. * 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. +* 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::setExpectedIssuer()`, required with a client secret, so a hostile MCP server can't receive the secret. +* Add `LoopbackAuthorizationHandler` and `HeadlessAuthorizationHandler` behind `AuthorizationHandlerInterface`, and `InMemoryCredentialStorage` and `FileCredentialStorage` behind `CredentialStorageInterface`. * [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 c2dd80f3f..8dc5f8fb6 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 000000000..1ec15da4b --- /dev/null +++ b/docs/client/authorization.md @@ -0,0 +1,171 @@ +# 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. It also creates a lock file next to it, so processes can share the file. 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 +OAuth::forApplication('My App') + // the secret is optional + ->setClientCredentials('my-client-id', 'my-client-secret') + // required with a secret + ->setExpectedIssuer('https://auth.example.com'); +``` + +The MCP server names its authorization server. Without an expected issuer, a hostile MCP server could name its own and receive your secret. Pass the `issuer` value from the metadata of your authorization server. If you set a secret without an expected issuer, `build()` throws an `InvalidArgumentException`. + +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 authorization server has another issuer than `setExpectedIssuer()` | It would receive the client's credentials | +| 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 2ecc4fa15..8fcf843bb 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 b0080acc9..4f9a56648 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 1ba97f3fb..6c6b78185 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -64,7 +64,7 @@ $transport = new HttpTransport( - `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) - `maxResponseBodyBytes` (int): Maximum bytes read from a response body that is not an SSE stream (default: 8 MiB) -- `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. @@ -185,7 +185,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 ccdc8604c..88d1cc881 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 c56abb3bd..2b9c8d1b3 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 000000000..c60b535d5 --- /dev/null +++ b/examples/client/oauth_client.php @@ -0,0 +1,74 @@ +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') + // Keycloak rejects the "mcp:read" scope the server advertises, and adds it to the token by default. + ->setScopes('openid') + ->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|ConnectionException $e) { + echo "Could not connect or authorize: {$e->getMessage()}\n"; + + exit(1); +} diff --git a/mkdocs.yml b/mkdocs.yml index c55d54b07..b34962df5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,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 000000000..f01220727 --- /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 preg_split('/\s+/', $scope, -1, \PREG_SPLIT_NO_EMPTY) ?: []; + } +} diff --git a/src/Client/Auth/AuthorizationHandlerInterface.php b/src/Client/Auth/AuthorizationHandlerInterface.php new file mode 100644 index 000000000..639d61393 --- /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 000000000..4d56f173a --- /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 000000000..b4b630ae8 --- /dev/null +++ b/src/Client/Auth/ClientRegistrar.php @@ -0,0 +1,88 @@ + + */ +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); + + if (!$metadata->supportsGrant(Grant::RefreshToken->value)) { + $body['grant_types'] = array_values(array_diff($clientMetadata->grantTypes, [Grant::RefreshToken->value])); + } + + $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 (ClientExceptionInterface $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())); + } + + try { + return ClientRegistration::fromResponse($decoded, $preferredAuthMethod); + } catch (InvalidArgumentException $e) { + throw new AuthorizationException(\sprintf('The authorization server sent an invalid client registration response: %s', $e->getMessage()), 0, $e); + } + } +} diff --git a/src/Client/Auth/ClientRegistration.php b/src/Client/Auth/ClientRegistration.php new file mode 100644 index 000000000..95615b33b --- /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 000000000..8e7cbec4a --- /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 000000000..1f419852b --- /dev/null +++ b/src/Client/Auth/FileCredentialStorage.php @@ -0,0 +1,180 @@ + + */ +final class FileCredentialStorage implements CredentialStorageInterface +{ + public function __construct(private readonly string $path) + { + } + + public function getClientRegistration(string $issuer): ?ClientRegistration + { + $registration = $this->read()['registrations'][$issuer] ?? null; + + if (!\is_array($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 + { + $this->update(static function (array $data) use ($issuer, $registration): array { + $data['registrations'][$issuer] = $registration->jsonSerialize(); + + return $data; + }); + } + + public function getToken(string $issuer, string $resource): ?AccessToken + { + $token = $this->read()['tokens'][$issuer][$resource] ?? null; + + if (!\is_array($token)) { + return null; + } + + try { + return AccessToken::fromArray($token); + } catch (InvalidArgumentException) { + return null; + } + } + + public function saveToken(string $issuer, string $resource, AccessToken $token): void + { + $this->update(static function (array $data) use ($issuer, $resource, $token): array { + $data['tokens'][$issuer][$resource] = $token->jsonSerialize(); + + return $data; + }); + } + + public function forget(?string $issuer = null): void + { + $this->update(static function (array $data) use ($issuer): array { + if (null === $issuer) { + return ['registrations' => [], 'tokens' => []]; + } + + unset($data['registrations'][$issuer], $data['tokens'][$issuer]); + + return $data; + }); + } + + /** + * @return array{registrations: array>, tokens: array>>} + */ + private function read(): array + { + if (!is_file($this->path)) { + return ['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 ['registrations' => [], 'tokens' => []]; + } + + return [ + 'registrations' => \is_array($decoded['registrations'] ?? null) ? $decoded['registrations'] : [], + 'tokens' => \is_array($decoded['tokens'] ?? null) ? $decoded['tokens'] : [], + ]; + } + + /** + * Applies a change under an exclusive lock, so another process can't overwrite it. + * + * @param callable(array{registrations: array>, tokens: array>>}): array{registrations: array>, tokens: array>>} $change + */ + private function update(callable $change): void + { + $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)); + } + + $lockPath = $directory.'/.'.basename($this->path).'.lock'; + $lock = @fopen($lockPath, 'c'); + + if (false === $lock) { + throw new RuntimeException(\sprintf('Could not open the credential lock file "%s".', $lockPath)); + } + + try { + @chmod($lockPath, 0600); + + if (!flock($lock, \LOCK_EX)) { + throw new RuntimeException(\sprintf('Could not lock the credential lock file "%s".', $lockPath)); + } + + $this->write($change($this->read())); + } finally { + fclose($lock); + } + } + + /** + * @param array{registrations: array>, tokens: array>>} $data + */ + private function write(array $data): void + { + $directory = \dirname($this->path); + + $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 000000000..aa4536a09 --- /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 000000000..c7af37e36 --- /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' === strtolower($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 000000000..bce30a71c --- /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 000000000..2329fd1d8 --- /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 000000000..2114b5d6e --- /dev/null +++ b/src/Client/Auth/OAuth.php @@ -0,0 +1,282 @@ +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 $expectedIssuer = 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; + } + + /** + * Only authorize with the authorization server that has this issuer. + * + * The MCP server names its authorization server, so without this a hostile server could + * name its own and receive the client's secret. Required when a client secret is set. + */ + public function setExpectedIssuer(string $issuer): self + { + $this->expectedIssuer = $issuer; + + 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, + $this->expectedIssuer, + ); + + 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 000000000..06d1375c7 --- /dev/null +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -0,0 +1,512 @@ + + */ +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 (null !== $this->configuration->expectedIssuer && $metadata->issuer !== $this->configuration->expectedIssuer) { + throw new AuthorizationException(\sprintf('The server at "%s" names the authorization server "%s", but only "%s" is expected.', $endpoint, $metadata->issuer, $this->configuration->expectedIssuer)); + } + + 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 000000000..3d569635d --- /dev/null +++ b/src/Client/Auth/OAuthConfiguration.php @@ -0,0 +1,59 @@ + + */ +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 ?string $expectedIssuer the only authorization server to authorize with + * + * @throws InvalidArgumentException when a client secret is set without an expected issuer + */ + 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, + public readonly ?string $expectedIssuer = null, + ) { + if (null !== $clientSecret && null === $expectedIssuer) { + throw new InvalidArgumentException('A client secret needs an expected issuer, or a hostile MCP server could receive it. Call OAuth::setExpectedIssuer().'); + } + } +} diff --git a/src/Client/Auth/Pkce.php b/src/Client/Auth/Pkce.php new file mode 100644 index 000000000..ea54481ac --- /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 000000000..4ebc322b9 --- /dev/null +++ b/src/Client/Auth/TokenEndpoint.php @@ -0,0 +1,133 @@ + + */ +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, + ]); + + $response = $this->post($metadata->tokenEndpoint, $parameters, $authorization); + + try { + return AccessToken::fromResponse($response); + } catch (InvalidArgumentException $e) { + throw new AuthorizationException(\sprintf('The authorization server sent an invalid token response: %s', $e->getMessage()), 0, $e); + } + } + + /** + * @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 000000000..5cc7610fb --- /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 ede918160..0b9b7b9ef 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -128,11 +128,12 @@ class HttpTransport extends BaseTransport implements HeaderAwareTransportInterfa * arrived is resumed (default: 5 attempts, 1-10 s backoff) * @param ClockInterface|null $clock Clock for scheduling reconnects (default: a monotonic clock) * @param int $maxResponseBodyBytes Maximum bytes read from a response body that is not an SSE stream - * @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 d71785df4..4f6446ba6 100644 --- a/tests/Conformance/client.php +++ b/tests/Conformance/client.php @@ -11,7 +11,13 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; +use Http\Discovery\Psr17FactoryDiscovery; +use Http\Discovery\Psr18ClientDiscovery; use Mcp\Client; +use Mcp\Client\Auth\AuthenticatorInterface; +use Mcp\Client\Auth\HeadlessAuthorizationHandler; +use Mcp\Client\Auth\MetadataDiscovery; +use Mcp\Client\Auth\OAuth; use Mcp\Client\Handler\Request\RequestHandlerInterface; use Mcp\Client\Transport\HttpTransport; use Mcp\Schema\ClientCapabilities; @@ -36,7 +42,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 +97,65 @@ public function handle(Request $request): Response $builder->addRequestHandler($acceptElicitation); } +/** + * Reads the issuer from the harness, which starts its authorization server on a random port. + * + * An application configures this value, since it can't trust the server to name it. + */ +function discoverIssuer(string $url): string +{ + $discovery = new MetadataDiscovery(Psr18ClientDiscovery::find(), Psr17FactoryDiscovery::findRequestFactory()); + $issuer = $discovery->discoverProtectedResource($url)?->authorizationServers[0] ?? null; + + if (null === $issuer) { + throw new RuntimeException(sprintf('The server at "%s" names no authorization server.', $url)); + } + + return $issuer; +} + +/** + * 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 $url, 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); + + if (isset($context['client_secret'])) { + $oauth->setExpectedIssuer(discoverIssuer($url)); + } + } + + // 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($url, $scenario, $context, $logger)); try { $client->connect($transport); @@ -188,6 +256,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 3c2fa8536..699cafdd2 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 c4abd37ff..cf86e6a7e 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 000000000..ae4f7154b --- /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 000000000..8a61a5149 --- /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 000000000..3b754acd5 --- /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 000000000..02b85b3af --- /dev/null +++ b/tests/E2E/docker-compose.yml @@ -0,0 +1,71 @@ +# 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" + E2E_MCP_PORT: ${E2E_MCP_PORT:-8001} + 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 000000000..e29df05cb --- /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:${E2E_MCP_PORT}/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 000000000..9da5ab80c --- /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 000000000..63fec6279 --- /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 000000000..63491e8eb --- /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 000000000..079d76ea4 --- /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 000000000..fd785a79c --- /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 000000000..cfc119a68 --- /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 000000000..0861cea98 --- /dev/null +++ b/tests/Unit/Client/Auth/AccessTokenTest.php @@ -0,0 +1,102 @@ + 'token', 'scope' => '0 mcp:read']); + + $this->assertSame(['0', 'mcp:read'], $token->scopes); + } + + #[TestDox('a token response is turned into an absolute expiry')] + public function testFromResponse(): void + { + $token = AccessToken::fromResponse([ + 'access_token' => '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 000000000..333b1e939 --- /dev/null +++ b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php @@ -0,0 +1,120 @@ +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. + // The listener speaks no TLS and never accepts, so only a plain socket can connect and write. + #[TestDox('the headless handler uses TLS for an uppercase HTTPS scheme')] + public function testHeadlessHandlerUsesTlsForAnUppercaseScheme(): void + { + $listener = stream_socket_server('tcp://127.0.0.1:0'); + $this->assertIsResource($listener); + $port = (int) explode(':', (string) stream_socket_get_name($listener, false))[1]; + + try { + (new HeadlessAuthorizationHandler(1))->authorize(\sprintf('HTTPS://127.0.0.1:%d/authorize', $port), 'http://127.0.0.1:8765/callback'); + $this->fail('The authorization should have failed.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('Could not reach', $e->getMessage()); + } finally { + fclose($listener); + } + } + + 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 000000000..14a7412a7 --- /dev/null +++ b/tests/Unit/Client/Auth/ClientRegistrationTest.php @@ -0,0 +1,185 @@ + '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 refresh token grant is not registered where the server does not offer it')] + public function testDropsAnUnsupportedRefreshGrant(): void + { + $server = new CapturingRegistrationServer(); + $factory = new Psr17Factory(); + + (new ClientRegistrar($server, $factory, $factory))->register( + new AuthorizationServerMetadata('https://auth.example.com', 'https://auth.example.com/authorize', 'https://auth.example.com/token', 'https://auth.example.com/register', grantTypesSupported: ['authorization_code']), + new ClientMetadata('My App', ['http://127.0.0.1:8765/callback'], ['authorization_code', 'refresh_token']), + TokenEndpointAuthMethod::None, + ); + + $this->assertSame(['authorization_code'], $server->body['grant_types'] ?? null); + } + + #[TestDox('a refresh token grant is registered where the server offers it')] + public function testKeepsASupportedRefreshGrant(): void + { + $server = new CapturingRegistrationServer(); + $factory = new Psr17Factory(); + + (new ClientRegistrar($server, $factory, $factory))->register( + new AuthorizationServerMetadata('https://auth.example.com', 'https://auth.example.com/authorize', 'https://auth.example.com/token', 'https://auth.example.com/register', grantTypesSupported: ['authorization_code', 'refresh_token']), + new ClientMetadata('My App', ['http://127.0.0.1:8765/callback'], ['authorization_code', 'refresh_token']), + TokenEndpointAuthMethod::None, + ); + + $this->assertSame(['authorization_code', 'refresh_token'], $server->body['grant_types'] ?? null); + } + + #[TestDox('a successful registration response without a client id is an authorization failure')] + public function testMalformedRegistrationResponse(): void + { + $client = new class implements ClientInterface { + public function sendRequest(RequestInterface $request): ResponseInterface + { + return new Response(201, [], '{"client_secret":"a-secret"}'); + } + }; + $factory = new Psr17Factory(); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('invalid client registration response'); + + (new ClientRegistrar($client, $factory, $factory))->register( + $this->metadata('https://auth.example.com/register'), + new ClientMetadata('My App'), + TokenEndpointAuthMethod::None, + ); + } + + #[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 000000000..918013cbc --- /dev/null +++ b/tests/Unit/Client/Auth/FileCredentialStorageTest.php @@ -0,0 +1,176 @@ +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); + } + + $lock = \dirname($this->path).'/.'.basename($this->path).'.lock'; + + if (is_file($lock)) { + unlink($lock); + } + + if (is_dir(\dirname($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('an entry of the wrong type costs one authorization, not a crash')] + public function testToleratesAnEntryOfTheWrongType(): void + { + @mkdir(\dirname($this->path), 0700, true); + file_put_contents($this->path, (string) json_encode([ + 'registrations' => ['https://auth.example.com' => 'not an array'], + 'tokens' => ['https://auth.example.com' => ['https://mcp.example.com/mcp' => 'not an array']], + ])); + + $storage = new FileCredentialStorage($this->path); + + $this->assertNull($storage->getClientRegistration('https://auth.example.com')); + $this->assertNull($storage->getToken('https://auth.example.com', 'https://mcp.example.com/mcp')); + } + + #[TestDox('two instances on one file do not overwrite each other')] + public function testInstancesDoNotLoseEachOthersUpdates(): void + { + $first = new FileCredentialStorage($this->path); + $second = new FileCredentialStorage($this->path); + + $this->assertNull($second->getToken('https://auth.example.com', 'https://one.example.com/mcp')); + + $first->saveToken('https://auth.example.com', 'https://one.example.com/mcp', new AccessToken('one')); + $second->saveToken('https://auth.example.com', 'https://two.example.com/mcp', new AccessToken('two')); + + $this->assertSame('one', $first->getToken('https://auth.example.com', 'https://one.example.com/mcp')?->accessToken); + $this->assertSame('two', $first->getToken('https://auth.example.com', 'https://two.example.com/mcp')?->accessToken); + } + + #[TestDox('the lock file is as private as the credentials')] + public function testLockFilePermissions(): void + { + (new FileCredentialStorage($this->path))->saveToken('https://auth.example.com', 'https://mcp.example.com/mcp', new AccessToken('one')); + + clearstatcache(); + $this->assertSame('0600', substr(\sprintf('%o', fileperms(\dirname($this->path).'/.'.basename($this->path).'.lock')), -4)); + } + + #[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 000000000..ce98db564 --- /dev/null +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -0,0 +1,744 @@ +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') + ->setExpectedIssuer('https://auth.example.com')); + + $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); + } + + #[TestDox('a client secret never reaches an authorization server other than the expected one')] + public function testRefusesAnUnexpectedIssuer(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test') + ->setClientCredentials('known-client', 'known-secret') + ->setExpectedIssuer('https://trusted.example.com')); + + try { + $authenticator->handleChallenge($this->request(), $server->challenge()); + $this->fail('The unexpected issuer was not refused.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('https://trusted.example.com', $e->getMessage()); + } + + $this->assertSame([], $server->authorization); + $this->assertSame([], $server->token); + $this->assertNotContains('https://auth.example.com/token', $server->requested); + } + + #[TestDox('an expected issuer also binds a client without a secret')] + public function testExpectedIssuerBindsAPublicClient(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test') + ->setClientCredentials('known-client') + ->setExpectedIssuer('https://trusted.example.com')); + + $this->expectException(AuthorizationException::class); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + } + + 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 000000000..114ce7817 --- /dev/null +++ b/tests/Unit/Client/Auth/OAuthTest.php @@ -0,0 +1,105 @@ +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('a client secret needs an expected issuer')] + public function testClientSecretNeedsAnExpectedIssuer(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('expected issuer'); + + OAuth::forApplication('My App')->setClientCredentials('client', 'secret')->build(); + } + + #[TestDox('a client id without a secret needs no expected issuer')] + public function testPublicClientNeedsNoExpectedIssuer(): void + { + $configuration = $this->configurationOf(OAuth::forApplication('My App')->setClientCredentials('client')); + + $this->assertNull($configuration->expectedIssuer); + } + + #[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 000000000..9d1b60588 --- /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 000000000..2d99e81e0 --- /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 000000000..f74d6e32a --- /dev/null +++ b/tests/Unit/Client/Auth/TokenEndpointTest.php @@ -0,0 +1,184 @@ +}> + */ + 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']); + } + + #[TestDox('a successful token response without an access token is an authorization failure')] + public function testMalformedTokenResponse(): void + { + $server = new CapturingTokenServer(new Response(200, [], '{"token_type":"Bearer"}')); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('invalid token response'); + + $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 dd650255d904e4dbc4d4066474572ddc9cf18548 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 23:18:52 +0100 Subject: [PATCH 02/60] [Docs] Explain that a parent resource covers the paths below it --- docs/client/authorization.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 1ec15da4b..b2c273206 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -148,6 +148,8 @@ The client treats the MCP server and its metadata as untrusted input. It stops w 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. +A `resource` can name a parent path of the MCP server. All paths below it then belong to the resource, and the client can send the token to them. If several servers share a host, give each one its own host (e.g. `tenant-a.example.com`). + !!! 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. From 642236a8efb11740f224c9d477ca6ed6a5ba6259 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 23:53:18 +0100 Subject: [PATCH 03/60] [Client] Refuse an authorization server that does not list S256 for PKCE --- docs/client/authorization.md | 1 + .../Auth/AuthorizationServerMetadata.php | 8 ++++- src/Client/Auth/OAuthAuthenticator.php | 4 +++ .../Auth/AuthorizationServerMetadataTest.php | 1 + .../Client/Auth/OAuthAuthenticatorTest.php | 34 ++++++++++++++++++- 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index b2c273206..e850689b7 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -141,6 +141,7 @@ The client treats the MCP server and its metadata as untrusted input. It stops w | 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 server metadata doesn't list `S256` in `code_challenge_methods_supported` | The client can't use PKCE with this server | | 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 authorization server has another issuer than `setExpectedIssuer()` | It would receive the client's credentials | diff --git a/src/Client/Auth/AuthorizationServerMetadata.php b/src/Client/Auth/AuthorizationServerMetadata.php index 63ec542af..5dabf5516 100644 --- a/src/Client/Auth/AuthorizationServerMetadata.php +++ b/src/Client/Auth/AuthorizationServerMetadata.php @@ -119,7 +119,13 @@ public static function forLegacyServer(string $issuer): self $base = rtrim($issuer, '/'); - return new self($issuer, $base.'/authorize', $base.'/token', $base.'/register'); + // The 2025-03-26 revision requires PKCE of every server, and these have no list to read. + return new self($issuer, $base.'/authorize', $base.'/token', $base.'/register', codeChallengeMethodsSupported: [Pkce::METHOD]); + } + + public function supportsPkce(): bool + { + return \in_array(Pkce::METHOD, $this->codeChallengeMethodsSupported, true); } public function supportsGrant(string $grantType): bool diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 06d1375c7..59988ecef 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -369,6 +369,10 @@ private function grantWithFallback(AuthorizationServerMetadata $metadata, Client */ private function grant(AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes, string $resource): AccessToken { + if (!$metadata->supportsPkce()) { + throw new AuthorizationException(\sprintf('The authorization server "%s" does not list "%s" in "code_challenge_methods_supported", so the client cannot use PKCE with it.', $metadata->issuer, Pkce::METHOD)); + } + $pkce = Pkce::generate(); $state = rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '='); diff --git a/tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php b/tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php index ecba4de3a..c89165932 100644 --- a/tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php +++ b/tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php @@ -361,6 +361,7 @@ public function testLegacyEndpoints(): void $this->assertSame('https://mcp.example.com/token', $metadata->tokenEndpoint); $this->assertSame('https://mcp.example.com/register', $metadata->registrationEndpoint); $this->assertFalse($metadata->issuerParameterSupported); + $this->assertTrue($metadata->supportsPkce()); $this->assertTrue($metadata->supportsGrant('authorization_code')); $this->assertFalse($metadata->supportsGrant('refresh_token')); } diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index ce98db564..4b5322078 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -24,6 +24,7 @@ use Mcp\Exception\AuthorizationException; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientInterface; @@ -521,6 +522,35 @@ public function testExpectedIssuerBindsAPublicClient(): void $authenticator->handleChallenge($this->request(), $server->challenge()); } + /** + * @return iterable + */ + public static function serverWithoutPkceProvider(): iterable + { + yield 'no list' => [null]; + yield 'an empty list' => [[]]; + yield 'only plain' => [['plain']]; + } + + /** + * @param ?string[] $methods + */ + #[DataProvider('serverWithoutPkceProvider')] + #[TestDox('a server without S256 PKCE is refused before the browser opens: $_dataName')] + public function testRefusesAServerWithoutPkce(?array $methods): void + { + $server = new FakeOAuthServer(codeChallengeMethods: $methods); + + try { + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge()); + $this->fail('The server without PKCE was not refused.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('code_challenge_methods_supported', $e->getMessage()); + } + + $this->assertSame([], $server->authorization); + } + private function request(): RequestInterface { return (new Psr17Factory())->createRequest('POST', self::ENDPOINT); @@ -607,6 +637,7 @@ final class FakeOAuthServer implements ClientInterface /** * @param ?string[] $resourceScopes * @param string[] $serverScopes + * @param ?string[] $codeChallengeMethods */ public function __construct( private readonly string $resource = 'https://mcp.example.com/mcp', @@ -618,6 +649,7 @@ public function __construct( private readonly bool $refuseOfflineAccess = false, private readonly ?string $codeError = null, private readonly bool $hasAuthorizationEndpoint = true, + private readonly ?array $codeChallengeMethods = ['S256'], ) { } @@ -667,7 +699,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface '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'], + 'code_challenge_methods_supported' => $this->codeChallengeMethods, 'authorization_response_iss_parameter_supported' => true, ]), From 469c55134e7989b1028b099d972040df93623d53 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 23:53:19 +0100 Subject: [PATCH 04/60] [Client] Turn off redirects wherever a token or client secret is sent --- CHANGELOG.md | 1 + docs/client/authorization.md | 2 + docs/client/transports.md | 11 ++- src/Client/Auth/AuthenticatingHttpClient.php | 7 +- src/Client/Auth/OAuth.php | 4 +- src/Client/Transport/HttpTransport.php | 19 +---- src/Client/Transport/NoRedirects.php | 50 +++++++++++++ .../Auth/AuthenticatingHttpClientTest.php | 23 ++++++ tests/Unit/Client/Auth/OAuthTest.php | 73 +++++++++++++++++++ 9 files changed, 165 insertions(+), 25 deletions(-) create mode 100644 src/Client/Transport/NoRedirects.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b21129d9b..261e23614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Fix `HttpTransport` taking an `Mcp-Session-Id` from any response: it replaced the session mid-way and, on `2026-07-28`, made `getSessionId()` return a value and `close()` send a `DELETE`. Only the answer to `initialize` sets it. * 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. * 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). +* `AuthenticatingHttpClient` and `OAuth` turn off redirects on a `symfony/http-client` client, so a redirect can't carry a token or client secret to another path or origin. * Add `OAuth::setExpectedIssuer()`, required with a client secret, so a hostile MCP server can't receive the secret. * Add `LoopbackAuthorizationHandler` and `HeadlessAuthorizationHandler` behind `AuthorizationHandlerInterface`, and `InMemoryCredentialStorage` and `FileCredentialStorage` behind `CredentialStorageInterface`. * [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 e850689b7..d10c0229c 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -130,6 +130,8 @@ use Mcp\Client\Auth\AuthenticatingHttpClient; $httpClient = new AuthenticatingHttpClient($yourPsr18Client, $auth); ``` +The token is only added to requests for this server, but a redirect receives the same headers. This class and `OAuth` turn off redirects on a `symfony/http-client` client. Before `symfony/http-client` 6.2, create that client with `max_redirects` set to `0`. Any other client *must not* follow redirects. + 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 diff --git a/docs/client/transports.md b/docs/client/transports.md index 6c6b78185..3c85145f1 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -134,10 +134,13 @@ If the server answers a request with a `3xx` status, the request fails at once w `ConnectionException` at once, with no retry and no fallback to the `initialize` handshake. To reach a server that moved, configure its new URL as `endpoint`. -The transport turns off redirects on a `symfony/http-client` client that it discovers. Any other -client, and a client you pass as `httpClient`, keeps its own configuration. Such a client *must not* -follow redirects to another origin. If it does, your headers and the request body reach that -origin, and the transport accepts its answer as the server's. +The transport turns off redirects on a `symfony/http-client` client that it discovers. With `auth`, +it also turns them off on a `symfony/http-client` client that you pass as `httpClient`. Any other +client keeps its own configuration. + +Such a client *must not* follow redirects to another origin. If it does, your headers and the +request body reach that origin, and the transport accepts its answer as the server's. With `auth`, +it *must not* follow any redirect, because a redirect on the same origin also receives the token. To pass a `symfony/http-client` client, create it with `max_redirects` set to `0`: diff --git a/src/Client/Auth/AuthenticatingHttpClient.php b/src/Client/Auth/AuthenticatingHttpClient.php index 40a02930d..f13fa0c1f 100644 --- a/src/Client/Auth/AuthenticatingHttpClient.php +++ b/src/Client/Auth/AuthenticatingHttpClient.php @@ -11,6 +11,7 @@ namespace Mcp\Client\Auth; +use Mcp\Client\Transport\NoRedirects; use Mcp\Exception\InvalidArgumentException; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; @@ -36,14 +37,18 @@ final class AuthenticatingHttpClient implements ClientInterface */ public const DEFAULT_MAX_AUTHORIZATION_ATTEMPTS = 3; + private readonly ClientInterface $client; + public function __construct( - private readonly ClientInterface $client, + 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)); } + + $this->client = NoRedirects::apply($client); } public function sendRequest(RequestInterface $request): ResponseInterface diff --git a/src/Client/Auth/OAuth.php b/src/Client/Auth/OAuth.php index 2114b5d6e..fdeac87f8 100644 --- a/src/Client/Auth/OAuth.php +++ b/src/Client/Auth/OAuth.php @@ -12,7 +12,7 @@ namespace Mcp\Client\Auth; use Http\Discovery\Psr17FactoryDiscovery; -use Http\Discovery\Psr18ClientDiscovery; +use Mcp\Client\Transport\NoRedirects; use Mcp\Exception\InvalidArgumentException; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; @@ -241,7 +241,7 @@ public function setLogger(LoggerInterface $logger): self public function build(): OAuthAuthenticator { - $httpClient = $this->httpClient ?? Psr18ClientDiscovery::find(); + $httpClient = null === $this->httpClient ? NoRedirects::discover() : NoRedirects::apply($this->httpClient); $requestFactory = $this->requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); $streamFactory = $this->streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 0b9b7b9ef..e03ec48fd 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -12,7 +12,6 @@ namespace Mcp\Client\Transport; use Http\Discovery\Psr17FactoryDiscovery; -use Http\Discovery\Psr18ClientDiscovery; use Mcp\Client\Auth\AuthenticatingHttpClient; use Mcp\Client\Auth\AuthenticatorInterface; use Mcp\Client\MonotonicClock; @@ -32,8 +31,6 @@ use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\StreamInterface; use Psr\Log\LoggerInterface; -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Component\HttpClient\Psr18Client; /** * HTTP-based client transport using PSR-18 HTTP client. @@ -159,26 +156,12 @@ public function __construct( $this->listenParser = new SseParser($maxSseBufferBytes); $this->reconnectPolicy = $reconnectPolicy ?? new ReconnectPolicy(); $this->clock = $clock ?? new MonotonicClock(); - $client = $httpClient ?? self::discoverClient(); + $client = $httpClient ?? NoRedirects::discover(); $this->httpClient = null === $auth ? $client : new AuthenticatingHttpClient($client, $auth); $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } - private static function discoverClient(): ClientInterface - { - $client = Psr18ClientDiscovery::find(); - - if (!$client instanceof Psr18Client) { - return $client; - } - - // Psr18Client has no withOptions() before symfony/http-client 6.2. - return \is_callable([$client, 'withOptions']) - ? $client->withOptions(['max_redirects' => 0]) - : new Psr18Client(HttpClient::create(['max_redirects' => 0])); - } - public function connect(): void { $this->resetSseState(); diff --git a/src/Client/Transport/NoRedirects.php b/src/Client/Transport/NoRedirects.php new file mode 100644 index 000000000..a4181f090 --- /dev/null +++ b/src/Client/Transport/NoRedirects.php @@ -0,0 +1,50 @@ + + */ +final class NoRedirects +{ + public static function discover(): ClientInterface + { + $client = Psr18ClientDiscovery::find(); + + if (!$client instanceof Psr18Client) { + return $client; + } + + // Psr18Client has no withOptions() before symfony/http-client 6.2. + return \is_callable([$client, 'withOptions']) + ? $client->withOptions(['max_redirects' => 0]) + : new Psr18Client(HttpClient::create(['max_redirects' => 0])); + } + + public static function apply(ClientInterface $client): ClientInterface + { + return $client instanceof Psr18Client && \is_callable([$client, 'withOptions']) + ? $client->withOptions(['max_redirects' => 0]) + : $client; + } +} diff --git a/tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php b/tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php index 9b9e815fc..f2bb5b4e9 100644 --- a/tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php +++ b/tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php @@ -25,9 +25,32 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Psr18Client; +use Symfony\Component\HttpClient\Response\MockResponse; final class AuthenticatingHttpClientTest extends TestCase { + #[TestDox('a Symfony client stops following redirects, so a redirect cannot carry the token elsewhere')] + public function testTurnsOffRedirectsOnASymfonyClient(): void + { + if (!(new \ReflectionClass(Psr18Client::class))->hasMethod('withOptions')) { + $this->markTestSkipped('Psr18Client has no withOptions() before symfony/http-client 6.2.'); + } + + $maxRedirects = null; + $mock = new MockHttpClient(static function (string $method, string $url, array $options) use (&$maxRedirects): MockResponse { + $maxRedirects = $options['max_redirects']; + + return new MockResponse('', ['http_code' => 200]); + }); + + (new AuthenticatingHttpClient(new Psr18Client($mock), new BearerToken('token'))) + ->sendRequest((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/mcp')); + + $this->assertSame(0, $maxRedirects); + } + #[TestDox('every request carries the credentials the authenticator provides')] public function testAuthenticatesEveryRequest(): void { diff --git a/tests/Unit/Client/Auth/OAuthTest.php b/tests/Unit/Client/Auth/OAuthTest.php index 114ce7817..734c31a43 100644 --- a/tests/Unit/Client/Auth/OAuthTest.php +++ b/tests/Unit/Client/Auth/OAuthTest.php @@ -11,13 +11,22 @@ namespace Mcp\Tests\Unit\Client\Auth; +use Http\Discovery\Psr18ClientDiscovery; +use Http\Discovery\Strategy\DiscoveryStrategy; use Mcp\Client\Auth\OAuth; use Mcp\Client\Auth\OAuthAuthenticator; use Mcp\Client\Auth\OAuthConfiguration; use Mcp\Client\Auth\TokenEndpointAuthMethod; +use Mcp\Exception\AuthorizationException; use Mcp\Exception\InvalidArgumentException; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; +use Psr\Http\Client\ClientInterface; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Psr18Client; +use Symfony\Component\HttpClient\Response\MockResponse; final class OAuthTest extends TestCase { @@ -33,6 +42,45 @@ public function testApplicationDefaults(): void $this->assertNull($configuration->clientId); } + #[TestDox('a Symfony client passed in stops following redirects, so one cannot carry a client secret elsewhere')] + public function testPassedClientStopsFollowingRedirects(): void + { + $seen = []; + + $this->challenge(OAuth::forApplication('My App')->setHttpClient(new Psr18Client($this->recordingRedirects($seen)))); + + $this->assertNotSame([], $seen); + $this->assertSame([0], array_values(array_unique($seen))); + } + + #[TestDox('a discovered Symfony client stops following redirects, so one cannot carry a client secret elsewhere')] + public function testDiscoveredClientStopsFollowingRedirects(): void + { + $seen = []; + + $strategy = new class implements DiscoveryStrategy { + public static ClientInterface $client; + + public static function getCandidates($type): array + { + return ClientInterface::class === $type ? [['class' => static fn (): ClientInterface => self::$client]] : []; + } + }; + $strategy::$client = new Psr18Client($this->recordingRedirects($seen)); + + $strategies = [...Psr18ClientDiscovery::getStrategies()]; + Psr18ClientDiscovery::prependStrategy($strategy::class); + + try { + $this->challenge(OAuth::forApplication('My App')); + } finally { + Psr18ClientDiscovery::setStrategies($strategies); + } + + $this->assertNotSame([], $seen); + $this->assertSame([0], array_values(array_unique($seen))); + } + #[TestDox('a client secret needs an expected issuer')] public function testClientSecretNeedsAnExpectedIssuer(): void { @@ -98,6 +146,31 @@ public function testSoftwareInfo(): void $this->assertSame('2.1.0', $body['software_version']); } + /** + * @param int[] $seen + */ + private function recordingRedirects(array &$seen): MockHttpClient + { + if (!(new \ReflectionClass(Psr18Client::class))->hasMethod('withOptions')) { + $this->markTestSkipped('Psr18Client has no withOptions() before symfony/http-client 6.2.'); + } + + return new MockHttpClient(static function (string $method, string $url, array $options) use (&$seen): MockResponse { + $seen[] = $options['max_redirects']; + + return new MockResponse('', ['http_code' => 404]); + }); + } + + private function challenge(OAuth $oauth): void + { + try { + $oauth->build()->handleChallenge((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/mcp'), new Response(401)); + } catch (AuthorizationException) { + // Discovery finds nothing here, only its requests matter. + } + } + private function configurationOf(OAuth $oauth): OAuthConfiguration { return (new \ReflectionProperty(OAuthAuthenticator::class, 'configuration'))->getValue($oauth->build()); From 8a29ba8b1b8422aa1729505d8c2a7c9c0b4b0ca7 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 23:53:19 +0100 Subject: [PATCH 05/60] [Client] Only assume the fixed legacy endpoints after a 404 --- docs/client/authorization.md | 1 + src/Client/Auth/MetadataDiscovery.php | 35 +++++++- src/Client/Auth/OAuthAuthenticator.php | 3 +- .../Client/Auth/MetadataDiscoveryTest.php | 85 +++++++++++++++++++ 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index d10c0229c..cf9ab7125 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -147,6 +147,7 @@ The client treats the MCP server and its metadata as untrusted input. It stops w | 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 authorization server has another issuer than `setExpectedIssuer()` | It would receive the client's credentials | +| With `setLegacyDiscovery(true)`, a metadata location answers anything but a `404` and gives no usable metadata | The client only uses the fixed endpoints after a `404` | | 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. diff --git a/src/Client/Auth/MetadataDiscovery.php b/src/Client/Auth/MetadataDiscovery.php index 5eb009de9..5e0332397 100644 --- a/src/Client/Auth/MetadataDiscovery.php +++ b/src/Client/Auth/MetadataDiscovery.php @@ -71,15 +71,45 @@ public function discoverProtectedResource(string $resourceUrl, ?string $metadata * @param string $issuer the issuer identifier, as advertised by the resource */ public function discoverAuthorizationServer(string $issuer): ?AuthorizationServerMetadata + { + $everyLocationMissing = true; + + return $this->findAuthorizationServer($issuer, $everyLocationMissing); + } + + /** + * Find the authorization server of a 2025-03-26 server, which may publish no metadata. + * + * That revision only assumes its fixed endpoints after a 404, so any other answer + * leaves the client without an authorization server. + * + * @param string $issuer the origin of the MCP server + */ + public function discoverLegacyAuthorizationServer(string $issuer): ?AuthorizationServerMetadata + { + $everyLocationMissing = true; + $metadata = $this->findAuthorizationServer($issuer, $everyLocationMissing); + + if (null !== $metadata || !$everyLocationMissing) { + return $metadata; + } + + return AuthorizationServerMetadata::forLegacyServer($issuer); + } + + private function findAuthorizationServer(string $issuer, bool &$everyLocationMissing): ?AuthorizationServerMetadata { if (!AuthorizationServerMetadata::isTransportSecure($issuer)) { + $everyLocationMissing = false; $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); + $missing = false; + $document = $this->fetch($candidate, $missing); + $everyLocationMissing = $everyLocationMissing && $missing; if (null === $document) { continue; @@ -158,7 +188,7 @@ public static function authorizationServerCandidates(string $issuer): array /** * @return array|null */ - private function fetch(string $url): ?array + private function fetch(string $url, bool &$missing = false): ?array { $request = $this->requestFactory->createRequest('GET', $url)->withHeader('Accept', 'application/json'); @@ -171,6 +201,7 @@ private function fetch(string $url): ?array } if (200 !== $response->getStatusCode()) { + $missing = 404 === $response->getStatusCode(); $this->logger->debug('Metadata not available', ['url' => $url, 'status' => $response->getStatusCode()]); return null; diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 59988ecef..ce6f67014 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -231,7 +231,8 @@ private function discoverLegacyServer(string $endpoint): AuthorizationServerMeta $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); + return $this->discovery->discoverLegacyAuthorizationServer($issuer) + ?? throw new AuthorizationException(\sprintf('The authorization server metadata of "%s" could not be read, and only a 404 falls back to the fixed endpoints.', $issuer)); } /** diff --git a/tests/Unit/Client/Auth/MetadataDiscoveryTest.php b/tests/Unit/Client/Auth/MetadataDiscoveryTest.php index c5f2b5a2f..a783a274b 100644 --- a/tests/Unit/Client/Auth/MetadataDiscoveryTest.php +++ b/tests/Unit/Client/Auth/MetadataDiscoveryTest.php @@ -431,6 +431,91 @@ public function testSurvivesATransportFailure(): void $this->assertNotNull($this->discovery($client)->discoverProtectedResource('https://mcp.example.com/mcp')); } + #[TestDox('a legacy server gets its fixed endpoints only when every metadata location answers 404')] + public function testLegacyDiscoveryGuessesAfterA404(): void + { + $metadata = $this->discovery(self::answering(new Response(404)))->discoverLegacyAuthorizationServer('https://mcp.example.com'); + + $this->assertSame('https://mcp.example.com/token', $metadata?->tokenEndpoint); + } + + /** + * @return iterable + */ + public static function brokenMetadataProvider(): iterable + { + yield 'a server error' => [new Response(500)]; + yield 'a forbidden answer' => [new Response(403)]; + yield 'a redirect' => [new Response(307, ['Location' => 'http://evil.example.com/'])]; + yield 'another issuer' => [self::json([ + 'issuer' => 'https://evil.example.com', + 'authorization_endpoint' => 'https://evil.example.com/authorize', + 'token_endpoint' => 'https://evil.example.com/token', + ])]; + yield 'a malformed document' => [self::json(['issuer' => 'https://mcp.example.com'])]; + } + + #[DataProvider('brokenMetadataProvider')] + #[TestDox('a legacy server keeps its metadata problem and gets no guessed endpoints: $_dataName')] + public function testLegacyDiscoveryDoesNotGuessAfterAnotherAnswer(Response $response): void + { + $this->assertNull($this->discovery(self::answering($response))->discoverLegacyAuthorizationServer('https://mcp.example.com')); + } + + #[TestDox('a legacy server that answers 404 at one location and fails at the next gets no guessed endpoints')] + public function testLegacyDiscoveryNeedsA404FromEveryLocation(): void + { + $client = new class implements ClientInterface { + private int $requests = 0; + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return new Response(1 === ++$this->requests ? 404 : 500); + } + }; + + $this->assertNull($this->discovery($client)->discoverLegacyAuthorizationServer('https://mcp.example.com')); + } + + #[TestDox('a legacy server that cannot be reached gets no guessed endpoints')] + public function testLegacyDiscoveryDoesNotGuessAfterATransportFailure(): void + { + $client = new class implements ClientInterface { + public function sendRequest(RequestInterface $request): ResponseInterface + { + throw new class('unreachable') extends \RuntimeException implements ClientExceptionInterface {}; + } + }; + + $this->assertNull($this->discovery($client)->discoverLegacyAuthorizationServer('https://mcp.example.com')); + } + + #[TestDox('a legacy server that publishes metadata keeps it')] + public function testLegacyDiscoveryPrefersPublishedMetadata(): void + { + $client = self::answering(self::json([ + 'issuer' => 'https://mcp.example.com', + 'authorization_endpoint' => 'https://mcp.example.com/oauth/authorize', + 'token_endpoint' => 'https://mcp.example.com/oauth/token', + ])); + + $this->assertSame('https://mcp.example.com/oauth/token', $this->discovery($client)->discoverLegacyAuthorizationServer('https://mcp.example.com')?->tokenEndpoint); + } + + private static function answering(ResponseInterface $response): ClientInterface + { + return new class($response) implements ClientInterface { + public function __construct(private readonly ResponseInterface $response) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return $this->response; + } + }; + } + private function discovery(ClientInterface $client): MetadataDiscovery { return new MetadataDiscovery($client, new Psr17Factory()); From f17d11ca8a4c32f184ee801ea56498b0ecff1288 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 23:53:20 +0100 Subject: [PATCH 06/60] [Client] Say "no usable" when protected resource metadata was rejected --- src/Client/Auth/OAuthAuthenticator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index ce6f67014..ceca2b9fc 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -225,7 +225,7 @@ private static function trustedMetadataUrl(string $endpoint, AuthorizationChalle 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)); + throw new AuthorizationException(\sprintf('The server at "%s" published no usable protected resource metadata, and discovery of pre-2025-06-18 servers is disabled.', $endpoint)); } $issuer = self::origin($endpoint); From 7ee9e5c24e4d523fb468a77fb1df02d406e6e055 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:09:44 +0100 Subject: [PATCH 07/60] [Client] Only authorize again after a 403 with insufficient_scope --- docs/client/authorization.md | 2 +- src/Client/Auth/OAuthAuthenticator.php | 6 ++++ .../Client/Auth/OAuthAuthenticatorTest.php | 31 ++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index cf9ab7125..85aed4471 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -112,7 +112,7 @@ By default, the client requests the scopes of the challenge. If there are none, 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 server answers with `insufficient_scope`, the client adds the scopes of this challenge and authorizes again. With any other `403`, the client doesn't authorize 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. diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index ceca2b9fc..fe8c90be9 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -87,6 +87,12 @@ public function authenticate(RequestInterface $request): RequestInterface public function handleChallenge(RequestInterface $request, ResponseInterface $response): bool { $challenge = AuthorizationChallenge::fromResponse($response); + + // Signing in again only helps a 403 that asks for more scopes. + if (403 === $response->getStatusCode() && 'insufficient_scope' !== $challenge->getError()) { + return false; + } + $endpoint = self::canonicalize((string) $request->getUri()); // Metadata fetched over plain http can be forged and the token would travel in the clear. diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 4b5322078..f261f9aa1 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -123,7 +123,7 @@ public function testUnionsScopesOnReAuthorization(): void $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read'])); $rejected = $authenticator->authenticate($this->request()); - $authenticator->handleChallenge($rejected, $server->challenge(['mcp:write'], 403)); + $authenticator->handleChallenge($rejected, $server->challenge(['mcp:write'], 403, 'insufficient_scope')); $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); } @@ -296,6 +296,35 @@ public function testRejectsAMismatchedState(): void $authenticator->handleChallenge($this->request(), $server->challenge()); } + /** + * @param array $headers + */ + #[DataProvider('forbiddenChallengeProvider')] + #[TestDox('a 403 that does not ask for more scopes leaves the user out of it')] + public function testDeclinesAForbiddenResponseWithoutInsufficientScope(array $headers): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server); + $authenticator->handleChallenge($this->request(), $server->challenge()); + $requested = $server->requested; + + $sent = $authenticator->authenticate($this->request()); + + $this->assertFalse($authenticator->handleChallenge($sent, new Response(403, $headers))); + $this->assertSame($requested, $server->requested); + $this->assertSame('Bearer access-token-1', $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); + } + + /** + * @return iterable}> + */ + public static function forbiddenChallengeProvider(): iterable + { + yield 'no challenge' => [[]]; + yield 'a challenge without an error' => [['WWW-Authenticate' => 'Bearer scope="admin"']]; + yield 'another error' => [['WWW-Authenticate' => 'Bearer error="invalid_token"']]; + } + #[TestDox('a stored token is reused instead of sending the user through the browser again')] public function testReusesAStoredToken(): void { From f2f062debf5e992aeb1cac66c295960a0948795e Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:10:39 +0100 Subject: [PATCH 08/60] [Client] Keep a token bound to its resource when authorization fails --- src/Client/Auth/OAuthAuthenticator.php | 3 +- .../Client/Auth/OAuthAuthenticatorTest.php | 40 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index fe8c90be9..229d523bd 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -118,7 +118,6 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re $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. @@ -128,6 +127,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re if (null !== $stored && $this->isUsable($stored, $needed, $request)) { $this->logger->debug('Reusing a stored access token', ['issuer' => $metadata->issuer, 'resource' => $resource]); $this->token = $stored; + $this->audience = $audience; return true; } @@ -140,6 +140,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re } $this->token = $token->withGrantedScopes($scopes); + $this->audience = $audience; $this->requestedScopes = $scopes; $this->storage->saveToken($metadata->issuer, $resource, $this->token); diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index f261f9aa1..b2d188691 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -375,6 +375,44 @@ public function testTokenIsBoundToItsResource(): void $this->assertSame('Bearer access-token-1', $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); } + #[TestDox('a failed authorization for another resource does not make the token available to it')] + public function testFailedAuthorizationKeepsTheTokenBoundToItsResource(): void + { + $server = new FakeOAuthServer(); + $handler = new class(new StubAuthorizationHandler($server)) implements AuthorizationHandlerInterface { + public bool $fails = false; + + public function __construct(private readonly AuthorizationHandlerInterface $handler) + { + } + + public function authorize(string $authorizationUrl, string $redirectUri): array + { + if ($this->fails) { + throw new AuthorizationException('The user canceled the authorization.'); + } + + return $this->handler->authorize($authorizationUrl, $redirectUri); + } + }; + $authenticator = $this->authenticator($server, $handler); + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $elsewhere = (new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/other'); + $server->resource = 'https://mcp.example.com/other'; + $handler->fails = true; + + try { + $authenticator->handleChallenge($elsewhere, $server->challenge()); + $this->fail('The authorization should have failed.'); + } catch (AuthorizationException $e) { + $this->assertSame('The user canceled the authorization.', $e->getMessage()); + } + + $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 { @@ -669,7 +707,7 @@ final class FakeOAuthServer implements ClientInterface * @param ?string[] $codeChallengeMethods */ public function __construct( - private readonly string $resource = 'https://mcp.example.com/mcp', + public string $resource = 'https://mcp.example.com/mcp', private readonly ?array $resourceScopes = null, private readonly array $serverScopes = [], private readonly bool $insecureEndpoints = false, From 12aff2a6d9410e9ff010342adf1ce61c904ca9c6 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:11:14 +0100 Subject: [PATCH 09/60] [Client] Always show the authorization URL --- docs/client/authorization.md | 2 +- .../Auth/LoopbackAuthorizationHandler.php | 12 ++-- .../Client/Auth/AuthorizationHandlerTest.php | 55 +++++++++++++++++++ 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 85aed4471..aec2147b2 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -49,7 +49,7 @@ $auth = OAuth::forApplication('My App') 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. +- `LoopbackAuthorizationHandler` is the default. It opens the system browser and listens on the port of the redirect URI until the browser returns. It also writes the URL to `STDERR`, so you can open it yourself if no browser starts. 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: diff --git a/src/Client/Auth/LoopbackAuthorizationHandler.php b/src/Client/Auth/LoopbackAuthorizationHandler.php index 2329fd1d8..34478878e 100644 --- a/src/Client/Auth/LoopbackAuthorizationHandler.php +++ b/src/Client/Auth/LoopbackAuthorizationHandler.php @@ -146,6 +146,9 @@ private function open(string $url): void $this->logger->info('Opening the authorization page in the browser', ['url' => $url]); + // Printed every time, as a backgrounded launcher can't report that no browser opened. + file_put_contents('php://stderr', \sprintf("\nOpen this URL to authorize the application if your browser doesn't open it:\n\n %s\n\n", $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)), @@ -154,14 +157,7 @@ private function open(string $url): void }; if (\function_exists('exec')) { - @exec($command, $output, $status); - - if (0 === $status) { - return; - } + @exec($command); } - - // 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/tests/Unit/Client/Auth/AuthorizationHandlerTest.php b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php index 333b1e939..c9619a7ce 100644 --- a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php +++ b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php @@ -23,6 +23,9 @@ */ final class AuthorizationHandlerTest extends TestCase { + /** @var string[] */ + private array $directories = []; + #[TestDox('the loopback handler answers the browser and returns what it landed with')] public function testLoopbackHandlerCatchesTheCallback(): void { @@ -108,6 +111,58 @@ public function testHeadlessHandlerUsesTlsForAnUppercaseScheme(): void } } + #[TestDox('the loopback handler shows the URL when no browser can be opened')] + public function testLoopbackHandlerShowsTheUrlWithoutABrowser(): void + { + $url = 'https://auth.example.com/authorize?client_id=abc'; + + $this->assertStringContainsString($url, $this->runDefaultHandler($url, $this->emptyDirectory())); + } + + /** + * Runs in its own process, as only the environment decides which opener the handler finds. + */ + private function runDefaultHandler(string $url, string $path): string + { + if ('Windows' === \PHP_OS_FAMILY) { + $this->markTestSkipped('Windows finds its opener in the system directory, whatever the PATH is.'); + } + + $code = \sprintf( + 'require %s; try { (new %s(null, 1))->authorize(%s, %s); } catch (%s) {}', + var_export(\dirname(__DIR__, 4).'/vendor/autoload.php', true), + LoopbackAuthorizationHandler::class, + var_export($url, true), + var_export(\sprintf('http://127.0.0.1:%d/callback', self::freePort()), true), + AuthorizationException::class, + ); + + $process = proc_open([\PHP_BINARY, '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, ['PATH' => $path]); + $this->assertIsResource($process); + + $errorOutput = (string) stream_get_contents($pipes[2]); + proc_close($process); + + return $errorOutput; + } + + private function emptyDirectory(): string + { + $directory = sys_get_temp_dir().'/mcp-loopback-'.bin2hex(random_bytes(6)); + mkdir($directory); + $this->directories[] = $directory; + + return $directory; + } + + protected function tearDown(): void + { + foreach ($this->directories as $directory) { + array_map('unlink', glob($directory.'/*') ?: []); + rmdir($directory); + } + } + private static function freePort(): int { $socket = stream_socket_server('tcp://127.0.0.1:0'); From 4450fad5cc9ff9a8785a77319b6395d597aa72f0 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:11:41 +0100 Subject: [PATCH 10/60] [Client] Keep the authorization URL intact when opening the browser on Windows --- .../Auth/LoopbackAuthorizationHandler.php | 32 ++++++++++++++----- .../Client/Auth/AuthorizationHandlerTest.php | 28 ++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/Client/Auth/LoopbackAuthorizationHandler.php b/src/Client/Auth/LoopbackAuthorizationHandler.php index 34478878e..11e133407 100644 --- a/src/Client/Auth/LoopbackAuthorizationHandler.php +++ b/src/Client/Auth/LoopbackAuthorizationHandler.php @@ -149,15 +149,31 @@ private function open(string $url): void // Printed every time, as a backgrounded launcher can't report that no browser opened. file_put_contents('php://stderr', \sprintf("\nOpen this URL to authorize the application if your browser doesn't open it:\n\n %s\n\n", $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('proc_open')) { + return; + } - if (\function_exists('exec')) { - @exec($command); + $process = @proc_open(self::browserCommand($url, \PHP_OS_FAMILY), [], $pipes); + + if (\is_resource($process)) { + proc_close($process); } } + + /** + * The URL stays a separate argument, as escapeshellarg() removes "%" on Windows and cmd.exe would expand it. + * + * @internal + * + * @return list + */ + public static function browserCommand(string $url, string $osFamily): array + { + // sh backgrounds the opener, because it may not return until the browser exits. + return match ($osFamily) { + 'Windows' => ['rundll32', 'url.dll,FileProtocolHandler', $url], + 'Darwin' => ['/bin/sh', '-c', 'open "$1" > /dev/null 2>&1 &', 'sh', $url], + default => ['/bin/sh', '-c', 'xdg-open "$1" > /dev/null 2>&1 &', 'sh', $url], + }; + } } diff --git a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php index c9619a7ce..9d33c6944 100644 --- a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php +++ b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php @@ -119,6 +119,34 @@ public function testLoopbackHandlerShowsTheUrlWithoutABrowser(): void $this->assertStringContainsString($url, $this->runDefaultHandler($url, $this->emptyDirectory())); } + #[TestDox('the loopback handler passes the URL to the opener unchanged')] + public function testLoopbackHandlerPassesTheUrlUnchanged(): void + { + $directory = $this->emptyDirectory(); + + foreach (['xdg-open', 'open'] as $opener) { + file_put_contents($directory.'/'.$opener, "#!/bin/sh\nprintf '%s' \"\$1\" > \"\${0%/*}/opened\"\n"); + chmod($directory.'/'.$opener, 0755); + } + + $url = "https://auth.example.com/authorize?redirect_uri=http%3A%2F%2F127.0.0.1%3A8765&scope=a%20b&state=it's"; + + $this->runDefaultHandler($url, $directory); + + $this->assertSame($url, file_get_contents($directory.'/opened')); + } + + #[TestDox('the loopback handler keeps percent-encoding and shell variables out of reach of cmd.exe')] + public function testLoopbackHandlerOpensTheUrlWithoutAShellOnWindows(): void + { + $url = 'https://auth.example.com/authorize?redirect_uri=http%3A%2F%2F127.0.0.1&x=%PATH%'; + + $this->assertSame( + ['rundll32', 'url.dll,FileProtocolHandler', $url], + LoopbackAuthorizationHandler::browserCommand($url, 'Windows'), + ); + } + /** * Runs in its own process, as only the environment decides which opener the handler finds. */ From d14d8e0d41e6846d2ededc86acf2a6c266ab78be Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:12:40 +0100 Subject: [PATCH 11/60] [Client] Only accept the loopback callback of the current authorization request --- .../Auth/LoopbackAuthorizationHandler.php | 49 ++++++++++--------- src/Client/Auth/OAuthAuthenticator.php | 8 +-- .../Client/Auth/AuthorizationHandlerTest.php | 45 +++++++++++++++++ .../Client/Auth/OAuthAuthenticatorTest.php | 44 +++++++++++++++++ 4 files changed, 120 insertions(+), 26 deletions(-) diff --git a/src/Client/Auth/LoopbackAuthorizationHandler.php b/src/Client/Auth/LoopbackAuthorizationHandler.php index 11e133407..3f3142ee0 100644 --- a/src/Client/Auth/LoopbackAuthorizationHandler.php +++ b/src/Client/Auth/LoopbackAuthorizationHandler.php @@ -29,9 +29,9 @@ */ final class LoopbackAuthorizationHandler implements AuthorizationHandlerInterface { - private const SUCCESS_PAGE = 'Authorized' + private const CALLBACK_PAGE = 'Authorization finished' .'' - .'

You are signed in.

You can close this tab and return to the application.

'; + .'

Authorization finished.

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, @@ -50,6 +50,10 @@ 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); + $callbackPath = $parts['path'] ?? '/'; + + parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query); + $state = \is_string($query['state'] ?? null) ? $query['state'] : null; $server = @stream_socket_server(\sprintf('tcp://%s:%d', $host, $port), $errorCode, $errorMessage); @@ -69,7 +73,7 @@ public function authorize(string $authorizationUrl, string $redirectUri): array continue; } - $parameters = $this->serve($connection); + $parameters = $this->serve($connection, $callbackPath, $state); if (null !== $parameters) { return $parameters; @@ -83,16 +87,15 @@ public function authorize(string $authorizationUrl, string $redirectUri): array } /** - * Answer one connection, returning its query parameters when it looks like the callback. + * Answer one connection, returning its query parameters when it is 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. + * Any local process or web page can reach this port, so everything else is answered and ignored. * * @param resource $connection * * @return array|null */ - private function serve($connection): ?array + private function serve($connection, string $callbackPath, ?string $state): ?array { stream_set_timeout($connection, 5); $requestLine = fgets($connection, 8192); @@ -103,20 +106,8 @@ private function serve($connection): ?array 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; - } + $target = parse_url($matches[1]); + parse_str(\is_string($target['query'] ?? null) ? $target['query'] : '', $parameters); $callback = []; @@ -126,7 +117,21 @@ private function serve($connection): ?array } } - return $callback; + $isCallback = [] !== $callback + && $callbackPath === ($target['path'] ?? null) + && (null === $state || hash_equals($state, $callback['state'] ?? '')); + + $status = $isCallback ? '200 OK' : '404 Not Found'; + $body = $isCallback ? self::CALLBACK_PAGE : 'Not Found'; + fwrite($connection, \sprintf( + "HTTP/1.1 %s\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", + $status, + \strlen($body), + $body, + )); + fclose($connection); + + return $isCallback ? $callback : null; } private function open(string $url): void diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 229d523bd..a29479589 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -427,10 +427,6 @@ private function grant(AuthorizationServerMetadata $metadata, ClientRegistration */ 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 @@ -439,6 +435,10 @@ private function verifyCallback(array $callback, string $state, AuthorizationSer throw new AuthorizationException('The authorization response did not carry back the state parameter this client sent.'); } + 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'].')' : '')); + } + // 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 diff --git a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php index 9d33c6944..75de6b855 100644 --- a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php +++ b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php @@ -81,6 +81,51 @@ public function testLoopbackHandlerReportsABusyPort(): void } } + #[TestDox('the loopback handler only takes the callback of its own request')] + public function testLoopbackHandlerOnlyAcceptsItsOwnCallback(): void + { + $port = self::freePort(); + $answers = []; + + $browser = static function () use ($port, &$answers): void { + foreach (['/other?code=forged&state=xyz', '/callback?error=access_denied', '/callback?code=forged&state=wrong', '/callback?code=abc&state=xyz'] 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)); + $answers[] = $socket; + } + }; + + $parameters = (new LoopbackAuthorizationHandler($browser, 5)) + ->authorize('https://auth.example.com/authorize?state=xyz', \sprintf('http://127.0.0.1:%d/callback', $port)); + + $this->assertSame(['code' => 'abc', 'state' => 'xyz'], $parameters); + + $statuses = array_map(static fn ($socket): string => trim((string) fgets($socket)), $answers); + $this->assertSame(['HTTP/1.1 404 Not Found', 'HTTP/1.1 404 Not Found', 'HTTP/1.1 404 Not Found', 'HTTP/1.1 200 OK'], $statuses); + } + + #[TestDox('the loopback handler does not say the sign-in worked before it is checked')] + public function testLoopbackHandlerDoesNotClaimSuccess(): void + { + $port = self::freePort(); + $socket = null; + + $browser = static function () use ($port, &$socket): void { + $socket = stream_socket_client(\sprintf('tcp://127.0.0.1:%d', $port), $code, $message, 2); + self::assertIsResource($socket); + fwrite($socket, "GET /callback?error=access_denied&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + }; + + (new LoopbackAuthorizationHandler($browser, 5)) + ->authorize('https://auth.example.com/authorize?state=xyz', \sprintf('http://127.0.0.1:%d/callback', $port)); + + $this->assertIsResource($socket); + $page = (string) stream_get_contents($socket); + $this->assertStringContainsString('close this tab', $page); + $this->assertStringNotContainsString('signed in', $page); + } + #[TestDox('the loopback handler gives up rather than waiting forever')] public function testLoopbackHandlerTimesOut(): void { diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index b2d188691..8cf7fe1e1 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -413,6 +413,50 @@ public function authorize(string $authorizationUrl, string $redirectUri): array $this->assertSame('Bearer access-token-1', $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); } + #[TestDox('an error without the state of this request does not count as an answer')] + public function testRejectsAForgedErrorResponse(): void + { + $server = new FakeOAuthServer(serverScopes: ['offline_access']); + $handler = new class implements AuthorizationHandlerInterface { + public int $calls = 0; + + public function authorize(string $authorizationUrl, string $redirectUri): array + { + ++$this->calls; + + return ['error' => 'invalid_scope']; + } + }; + + try { + $this->authenticator($server, $handler)->handleChallenge($this->request(), $server->challenge()); + $this->fail('The authorization should have failed.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('state parameter', $e->getMessage()); + } + + $this->assertSame(1, $handler->calls); + } + + #[TestDox('an error with the state of this request is reported')] + public function testReportsAnAuthorizationError(): void + { + $server = new FakeOAuthServer(); + $handler = new class implements AuthorizationHandlerInterface { + public function authorize(string $authorizationUrl, string $redirectUri): array + { + parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query); + + return ['error' => 'access_denied', 'state' => \is_string($query['state'] ?? null) ? $query['state'] : '']; + } + }; + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('denied the request: access_denied'); + + $this->authenticator($server, $handler)->handleChallenge($this->request(), $server->challenge()); + } + #[TestDox('an authorization response with no state at all is refused')] public function testRejectsAMissingState(): void { From 4ade3f6fe8aef156d0372eb2ee7c85ca49df577f Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:19:08 +0100 Subject: [PATCH 12/60] [Client] Choose the expected issuer wherever the resource lists it --- src/Client/Auth/OAuthAuthenticator.php | 28 +++++++++++--- .../Client/Auth/OAuthAuthenticatorTest.php | 38 ++++++++++++++++++- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index a29479589..f3b69a430 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -174,11 +174,7 @@ private function discover(string $endpoint, AuthorizationChallenge $challenge): 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)); - } + $issuer = $this->selectIssuer($endpoint, $metadata->authorizationServers); // 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 @@ -196,6 +192,28 @@ private function discover(string $endpoint, AuthorizationChallenge $challenge): return [$this->configuration->resource ?? $metadata->resource, $metadata->resource, $server, $metadata->scopesSupported]; } + /** + * @param string[] $authorizationServers + */ + private function selectIssuer(string $endpoint, array $authorizationServers): string + { + if ([] === $authorizationServers) { + throw new AuthorizationException(\sprintf('The protected resource metadata for "%s" names no authorization server.', $endpoint)); + } + + $expected = $this->configuration->expectedIssuer; + + if (null === $expected) { + return $authorizationServers[0]; + } + + if (!\in_array($expected, $authorizationServers, true)) { + throw new AuthorizationException(\sprintf('The server at "%s" names the authorization servers "%s", but only "%s" is expected.', $endpoint, implode('", "', $authorizationServers), $expected)); + } + + return $expected; + } + /** * The metadata location named by the challenge, but only when it belongs to the * server that issued the challenge. diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 8cf7fe1e1..532ad7218 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -620,6 +620,39 @@ public function testRefusesAnUnexpectedIssuer(): void $this->assertNotContains('https://auth.example.com/token', $server->requested); } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] + public function testChoosesTheExpectedIssuerFromTheList(): void + { + $server = new FakeOAuthServer(); + $server->otherAuthorizationServers = ['https://other.example.com']; + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test') + ->setClientCredentials('known-client') + ->setExpectedIssuer('https://auth.example.com')); + + $this->assertTrue($authenticator->handleChallenge($this->request(), $server->challenge())); + + $this->assertSame('Bearer access-token-1', $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); + $this->assertSame([], array_filter($server->requested, static fn (string $url): bool => str_contains($url, 'other.example.com'))); + } + + #[TestDox('an authorization server that is not expected is not even contacted')] + public function testDoesNotContactAnUnexpectedIssuer(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test') + ->setClientCredentials('known-client') + ->setExpectedIssuer('https://trusted.example.com')); + + try { + $authenticator->handleChallenge($this->request(), $server->challenge()); + $this->fail('The unexpected issuer was not refused.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('https://trusted.example.com', $e->getMessage()); + } + + $this->assertSame(['https://mcp.example.com/.well-known/oauth-protected-resource/mcp'], $server->requested); + } + #[TestDox('an expected issuer also binds a client without a secret')] public function testExpectedIssuerBindsAPublicClient(): void { @@ -743,6 +776,9 @@ final class FakeOAuthServer implements ClientInterface /** @var array */ public array $token = []; + /** @var string[] */ + public array $otherAuthorizationServers = []; + private int $issued = 0; /** @@ -799,7 +835,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface '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], + 'authorization_servers' => [...$this->otherAuthorizationServers, $this->authorizationServer], 'scopes_supported' => $this->resourceScopes, ], static fn (mixed $value): bool => null !== $value)), From c6198c3fb38edd093338f61d671266dafb75eb03 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:19:37 +0100 Subject: [PATCH 13/60] [Client] Only record scopes a refresh actually granted --- src/Client/Auth/OAuthAuthenticator.php | 18 +++++--- .../Client/Auth/OAuthAuthenticatorTest.php | 41 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index f3b69a430..0f79108dc 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -133,13 +133,15 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re } $client = $this->resolveClient($metadata, $scopes); - $token = $this->refresh($stored, $metadata, $client, $needed); + // A refresh can't widen a grant (RFC 6749 section 6), so a step-up needs a new authorization. + $token = 'insufficient_scope' === $challenge->getError() ? null : $this->refresh($stored, $metadata, $client, $needed); if (null === $token) { [$token, $scopes] = $this->grantWithFallback($metadata, $client, $scopes, $resource); + $token = $token->withGrantedScopes($scopes); } - $this->token = $token->withGrantedScopes($scopes); + $this->token = $token; $this->audience = $audience; $this->requestedScopes = $scopes; $this->storage->saveToken($metadata->issuer, $resource, $this->token); @@ -348,10 +350,14 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta 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; + // A response may leave out the refresh token and the scope, and both then stay as they were. + return new AccessToken( + $token->accessToken, + $token->tokenType, + $token->expiresAt, + $token->refreshToken ?? $stored->refreshToken, + [] === $token->scopes ? $stored->scopes : $token->scopes, + ); } /** diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 532ad7218..3aa0b1dac 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -620,6 +620,47 @@ public function testRefusesAnUnexpectedIssuer(): void $this->assertNotContains('https://auth.example.com/token', $server->requested); } + #[TestDox('a step-up challenge authorizes again, even if the token reports no scopes to compare')] + public function testStepUpDoesNotRefreshATokenWithoutReportedScopes(): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $storage = new InMemoryCredentialStorage(); + $authenticator = $this->authenticator($server, storage: $storage); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + $rejected = $authenticator->authenticate($this->request()); + $authenticator->handleChallenge($rejected, $server->challenge(['admin'], 403, 'insufficient_scope')); + + $this->assertSame('authorization_code', $server->token['grant_type'] ?? null); + $this->assertSame('admin', $server->authorization['scope'] ?? null); + } + + #[TestDox('a refreshed token keeps the stored scopes when the response names none')] + public function testRefreshKeepsTheStoredScopes(): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('expired', 'Bearer', time() - 60, 'a-refresh-token', ['mcp:read', 'mcp:write'])); + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + + $this->assertSame('refresh_token', $server->token['grant_type'] ?? null); + $this->assertSame(['mcp:read', 'mcp:write'], $storage->getToken('https://auth.example.com', self::ENDPOINT)?->scopes); + } + + #[TestDox('a refresh does not record scopes the authorization server did not grant')] + public function testRefreshDoesNotInventScopes(): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('expired', 'Bearer', time() - 60, 'a-refresh-token')); + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge(['admin'])); + + $this->assertSame('refresh_token', $server->token['grant_type'] ?? null); + $this->assertSame([], $storage->getToken('https://auth.example.com', self::ENDPOINT)?->scopes); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From cad838e6cdec83fbf5c83d67add947740fd9deb4 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:20:04 +0100 Subject: [PATCH 14/60] [Client] Treat a registration without a secret as a public client --- src/Client/Auth/ClientRegistration.php | 7 ++++--- tests/Unit/Client/Auth/ClientRegistrationTest.php | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Client/Auth/ClientRegistration.php b/src/Client/Auth/ClientRegistration.php index 95615b33b..28fb52845 100644 --- a/src/Client/Auth/ClientRegistration.php +++ b/src/Client/Auth/ClientRegistration.php @@ -58,9 +58,10 @@ public static function fromResponse(array $response, TokenEndpointAuthMethod $fa $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; + // Without a secret there is nothing to authenticate with, whatever the method says. + $method = null === $secret + ? TokenEndpointAuthMethod::None + : TokenEndpointAuthMethod::tryFrom((string) ($response['token_endpoint_auth_method'] ?? '')) ?? $fallback; $expiresAt = $response['client_secret_expires_at'] ?? null; diff --git a/tests/Unit/Client/Auth/ClientRegistrationTest.php b/tests/Unit/Client/Auth/ClientRegistrationTest.php index 14a7412a7..36e6d9c0b 100644 --- a/tests/Unit/Client/Auth/ClientRegistrationTest.php +++ b/tests/Unit/Client/Auth/ClientRegistrationTest.php @@ -21,6 +21,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; @@ -57,6 +58,19 @@ public function testRequiresAClientId(): void ClientRegistration::fromResponse(['client_secret' => 'secret']); } + /** + * @param array $response + */ + #[TestWith([['client_id' => 'issued']])] + #[TestWith([['client_id' => 'issued', 'token_endpoint_auth_method' => 'client_secret_basic']])] + #[TestDox('a registration without a secret is a public client, whatever was requested')] + public function testNoSecretMeansAPublicClient(array $response): void + { + $registration = ClientRegistration::fromResponse($response, TokenEndpointAuthMethod::ClientSecretBasic); + + $this->assertSame(TokenEndpointAuthMethod::None, $registration->tokenEndpointAuthMethod); + } + #[TestDox('a secret with an expiry is treated as expired once it passes')] public function testSecretExpiry(): void { From ecc4f050ebb17190a183a1d02d27cb963e6f98dd Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:22:07 +0100 Subject: [PATCH 15/60] [Client] Register again when a stored registration can no longer work --- docs/client/authorization.md | 2 +- src/Client/Auth/ClientRegistrar.php | 2 +- src/Client/Auth/ClientRegistration.php | 12 ++- src/Client/Auth/OAuthAuthenticator.php | 40 +++++++- src/Client/Auth/TokenEndpoint.php | 11 ++- src/Exception/OAuthErrorException.php | 27 ++++++ .../Client/Auth/ClientRegistrationTest.php | 8 ++ .../Client/Auth/OAuthAuthenticatorTest.php | 93 +++++++++++++++++++ 8 files changed, 185 insertions(+), 10 deletions(-) create mode 100644 src/Exception/OAuthErrorException.php diff --git a/docs/client/authorization.md b/docs/client/authorization.md index aec2147b2..97e32c281 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -86,7 +86,7 @@ $auth = OAuth::forApplication('My App') 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. It also creates a lock file next to it, so processes can share the file. 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. +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. It also registers again if you change the redirect URI, or if the token endpoint answers `invalid_client` for a client that it registered. ## Using a Pre-registered Client diff --git a/src/Client/Auth/ClientRegistrar.php b/src/Client/Auth/ClientRegistrar.php index b4b630ae8..f1510015e 100644 --- a/src/Client/Auth/ClientRegistrar.php +++ b/src/Client/Auth/ClientRegistrar.php @@ -80,7 +80,7 @@ public function register( } try { - return ClientRegistration::fromResponse($decoded, $preferredAuthMethod); + return ClientRegistration::fromResponse($decoded, $preferredAuthMethod, $clientMetadata->redirectUris); } catch (InvalidArgumentException $e) { throw new AuthorizationException(\sprintf('The authorization server sent an invalid client registration response: %s', $e->getMessage()), 0, $e); } diff --git a/src/Client/Auth/ClientRegistration.php b/src/Client/Auth/ClientRegistration.php index 28fb52845..ac73b6a36 100644 --- a/src/Client/Auth/ClientRegistration.php +++ b/src/Client/Auth/ClientRegistration.php @@ -31,6 +31,7 @@ final class ClientRegistration implements \JsonSerializable * @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 + * @param string[] $redirectUris the redirect URIs this client was registered with, empty when unknown */ public function __construct( public readonly string $clientId, @@ -38,6 +39,7 @@ public function __construct( public readonly TokenEndpointAuthMethod $tokenEndpointAuthMethod = TokenEndpointAuthMethod::None, public readonly bool $dynamic = false, public readonly ?int $clientSecretExpiresAt = null, + public readonly array $redirectUris = [], ) { if ('' === trim($clientId)) { throw new InvalidArgumentException('The client id must not be empty.'); @@ -47,10 +49,11 @@ public function __construct( /** * 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 + * @param array $response the parsed registration response + * @param TokenEndpointAuthMethod $fallback the method to assume when the server does not echo one back + * @param string[] $redirectUris the redirect URIs that were registered */ - public static function fromResponse(array $response, TokenEndpointAuthMethod $fallback = TokenEndpointAuthMethod::None): self + public static function fromResponse(array $response, TokenEndpointAuthMethod $fallback = TokenEndpointAuthMethod::None, array $redirectUris = []): self { if (!isset($response['client_id']) || !\is_string($response['client_id'])) { throw new InvalidArgumentException('The client registration response is missing a string "client_id".'); @@ -71,6 +74,7 @@ public static function fromResponse(array $response, TokenEndpointAuthMethod $fa $method, true, is_numeric($expiresAt) && 0 !== (int) $expiresAt ? (int) $expiresAt : null, + $redirectUris, ); } @@ -90,6 +94,7 @@ public function jsonSerialize(): array 'token_endpoint_auth_method' => $this->tokenEndpointAuthMethod->value, 'dynamic' => $this->dynamic, 'client_secret_expires_at' => $this->clientSecretExpiresAt, + 'redirect_uris' => $this->redirectUris, ], static fn (mixed $value): bool => null !== $value); } @@ -104,6 +109,7 @@ public static function fromArray(array $data): self 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, + array_values(array_filter((array) ($data['redirect_uris'] ?? []), 'is_string')), ); } } diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 0f79108dc..bdedf2dbb 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -12,6 +12,7 @@ namespace Mcp\Client\Auth; use Mcp\Exception\AuthorizationException; +use Mcp\Exception\OAuthErrorException; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; @@ -134,10 +135,29 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re $client = $this->resolveClient($metadata, $scopes); // A refresh can't widen a grant (RFC 6749 section 6), so a step-up needs a new authorization. - $token = 'insufficient_scope' === $challenge->getError() ? null : $this->refresh($stored, $metadata, $client, $needed); + $refreshable = 'insufficient_scope' === $challenge->getError() ? null : $stored; + + try { + $token = $this->refresh($refreshable, $metadata, $client, $needed); + } catch (OAuthErrorException) { + $this->logger->info('The authorization server no longer knows this client; registering it again.', ['issuer' => $metadata->issuer]); + $this->storage->forget($metadata->issuer); + $token = null; + $client = $this->resolveClient($metadata, $scopes); + } if (null === $token) { - [$token, $scopes] = $this->grantWithFallback($metadata, $client, $scopes, $resource); + try { + [$token, $scopes] = $this->grantWithFallback($metadata, $client, $scopes, $resource); + } catch (AuthorizationException $e) { + // The user already went through the browser once, so only the next attempt registers again. + if (self::isForgottenClient($e, $client)) { + $this->storage->forget($metadata->issuer); + } + + throw $e; + } + $token = $token->withGrantedScopes($scopes); } @@ -194,6 +214,11 @@ private function discover(string $endpoint, AuthorizationChallenge $challenge): return [$this->configuration->resource ?? $metadata->resource, $metadata->resource, $server, $metadata->scopesSupported]; } + private static function isForgottenClient(AuthorizationException $e, ClientRegistration $client): bool + { + return $client->dynamic && $e instanceof OAuthErrorException && 'invalid_client' === $e->error; + } + /** * @param string[] $authorizationServers */ @@ -345,6 +370,10 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta 'resource' => $this->resource, ])); } catch (AuthorizationException $e) { + if (self::isForgottenClient($e, $client)) { + throw $e; + } + $this->logger->info('Refreshing the access token failed; falling back to a new authorization.', ['exception' => $e]); return null; @@ -502,7 +531,7 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco $stored = $this->storage->getClientRegistration($metadata->issuer); - if (null !== $stored && !$stored->isExpired()) { + if (null !== $stored && !$stored->isExpired() && $this->matchesRedirectUris($stored)) { return $stored; } @@ -518,6 +547,11 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco return $registration; } + private function matchesRedirectUris(ClientRegistration $registration): bool + { + return [] === $registration->redirectUris || $registration->redirectUris === $this->configuration->clientMetadata->redirectUris; + } + /** * The canonical URI a token is requested for: no fragment, and nothing a server * would not recognise as its own identity. diff --git a/src/Client/Auth/TokenEndpoint.php b/src/Client/Auth/TokenEndpoint.php index 4ebc322b9..960ea4d6f 100644 --- a/src/Client/Auth/TokenEndpoint.php +++ b/src/Client/Auth/TokenEndpoint.php @@ -13,6 +13,7 @@ use Mcp\Exception\AuthorizationException; use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\OAuthErrorException; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; @@ -112,9 +113,15 @@ private function post(string $endpoint, array $parameters, ?string $authorizatio // 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) { + $message = \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 => '', - })); + }); + + if (\is_array($decoded) && \is_string($decoded['error'] ?? null)) { + throw new OAuthErrorException($message, $decoded['error']); + } + + throw new AuthorizationException($message); } return $decoded; diff --git a/src/Exception/OAuthErrorException.php b/src/Exception/OAuthErrorException.php new file mode 100644 index 000000000..75a1fcebf --- /dev/null +++ b/src/Exception/OAuthErrorException.php @@ -0,0 +1,27 @@ + + */ +final class OAuthErrorException extends AuthorizationException +{ + public function __construct(string $message, public readonly string $error) + { + parent::__construct($message); + } +} diff --git a/tests/Unit/Client/Auth/ClientRegistrationTest.php b/tests/Unit/Client/Auth/ClientRegistrationTest.php index 36e6d9c0b..ae3101296 100644 --- a/tests/Unit/Client/Auth/ClientRegistrationTest.php +++ b/tests/Unit/Client/Auth/ClientRegistrationTest.php @@ -71,6 +71,14 @@ public function testNoSecretMeansAPublicClient(array $response): void $this->assertSame(TokenEndpointAuthMethod::None, $registration->tokenEndpointAuthMethod); } + #[TestDox('the redirect URIs of a registration survive being stored')] + public function testRedirectUrisSurviveStoring(): void + { + $registration = ClientRegistration::fromResponse(['client_id' => 'issued'], redirectUris: ['http://127.0.0.1:8765/callback']); + + $this->assertSame(['http://127.0.0.1:8765/callback'], ClientRegistration::fromArray($registration->jsonSerialize())->redirectUris); + } + #[TestDox('a secret with an expiry is treated as expired once it passes')] public function testSecretExpiry(): void { diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 3aa0b1dac..631195b34 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\ClientRegistration; use Mcp\Client\Auth\InMemoryCredentialStorage; use Mcp\Client\Auth\MetadataDiscovery; use Mcp\Client\Auth\OAuth; @@ -661,6 +662,92 @@ public function testRefreshDoesNotInventScopes(): void $this->assertSame([], $storage->getToken('https://auth.example.com', self::ENDPOINT)?->scopes); } + #[TestDox('a new redirect URI gets a new registration, as the old one would be refused')] + public function testRegistersAgainForAnotherRedirectUri(): void + { + $server = new FakeOAuthServer(); + $storage = new InMemoryCredentialStorage(); + $storage->saveClientRegistration('https://auth.example.com', new ClientRegistration('old-client', dynamic: true, redirectUris: ['http://127.0.0.1:9999/callback'])); + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('registered-client', $server->authorization['client_id'] ?? null); + $this->assertSame([OAuth::DEFAULT_REDIRECT_URI], $storage->getClientRegistration('https://auth.example.com')?->redirectUris); + } + + #[TestDox('a registration for the same redirect URI is reused')] + public function testReusesARegistrationForTheSameRedirectUri(): void + { + $server = new FakeOAuthServer(); + $storage = new InMemoryCredentialStorage(); + $storage->saveClientRegistration('https://auth.example.com', new ClientRegistration('stored-client', dynamic: true, redirectUris: [OAuth::DEFAULT_REDIRECT_URI])); + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge()); + + $this->assertNotContains('https://auth.example.com/register', $server->requested); + $this->assertSame('stored-client', $server->authorization['client_id'] ?? null); + } + + #[TestDox('a registered client the authorization server no longer knows registers again')] + public function testRegistersAgainWhenTheClientIsUnknown(): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $server->unknownClient = 'forgotten-client'; + $storage = new InMemoryCredentialStorage(); + $storage->saveClientRegistration('https://auth.example.com', new ClientRegistration('forgotten-client', dynamic: true)); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('expired', 'Bearer', time() - 60, 'a-refresh-token')); + + $authenticator = $this->authenticator($server, storage: $storage); + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $this->assertContains('https://auth.example.com/register', $server->requested); + $this->assertSame('registered-client', $server->token['client_id'] ?? null); + $this->assertSame('authorization_code', $server->token['grant_type'] ?? null); + $this->assertSame('registered-client', $storage->getClientRegistration('https://auth.example.com')?->clientId); + $this->assertSame('Bearer access-token-1', $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); + } + + #[TestDox('a code the unknown client cannot exchange fails once, and the next attempt registers again')] + public function testDropsAnUnknownClientAfterTheCodeExchange(): void + { + $server = new FakeOAuthServer(); + $server->unknownClient = 'forgotten-client'; + $storage = new InMemoryCredentialStorage(); + $storage->saveClientRegistration('https://auth.example.com', new ClientRegistration('forgotten-client', dynamic: true)); + $authenticator = $this->authenticator($server, storage: $storage); + + try { + $authenticator->handleChallenge($this->request(), $server->challenge()); + $this->fail('The unknown client should have failed.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('invalid_client', $e->getMessage()); + } + + $this->assertNotContains('https://auth.example.com/register', $server->requested); + $this->assertNull($storage->getClientRegistration('https://auth.example.com')); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('registered-client', $server->token['client_id'] ?? null); + } + + #[TestDox('a client that was not registered dynamically is not replaced when it is unknown')] + public function testDoesNotReplaceAConfiguredClient(): void + { + $server = new FakeOAuthServer(); + $server->unknownClient = 'known-client'; + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test')->setClientCredentials('known-client')); + + try { + $authenticator->handleChallenge($this->request(), $server->challenge()); + $this->fail('The unknown client should have failed.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('invalid_client', $e->getMessage()); + } + + $this->assertNotContains('https://auth.example.com/register', $server->requested); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { @@ -820,6 +907,8 @@ final class FakeOAuthServer implements ClientInterface /** @var string[] */ public array $otherAuthorizationServers = []; + public ?string $unknownClient = null; + private int $issued = 0; /** @@ -937,6 +1026,10 @@ private function issueToken(array $form): ResponseInterface $this->token = $flat; $scope = $this->authorization['scope'] ?? ''; + if (null !== $this->unknownClient && $this->unknownClient === ($form['client_id'] ?? null)) { + return self::json(['error' => 'invalid_client'], 401); + } + // 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)) { From 184c4b6dba25ccd5270ae95e521fbc89f62c2c20 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:23:13 +0100 Subject: [PATCH 16/60] [Client] Limit the size of the responses read during authorization --- src/Client/Auth/ClientRegistrar.php | 2 +- src/Client/Auth/MetadataDiscovery.php | 2 +- src/Client/Auth/ResponseBody.php | 55 ++++++++++ src/Client/Auth/TokenEndpoint.php | 3 +- tests/Unit/Client/Auth/ResponseBodyTest.php | 106 ++++++++++++++++++++ 5 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 src/Client/Auth/ResponseBody.php create mode 100644 tests/Unit/Client/Auth/ResponseBodyTest.php diff --git a/src/Client/Auth/ClientRegistrar.php b/src/Client/Auth/ClientRegistrar.php index f1510015e..4568242fb 100644 --- a/src/Client/Auth/ClientRegistrar.php +++ b/src/Client/Auth/ClientRegistrar.php @@ -73,7 +73,7 @@ public function register( 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); + $decoded = json_decode(ResponseBody::read($response) ?? '', 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())); diff --git a/src/Client/Auth/MetadataDiscovery.php b/src/Client/Auth/MetadataDiscovery.php index 5e0332397..9e47f8ba7 100644 --- a/src/Client/Auth/MetadataDiscovery.php +++ b/src/Client/Auth/MetadataDiscovery.php @@ -207,7 +207,7 @@ private function fetch(string $url, bool &$missing = false): ?array return null; } - $decoded = json_decode((string) $response->getBody(), true); + $decoded = json_decode(ResponseBody::read($response) ?? '', true); return \is_array($decoded) ? $decoded : null; } diff --git a/src/Client/Auth/ResponseBody.php b/src/Client/Auth/ResponseBody.php new file mode 100644 index 000000000..4d261a1cb --- /dev/null +++ b/src/Client/Auth/ResponseBody.php @@ -0,0 +1,55 @@ + + */ +final class ResponseBody +{ + public const MAX_BYTES = 1048576; + + /** + * @return string|null the body, or null if it is too large or can't be read + */ + public static function read(ResponseInterface $response): ?string + { + $stream = $response->getBody(); + $body = ''; + + try { + if ($stream->isSeekable()) { + $stream->rewind(); + } + + while (\strlen($body) <= self::MAX_BYTES && !$stream->eof()) { + $chunk = $stream->read(min(8192, self::MAX_BYTES + 1 - \strlen($body))); + + if ('' === $chunk) { + break; + } + + $body .= $chunk; + } + } catch (\RuntimeException) { + return null; + } + + return \strlen($body) > self::MAX_BYTES ? null : $body; + } +} diff --git a/src/Client/Auth/TokenEndpoint.php b/src/Client/Auth/TokenEndpoint.php index 960ea4d6f..43e06a636 100644 --- a/src/Client/Auth/TokenEndpoint.php +++ b/src/Client/Auth/TokenEndpoint.php @@ -105,8 +105,7 @@ private function post(string $endpoint, array $parameters, ?string $authorizatio 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); + $decoded = json_decode(ResponseBody::read($response) ?? '', true); if (200 !== $response->getStatusCode() || !\is_array($decoded)) { // Only an error document is quoted back. A 200 that failed to decode is a diff --git a/tests/Unit/Client/Auth/ResponseBodyTest.php b/tests/Unit/Client/Auth/ResponseBodyTest.php new file mode 100644 index 000000000..76d8f0e47 --- /dev/null +++ b/tests/Unit/Client/Auth/ResponseBodyTest.php @@ -0,0 +1,106 @@ +assertSame($body, ResponseBody::read(new Response(200, [], $body))); + } + + #[TestDox('a body over the limit is refused without reading all of it')] + public function testStopsReadingAtTheLimit(): void + { + $response = new Response(200, [], str_repeat('a', 5 * ResponseBody::MAX_BYTES)); + + $this->assertNull(ResponseBody::read($response)); + $this->assertLessThanOrEqual(ResponseBody::MAX_BYTES + 1, $response->getBody()->tell()); + } + + #[TestDox('a token response over the limit is an authorization failure')] + public function testTokenEndpointRefusesAHugeResponse(): void + { + $factory = new Psr17Factory(); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('could not read as JSON'); + + (new TokenEndpoint(self::answeringWithAHugeDocument('access_token'), $factory, $factory))->request( + new AuthorizationServerMetadata('https://auth.example.com', 'https://auth.example.com/authorize', 'https://auth.example.com/token'), + new ClientRegistration('a-client'), + ['grant_type' => 'authorization_code'], + ); + } + + #[TestDox('a registration response over the limit is an authorization failure')] + public function testRegistrarRefusesAHugeResponse(): void + { + $factory = new Psr17Factory(); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('refused the client registration'); + + (new ClientRegistrar(self::answeringWithAHugeDocument('client_id'), $factory, $factory))->register( + new AuthorizationServerMetadata('https://auth.example.com', 'https://auth.example.com/authorize', 'https://auth.example.com/token', 'https://auth.example.com/register'), + new ClientMetadata('My App'), + TokenEndpointAuthMethod::None, + ); + } + + #[TestDox('metadata over the limit counts as no metadata')] + public function testDiscoveryIgnoresAHugeDocument(): void + { + $discovery = new MetadataDiscovery(self::answeringWithAHugeDocument('issuer'), new Psr17Factory()); + + $this->assertNull($discovery->discoverAuthorizationServer('https://auth.example.com')); + } + + private static function answeringWithAHugeDocument(string $field): ClientInterface + { + return new class($field) implements ClientInterface { + public function __construct(private readonly string $field) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return new Response(200, [], (string) json_encode([ + $this->field => 'https://auth.example.com', + 'token_endpoint' => 'https://auth.example.com/token', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'padding' => str_repeat('a', ResponseBody::MAX_BYTES), + ])); + } + }; + } +} From 3668fac0df9c0cfc5f0d6e0c9f4affc091050888 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:23:20 +0100 Subject: [PATCH 17/60] [Client] Make the credential file and the busy port tests hold on Windows --- docs/client/authorization.md | 2 +- .../Auth/LoopbackAuthorizationHandler.php | 18 ++++++++++++++++++ .../Client/Auth/FileCredentialStorageTest.php | 12 ++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 97e32c281..96947367c 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -84,7 +84,7 @@ $auth = OAuth::forApplication('My App') ->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. It also creates a lock file next to it, so processes can share the file. To use a keychain or a secrets manager, implement `CredentialStorageInterface`. +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. Windows ignores these modes, so store the file in a directory that only you can read (e.g. your user profile). The SDK also creates a lock file next to it, so processes can share the file. 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. It also registers again if you change the redirect URI, or if the token endpoint answers `invalid_client` for a client that it registered. diff --git a/src/Client/Auth/LoopbackAuthorizationHandler.php b/src/Client/Auth/LoopbackAuthorizationHandler.php index 3f3142ee0..ddca4e903 100644 --- a/src/Client/Auth/LoopbackAuthorizationHandler.php +++ b/src/Client/Auth/LoopbackAuthorizationHandler.php @@ -55,6 +55,11 @@ public function authorize(string $authorizationUrl, string $redirectUri): array parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query); $state = \is_string($query['state'] ?? null) ? $query['state'] : null; + // Windows lets a second listener bind a port that is in use. + if ('Windows' === \PHP_OS_FAMILY && self::isPortInUse($host, $port)) { + throw new AuthorizationException(\sprintf('Could not listen on "%s:%d" for the authorization redirect: the port is in use.', $host, $port)); + } + $server = @stream_socket_server(\sprintf('tcp://%s:%d', $host, $port), $errorCode, $errorMessage); if (false === $server) { @@ -134,6 +139,19 @@ private function serve($connection, string $callbackPath, ?string $state): ?arra return $isCallback ? $callback : null; } + private static function isPortInUse(string $host, int $port): bool + { + $connection = @stream_socket_client(\sprintf('tcp://%s:%d', $host, $port), $errorCode, $errorMessage, 1); + + if (false === $connection) { + return false; + } + + fclose($connection); + + return true; + } + private function open(string $url): void { if (\is_callable($this->openBrowser)) { diff --git a/tests/Unit/Client/Auth/FileCredentialStorageTest.php b/tests/Unit/Client/Auth/FileCredentialStorageTest.php index 918013cbc..3f7ccbef1 100644 --- a/tests/Unit/Client/Auth/FileCredentialStorageTest.php +++ b/tests/Unit/Client/Auth/FileCredentialStorageTest.php @@ -90,6 +90,10 @@ public function testForgetsOneIssuer(): void #[TestDox('the file is not readable by anyone else')] public function testFilePermissions(): void { + if ('Windows' === \PHP_OS_FAMILY) { + $this->markTestSkipped('Windows has no POSIX file modes.'); + } + $storage = new FileCredentialStorage($this->path); $storage->saveToken('https://auth.example.com', 'https://mcp.example.com/mcp', new AccessToken('token')); @@ -102,6 +106,10 @@ public function testFilePermissions(): void #[TestDox('the credentials are never world-readable, not even briefly')] public function testFileIsNeverExposedWhileBeingWritten(): void { + if ('Windows' === \PHP_OS_FAMILY) { + $this->markTestSkipped('Windows has no POSIX file modes.'); + } + $storage = new FileCredentialStorage($this->path); $storage->saveToken('https://auth.example.com', 'https://mcp.example.com/mcp', new AccessToken('first')); @@ -159,6 +167,10 @@ public function testInstancesDoNotLoseEachOthersUpdates(): void #[TestDox('the lock file is as private as the credentials')] public function testLockFilePermissions(): void { + if ('Windows' === \PHP_OS_FAMILY) { + $this->markTestSkipped('Windows has no POSIX file modes.'); + } + (new FileCredentialStorage($this->path))->saveToken('https://auth.example.com', 'https://mcp.example.com/mcp', new AccessToken('one')); clearstatcache(); From a7427bb443712ab8f640b9c11fb3302316b98097 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:24:43 +0100 Subject: [PATCH 18/60] [Client] Keep the comments of the authorization classes to one line --- src/Client/Auth/AccessToken.php | 17 +-- .../Auth/AuthorizationHandlerInterface.php | 15 +-- src/Client/Auth/ClientMetadata.php | 6 +- src/Client/Auth/ClientRegistrar.php | 6 +- src/Client/Auth/ClientRegistration.php | 4 - .../Auth/CredentialStorageInterface.php | 8 +- src/Client/Auth/FileCredentialStorage.php | 14 +-- .../Auth/HeadlessAuthorizationHandler.php | 16 +-- src/Client/Auth/InMemoryCredentialStorage.php | 6 +- .../Auth/LoopbackAuthorizationHandler.php | 15 +-- src/Client/Auth/OAuth.php | 44 ++----- src/Client/Auth/OAuthAuthenticator.php | 113 +++--------------- src/Client/Auth/OAuthConfiguration.php | 6 +- src/Client/Auth/Pkce.php | 7 +- src/Client/Auth/TokenEndpoint.php | 14 +-- src/Client/Auth/TokenEndpointAuthMethod.php | 9 +- 16 files changed, 47 insertions(+), 253 deletions(-) diff --git a/src/Client/Auth/AccessToken.php b/src/Client/Auth/AccessToken.php index f01220727..e03554706 100644 --- a/src/Client/Auth/AccessToken.php +++ b/src/Client/Auth/AccessToken.php @@ -21,10 +21,7 @@ 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. + * Seconds before the nominal expiry at which a token counts as expired, so it doesn't expire in flight. */ private const EXPIRY_LEEWAY_SECONDS = 30; @@ -81,16 +78,12 @@ public function isExpired(?int $now = null): bool /** * 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. + // RFC 6749 section 5.1: no reported scope means that what was requested was granted. return true; } @@ -98,11 +91,7 @@ public function covers(array $scopes): bool } /** - * 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. + * Record the requested scopes when the server reported none (RFC 6749 section 5.1). * * @param string[] $scopes */ diff --git a/src/Client/Auth/AuthorizationHandlerInterface.php b/src/Client/Auth/AuthorizationHandlerInterface.php index 639d61393..4abf2628d 100644 --- a/src/Client/Auth/AuthorizationHandlerInterface.php +++ b/src/Client/Auth/AuthorizationHandlerInterface.php @@ -12,25 +12,14 @@ namespace Mcp\Client\Auth; /** - * Carries the user through the authorization endpoint and brings the answer back. - * - * This is the one step of the flow the SDK cannot do on its own: what happens between - * "here is a URL the user must visit" and "here is what the authorization server - * redirected to" depends entirely on what the application is -- a terminal, a desktop - * app with a browser, a web application with a real callback route, or an unattended - * job against a server that grants without asking. + * Takes the user to the authorization endpoint and returns the answer of the authorization server. * * @author Christopher Hertel */ 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. + * Send the user to $authorizationUrl and return the query parameters of the redirect to $redirectUri, unchecked. * * @return array the callback's query parameters * diff --git a/src/Client/Auth/ClientMetadata.php b/src/Client/Auth/ClientMetadata.php index 4d56f173a..9740a8811 100644 --- a/src/Client/Auth/ClientMetadata.php +++ b/src/Client/Auth/ClientMetadata.php @@ -12,11 +12,7 @@ namespace Mcp\Client\Auth; /** - * What the client tells an authorization server about itself. - * - * The same document is posted to a registration endpoint (RFC 7591) and published at a - * URL when the server accepts client id metadata documents, so it is modelled once and - * serialised the same way for both. + * What the client tells an authorization server about itself when it registers (RFC 7591). * * @internal * diff --git a/src/Client/Auth/ClientRegistrar.php b/src/Client/Auth/ClientRegistrar.php index 4568242fb..a87e138fb 100644 --- a/src/Client/Auth/ClientRegistrar.php +++ b/src/Client/Auth/ClientRegistrar.php @@ -21,11 +21,7 @@ use Psr\Log\NullLogger; /** - * Registers the client with an authorization server on the fly (RFC 7591). - * - * MCP clients meet servers they have never seen before, so asking an administrator to - * pre-provision a client id for each one does not scale. Where the authorization server - * publishes a registration endpoint, the client creates its own identity instead. + * Registers the client with an authorization server (RFC 7591). * * @internal * diff --git a/src/Client/Auth/ClientRegistration.php b/src/Client/Auth/ClientRegistration.php index ac73b6a36..173afb295 100644 --- a/src/Client/Auth/ClientRegistration.php +++ b/src/Client/Auth/ClientRegistration.php @@ -16,10 +16,6 @@ /** * The client's identity at one authorization server. * - * Whether it was handed out by dynamic registration, configured up front, or is a URL - * pointing at a client metadata document, everything downstream needs the same three - * facts: who the client claims to be, what secret it holds, and how it proves it. - * * @author Christopher Hertel */ final class ClientRegistration implements \JsonSerializable diff --git a/src/Client/Auth/CredentialStorageInterface.php b/src/Client/Auth/CredentialStorageInterface.php index 8e7cbec4a..dd408e6ec 100644 --- a/src/Client/Auth/CredentialStorageInterface.php +++ b/src/Client/Auth/CredentialStorageInterface.php @@ -12,13 +12,7 @@ namespace Mcp\Client\Auth; /** - * Where the OAuth credentials a client accumulates are kept between requests, and - * between runs. - * - * Everything is keyed by the authorization server's issuer identifier. Credentials are - * issued by one authorization server and mean nothing at another, so a resource whose - * metadata starts pointing somewhere new finds an empty store and registers afresh - * rather than leaking the previous server's client id to the new one. + * Keeps OAuth credentials per issuer, so an authorization server never receives those of another. * * @author Christopher Hertel */ diff --git a/src/Client/Auth/FileCredentialStorage.php b/src/Client/Auth/FileCredentialStorage.php index 1f419852b..4aeb1a893 100644 --- a/src/Client/Auth/FileCredentialStorage.php +++ b/src/Client/Auth/FileCredentialStorage.php @@ -15,12 +15,7 @@ use Mcp\Exception\RuntimeException; /** - * Persists credentials as JSON in a single file, so a CLI only has to authorize once. - * - * The file holds bearer tokens and, where the authorization server issues them, client - * secrets, so it is created 0600 and the directory 0700. It is deliberately not - * encrypted: an application with a keychain or a secrets manager should implement - * {@see CredentialStorageInterface} against that instead. + * Persists credentials unencrypted in a JSON file with the mode 0600, in a directory with the mode 0700. * * @author Christopher Hertel */ @@ -105,8 +100,7 @@ private function read(): array $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. + // Not worth failing a connection over: the worst case is one more authorization. return ['registrations' => [], 'tokens' => []]; } @@ -162,9 +156,7 @@ private function write(array $data): void 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. + // A private file moved into place is never world-readable or half-written. $temporary = @tempnam($directory, '.credentials'); if (false === $temporary) { diff --git a/src/Client/Auth/HeadlessAuthorizationHandler.php b/src/Client/Auth/HeadlessAuthorizationHandler.php index c7af37e36..6aef9eedc 100644 --- a/src/Client/Auth/HeadlessAuthorizationHandler.php +++ b/src/Client/Auth/HeadlessAuthorizationHandler.php @@ -16,18 +16,7 @@ use Psr\Log\NullLogger; /** - * Requests the authorization endpoint directly and reads the code out of the redirect. - * - * There is no user and no browser here: the handler plays the user agent itself, asks - * for the authorization URL, and stops at the `Location` header instead of following it. - * 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}. - * - * 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 - * is to *not* follow the redirect. + * Requests the authorization endpoint itself and reads the code from the redirect, for servers that grant without a prompt. * * @author Christopher Hertel */ @@ -58,8 +47,7 @@ public function authorize(string $authorizationUrl, string $redirectUri): array } } - // 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. + // Only the names, as the query carries the authorization code. $this->logger->debug('Authorization endpoint redirected', ['parameters' => array_keys($callback)]); return $callback; diff --git a/src/Client/Auth/InMemoryCredentialStorage.php b/src/Client/Auth/InMemoryCredentialStorage.php index bce30a71c..671f4f42d 100644 --- a/src/Client/Auth/InMemoryCredentialStorage.php +++ b/src/Client/Auth/InMemoryCredentialStorage.php @@ -12,11 +12,7 @@ namespace Mcp\Client\Auth; /** - * Keeps credentials for the lifetime of the process and no longer. - * - * The right default for a short-lived script and for tests; a long-lived agent or a CLI - * a user runs repeatedly wants {@see FileCredentialStorage} so the browser dance happens - * once rather than every time. + * Keeps credentials for the lifetime of the process. * * @author Christopher Hertel */ diff --git a/src/Client/Auth/LoopbackAuthorizationHandler.php b/src/Client/Auth/LoopbackAuthorizationHandler.php index ddca4e903..39086cc90 100644 --- a/src/Client/Auth/LoopbackAuthorizationHandler.php +++ b/src/Client/Auth/LoopbackAuthorizationHandler.php @@ -18,13 +18,6 @@ /** * Opens the user's browser and catches the redirect on a loopback listener. * - * This is the flow a desktop or command line application wants: the redirect URI points - * at `http://127.0.0.1:/...`, 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 @@ -92,9 +85,7 @@ public function authorize(string $authorizationUrl, string $redirectUri): array } /** - * Answer one connection, returning its query parameters when it is the callback. - * - * Any local process or web page can reach this port, so everything else is answered and ignored. + * Answer one connection, and return its query parameters only for the callback, as anyone local can connect. * * @param resource $connection * @@ -160,9 +151,7 @@ private function open(string $url): void 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. + // This string reaches the desktop's protocol handler, so it is checked again here. 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)); } diff --git a/src/Client/Auth/OAuth.php b/src/Client/Auth/OAuth.php index fdeac87f8..b4c1d689c 100644 --- a/src/Client/Auth/OAuth.php +++ b/src/Client/Auth/OAuth.php @@ -21,26 +21,14 @@ use Psr\Log\NullLogger; /** - * Fluent builder for the OAuth authenticator an {@see \Mcp\Client\Transport\HttpTransport} uses. - * - * The defaults describe the common case -- a local application, a user with a browser, - * an authorization server that supports dynamic client registration -- so a working - * setup is one call: - * - * $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. + * Fluent builder for the OAuth authenticator of an {@see \Mcp\Client\Transport\HttpTransport}. * * @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. + * A fixed port, as many authorization servers require redirect URIs to be registered up front. */ public const DEFAULT_REDIRECT_URI = 'http://127.0.0.1:8765/callback'; @@ -86,10 +74,7 @@ public static function forApplication(string $clientName): self } /** - * 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. + * Must match what the authorization handler listens on and, for a pre-registered client, what the server has on file. */ public function setRedirectUri(string $redirectUri): self { @@ -110,10 +95,7 @@ public function setClientCredentials(string $clientId, ?string $clientSecret = n } /** - * Only authorize with the authorization server that has this issuer. - * - * The MCP server names its authorization server, so without this a hostile server could - * name its own and receive the client's secret. Required when a client secret is set. + * Only authorize with this issuer; required with a client secret, or a hostile MCP server could receive it. */ public function setExpectedIssuer(string $issuer): self { @@ -123,10 +105,7 @@ public function setExpectedIssuer(string $issuer): self } /** - * 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. + * Request these scopes instead of those the server asks for; a step-up challenge still adds its own. */ public function setScopes(string ...$scopes): self { @@ -136,9 +115,7 @@ public function setScopes(string ...$scopes): self } /** - * 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. + * Whether to request a refresh token where the authorization server offers one (default: true). */ public function setOfflineAccess(bool $offlineAccess): self { @@ -148,14 +125,7 @@ public function setOfflineAccess(bool $offlineAccess): self } /** - * 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. + * Whether to fall back to pre-2025-06-18 discovery for a server without protected resource metadata (default: false). */ public function setLegacyDiscovery(bool $legacyDiscovery): self { diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index bdedf2dbb..c2c15b6e2 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -19,20 +19,7 @@ use Psr\Log\NullLogger; /** - * Drives the full MCP authorization flow on behalf of an HTTP client. - * - * Nothing happens until a server says no. The first request goes out bare; the 401 that - * comes back names the resource's metadata, which names the authorization server, which - * is asked for a client identity, an authorization code, and finally a token. Every step - * after the first is skipped whenever what it would produce is already on hand, so a - * warm client sends one request and a cold one sends the whole chain exactly once. - * - * Discovery is the exception: it runs again on every challenge rather than being - * remembered. A resource that has moved to a different authorization server announces it - * in exactly that document, and noticing is what stops one server's credentials from - * being offered to another. - * - * Build one with {@see OAuth} rather than by hand. + * Drives the MCP authorization flow when a server answers with a challenge; build one with {@see OAuth}. * * @author Christopher Hertel */ @@ -43,11 +30,7 @@ final class OAuthAuthenticator implements AuthenticatorInterface 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. + * The resource the current token may be sent to, which an overridden $resource doesn't change. */ private ?string $audience = null; @@ -73,11 +56,7 @@ public function authenticate(RequestInterface $request): RequestInterface 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. + // An authenticator may be reused, and another resource must not receive this token. if (null === $this->audience || !ProtectedResourceMetadata::isWithin(self::canonicalize((string) $request->getUri()), $this->audience)) { return $request; } @@ -108,9 +87,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re } 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. + // Credentials of one authorization server must never reach another. $this->logger->info('Authorization server changed', ['from' => $this->issuer, 'to' => $metadata->issuer]); $this->token = null; $this->requestedScopes = []; @@ -170,13 +147,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re } /** - * 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. + * Find the requested resource, the token's audience and the server, on every challenge as a resource may move. * * @return array{string, string, AuthorizationServerMetadata, ?string[]} */ @@ -189,18 +160,13 @@ private function discover(string $endpoint, AuthorizationChallenge $challenge): } 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. + // A token for another resource would be sent to this one: a confused deputy. throw new AuthorizationException(\sprintf('The protected resource metadata for "%s" declares the unrelated resource "%s"; refusing to authorize against it.', $endpoint, $metadata->resource)); } $issuer = $this->selectIssuer($endpoint, $metadata->authorizationServers); - // 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. + // Checked before the fetch, as even the request is made on the server's behalf. 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)); } @@ -242,15 +208,7 @@ private function selectIssuer(string $endpoint, array $authorizationServers): st } /** - * 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. + * The metadata URL of the challenge, unless it would make the client request another origin on the server's behalf. */ private static function trustedMetadataUrl(string $endpoint, AuthorizationChallenge $challenge): ?string { @@ -268,11 +226,7 @@ private static function trustedMetadataUrl(string $endpoint, AuthorizationChalle } /** - * 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. + * Fall back to the discovery of the 2025-03-26 revision, where a server is its own authorization server. */ private function discoverLegacyServer(string $endpoint): AuthorizationServerMetadata { @@ -288,16 +242,7 @@ private function discoverLegacyServer(string $endpoint): AuthorizationServerMeta } /** - * 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. + * Prefer the scopes of the challenge, then those of the resource, and keep the ones requested before. * * @param ?string[] $scopesSupported * @@ -319,8 +264,7 @@ private function selectScopes(AuthorizationChallenge $challenge, ?array $scopesS 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. + // An unknown scope is an error, so it is only requested where the server lists it. if ($this->configuration->offlineAccess && $metadata->supportsScope('offline_access') && $metadata->supportsGrant(Grant::RefreshToken->value) @@ -339,8 +283,7 @@ private function selectScopes(AuthorizationChallenge $challenge, ?array $scopesS */ 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. + // The server just rejected this very token. if ($request->getHeaderLine('Authorization') === $token->getAuthorizationHeader()) { return false; } @@ -349,11 +292,7 @@ private function isUsable(AccessToken $token, array $scopes, RequestInterface $r } /** - * 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. + * Trade a refresh token for a new access token, if it covers the scopes. * * @param string[] $scopes */ @@ -390,18 +329,7 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta } /** - * 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. + * Run the grant, and retry without offline_access if it was refused (invalid_scope, or not_allowed from Keycloak). * * @param string[] $scopes * @@ -480,10 +408,7 @@ private function grant(AuthorizationServerMetadata $metadata, ClientRegistration */ private function verifyCallback(array $callback, string $state, AuthorizationServerMetadata $metadata): void { - // 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. + // RFC 6749 section 10.12: required, as any local process can reach the loopback listener. 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.'); } @@ -492,10 +417,7 @@ private function verifyCallback(array $callback, string $state, AuthorizationSer throw new AuthorizationException(\sprintf('The authorization server denied the request: %s%s', $callback['error'], isset($callback['error_description']) ? ' ('.$callback['error_description'].')' : '')); } - // 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. + // RFC 9207: compared as a string, and required if the server announced it, to catch a mix-up. 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)); @@ -553,8 +475,7 @@ private function matchesRedirectUris(ClientRegistration $registration): bool } /** - * The canonical URI a token is requested for: no fragment, and nothing a server - * would not recognise as its own identity. + * The canonical URI a token is requested for, without a fragment. */ private static function canonicalize(string $url): string { diff --git a/src/Client/Auth/OAuthConfiguration.php b/src/Client/Auth/OAuthConfiguration.php index 3d569635d..35574761b 100644 --- a/src/Client/Auth/OAuthConfiguration.php +++ b/src/Client/Auth/OAuthConfiguration.php @@ -14,11 +14,7 @@ use Mcp\Exception\InvalidArgumentException; /** - * Everything about how this client wants to authorize, decided before the first request. - * - * Built by {@see OAuth}; nothing here is discovered at runtime, which is what keeps the - * discovered half -- issuers, endpoints, scopes, tokens -- clearly separate from the - * half the application chose. + * What the application decided about authorization before the first request. * * @internal * diff --git a/src/Client/Auth/Pkce.php b/src/Client/Auth/Pkce.php index ea54481ac..f84169b9b 100644 --- a/src/Client/Auth/Pkce.php +++ b/src/Client/Auth/Pkce.php @@ -12,12 +12,7 @@ namespace Mcp\Client\Auth; /** - * A Proof Key for Code Exchange pair (RFC 7636). - * - * The verifier stays with the client and is replayed at the token endpoint; only its - * S256 hash travels through the browser, so an authorization code intercepted on the - * way back is useless on its own. MCP requires S256, and so does this class -- the - * `plain` method exists in the RFC only for clients that cannot hash. + * A Proof Key for Code Exchange pair (RFC 7636), always with S256. * * @internal * diff --git a/src/Client/Auth/TokenEndpoint.php b/src/Client/Auth/TokenEndpoint.php index 43e06a636..41a97d122 100644 --- a/src/Client/Auth/TokenEndpoint.php +++ b/src/Client/Auth/TokenEndpoint.php @@ -22,11 +22,7 @@ use Psr\Log\NullLogger; /** - * Exchanges whatever the client is holding for an access token. - * - * All four grants land here, and so do all four ways of proving which client is asking: - * the difference between them is a handful of form fields and one header, which is why - * they share one place rather than one class each. + * Exchanges an authorization code or a refresh token for an access token. * * @internal * @@ -55,8 +51,7 @@ public function request( 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. + // RFC 6749 section 2.3.1: form-urlencoded before joining, so a secret may contain a colon. $authorization = 'Basic '.base64_encode(rawurlencode($client->clientId).':'.rawurlencode((string) $client->clientSecret)); break; @@ -108,10 +103,7 @@ private function post(string $endpoint, array $parameters, ?string $authorizatio $decoded = json_decode(ResponseBody::read($response) ?? '', 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. + // Only an error document is quoted, as an unreadable 200 may carry a live token. $message = \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 => '', }); diff --git a/src/Client/Auth/TokenEndpointAuthMethod.php b/src/Client/Auth/TokenEndpointAuthMethod.php index 5cc7610fb..65d1529a8 100644 --- a/src/Client/Auth/TokenEndpointAuthMethod.php +++ b/src/Client/Auth/TokenEndpointAuthMethod.php @@ -28,11 +28,7 @@ enum TokenEndpointAuthMethod: string 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. + * Pick the method for the advertised list: no list means Basic (RFC 8414), and a client without a secret is public. * * @param string[] $supported the server's token_endpoint_auth_methods_supported */ @@ -52,8 +48,7 @@ public static function negotiate(array $supported, bool $hasSecret): self } } - // Nothing the client can satisfy was advertised. Sending the strongest thing it - // has is more useful than refusing outright: servers under-report this field. + // Servers under-report this field, so the strongest method is still worth trying. return $candidates[0]; } } From 632d74b9162d57536c11336311ff5f671ba59f68 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:29:27 +0100 Subject: [PATCH 19/60] [Client] Give up offline_access based on the OAuth error code --- src/Client/Auth/OAuthAuthenticator.php | 4 +- src/Exception/OAuthErrorException.php | 2 +- .../Client/Auth/OAuthAuthenticatorTest.php | 55 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index c2c15b6e2..334d43e13 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -342,7 +342,7 @@ private function grantWithFallback(AuthorizationServerMetadata $metadata, Client } 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'))) { + if ($reduced === $scopes || !$e instanceof OAuthErrorException || !\in_array($e->error, ['invalid_scope', 'not_allowed'], true)) { throw $e; } @@ -414,7 +414,7 @@ private function verifyCallback(array $callback, string $state, AuthorizationSer } 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'].')' : '')); + throw new OAuthErrorException(\sprintf('The authorization server denied the request: %s%s', $callback['error'], isset($callback['error_description']) ? ' ('.$callback['error_description'].')' : ''), $callback['error']); } // RFC 9207: compared as a string, and required if the server announced it, to catch a mix-up. diff --git a/src/Exception/OAuthErrorException.php b/src/Exception/OAuthErrorException.php index 75a1fcebf..bec2926ff 100644 --- a/src/Exception/OAuthErrorException.php +++ b/src/Exception/OAuthErrorException.php @@ -12,7 +12,7 @@ namespace Mcp\Exception; /** - * Thrown when an authorization server answers a token request with an OAuth error. + * Thrown when an authorization server answers with an OAuth error code. * * @internal * diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 631195b34..385f822b6 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -748,6 +748,61 @@ public function testDoesNotReplaceAConfiguredClient(): void $this->assertNotContains('https://auth.example.com/register', $server->requested); } + #[TestDox('an error that only mentions a refused scope in its text does not send the user back')] + public function testErrorDescriptionDoesNotTriggerTheOfflineAccessFallback(): void + { + $server = new FakeOAuthServer(serverScopes: ['offline_access']); + $handler = new class implements AuthorizationHandlerInterface { + public int $calls = 0; + + public function authorize(string $authorizationUrl, string $redirectUri): array + { + ++$this->calls; + parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query); + + return ['error' => 'access_denied', 'error_description' => 'not_allowed', 'state' => \is_string($query['state'] ?? null) ? $query['state'] : '']; + } + }; + + try { + $this->authenticator($server, $handler)->handleChallenge($this->request(), $server->challenge()); + $this->fail('The denied authorization should have failed.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('access_denied', $e->getMessage()); + } + + $this->assertSame(1, $handler->calls); + } + + #[TestDox('an offline_access refused at the authorization endpoint is given up')] + public function testGivesUpOfflineAccessRefusedAtTheAuthorizationEndpoint(): void + { + $server = new FakeOAuthServer(serverScopes: ['offline_access']); + $handler = new class(new StubAuthorizationHandler($server)) implements AuthorizationHandlerInterface { + public int $calls = 0; + + public function __construct(private readonly AuthorizationHandlerInterface $handler) + { + } + + public function authorize(string $authorizationUrl, string $redirectUri): array + { + if (1 === ++$this->calls) { + parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query); + + return ['error' => 'invalid_scope', 'state' => \is_string($query['state'] ?? null) ? $query['state'] : '']; + } + + return $this->handler->authorize($authorizationUrl, $redirectUri); + } + }; + + $this->authenticator($server, $handler)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame(2, $handler->calls); + $this->assertArrayNotHasKey('scope', $server->authorization); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From fcfc2dd402534f8976d35537b5ef035dd17daeed Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:29:35 +0100 Subject: [PATCH 20/60] [Client] Refuse an authorization server without PKCE before registering --- src/Client/Auth/OAuthAuthenticator.php | 13 ++++++++++--- tests/Unit/Client/Auth/OAuthAuthenticatorTest.php | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 334d43e13..787b6dfb0 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -358,9 +358,7 @@ private function grantWithFallback(AuthorizationServerMetadata $metadata, Client */ private function grant(AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes, string $resource): AccessToken { - if (!$metadata->supportsPkce()) { - throw new AuthorizationException(\sprintf('The authorization server "%s" does not list "%s" in "code_challenge_methods_supported", so the client cannot use PKCE with it.', $metadata->issuer, Pkce::METHOD)); - } + self::assertSupportsPkce($metadata); $pkce = Pkce::generate(); $state = rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '='); @@ -457,6 +455,8 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco return $stored; } + self::assertSupportsPkce($metadata); + $registration = $this->registrar->register( $metadata, $configuration->clientMetadata, @@ -469,6 +469,13 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco return $registration; } + private static function assertSupportsPkce(AuthorizationServerMetadata $metadata): void + { + if (!$metadata->supportsPkce()) { + throw new AuthorizationException(\sprintf('The authorization server "%s" does not list "%s" in "code_challenge_methods_supported", so the client cannot use PKCE with it.', $metadata->issuer, Pkce::METHOD)); + } + } + private function matchesRedirectUris(ClientRegistration $registration): bool { return [] === $registration->redirectUris || $registration->redirectUris === $this->configuration->clientMetadata->redirectUris; diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 385f822b6..ca8b8fcc7 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -876,6 +876,7 @@ public function testRefusesAServerWithoutPkce(?array $methods): void } $this->assertSame([], $server->authorization); + $this->assertNotContains('https://auth.example.com/register', $server->requested); } private function request(): RequestInterface From dc064cc821256fe6be4b1cacd24fdee021cf967b Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:29:44 +0100 Subject: [PATCH 21/60] [Client] Keep a scope or an authorization code of "0" --- src/Client/Auth/OAuthAuthenticator.php | 24 ++++++++++++++++--- .../Client/Auth/OAuthAuthenticatorTest.php | 24 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 787b6dfb0..3c121f8ca 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -303,7 +303,7 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta } try { - $token = $this->tokenEndpoint->request($metadata, $client, array_filter([ + $token = $this->tokenEndpoint->request($metadata, $client, self::withoutEmptyValues([ 'grant_type' => Grant::RefreshToken->value, 'refresh_token' => $stored->refreshToken, 'resource' => $this->resource, @@ -363,7 +363,7 @@ private function grant(AuthorizationServerMetadata $metadata, ClientRegistration $pkce = Pkce::generate(); $state = rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '='); - $parameters = array_filter([ + $parameters = self::withoutEmptyValues([ 'response_type' => 'code', 'client_id' => $client->clientId, 'redirect_uri' => $this->configuration->redirectUri, @@ -388,7 +388,7 @@ private function grant(AuthorizationServerMetadata $metadata, ClientRegistration $this->verifyCallback($callback, $state, $metadata); - return $this->tokenEndpoint->request($metadata, $client, array_filter([ + return $this->tokenEndpoint->request($metadata, $client, self::withoutEmptyValues([ 'grant_type' => Grant::AuthorizationCode->value, 'code' => $callback['code'], 'redirect_uri' => $this->configuration->redirectUri, @@ -469,6 +469,24 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco return $registration; } + /** + * @param array $parameters + * + * @return array + */ + private static function withoutEmptyValues(array $parameters): array + { + $values = []; + + foreach ($parameters as $name => $value) { + if (null !== $value && '' !== $value) { + $values[$name] = $value; + } + } + + return $values; + } + private static function assertSupportsPkce(AuthorizationServerMetadata $metadata): void { if (!$metadata->supportsPkce()) { diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index ca8b8fcc7..c2fe8dccd 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -803,6 +803,30 @@ public function authorize(string $authorizationUrl, string $redirectUri): array $this->assertArrayNotHasKey('scope', $server->authorization); } + #[TestDox('a scope and a code of "0" reach the authorization server')] + public function testKeepsAScopeAndACodeOfZero(): void + { + $server = new FakeOAuthServer(); + $handler = new class($server) implements AuthorizationHandlerInterface { + public function __construct(private readonly FakeOAuthServer $server) + { + } + + public function authorize(string $authorizationUrl, string $redirectUri): array + { + $this->server->recordAuthorization($authorizationUrl); + parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query); + + return ['code' => '0', 'state' => \is_string($query['state'] ?? null) ? $query['state'] : '', 'iss' => 'https://auth.example.com']; + } + }; + + $this->authenticator($server, $handler)->handleChallenge($this->request(), $server->challenge(['0'])); + + $this->assertSame('0', $server->authorization['scope'] ?? null); + $this->assertSame('0', $server->token['code'] ?? null); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From 3b1462277f9a5501b2dd40c3d2e37aeef621b40b Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:30:12 +0100 Subject: [PATCH 22/60] [Client] Save credentials over an entry of the wrong type --- src/Client/Auth/FileCredentialStorage.php | 2 +- tests/Unit/Client/Auth/FileCredentialStorageTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Client/Auth/FileCredentialStorage.php b/src/Client/Auth/FileCredentialStorage.php index 4aeb1a893..494b2ebb6 100644 --- a/src/Client/Auth/FileCredentialStorage.php +++ b/src/Client/Auth/FileCredentialStorage.php @@ -106,7 +106,7 @@ private function read(): array return [ 'registrations' => \is_array($decoded['registrations'] ?? null) ? $decoded['registrations'] : [], - 'tokens' => \is_array($decoded['tokens'] ?? null) ? $decoded['tokens'] : [], + 'tokens' => \is_array($decoded['tokens'] ?? null) ? array_filter($decoded['tokens'], 'is_array') : [], ]; } diff --git a/tests/Unit/Client/Auth/FileCredentialStorageTest.php b/tests/Unit/Client/Auth/FileCredentialStorageTest.php index 3f7ccbef1..6e7b5d87a 100644 --- a/tests/Unit/Client/Auth/FileCredentialStorageTest.php +++ b/tests/Unit/Client/Auth/FileCredentialStorageTest.php @@ -177,6 +177,18 @@ public function testLockFilePermissions(): void $this->assertSame('0600', substr(\sprintf('%o', fileperms(\dirname($this->path).'/.'.basename($this->path).'.lock')), -4)); } + #[TestDox('an entry of the wrong type does not stop a new token from being saved')] + public function testSavesOverAnEntryOfTheWrongType(): void + { + @mkdir(\dirname($this->path), 0700, true); + file_put_contents($this->path, (string) json_encode(['tokens' => ['https://auth.example.com' => 'not an array']])); + + $storage = new FileCredentialStorage($this->path); + $storage->saveToken('https://auth.example.com', 'https://mcp.example.com/mcp', new AccessToken('token')); + + $this->assertSame('token', $storage->getToken('https://auth.example.com', 'https://mcp.example.com/mcp')?->accessToken); + } + #[TestDox('nothing is stored until there is something to store')] public function testDoesNotTouchTheFileSystemWhenIdle(): void { From 685f102bf446a127146f3c6814f856012820950d Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:30:21 +0100 Subject: [PATCH 23/60] [Client] Try a refresh token when the server does not list its grants --- src/Client/Auth/OAuthAuthenticator.php | 7 +++++- .../Client/Auth/OAuthAuthenticatorTest.php | 25 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 3c121f8ca..111af3a3e 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -298,7 +298,12 @@ private function isUsable(AccessToken $token, array $scopes, RequestInterface $r */ 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)) { + if (null === $stored || null === $stored->refreshToken || !$stored->covers($scopes)) { + return null; + } + + // Without a list, the refresh token this server issued is the best evidence, and a refusal only costs one request. + if (null !== $metadata->grantTypesSupported && !$metadata->supportsGrant(Grant::RefreshToken->value)) { return null; } diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index c2fe8dccd..234e3ff4f 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -27,6 +27,7 @@ use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; @@ -827,6 +828,25 @@ public function authorize(string $authorizationUrl, string $redirectUri): array $this->assertSame('0', $server->token['code'] ?? null); } + /** + * @param ?string[] $grantTypes + */ + #[TestWith([null, 'refresh_token'])] + #[TestWith([['authorization_code', 'refresh_token'], 'refresh_token'])] + #[TestWith([['authorization_code'], 'authorization_code'])] + #[TestDox('a refresh token is only left unused if the server lists its grants without refresh_token')] + public function testUsesARefreshTokenUnlessTheGrantIsNotListed(?array $grantTypes, string $expectedGrant): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $server->grantTypes = $grantTypes; + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('expired', 'Bearer', time() - 60, 'a-refresh-token')); + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame($expectedGrant, $server->token['grant_type'] ?? null); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { @@ -989,6 +1009,9 @@ final class FakeOAuthServer implements ClientInterface public ?string $unknownClient = null; + /** @var ?string[] */ + public ?array $grantTypes = ['authorization_code', 'refresh_token']; + private int $issued = 0; /** @@ -1055,7 +1078,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface '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'], + 'grant_types_supported' => $this->hasAuthorizationEndpoint ? $this->grantTypes : ['client_credentials'], 'code_challenge_methods_supported' => $this->codeChallengeMethods, 'authorization_response_iss_parameter_supported' => true, ]), From c572c97b4706f4f06ce7b8b4015eb5014f7e143c Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:30:22 +0100 Subject: [PATCH 24/60] [Client] Ignore the lock file of the OAuth example --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d7bb6ac21..d50e477dc 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ examples/**/dev.log examples/**/cache examples/**/sessions examples/client/.oauth-credentials.json -examples/client/.oauth-credentials.json.lock +examples/client/..oauth-credentials.json.lock tests/Conformance/client-conformance*.json tests/Conformance/server-conformance*.json tests/Conformance/results* From 417d6b019065aafd4ea486cb74ce463a109e6211 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:30:30 +0100 Subject: [PATCH 25/60] [Client] Remove what nothing uses from ClientMetadata --- src/Client/Auth/ClientMetadata.php | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/Client/Auth/ClientMetadata.php b/src/Client/Auth/ClientMetadata.php index 9740a8811..c626b1874 100644 --- a/src/Client/Auth/ClientMetadata.php +++ b/src/Client/Auth/ClientMetadata.php @@ -18,7 +18,7 @@ * * @author Christopher Hertel */ -final class ClientMetadata implements \JsonSerializable +final class ClientMetadata { /** * @param string $clientName the name shown to the user on the consent screen @@ -61,17 +61,4 @@ public function toArray(TokenEndpointAuthMethod $authMethod, ?string $scope = nu '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); - } } From 07d7ee785c739a8a02ec0f79a839d28876ea0fc0 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:38:35 +0100 Subject: [PATCH 26/60] [Client] Send a token until it has really expired --- src/Client/Auth/AccessToken.php | 9 ++++-- src/Client/Auth/OAuthAuthenticator.php | 2 +- tests/Unit/Client/Auth/AccessTokenTest.php | 20 +++++++++---- .../Client/Auth/OAuthAuthenticatorTest.php | 30 +++++++++++++++++-- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/Client/Auth/AccessToken.php b/src/Client/Auth/AccessToken.php index e03554706..b8eebfbee 100644 --- a/src/Client/Auth/AccessToken.php +++ b/src/Client/Auth/AccessToken.php @@ -21,7 +21,7 @@ final class AccessToken implements \JsonSerializable { /** - * Seconds before the nominal expiry at which a token counts as expired, so it doesn't expire in flight. + * Seconds before the expiry at which a stored token isn't worth reusing, as it could expire in flight. */ private const EXPIRY_LEEWAY_SECONDS = 30; @@ -72,7 +72,12 @@ public function isExpired(?int $now = null): bool return false; } - return ($now ?? time()) + self::EXPIRY_LEEWAY_SECONDS >= $this->expiresAt; + return ($now ?? time()) >= $this->expiresAt; + } + + public function isExpiringSoon(?int $now = null): bool + { + return $this->isExpired(($now ?? time()) + self::EXPIRY_LEEWAY_SECONDS); } /** diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 111af3a3e..5f2b6496e 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -288,7 +288,7 @@ private function isUsable(AccessToken $token, array $scopes, RequestInterface $r return false; } - return !$token->isExpired() && $token->covers($scopes); + return !$token->isExpiringSoon() && $token->covers($scopes); } /** diff --git a/tests/Unit/Client/Auth/AccessTokenTest.php b/tests/Unit/Client/Auth/AccessTokenTest.php index 0861cea98..d15d81230 100644 --- a/tests/Unit/Client/Auth/AccessTokenTest.php +++ b/tests/Unit/Client/Auth/AccessTokenTest.php @@ -52,20 +52,30 @@ public function testRejectsResponseWithoutToken(): void AccessToken::fromResponse(['token_type' => 'Bearer']); } - #[TestDox('a token is treated as expired shortly before it actually is')] - public function testExpiresEarly(): void + #[TestDox('a token expires at its expiry')] + public function testExpires(): void { $token = new AccessToken('abc', expiresAt: 1000); - $this->assertFalse($token->isExpired(900)); - $this->assertTrue($token->isExpired(980)); - $this->assertTrue($token->isExpired(1001)); + $this->assertFalse($token->isExpired(999)); + $this->assertTrue($token->isExpired(1000)); + } + + #[TestDox('a token is expiring soon shortly before it actually expires')] + public function testIsExpiringSoon(): void + { + $token = new AccessToken('abc', expiresAt: 1000); + + $this->assertFalse($token->isExpiringSoon(900)); + $this->assertTrue($token->isExpiringSoon(980)); + $this->assertTrue($token->isExpiringSoon(1001)); } #[TestDox('a token without an expiry never goes stale on its own')] public function testNeverExpiresWithoutExpiry(): void { $this->assertFalse((new AccessToken('abc'))->isExpired()); + $this->assertFalse((new AccessToken('abc'))->isExpiringSoon()); } #[TestDox('scope coverage decides whether a challenge needs a new authorization')] diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 234e3ff4f..d664e5fdb 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -847,6 +847,30 @@ public function testUsesARefreshTokenUnlessTheGrantIsNotListed(?array $grantType $this->assertSame($expectedGrant, $server->token['grant_type'] ?? null); } + #[TestDox('a token that lives no longer than the expiry margin is still sent')] + public function testSendsAShortLivedToken(): void + { + $server = new FakeOAuthServer(); + $server->expiresIn = 30; + $authenticator = $this->authenticator($server); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('Bearer access-token-1', $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); + } + + #[TestDox('a stored token that is about to expire is refreshed instead of reused')] + public function testRefreshesAStoredTokenThatIsAboutToExpire(): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('expiring', 'Bearer', time() + 10, 'a-refresh-token')); + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('refresh_token', $server->token['grant_type'] ?? null); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { @@ -1009,6 +1033,8 @@ final class FakeOAuthServer implements ClientInterface public ?string $unknownClient = null; + public int $expiresIn = 3600; + /** @var ?string[] */ public ?array $grantTypes = ['authorization_code', 'refresh_token']; @@ -1144,10 +1170,10 @@ private function issueToken(array $form): ResponseInterface } 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' => $this->expiresIn]); } - return self::json(['access_token' => 'access-token-'.++$this->issued, 'token_type' => 'Bearer', 'expires_in' => 3600, 'refresh_token' => 'refresh-token-'.$this->issued, 'scope' => $scope]); + return self::json(['access_token' => 'access-token-'.++$this->issued, 'token_type' => 'Bearer', 'expires_in' => $this->expiresIn, 'refresh_token' => 'refresh-token-'.$this->issued, 'scope' => $scope]); } /** From 2e5b11edaba37d8dd4f04a256fbd3522a5ce2848 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:38:44 +0100 Subject: [PATCH 27/60] [Client] Keep the scopes of a stored token in a new authorization --- src/Client/Auth/OAuthAuthenticator.php | 9 +++--- .../Client/Auth/OAuthAuthenticatorTest.php | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 5f2b6496e..bd56c9275 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -97,10 +97,10 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re $this->issuer = $metadata->issuer; $this->resource = $resource; - $scopes = $this->selectScopes($challenge, $scopesSupported, $metadata); + $stored = $this->storage->getToken($metadata->issuer, $resource); + $scopes = $this->selectScopes($challenge, $scopesSupported, $metadata, $stored); // 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]); @@ -248,7 +248,7 @@ private function discoverLegacyServer(string $endpoint): AuthorizationServerMeta * * @return string[] */ - private function selectScopes(AuthorizationChallenge $challenge, ?array $scopesSupported, AuthorizationServerMetadata $metadata): array + private function selectScopes(AuthorizationChallenge $challenge, ?array $scopesSupported, AuthorizationServerMetadata $metadata, ?AccessToken $stored): array { $scopes = $this->configuration->scopes; @@ -258,7 +258,8 @@ private function selectScopes(AuthorizationChallenge $challenge, ?array $scopesS $scopes = [...$scopes, ...$challenge->getScopes()]; } - $scopes = array_values(array_unique([...$this->requestedScopes, ...$scopes])); + $held = array_diff($stored->scopes ?? [], ['offline_access']); + $scopes = array_values(array_unique([...$this->requestedScopes, ...$held, ...$scopes])); if ($this->offlineAccessRefused) { return array_values(array_diff($scopes, ['offline_access'])); diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index d664e5fdb..74f3820a9 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -871,6 +871,34 @@ public function testRefreshesAStoredTokenThatIsAboutToExpire(): void $this->assertSame('refresh_token', $server->token['grant_type'] ?? null); } + #[TestDox('a step-up keeps the scopes of a stored token')] + public function testStepUpKeepsTheScopesOfAStoredToken(): void + { + $server = new FakeOAuthServer(); + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('stored', 'Bearer', time() + 3600, null, ['mcp:read'])); + $authenticator = $this->authenticator($server, storage: $storage); + + $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + $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); + } + + #[TestDox('a stored offline_access does not override turning it off')] + public function testStoredOfflineAccessIsNotRequestedWhenTurnedOff(): void + { + $server = new FakeOAuthServer(serverScopes: ['offline_access']); + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('stored', 'Bearer', time() + 3600, null, ['mcp:read', 'offline_access'])); + $authenticator = $this->authenticator($server, storage: $storage, configuration: OAuth::forApplication('test')->setOfflineAccess(false)); + + $authenticator->handleChallenge((new Psr17Factory())->createRequest('POST', self::ENDPOINT)->withHeader('Authorization', 'Bearer stored'), $server->challenge(['mcp:write'], 403, 'insufficient_scope')); + + $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From db9fb1e653b5e133624f412e0923e99eb58fc08e Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:38:52 +0100 Subject: [PATCH 28/60] [Client] Not carry requested scopes over to another resource --- src/Client/Auth/OAuthAuthenticator.php | 2 ++ tests/Unit/Client/Auth/OAuthAuthenticatorTest.php | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index bd56c9275..bb2f5845f 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -92,6 +92,8 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re $this->token = null; $this->requestedScopes = []; $this->offlineAccessRefused = false; + } elseif ($resource !== $this->resource) { + $this->requestedScopes = []; } $this->issuer = $metadata->issuer; diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 74f3820a9..2ce8fe316 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -899,6 +899,19 @@ public function testStoredOfflineAccessIsNotRequestedWhenTurnedOff(): void $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); } + #[TestDox('the scopes of one resource are not requested for another')] + public function testDoesNotCarryScopesToAnotherResource(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server); + $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + + $server->resource = 'https://mcp.example.com/other'; + $authenticator->handleChallenge((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/other'), $server->challenge(['other:read'])); + + $this->assertSame('other:read', $server->authorization['scope'] ?? null); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From 0f8e14a0a1e59a9a9ee468c36d89b9524a2b2168 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:39:01 +0100 Subject: [PATCH 29/60] [Client] Check the issuer of an authorization error --- src/Client/Auth/OAuthAuthenticator.php | 8 ++--- .../Client/Auth/OAuthAuthenticatorTest.php | 32 +++++++++++++++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index bb2f5845f..2d9153866 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -419,10 +419,6 @@ private function verifyCallback(array $callback, string $state, AuthorizationSer throw new AuthorizationException('The authorization response did not carry back the state parameter this client sent.'); } - if (isset($callback['error'])) { - throw new OAuthErrorException(\sprintf('The authorization server denied the request: %s%s', $callback['error'], isset($callback['error_description']) ? ' ('.$callback['error_description'].')' : ''), $callback['error']); - } - // RFC 9207: compared as a string, and required if the server announced it, to catch a mix-up. if (isset($callback['iss'])) { if ($callback['iss'] !== $metadata->issuer) { @@ -432,6 +428,10 @@ private function verifyCallback(array $callback, string $state, AuthorizationSer throw new AuthorizationException(\sprintf('The authorization server "%s" advertises the iss parameter, but its response did not carry one.', $metadata->issuer)); } + if (isset($callback['error'])) { + throw new OAuthErrorException(\sprintf('The authorization server denied the request: %s%s', $callback['error'], isset($callback['error_description']) ? ' ('.$callback['error_description'].')' : ''), $callback['error']); + } + if (!isset($callback['code']) || '' === $callback['code']) { throw new AuthorizationException('The authorization response carried no authorization code.'); } diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 2ce8fe316..1d14595e0 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -449,7 +449,7 @@ public function authorize(string $authorizationUrl, string $redirectUri): array { parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query); - return ['error' => 'access_denied', 'state' => \is_string($query['state'] ?? null) ? $query['state'] : '']; + return ['error' => 'access_denied', 'iss' => 'https://auth.example.com', 'state' => \is_string($query['state'] ?? null) ? $query['state'] : '']; } }; @@ -761,7 +761,7 @@ public function authorize(string $authorizationUrl, string $redirectUri): array ++$this->calls; parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query); - return ['error' => 'access_denied', 'error_description' => 'not_allowed', 'state' => \is_string($query['state'] ?? null) ? $query['state'] : '']; + return ['error' => 'access_denied', 'error_description' => 'not_allowed', 'iss' => 'https://auth.example.com', 'state' => \is_string($query['state'] ?? null) ? $query['state'] : '']; } }; @@ -791,7 +791,7 @@ public function authorize(string $authorizationUrl, string $redirectUri): array if (1 === ++$this->calls) { parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query); - return ['error' => 'invalid_scope', 'state' => \is_string($query['state'] ?? null) ? $query['state'] : '']; + return ['error' => 'invalid_scope', 'iss' => 'https://auth.example.com', 'state' => \is_string($query['state'] ?? null) ? $query['state'] : '']; } return $this->handler->authorize($authorizationUrl, $redirectUri); @@ -912,6 +912,32 @@ public function testDoesNotCarryScopesToAnotherResource(): void $this->assertSame('other:read', $server->authorization['scope'] ?? null); } + #[TestDox('an error naming another issuer does not count as an answer')] + public function testRejectsAnErrorFromAnotherIssuer(): void + { + $server = new FakeOAuthServer(serverScopes: ['offline_access']); + $handler = new class implements AuthorizationHandlerInterface { + public int $calls = 0; + + public function authorize(string $authorizationUrl, string $redirectUri): array + { + ++$this->calls; + parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query); + + return ['error' => 'invalid_scope', 'iss' => 'https://other.example.com', 'state' => \is_string($query['state'] ?? null) ? $query['state'] : '']; + } + }; + + try { + $this->authenticator($server, $handler)->handleChallenge($this->request(), $server->challenge()); + $this->fail('The authorization should have failed.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('names the issuer', $e->getMessage()); + } + + $this->assertSame(1, $handler->calls); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From b5d4e0023e77735ede2262a3d544cb5a1c043a68 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 00:55:13 +0100 Subject: [PATCH 30/60] [Client] Require an expected issuer for any configured client --- CHANGELOG.md | 2 +- docs/client/authorization.md | 4 ++-- examples/client/oauth_client.php | 3 ++- src/Client/Auth/OAuth.php | 2 +- src/Client/Auth/OAuthConfiguration.php | 6 +++--- tests/Conformance/client.php | 5 +---- tests/E2E/client.php | 4 +++- tests/E2E/run.sh | 1 + tests/Unit/Client/Auth/OAuthAuthenticatorTest.php | 2 +- tests/Unit/Client/Auth/OAuthTest.php | 15 +++++++++------ 10 files changed, 24 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 261e23614..a05cd0733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,7 +80,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * 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. * 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). * `AuthenticatingHttpClient` and `OAuth` turn off redirects on a `symfony/http-client` client, so a redirect can't carry a token or client secret to another path or origin. -* Add `OAuth::setExpectedIssuer()`, required with a client secret, so a hostile MCP server can't receive the secret. +* Add `OAuth::setExpectedIssuer()`, required with `setClientCredentials()`, so a hostile MCP server can't receive the secret or an authorization code. * Add `LoopbackAuthorizationHandler` and `HeadlessAuthorizationHandler` behind `AuthorizationHandlerInterface`, and `InMemoryCredentialStorage` and `FileCredentialStorage` behind `CredentialStorageInterface`. * [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/docs/client/authorization.md b/docs/client/authorization.md index 96947367c..961501631 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -96,11 +96,11 @@ If an administrator created a client for you, or if the authorization server doe OAuth::forApplication('My App') // the secret is optional ->setClientCredentials('my-client-id', 'my-client-secret') - // required with a secret + // required ->setExpectedIssuer('https://auth.example.com'); ``` -The MCP server names its authorization server. Without an expected issuer, a hostile MCP server could name its own and receive your secret. Pass the `issuer` value from the metadata of your authorization server. If you set a secret without an expected issuer, `build()` throws an `InvalidArgumentException`. +The MCP server names its authorization server. Without an expected issuer, a hostile MCP server could name its own and receive your secret. It could also send the user to your authorization server with your client ID and receive the authorization code. Pass the `issuer` value from the metadata of your authorization server. If you set a client ID without an expected issuer, `build()` throws an `InvalidArgumentException`. The client reads from the metadata of the authorization server how to send the secret. diff --git a/examples/client/oauth_client.php b/examples/client/oauth_client.php index c60b535d5..93cf6c042 100644 --- a/examples/client/oauth_client.php +++ b/examples/client/oauth_client.php @@ -46,8 +46,9 @@ $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. + // to register. Drop these lines against a server that offers dynamic registration. ->setClientCredentials('mcp-client') + ->setExpectedIssuer('http://localhost:8180/realms/mcp') // Keycloak rejects the "mcp:read" scope the server advertises, and adds it to the token by default. ->setScopes('openid') ->setCredentialStorage(new FileCredentialStorage(__DIR__.'/.oauth-credentials.json')) diff --git a/src/Client/Auth/OAuth.php b/src/Client/Auth/OAuth.php index b4c1d689c..ef33b81da 100644 --- a/src/Client/Auth/OAuth.php +++ b/src/Client/Auth/OAuth.php @@ -95,7 +95,7 @@ public function setClientCredentials(string $clientId, ?string $clientSecret = n } /** - * Only authorize with this issuer; required with a client secret, or a hostile MCP server could receive it. + * Only authorize with this issuer; required with a configured client, or a hostile MCP server could receive its codes and secret. */ public function setExpectedIssuer(string $issuer): self { diff --git a/src/Client/Auth/OAuthConfiguration.php b/src/Client/Auth/OAuthConfiguration.php index 35574761b..10b2204d2 100644 --- a/src/Client/Auth/OAuthConfiguration.php +++ b/src/Client/Auth/OAuthConfiguration.php @@ -35,7 +35,7 @@ final class OAuthConfiguration * should not be derived from the endpoint being called * @param ?string $expectedIssuer the only authorization server to authorize with * - * @throws InvalidArgumentException when a client secret is set without an expected issuer + * @throws InvalidArgumentException when a client id is set without an expected issuer */ public function __construct( public readonly ClientMetadata $clientMetadata, @@ -48,8 +48,8 @@ public function __construct( public readonly ?string $resource = null, public readonly ?string $expectedIssuer = null, ) { - if (null !== $clientSecret && null === $expectedIssuer) { - throw new InvalidArgumentException('A client secret needs an expected issuer, or a hostile MCP server could receive it. Call OAuth::setExpectedIssuer().'); + if (null !== $clientId && null === $expectedIssuer) { + throw new InvalidArgumentException('A configured client needs an expected issuer, or a hostile MCP server could receive its authorization codes and secret. Call OAuth::setExpectedIssuer().'); } } } diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php index 4f6446ba6..a51aea8bd 100644 --- a/tests/Conformance/client.php +++ b/tests/Conformance/client.php @@ -138,10 +138,7 @@ function buildAuthenticator(string $url, string $scenario, array $context, Psr\L // passes the credentials an administrator would have configured. if (isset($context['client_id'])) { $oauth->setClientCredentials($context['client_id'], $context['client_secret'] ?? null); - - if (isset($context['client_secret'])) { - $oauth->setExpectedIssuer(discoverIssuer($url)); - } + $oauth->setExpectedIssuer(discoverIssuer($url)); } // These two scenarios are 2025-03-26 servers: no protected resource metadata, and diff --git a/tests/E2E/client.php b/tests/E2E/client.php index 3b754acd5..0940c2bbf 100644 --- a/tests/E2E/client.php +++ b/tests/E2E/client.php @@ -33,6 +33,7 @@ use Mcp\Tests\E2E\BrowserAuthorizationHandler; $endpoint = getenv('MCP_E2E_ENDPOINT') ?: 'http://localhost:8001/mcp'; +$issuer = getenv('MCP_E2E_ISSUER') ?: 'http://localhost:8181/realms/mcp-e2e'; $username = getenv('MCP_E2E_USERNAME') ?: 'demo'; $password = getenv('MCP_E2E_PASSWORD') ?: 'demo123'; $credentialsFile = getenv('MCP_E2E_CREDENTIALS') ?: sys_get_temp_dir().'/mcp-e2e-credentials.json'; @@ -46,7 +47,7 @@ fwrite(\STDOUT, sprintf("%s %s%s\n", $passed ? ' ok ' : ' FAIL ', $what, '' === $detail ? '' : ' -- '.$detail)); }; -$connect = static function (FileCredentialStorage $storage) use ($endpoint, $username, $password): Client { +$connect = static function (FileCredentialStorage $storage) use ($endpoint, $issuer, $username, $password): Client { $client = Client::builder() ->setClientInfo('mcp-client-auth-e2e', '1.0.0') ->setInitTimeout(30) @@ -57,6 +58,7 @@ // 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') + ->setExpectedIssuer($issuer) ->setAuthorizationHandler(new BrowserAuthorizationHandler($username, $password)) ->setCredentialStorage($storage) ->build())); diff --git a/tests/E2E/run.sh b/tests/E2E/run.sh index 63fec6279..1cb1378c3 100755 --- a/tests/E2E/run.sh +++ b/tests/E2E/run.sh @@ -53,5 +53,6 @@ done echo "Running the client..." MCP_E2E_ENDPOINT="http://localhost:$mcp_port/mcp" \ +MCP_E2E_ISSUER="http://localhost:$keycloak_port/realms/mcp-e2e" \ MCP_E2E_CREDENTIALS="$directory/sessions/credentials.json" \ php "$directory/client.php" diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 1d14595e0..26bd472b4 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -737,7 +737,7 @@ public function testDoesNotReplaceAConfiguredClient(): void { $server = new FakeOAuthServer(); $server->unknownClient = 'known-client'; - $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test')->setClientCredentials('known-client')); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test')->setClientCredentials('known-client')->setExpectedIssuer('https://auth.example.com')); try { $authenticator->handleChallenge($this->request(), $server->challenge()); diff --git a/tests/Unit/Client/Auth/OAuthTest.php b/tests/Unit/Client/Auth/OAuthTest.php index 734c31a43..2c68c39d1 100644 --- a/tests/Unit/Client/Auth/OAuthTest.php +++ b/tests/Unit/Client/Auth/OAuthTest.php @@ -22,6 +22,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientInterface; use Symfony\Component\HttpClient\MockHttpClient; @@ -81,19 +82,21 @@ public static function getCandidates($type): array $this->assertSame([0], array_values(array_unique($seen))); } - #[TestDox('a client secret needs an expected issuer')] - public function testClientSecretNeedsAnExpectedIssuer(): void + #[TestWith(['client', 'secret'])] + #[TestWith(['client', null])] + #[TestDox('a configured client needs an expected issuer')] + public function testConfiguredClientNeedsAnExpectedIssuer(string $clientId, ?string $clientSecret): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('expected issuer'); - OAuth::forApplication('My App')->setClientCredentials('client', 'secret')->build(); + OAuth::forApplication('My App')->setClientCredentials($clientId, $clientSecret)->build(); } - #[TestDox('a client id without a secret needs no expected issuer')] - public function testPublicClientNeedsNoExpectedIssuer(): void + #[TestDox('a client that registers itself needs no expected issuer')] + public function testDynamicRegistrationNeedsNoExpectedIssuer(): void { - $configuration = $this->configurationOf(OAuth::forApplication('My App')->setClientCredentials('client')); + $configuration = $this->configurationOf(OAuth::forApplication('My App')); $this->assertNull($configuration->expectedIssuer); } From b49775bb3fe3340b8937377019b82790f9680549 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 01:06:58 +0100 Subject: [PATCH 31/60] [Client] Only keep the scopes of a stored token in a step-up --- src/Client/Auth/OAuthAuthenticator.php | 2 +- tests/Unit/Client/Auth/OAuthAuthenticatorTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 2d9153866..3ee54e89b 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -260,7 +260,7 @@ private function selectScopes(AuthorizationChallenge $challenge, ?array $scopesS $scopes = [...$scopes, ...$challenge->getScopes()]; } - $held = array_diff($stored->scopes ?? [], ['offline_access']); + $held = 'insufficient_scope' === $challenge->getError() ? array_diff($stored->scopes ?? [], ['offline_access']) : []; $scopes = array_values(array_unique([...$this->requestedScopes, ...$held, ...$scopes])); if ($this->offlineAccessRefused) { diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 26bd472b4..e7f0ff349 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -938,6 +938,18 @@ public function authorize(string $authorizationUrl, string $redirectUri): array $this->assertSame(1, $handler->calls); } + #[TestDox('a new authorization without a step-up does not add the scopes of a stored token')] + public function testOnlyAStepUpKeepsTheScopesOfAStoredToken(): void + { + $server = new FakeOAuthServer(); + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('expired', 'Bearer', time() - 60, null, ['openid', 'mcp:read'])); + + $this->authenticator($server, storage: $storage, configuration: OAuth::forApplication('test')->setScopes('openid'))->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('openid', $server->authorization['scope'] ?? null); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From fa6fcbe8c99c2fafb87796e49dbc7832c28be705 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 01:07:11 +0100 Subject: [PATCH 32/60] [Client] Treat a secret expiry of 0 as no expiry, however it was created --- src/Client/Auth/ClientRegistration.php | 6 +++++- tests/Unit/Client/Auth/ClientRegistrationTest.php | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Client/Auth/ClientRegistration.php b/src/Client/Auth/ClientRegistration.php index 173afb295..d5c41c1d3 100644 --- a/src/Client/Auth/ClientRegistration.php +++ b/src/Client/Auth/ClientRegistration.php @@ -76,7 +76,11 @@ public static function fromResponse(array $response, TokenEndpointAuthMethod $fa public function isExpired(?int $now = null): bool { - return null !== $this->clientSecretExpiresAt && ($now ?? time()) >= $this->clientSecretExpiresAt; + if (null === $this->clientSecretExpiresAt || 0 === $this->clientSecretExpiresAt) { + return false; + } + + return ($now ?? time()) >= $this->clientSecretExpiresAt; } /** diff --git a/tests/Unit/Client/Auth/ClientRegistrationTest.php b/tests/Unit/Client/Auth/ClientRegistrationTest.php index ae3101296..0940b590a 100644 --- a/tests/Unit/Client/Auth/ClientRegistrationTest.php +++ b/tests/Unit/Client/Auth/ClientRegistrationTest.php @@ -88,6 +88,13 @@ public function testSecretExpiry(): void $this->assertTrue($registration->isExpired(1001)); } + #[TestDox('an expiry of 0 never expires, however the registration was created')] + public function testExpiryOfZeroNeverExpires(): void + { + $this->assertFalse((new ClientRegistration('issued', clientSecretExpiresAt: 0))->isExpired()); + $this->assertFalse(ClientRegistration::fromArray(['client_id' => 'issued', 'client_secret_expires_at' => 0])->isExpired()); + } + #[TestDox('a secret that never expires is reported as 0, and is not an expiry')] public function testNonExpiringSecret(): void { From 40ebce7c083bdcfacd3f4736f619272bedb23200 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 01:07:26 +0100 Subject: [PATCH 33/60] [Client] Limit the size and time of the headers the headless handler reads --- .../Auth/HeadlessAuthorizationHandler.php | 16 +++++++++- .../Client/Auth/AuthorizationHandlerTest.php | 30 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/Client/Auth/HeadlessAuthorizationHandler.php b/src/Client/Auth/HeadlessAuthorizationHandler.php index 6aef9eedc..7d3f975b2 100644 --- a/src/Client/Auth/HeadlessAuthorizationHandler.php +++ b/src/Client/Auth/HeadlessAuthorizationHandler.php @@ -22,6 +22,8 @@ */ final class HeadlessAuthorizationHandler implements AuthorizationHandlerInterface { + private const MAX_HEADER_BYTES = 65536; + public function __construct( private readonly float $timeout = 10.0, private readonly LoggerInterface $logger = new NullLogger(), @@ -80,7 +82,8 @@ private function fetchRedirect(string $url): string } try { - stream_set_timeout($socket, (int) $this->timeout); + $deadline = microtime(true) + $this->timeout; + stream_set_timeout($socket, (int) ceil($this->timeout)); fwrite($socket, \sprintf( "GET %s HTTP/1.1\r\nHost: %s\r\nAccept: */*\r\nConnection: close\r\n\r\n", $target, @@ -90,6 +93,13 @@ private function fetchRedirect(string $url): string $headers = ''; while (!feof($socket) && !str_contains($headers, "\r\n\r\n")) { + $remaining = $deadline - microtime(true); + + if ($remaining <= 0) { + throw new AuthorizationException(\sprintf('The authorization endpoint "%s" did not answer within %s seconds.', $url, $this->timeout)); + } + + stream_set_timeout($socket, (int) ceil($remaining)); $chunk = fread($socket, 4096); if (false === $chunk || '' === $chunk) { @@ -97,6 +107,10 @@ private function fetchRedirect(string $url): string } $headers .= $chunk; + + if (\strlen($headers) > self::MAX_HEADER_BYTES) { + throw new AuthorizationException(\sprintf('The answer of the authorization endpoint "%s" is too large.', $url)); + } } } finally { fclose($socket); diff --git a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php index 75de6b855..db404ddf8 100644 --- a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php +++ b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php @@ -195,6 +195,36 @@ public function testLoopbackHandlerOpensTheUrlWithoutAShellOnWindows(): void /** * Runs in its own process, as only the environment decides which opener the handler finds. */ + #[TestDox('the headless handler stops reading a header block that never ends')] + public function testHeadlessHandlerLimitsTheSizeOfTheHeaders(): void + { + $this->assertHeadlessHandlerFailsOn('fwrite($c, str_repeat("x", 2097152)); sleep(5);', 'too large'); + } + + #[TestDox('the headless handler stops waiting for a server that answers byte by byte')] + public function testHeadlessHandlerLimitsTheTimeForTheHeaders(): void + { + $this->assertHeadlessHandlerFailsOn('for ($i = 0; $i < 25; ++$i) { fwrite($c, "x"); usleep(200000); }', 'within'); + } + + private function assertHeadlessHandlerFailsOn(string $answer, string $message): void + { + $code = '$s = stream_socket_server("tcp://127.0.0.1:0"); echo substr(strrchr(stream_socket_get_name($s, false), ":"), 1), "\n"; $c = stream_socket_accept($s, 5); fread($c, 4096); fwrite($c, "HTTP/1.1 200 OK\r\n"); '.$answer; + $server = proc_open([\PHP_BINARY, '-r', $code], [1 => ['pipe', 'w']], $pipes); + $this->assertIsResource($server); + + try { + $port = (int) fgets($pipes[1]); + (new HeadlessAuthorizationHandler(1))->authorize(\sprintf('http://127.0.0.1:%d/authorize', $port), 'http://127.0.0.1:1/callback'); + $this->fail('The handler should have given up.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString($message, $e->getMessage()); + } finally { + proc_terminate($server); + proc_close($server); + } + } + private function runDefaultHandler(string $url, string $path): string { if ('Windows' === \PHP_OS_FAMILY) { From 54f74c62dc2469b87bea9f6d56b75b42a0e722cd Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 01:08:21 +0100 Subject: [PATCH 34/60] [Client] Refresh a token one process at a time --- .gitignore | 1 + docs/client/authorization.md | 2 +- .../Auth/CredentialStorageInterface.php | 11 +++ src/Client/Auth/FileCredentialStorage.php | 24 ++++- src/Client/Auth/InMemoryCredentialStorage.php | 5 + src/Client/Auth/OAuthAuthenticator.php | 44 +++++++-- .../Client/Auth/FileCredentialStorageTest.php | 29 +++++- .../Client/Auth/OAuthAuthenticatorTest.php | 99 ++++++++++++++++++- 8 files changed, 198 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index d50e477dc..d786517e4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ examples/**/cache examples/**/sessions examples/client/.oauth-credentials.json examples/client/..oauth-credentials.json.lock +examples/client/..oauth-credentials.json.refresh.lock tests/Conformance/client-conformance*.json tests/Conformance/server-conformance*.json tests/Conformance/results* diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 961501631..7d1f04a04 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -84,7 +84,7 @@ $auth = OAuth::forApplication('My App') ->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. Windows ignores these modes, so store the file in a directory that only you can read (e.g. your user profile). The SDK also creates a lock file next to it, so processes can share the file. To use a keychain or a secrets manager, implement `CredentialStorageInterface`. +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. Windows ignores these modes, so store the file in a directory that only you can read (e.g. your user profile). The SDK also creates two lock files next to it, so processes can share the file and only one of them refreshes a token at a time. To use a keychain or a secrets manager, implement `CredentialStorageInterface`. Its `synchronize()` method must run one operation at a time across all processes that share the storage. 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. It also registers again if you change the redirect URI, or if the token endpoint answers `invalid_client` for a client that it registered. diff --git a/src/Client/Auth/CredentialStorageInterface.php b/src/Client/Auth/CredentialStorageInterface.php index dd408e6ec..d88ede0ba 100644 --- a/src/Client/Auth/CredentialStorageInterface.php +++ b/src/Client/Auth/CredentialStorageInterface.php @@ -37,4 +37,15 @@ public function saveToken(string $issuer, string $resource, AccessToken $token): * Drop everything held for one authorization server, or the whole store when null. */ public function forget(?string $issuer = null): void; + + /** + * Run an operation while no other process sharing this storage runs one. + * + * @template T + * + * @param callable(): T $operation + * + * @return T + */ + public function synchronize(callable $operation): mixed; } diff --git a/src/Client/Auth/FileCredentialStorage.php b/src/Client/Auth/FileCredentialStorage.php index 494b2ebb6..7567f3560 100644 --- a/src/Client/Auth/FileCredentialStorage.php +++ b/src/Client/Auth/FileCredentialStorage.php @@ -87,6 +87,12 @@ public function forget(?string $issuer = null): void }); } + public function synchronize(callable $operation): mixed + { + // Its own lock file, as the operation saves credentials under the other one. + return $this->underLock('.refresh.lock', $operation); + } + /** * @return array{registrations: array>, tokens: array>>} */ @@ -116,6 +122,20 @@ private function read(): array * @param callable(array{registrations: array>, tokens: array>>}): array{registrations: array>, tokens: array>>} $change */ private function update(callable $change): void + { + $this->underLock('.lock', function () use ($change): void { + $this->write($change($this->read())); + }); + } + + /** + * @template T + * + * @param callable(): T $operation + * + * @return T + */ + private function underLock(string $suffix, callable $operation): mixed { $directory = \dirname($this->path); @@ -123,7 +143,7 @@ private function update(callable $change): void throw new RuntimeException(\sprintf('Could not create the credential directory "%s".', $directory)); } - $lockPath = $directory.'/.'.basename($this->path).'.lock'; + $lockPath = $directory.'/.'.basename($this->path).$suffix; $lock = @fopen($lockPath, 'c'); if (false === $lock) { @@ -137,7 +157,7 @@ private function update(callable $change): void throw new RuntimeException(\sprintf('Could not lock the credential lock file "%s".', $lockPath)); } - $this->write($change($this->read())); + return $operation(); } finally { fclose($lock); } diff --git a/src/Client/Auth/InMemoryCredentialStorage.php b/src/Client/Auth/InMemoryCredentialStorage.php index 671f4f42d..d5fe7f3d4 100644 --- a/src/Client/Auth/InMemoryCredentialStorage.php +++ b/src/Client/Auth/InMemoryCredentialStorage.php @@ -55,4 +55,9 @@ public function forget(?string $issuer = null): void unset($this->registrations[$issuer], $this->tokens[$issuer]); } + + public function synchronize(callable $operation): mixed + { + return $operation(); + } } diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 3ee54e89b..f608af06c 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -117,7 +117,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re $refreshable = 'insufficient_scope' === $challenge->getError() ? null : $stored; try { - $token = $this->refresh($refreshable, $metadata, $client, $needed); + $token = $this->refresh($refreshable, $metadata, $client, $needed, $resource, $request); } catch (OAuthErrorException) { $this->logger->info('The authorization server no longer knows this client; registering it again.', ['issuer' => $metadata->issuer]); $this->storage->forget($metadata->issuer); @@ -138,12 +138,12 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re } $token = $token->withGrantedScopes($scopes); + $this->storage->saveToken($metadata->issuer, $resource, $token); } $this->token = $token; $this->audience = $audience; $this->requestedScopes = $scopes; - $this->storage->saveToken($metadata->issuer, $resource, $this->token); return true; } @@ -295,13 +295,13 @@ private function isUsable(AccessToken $token, array $scopes, RequestInterface $r } /** - * Trade a refresh token for a new access token, if it covers the scopes. + * Trade a refresh token for a new access token, one process at a time as a rotated refresh token can only be spent once. * * @param string[] $scopes */ - private function refresh(?AccessToken $stored, AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes): ?AccessToken + private function refresh(?AccessToken $stored, AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes, string $resource, RequestInterface $request): ?AccessToken { - if (null === $stored || null === $stored->refreshToken || !$stored->covers($scopes)) { + if (null === $stored || null === $stored->refreshToken) { return null; } @@ -310,11 +310,39 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta return null; } + return $this->storage->synchronize(function () use ($metadata, $client, $scopes, $resource, $request): ?AccessToken { + // Another process may have refreshed the token while this one waited. + $current = $this->storage->getToken($metadata->issuer, $resource); + + if (null === $current) { + return null; + } + + if ($this->isUsable($current, $scopes, $request)) { + return $current; + } + + if (null === $current->refreshToken || !$current->covers($scopes)) { + return null; + } + + $token = $this->spendRefreshToken($current, $current->refreshToken, $metadata, $client, $resource); + + if (null !== $token) { + $this->storage->saveToken($metadata->issuer, $resource, $token); + } + + return $token; + }); + } + + private function spendRefreshToken(AccessToken $stored, string $refreshToken, AuthorizationServerMetadata $metadata, ClientRegistration $client, string $resource): ?AccessToken + { try { $token = $this->tokenEndpoint->request($metadata, $client, self::withoutEmptyValues([ 'grant_type' => Grant::RefreshToken->value, - 'refresh_token' => $stored->refreshToken, - 'resource' => $this->resource, + 'refresh_token' => $refreshToken, + 'resource' => $resource, ])); } catch (AuthorizationException $e) { if (self::isForgottenClient($e, $client)) { @@ -331,7 +359,7 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta $token->accessToken, $token->tokenType, $token->expiresAt, - $token->refreshToken ?? $stored->refreshToken, + $token->refreshToken ?? $refreshToken, [] === $token->scopes ? $stored->scopes : $token->scopes, ); } diff --git a/tests/Unit/Client/Auth/FileCredentialStorageTest.php b/tests/Unit/Client/Auth/FileCredentialStorageTest.php index 6e7b5d87a..b5df96942 100644 --- a/tests/Unit/Client/Auth/FileCredentialStorageTest.php +++ b/tests/Unit/Client/Auth/FileCredentialStorageTest.php @@ -33,10 +33,10 @@ protected function tearDown(): void unlink($this->path); } - $lock = \dirname($this->path).'/.'.basename($this->path).'.lock'; - - if (is_file($lock)) { - unlink($lock); + foreach ([\dirname($this->path).'/counter', \dirname($this->path).'/.'.basename($this->path).'.lock', \dirname($this->path).'/.'.basename($this->path).'.refresh.lock'] as $file) { + if (is_file($file)) { + unlink($file); + } } if (is_dir(\dirname($this->path))) { @@ -189,6 +189,27 @@ public function testSavesOverAnEntryOfTheWrongType(): void $this->assertSame('token', $storage->getToken('https://auth.example.com', 'https://mcp.example.com/mcp')?->accessToken); } + #[TestDox('processes sharing the file run their synchronized operations one at a time')] + public function testSynchronizeExcludesOtherProcesses(): void + { + $counter = \dirname($this->path).'/counter'; + mkdir(\dirname($this->path), 0700, true); + $code = 'require $argv[1]; (new Mcp\Client\Auth\FileCredentialStorage($argv[2]))->synchronize(function () use ($argv) { $count = (int) @file_get_contents($argv[3]); usleep(300000); file_put_contents($argv[3], $count + 1); });'; + + $processes = []; + + for ($i = 0; $i < 3; ++$i) { + $processes[] = proc_open([\PHP_BINARY, '-r', $code, \dirname(__DIR__, 4).'/vendor/autoload.php', $this->path, $counter], [], $pipes); + } + + foreach ($processes as $process) { + $this->assertIsResource($process); + $this->assertSame(0, proc_close($process)); + } + + $this->assertSame('3', file_get_contents($counter)); + } + #[TestDox('nothing is stored until there is something to store')] public function testDoesNotTouchTheFileSystemWhenIdle(): void { diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index e7f0ff349..a5e3c4013 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -15,6 +15,7 @@ use Mcp\Client\Auth\AuthorizationHandlerInterface; use Mcp\Client\Auth\ClientRegistrar; use Mcp\Client\Auth\ClientRegistration; +use Mcp\Client\Auth\CredentialStorageInterface; use Mcp\Client\Auth\InMemoryCredentialStorage; use Mcp\Client\Auth\MetadataDiscovery; use Mcp\Client\Auth\OAuth; @@ -35,7 +36,7 @@ final class OAuthAuthenticatorTest extends TestCase { - private const ENDPOINT = 'https://mcp.example.com/mcp'; + public const ENDPOINT = 'https://mcp.example.com/mcp'; #[TestDox('a challenge drives discovery, registration and the code exchange in one pass')] public function testCompletesTheFlowFromASingleChallenge(): void @@ -950,6 +951,41 @@ public function testOnlyAStepUpKeepsTheScopesOfAStoredToken(): void $this->assertSame('openid', $server->authorization['scope'] ?? null); } + #[TestDox('a token another process refreshed in the meantime is used, without spending its refresh token')] + public function testUsesATokenRefreshedByAnotherProcess(): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $storage = $this->storageWithAnotherProcess(new AccessToken('from-other-process', 'Bearer', time() + 3600, 'rotated')); + $authenticator = $this->authenticator($server, storage: $storage); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('Bearer from-other-process', $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); + $this->assertSame([], $server->token); + } + + #[TestDox('a refresh spends the refresh token another process stored in the meantime')] + public function testSpendsTheRefreshTokenOfAnotherProcess(): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $storage = $this->storageWithAnotherProcess(new AccessToken('also-expired', 'Bearer', time() - 60, 'rotated')); + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('rotated', $server->token['refresh_token'] ?? null); + } + + #[TestDox('a refreshed token is stored before another process can start its refresh')] + public function testStoresARefreshedTokenWhileSynchronized(): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $storage = $this->storageWithAnotherProcess(new AccessToken('also-expired', 'Bearer', time() - 60, 'rotated')); + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame([true], $storage->savedWhileSynchronized); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { @@ -1031,10 +1067,69 @@ private function request(): RequestInterface return (new Psr17Factory())->createRequest('POST', self::ENDPOINT); } + /** + * @return CredentialStorageInterface&object{savedWhileSynchronized: bool[]} + */ + private function storageWithAnotherProcess(AccessToken $tokenOfOtherProcess): CredentialStorageInterface + { + return new class($tokenOfOtherProcess) implements CredentialStorageInterface { + /** @var bool[] */ + public array $savedWhileSynchronized = []; + + private readonly InMemoryCredentialStorage $storage; + + private bool $synchronized = false; + + public function __construct(private readonly AccessToken $tokenOfOtherProcess) + { + $this->storage = new InMemoryCredentialStorage(); + $this->storage->saveToken('https://auth.example.com', OAuthAuthenticatorTest::ENDPOINT, new AccessToken('expired', 'Bearer', time() - 60, 'original')); + } + + public function getClientRegistration(string $issuer): ?ClientRegistration + { + return $this->storage->getClientRegistration($issuer); + } + + public function saveClientRegistration(string $issuer, ClientRegistration $registration): void + { + $this->storage->saveClientRegistration($issuer, $registration); + } + + public function getToken(string $issuer, string $resource): ?AccessToken + { + return $this->storage->getToken($issuer, $resource); + } + + public function saveToken(string $issuer, string $resource, AccessToken $token): void + { + $this->savedWhileSynchronized[] = $this->synchronized; + $this->storage->saveToken($issuer, $resource, $token); + } + + public function forget(?string $issuer = null): void + { + $this->storage->forget($issuer); + } + + public function synchronize(callable $operation): mixed + { + $this->storage->saveToken('https://auth.example.com', OAuthAuthenticatorTest::ENDPOINT, $this->tokenOfOtherProcess); + $this->synchronized = true; + + try { + return $operation(); + } finally { + $this->synchronized = false; + } + } + }; + } + private function authenticator( FakeOAuthServer $server, ?AuthorizationHandlerInterface $handler = null, - ?InMemoryCredentialStorage $storage = null, + ?CredentialStorageInterface $storage = null, ?OAuth $configuration = null, ): OAuthAuthenticator { $factory = new Psr17Factory(); From 98cc69330556805edadc8f791ee8447a6487fb49 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 01:29:12 +0100 Subject: [PATCH 35/60] [Client] Store a token by the server it may be sent to --- src/Client/Auth/CredentialStorageInterface.php | 2 +- src/Client/Auth/OAuthAuthenticator.php | 14 +++++++------- .../Unit/Client/Auth/OAuthAuthenticatorTest.php | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/Client/Auth/CredentialStorageInterface.php b/src/Client/Auth/CredentialStorageInterface.php index d88ede0ba..0799541c5 100644 --- a/src/Client/Auth/CredentialStorageInterface.php +++ b/src/Client/Auth/CredentialStorageInterface.php @@ -27,7 +27,7 @@ public function saveClientRegistration(string $issuer, ClientRegistration $regis /** * @param string $issuer the authorization server's issuer identifier - * @param string $resource the canonical URI of the protected resource the token is for + * @param string $resource the canonical URI of the protected resource the token may be sent to */ public function getToken(string $issuer, string $resource): ?AccessToken; diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index f608af06c..967eb5419 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -99,7 +99,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re $this->issuer = $metadata->issuer; $this->resource = $resource; - $stored = $this->storage->getToken($metadata->issuer, $resource); + $stored = $this->storage->getToken($metadata->issuer, $audience); $scopes = $this->selectScopes($challenge, $scopesSupported, $metadata, $stored); // The resource never requires offline_access, which only gets a refresh token. $needed = array_values(array_diff($scopes, ['offline_access'])); @@ -117,7 +117,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re $refreshable = 'insufficient_scope' === $challenge->getError() ? null : $stored; try { - $token = $this->refresh($refreshable, $metadata, $client, $needed, $resource, $request); + $token = $this->refresh($refreshable, $metadata, $client, $needed, $resource, $audience, $request); } catch (OAuthErrorException) { $this->logger->info('The authorization server no longer knows this client; registering it again.', ['issuer' => $metadata->issuer]); $this->storage->forget($metadata->issuer); @@ -138,7 +138,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re } $token = $token->withGrantedScopes($scopes); - $this->storage->saveToken($metadata->issuer, $resource, $token); + $this->storage->saveToken($metadata->issuer, $audience, $token); } $this->token = $token; @@ -299,7 +299,7 @@ private function isUsable(AccessToken $token, array $scopes, RequestInterface $r * * @param string[] $scopes */ - private function refresh(?AccessToken $stored, AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes, string $resource, RequestInterface $request): ?AccessToken + private function refresh(?AccessToken $stored, AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes, string $resource, string $audience, RequestInterface $request): ?AccessToken { if (null === $stored || null === $stored->refreshToken) { return null; @@ -310,9 +310,9 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta return null; } - return $this->storage->synchronize(function () use ($metadata, $client, $scopes, $resource, $request): ?AccessToken { + return $this->storage->synchronize(function () use ($metadata, $client, $scopes, $resource, $audience, $request): ?AccessToken { // Another process may have refreshed the token while this one waited. - $current = $this->storage->getToken($metadata->issuer, $resource); + $current = $this->storage->getToken($metadata->issuer, $audience); if (null === $current) { return null; @@ -329,7 +329,7 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta $token = $this->spendRefreshToken($current, $current->refreshToken, $metadata, $client, $resource); if (null !== $token) { - $this->storage->saveToken($metadata->issuer, $resource, $token); + $this->storage->saveToken($metadata->issuer, $audience, $token); } return $token; diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index a5e3c4013..323d17dff 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -986,6 +986,22 @@ public function testStoresARefreshedTokenWhileSynchronized(): void $this->assertSame([true], $storage->savedWhileSynchronized); } + #[TestDox('a stored token stays with its server when the resource parameter is overridden')] + public function testStoredTokenOfAnOverriddenResourceStaysWithItsServer(): void + { + $server = new FakeOAuthServer(); + $storage = new InMemoryCredentialStorage(); + $this->authenticator($server, storage: $storage, configuration: OAuth::forApplication('test')->setResource('https://api.example.com/v1')) + ->handleChallenge($this->request(), $server->challenge()); + + $server->resource = 'https://mcp.example.com/other'; + $otherServer = (new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/other'); + $authenticator = $this->authenticator($server, storage: $storage, configuration: OAuth::forApplication('test')->setResource('https://api.example.com/v1')); + $authenticator->handleChallenge($otherServer, $server->challenge()); + + $this->assertSame('Bearer access-token-2', $authenticator->authenticate($otherServer)->getHeaderLine('Authorization')); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From c93f9501c4e0ad8d582aae5df6d2d5dfac776574 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 01:29:23 +0100 Subject: [PATCH 36/60] [Client] Refuse a redirect URI without https outside of loopback --- docs/client/authorization.md | 2 +- src/Client/Auth/OAuthConfiguration.php | 6 +++++- tests/Unit/Client/Auth/OAuthTest.php | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 7d1f04a04..8aadcabb9 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -69,7 +69,7 @@ final class AcmeAuthorizationHandler implements AuthorizationHandlerInterface } ``` -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. +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. The URI must use `https` or a loopback host (e.g. `127.0.0.1`). Otherwise, `build()` throws an `InvalidArgumentException`. ## Storing Credentials diff --git a/src/Client/Auth/OAuthConfiguration.php b/src/Client/Auth/OAuthConfiguration.php index 10b2204d2..f5deaaf4e 100644 --- a/src/Client/Auth/OAuthConfiguration.php +++ b/src/Client/Auth/OAuthConfiguration.php @@ -35,7 +35,7 @@ final class OAuthConfiguration * should not be derived from the endpoint being called * @param ?string $expectedIssuer the only authorization server to authorize with * - * @throws InvalidArgumentException when a client id is set without an expected issuer + * @throws InvalidArgumentException when the redirect URI is insecure, or a client id is set without an expected issuer */ public function __construct( public readonly ClientMetadata $clientMetadata, @@ -48,6 +48,10 @@ public function __construct( public readonly ?string $resource = null, public readonly ?string $expectedIssuer = null, ) { + if (!AuthorizationServerMetadata::isTransportSecure($redirectUri)) { + throw new InvalidArgumentException(\sprintf('The redirect URI "%s" must use https, or http on a loopback host.', $redirectUri)); + } + if (null !== $clientId && null === $expectedIssuer) { throw new InvalidArgumentException('A configured client needs an expected issuer, or a hostile MCP server could receive its authorization codes and secret. Call OAuth::setExpectedIssuer().'); } diff --git a/tests/Unit/Client/Auth/OAuthTest.php b/tests/Unit/Client/Auth/OAuthTest.php index 2c68c39d1..a3e578544 100644 --- a/tests/Unit/Client/Auth/OAuthTest.php +++ b/tests/Unit/Client/Auth/OAuthTest.php @@ -101,6 +101,28 @@ public function testDynamicRegistrationNeedsNoExpectedIssuer(): void $this->assertNull($configuration->expectedIssuer); } + #[TestWith(['http://app.example.com/callback'])] + #[TestWith(['http://0.0.0.0:8765/callback'])] + #[TestWith(['http://192.168.1.5:8765/callback'])] + #[TestWith(['com.example.app:/callback'])] + #[TestDox('a redirect URI must use https or a loopback host')] + public function testRefusesAnInsecureRedirectUri(string $redirectUri): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('redirect URI'); + + OAuth::forApplication('My App')->setRedirectUri($redirectUri)->build(); + } + + #[TestWith(['https://app.example.com/callback'])] + #[TestWith(['http://localhost:3000/callback'])] + #[TestWith(['http://[::1]:3000/callback'])] + #[TestDox('a redirect URI on https or a loopback host is accepted')] + public function testAcceptsASecureRedirectUri(string $redirectUri): void + { + $this->assertSame($redirectUri, $this->configurationOf(OAuth::forApplication('My App')->setRedirectUri($redirectUri))->redirectUri); + } + #[TestDox('the redirect URI the handler listens on is the one registered')] public function testRedirectUriIsUsedForRegistration(): void { From f138d847b107154a6d21a322fb50b6ce5e3e3605 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 01:46:19 +0100 Subject: [PATCH 37/60] [Client] Remove OAuth::setResource() --- src/Client/Auth/OAuth.php | 12 -------- src/Client/Auth/OAuthAuthenticator.php | 30 +++++++++---------- src/Client/Auth/OAuthConfiguration.php | 3 -- .../Client/Auth/OAuthAuthenticatorTest.php | 30 ------------------- 4 files changed, 15 insertions(+), 60 deletions(-) diff --git a/src/Client/Auth/OAuth.php b/src/Client/Auth/OAuth.php index ef33b81da..063441103 100644 --- a/src/Client/Auth/OAuth.php +++ b/src/Client/Auth/OAuth.php @@ -45,7 +45,6 @@ final class OAuth private array $scopes = []; private bool $offlineAccess = true; private bool $legacyDiscovery = false; - private ?string $resource = null; private ?CredentialStorageInterface $storage = null; private ?AuthorizationHandlerInterface $authorizationHandler = null; @@ -134,16 +133,6 @@ public function setLegacyDiscovery(bool $legacyDiscovery): self 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"). */ @@ -235,7 +224,6 @@ public function build(): OAuthAuthenticator $this->scopes, $this->offlineAccess, $this->legacyDiscovery, - $this->resource, $this->expectedIssuer, ); diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 967eb5419..3b2bc9c4e 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -30,7 +30,7 @@ final class OAuthAuthenticator implements AuthenticatorInterface private ?AccessToken $token = null; /** - * The resource the current token may be sent to, which an overridden $resource doesn't change. + * The resource the current token may be sent to, which stays behind $resource when an authorization fails. */ private ?string $audience = null; @@ -80,7 +80,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re 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); + [$resource, $metadata, $scopesSupported] = $this->discover($endpoint, $challenge); if (null !== $this->configuration->expectedIssuer && $metadata->issuer !== $this->configuration->expectedIssuer) { throw new AuthorizationException(\sprintf('The server at "%s" names the authorization server "%s", but only "%s" is expected.', $endpoint, $metadata->issuer, $this->configuration->expectedIssuer)); @@ -99,7 +99,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re $this->issuer = $metadata->issuer; $this->resource = $resource; - $stored = $this->storage->getToken($metadata->issuer, $audience); + $stored = $this->storage->getToken($metadata->issuer, $resource); $scopes = $this->selectScopes($challenge, $scopesSupported, $metadata, $stored); // The resource never requires offline_access, which only gets a refresh token. $needed = array_values(array_diff($scopes, ['offline_access'])); @@ -107,7 +107,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re if (null !== $stored && $this->isUsable($stored, $needed, $request)) { $this->logger->debug('Reusing a stored access token', ['issuer' => $metadata->issuer, 'resource' => $resource]); $this->token = $stored; - $this->audience = $audience; + $this->audience = $resource; return true; } @@ -117,7 +117,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re $refreshable = 'insufficient_scope' === $challenge->getError() ? null : $stored; try { - $token = $this->refresh($refreshable, $metadata, $client, $needed, $resource, $audience, $request); + $token = $this->refresh($refreshable, $metadata, $client, $needed, $resource, $request); } catch (OAuthErrorException) { $this->logger->info('The authorization server no longer knows this client; registering it again.', ['issuer' => $metadata->issuer]); $this->storage->forget($metadata->issuer); @@ -138,27 +138,27 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re } $token = $token->withGrantedScopes($scopes); - $this->storage->saveToken($metadata->issuer, $audience, $token); + $this->storage->saveToken($metadata->issuer, $resource, $token); } $this->token = $token; - $this->audience = $audience; + $this->audience = $resource; $this->requestedScopes = $scopes; return true; } /** - * Find the requested resource, the token's audience and the server, on every challenge as a resource may move. + * Find the resource and the server, on every challenge as a resource may move. * - * @return array{string, string, AuthorizationServerMetadata, ?string[]} + * @return array{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]; + return [$endpoint, $this->discoverLegacyServer($endpoint), null]; } if (!$metadata->covers($endpoint)) { @@ -179,7 +179,7 @@ private function discover(string $endpoint, AuthorizationChallenge $challenge): 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]; + return [$metadata->resource, $server, $metadata->scopesSupported]; } private static function isForgottenClient(AuthorizationException $e, ClientRegistration $client): bool @@ -299,7 +299,7 @@ private function isUsable(AccessToken $token, array $scopes, RequestInterface $r * * @param string[] $scopes */ - private function refresh(?AccessToken $stored, AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes, string $resource, string $audience, RequestInterface $request): ?AccessToken + private function refresh(?AccessToken $stored, AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes, string $resource, RequestInterface $request): ?AccessToken { if (null === $stored || null === $stored->refreshToken) { return null; @@ -310,9 +310,9 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta return null; } - return $this->storage->synchronize(function () use ($metadata, $client, $scopes, $resource, $audience, $request): ?AccessToken { + return $this->storage->synchronize(function () use ($metadata, $client, $scopes, $resource, $request): ?AccessToken { // Another process may have refreshed the token while this one waited. - $current = $this->storage->getToken($metadata->issuer, $audience); + $current = $this->storage->getToken($metadata->issuer, $resource); if (null === $current) { return null; @@ -329,7 +329,7 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta $token = $this->spendRefreshToken($current, $current->refreshToken, $metadata, $client, $resource); if (null !== $token) { - $this->storage->saveToken($metadata->issuer, $audience, $token); + $this->storage->saveToken($metadata->issuer, $resource, $token); } return $token; diff --git a/src/Client/Auth/OAuthConfiguration.php b/src/Client/Auth/OAuthConfiguration.php index f5deaaf4e..a2031fd47 100644 --- a/src/Client/Auth/OAuthConfiguration.php +++ b/src/Client/Auth/OAuthConfiguration.php @@ -31,8 +31,6 @@ final class OAuthConfiguration * @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 ?string $expectedIssuer the only authorization server to authorize with * * @throws InvalidArgumentException when the redirect URI is insecure, or a client id is set without an expected issuer @@ -45,7 +43,6 @@ public function __construct( public readonly array $scopes = [], public readonly bool $offlineAccess = true, public readonly bool $legacyDiscovery = false, - public readonly ?string $resource = null, public readonly ?string $expectedIssuer = null, ) { if (!AuthorizationServerMetadata::isTransportSecure($redirectUri)) { diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 323d17dff..4a56cab99 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -531,20 +531,6 @@ public function testRefusesAnInsecureIssuerWithoutFetchingIt(): void } } - // 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 { @@ -986,22 +972,6 @@ public function testStoresARefreshedTokenWhileSynchronized(): void $this->assertSame([true], $storage->savedWhileSynchronized); } - #[TestDox('a stored token stays with its server when the resource parameter is overridden')] - public function testStoredTokenOfAnOverriddenResourceStaysWithItsServer(): void - { - $server = new FakeOAuthServer(); - $storage = new InMemoryCredentialStorage(); - $this->authenticator($server, storage: $storage, configuration: OAuth::forApplication('test')->setResource('https://api.example.com/v1')) - ->handleChallenge($this->request(), $server->challenge()); - - $server->resource = 'https://mcp.example.com/other'; - $otherServer = (new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/other'); - $authenticator = $this->authenticator($server, storage: $storage, configuration: OAuth::forApplication('test')->setResource('https://api.example.com/v1')); - $authenticator->handleChallenge($otherServer, $server->challenge()); - - $this->assertSame('Bearer access-token-2', $authenticator->authenticate($otherServer)->getHeaderLine('Authorization')); - } - #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From a5890bf9d64f9a17050fe4bf75f4a631e4e2be47 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:00:19 +0100 Subject: [PATCH 38/60] [Client] Refuse loopback targets named by a remote server A remote MCP server could name http://127.0.0.1:port as its authorization server, and a remote authorization server could name loopback endpoints. The client then sent requests to services that only listen on the user's machine, including a POST with the registration or token request. Only a server on loopback may now name loopback, whatever the scheme. An application that does expect a loopback issuer can allow it with setExpectedIssuer(). --- docs/client/authorization.md | 1 + .../Auth/AuthorizationServerMetadata.php | 5 ++++ src/Client/Auth/OAuthAuthenticator.php | 6 ++++ src/Server/Transport/Http/OAuth/SecureUrl.php | 10 +++++++ .../Auth/AuthorizationServerMetadataTest.php | 30 +++++++++++++++++++ .../Client/Auth/OAuthAuthenticatorTest.php | 29 ++++++++++++++++++ 6 files changed, 81 insertions(+) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 8aadcabb9..dafce06e0 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -143,6 +143,7 @@ The client treats the MCP server and its metadata as untrusted input. It stops w | 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 | +| A server that isn't on loopback names a loopback authorization server or endpoint, unless `setExpectedIssuer()` allows that issuer | It could reach services that only listen on this machine | | The authorization server metadata doesn't list `S256` in `code_challenge_methods_supported` | The client can't use PKCE with this server | | 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 | diff --git a/src/Client/Auth/AuthorizationServerMetadata.php b/src/Client/Auth/AuthorizationServerMetadata.php index 5dabf5516..68bc79b63 100644 --- a/src/Client/Auth/AuthorizationServerMetadata.php +++ b/src/Client/Auth/AuthorizationServerMetadata.php @@ -83,6 +83,11 @@ public static function fromArray(array $data): self 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])); } + // Only a server on this machine may send the client back to this machine. + if (SecureUrl::isLoopback($data[$endpoint]) && \is_string($data['issuer'] ?? null) && !SecureUrl::isLoopback($data['issuer'])) { + throw new InvalidArgumentException(\sprintf('The authorization server metadata of "%s" declares "%s" as "%s", which is a loopback address.', $data['issuer'], $endpoint, $data[$endpoint])); + } + $parts = SecureUrl::parse($data[$endpoint], $endpoint); // RFC 6749 sections 3.1 and 3.2 rule out fragments; RFC 9110 section 4.2.4 treats untrusted userinfo as an error. diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 3b2bc9c4e..ddcdf058a 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -13,6 +13,7 @@ use Mcp\Exception\AuthorizationException; use Mcp\Exception\OAuthErrorException; +use Mcp\Server\Transport\Http\OAuth\SecureUrl; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; @@ -173,6 +174,11 @@ private function discover(string $endpoint, AuthorizationChallenge $challenge): 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)); } + // Even the request is made on the server's behalf, so a remote one can't reach what only listens locally. + if (SecureUrl::isLoopback($issuer) && !SecureUrl::isLoopback($endpoint) && $issuer !== $this->configuration->expectedIssuer) { + throw new AuthorizationException(\sprintf('The protected resource metadata for "%s" names "%s" as its authorization server, which is a loopback address. Call OAuth::setExpectedIssuer() if that is intended.', $endpoint, $issuer)); + } + $server = $this->discovery->discoverAuthorizationServer($issuer); if (null === $server) { diff --git a/src/Server/Transport/Http/OAuth/SecureUrl.php b/src/Server/Transport/Http/OAuth/SecureUrl.php index 53c5baeaf..94cbb3ce2 100644 --- a/src/Server/Transport/Http/OAuth/SecureUrl.php +++ b/src/Server/Transport/Http/OAuth/SecureUrl.php @@ -47,6 +47,16 @@ public static function parse(mixed $url, string $label): array return $parts; } + /** + * Whether a URL points at the machine it is requested from, on any scheme. + */ + public static function isLoopback(string $url): bool + { + $host = parse_url($url, \PHP_URL_HOST); + + return \is_string($host) && self::isLoopbackHost($host); + } + private static function isLoopbackHost(string $host): bool { $host = strtolower(trim($host, '[]')); diff --git a/tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php b/tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php index c89165932..da816a7b9 100644 --- a/tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php +++ b/tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php @@ -15,6 +15,7 @@ use Mcp\Exception\InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; final class AuthorizationServerMetadataTest extends TestCase @@ -106,6 +107,35 @@ public function testRejectsAnInsecureUnusedAuthorizationEndpoint(): void ]); } + #[TestWith(['token_endpoint'])] + #[TestWith(['authorization_endpoint'])] + #[TestWith(['registration_endpoint'])] + #[TestDox('a remote authorization server can not name a loopback endpoint')] + public function testRejectsALoopbackEndpointOfARemoteServer(string $endpoint): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('loopback'); + + AuthorizationServerMetadata::fromArray([ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + $endpoint => 'http://127.0.0.1:9000/x', + ]); + } + + #[TestDox('an authorization server on loopback can name loopback endpoints')] + public function testAcceptsLoopbackEndpointsOfALoopbackServer(): void + { + $metadata = AuthorizationServerMetadata::fromArray([ + 'issuer' => 'http://127.0.0.1:9000', + 'authorization_endpoint' => 'http://127.0.0.1:9000/authorize', + 'token_endpoint' => 'http://localhost:9000/token', + ]); + + $this->assertSame('http://localhost:9000/token', $metadata->tokenEndpoint); + } + #[TestDox('an unstated grant list means the authorization code grant, and nothing else')] public function testDefaultGrantTypes(): void { diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 4a56cab99..be4244a2e 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -545,6 +545,35 @@ public function testSkipsRegistrationForAKnownClient(): void $this->assertSame('known-client', $server->authorization['client_id'] ?? null); } + /** + * @return iterable + */ + public static function loopbackIssuerProvider(): iterable + { + yield 'an IPv4 address' => ['http://127.0.0.1:9000']; + yield 'another IPv4 loopback address' => ['http://127.1.2.3:9000']; + yield 'an IPv6 address' => ['http://[::1]:9000']; + yield 'localhost' => ['http://localhost:9000']; + yield 'a name below localhost' => ['http://admin.localhost:9000']; + yield 'https on loopback' => ['https://127.0.0.1:9000']; + } + + #[DataProvider('loopbackIssuerProvider')] + #[TestDox('a remote server can not make the client request its own loopback: $_dataName')] + public function testRefusesALoopbackIssuerOfARemoteServer(string $issuer): void + { + $server = new FakeOAuthServer(authorizationServer: $issuer); + + try { + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge()); + $this->fail('The loopback authorization server was not refused.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('loopback', $e->getMessage()); + } + + $this->assertSame([], array_filter($server->requested, static fn (string $url): bool => str_starts_with($url, $issuer))); + } + #[TestDox('a server on plain http is refused before anything is fetched')] public function testRefusesAnInsecureServer(): void { From e87c1a922b6848a8f2eb21958edad7c6b29dbb4d Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:06:09 +0100 Subject: [PATCH 39/60] [Client] Report any failure of a challenge as an authorization failure --- src/Client/Auth/AuthenticatingHttpClient.php | 8 ++++++- .../Auth/AuthenticatingHttpClientTest.php | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Client/Auth/AuthenticatingHttpClient.php b/src/Client/Auth/AuthenticatingHttpClient.php index f13fa0c1f..1a074789a 100644 --- a/src/Client/Auth/AuthenticatingHttpClient.php +++ b/src/Client/Auth/AuthenticatingHttpClient.php @@ -12,6 +12,7 @@ namespace Mcp\Client\Auth; use Mcp\Client\Transport\NoRedirects; +use Mcp\Exception\AuthorizationException; use Mcp\Exception\InvalidArgumentException; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; @@ -68,10 +69,15 @@ public function sendRequest(RequestInterface $request): ResponseInterface // authenticator needs to see which of them the server just rejected. try { $retry = $this->authenticator->handleChallenge($sent, $response); - } catch (\Throwable $e) { + } catch (AuthorizationException $e) { $response->getBody()->close(); throw $e; + } catch (\Throwable $e) { + $response->getBody()->close(); + + // Anything else counts as a connection problem, and retrying that means signing in again. + throw new AuthorizationException('The authorization failed: '.$e->getMessage(), 0, $e); } if (!$retry) { diff --git a/tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php b/tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php index f2bb5b4e9..974081f13 100644 --- a/tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php +++ b/tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php @@ -163,6 +163,27 @@ public function testClosesTheRejectedResponseWhenTheChallengeThrows(): void $this->assertFalse($rejected->getBody()->isReadable()); } + #[TestDox('any failure of a challenge is an authorization failure, so nothing retries the sign-in')] + public function testAnyFailureOfAChallengeIsAnAuthorizationFailure(): void + { + $failure = new \RuntimeException('Could not write the credential file.'); + $inner = $this->createStub(ClientInterface::class); + $inner->method('sendRequest')->willReturn(new Response(401)); + + $authenticator = $this->createStub(AuthenticatorInterface::class); + $authenticator->method('authenticate')->willReturnArgument(0); + $authenticator->method('handleChallenge')->willThrowException($failure); + + try { + (new AuthenticatingHttpClient($inner, $authenticator)) + ->sendRequest((new Psr17Factory())->createRequest('POST', 'https://mcp.example.com/mcp')); + $this->fail('The failure should have been reported.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('Could not write the credential file.', $e->getMessage()); + $this->assertSame($failure, $e->getPrevious()); + } + } + #[TestDox('a failed challenge reaches a caller that catches the PSR-18 exception')] public function testFailedChallengeIsAClientException(): void { From 2df487d3b6edc5f3047bed7b9c2d19fb7b4996ee Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:06:20 +0100 Subject: [PATCH 40/60] [Client] Refuse a token that does not fit in a header and ignore an unusable expiry --- src/Client/Auth/AccessToken.php | 18 ++++++++++++++--- tests/Unit/Client/Auth/AccessTokenTest.php | 23 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/Client/Auth/AccessToken.php b/src/Client/Auth/AccessToken.php index b8eebfbee..7ce52fb70 100644 --- a/src/Client/Auth/AccessToken.php +++ b/src/Client/Auth/AccessToken.php @@ -42,6 +42,10 @@ public function __construct( if ('' === trim($accessToken)) { throw new InvalidArgumentException('The access token must not be empty.'); } + + if (1 !== preg_match('/^[\x20-\x7E]+$/D', $accessToken)) { + throw new InvalidArgumentException('The access token contains characters that are not allowed.'); + } } /** @@ -55,17 +59,25 @@ public static function fromResponse(array $response, ?int $now = null): self 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, + self::expiresAt($response['expires_in'] ?? null, $now ?? time()), \is_string($response['refresh_token'] ?? null) ? $response['refresh_token'] : null, \is_string($response['scope'] ?? null) ? self::splitScopes($response['scope']) : [], ); } + // The resource server decides about a token that was just issued, so an unusable value is no reason to drop it. + private static function expiresAt(mixed $expiresIn, int $now): ?int + { + if (!is_numeric($expiresIn) || (float) $expiresIn <= 0.0 || (float) $expiresIn >= (float) (\PHP_INT_MAX - $now)) { + return null; + } + + return $now + (int) $expiresIn; + } + public function isExpired(?int $now = null): bool { if (null === $this->expiresAt) { diff --git a/tests/Unit/Client/Auth/AccessTokenTest.php b/tests/Unit/Client/Auth/AccessTokenTest.php index d15d81230..47ad6e801 100644 --- a/tests/Unit/Client/Auth/AccessTokenTest.php +++ b/tests/Unit/Client/Auth/AccessTokenTest.php @@ -14,6 +14,7 @@ use Mcp\Client\Auth\AccessToken; use Mcp\Exception\InvalidArgumentException; use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; final class AccessTokenTest extends TestCase @@ -52,6 +53,28 @@ public function testRejectsResponseWithoutToken(): void AccessToken::fromResponse(['token_type' => 'Bearer']); } + #[TestWith([\PHP_INT_MAX])] + #[TestWith(['99999999999999999999'])] + #[TestWith(['1e30'])] + #[TestWith([\INF])] + #[TestWith([0])] + #[TestWith([-1])] + #[TestDox('an expires_in that gives no usable expiry counts as not given')] + public function testIgnoresAnUnusableExpiry(mixed $expiresIn): void + { + $this->assertNull(AccessToken::fromResponse(['access_token' => 'abc', 'expires_in' => $expiresIn], 1000)->expiresAt); + } + + #[TestWith(["abc\r\nX-Evil: 1"])] + #[TestWith(["abc\x7f"])] + #[TestDox('a token that does not fit in a header is rejected')] + public function testRejectsATokenThatDoesNotFitInAHeader(string $accessToken): void + { + $this->expectException(InvalidArgumentException::class); + + new AccessToken($accessToken); + } + #[TestDox('a token expires at its expiry')] public function testExpires(): void { From bca1dbaa380e5326b1df1f0ccc71c2a7804bdcc2 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:06:31 +0100 Subject: [PATCH 41/60] [Client] Only accept and send Bearer tokens --- docs/client/authorization.md | 1 + src/Client/Auth/AccessToken.php | 6 +++++- tests/Unit/Client/Auth/AccessTokenTest.php | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index dafce06e0..baef86df4 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -147,6 +147,7 @@ The client treats the MCP server and its metadata as untrusted input. It stops w | The authorization server metadata doesn't list `S256` in `code_challenge_methods_supported` | The client can't use PKCE with this server | | 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 token response has another `token_type` than `Bearer` | The client can only send bearer tokens | | The authorization server has another issuer than `setExpectedIssuer()` | It would receive the client's credentials | | With `setLegacyDiscovery(true)`, a metadata location answers anything but a `404` and gives no usable metadata | The client only uses the fixed endpoints after a `404` | | The server has no protected resource metadata, without `setLegacyDiscovery(true)` | There is nothing to check the authorization server against | diff --git a/src/Client/Auth/AccessToken.php b/src/Client/Auth/AccessToken.php index 7ce52fb70..5ec6c6d35 100644 --- a/src/Client/Auth/AccessToken.php +++ b/src/Client/Auth/AccessToken.php @@ -46,6 +46,10 @@ public function __construct( if (1 !== preg_match('/^[\x20-\x7E]+$/D', $accessToken)) { throw new InvalidArgumentException('The access token contains characters that are not allowed.'); } + + if (0 !== strcasecmp('Bearer', $tokenType)) { + throw new InvalidArgumentException(\sprintf('The token type "%s" is not supported, only "Bearer" is.', $tokenType)); + } } /** @@ -123,7 +127,7 @@ public function withGrantedScopes(array $scopes): self public function getAuthorizationHeader(): string { - return $this->tokenType.' '.$this->accessToken; + return 'Bearer '.$this->accessToken; } /** diff --git a/tests/Unit/Client/Auth/AccessTokenTest.php b/tests/Unit/Client/Auth/AccessTokenTest.php index 47ad6e801..aa27b540d 100644 --- a/tests/Unit/Client/Auth/AccessTokenTest.php +++ b/tests/Unit/Client/Auth/AccessTokenTest.php @@ -75,6 +75,25 @@ public function testRejectsATokenThatDoesNotFitInAHeader(string $accessToken): v new AccessToken($accessToken); } + #[TestWith(['mac'])] + #[TestWith(['DPoP'])] + #[TestWith([''])] + #[TestWith(["Bearer\r\nX-Evil: 1"])] + #[TestDox('a token type other than Bearer is rejected')] + public function testRejectsAnotherTokenType(string $tokenType): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('token type'); + + new AccessToken('abc', $tokenType); + } + + #[TestDox('the token type is matched case-insensitively and always sent as Bearer')] + public function testAlwaysSendsBearer(): void + { + $this->assertSame('Bearer abc', (new AccessToken('abc', 'bearer'))->getAuthorizationHeader()); + } + #[TestDox('a token expires at its expiry')] public function testExpires(): void { From 820f79fc024f42bd4edcd084118ce0a7565f194c Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:06:43 +0100 Subject: [PATCH 42/60] [Client] Ignore an auth method that is not a string --- src/Client/Auth/ClientRegistration.php | 9 +++++++-- tests/Unit/Client/Auth/ClientRegistrationTest.php | 7 +++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Client/Auth/ClientRegistration.php b/src/Client/Auth/ClientRegistration.php index d5c41c1d3..e82dfdf0e 100644 --- a/src/Client/Auth/ClientRegistration.php +++ b/src/Client/Auth/ClientRegistration.php @@ -60,7 +60,7 @@ public static function fromResponse(array $response, TokenEndpointAuthMethod $fa // Without a secret there is nothing to authenticate with, whatever the method says. $method = null === $secret ? TokenEndpointAuthMethod::None - : TokenEndpointAuthMethod::tryFrom((string) ($response['token_endpoint_auth_method'] ?? '')) ?? $fallback; + : self::authMethod($response['token_endpoint_auth_method'] ?? null) ?? $fallback; $expiresAt = $response['client_secret_expires_at'] ?? null; @@ -74,6 +74,11 @@ public static function fromResponse(array $response, TokenEndpointAuthMethod $fa ); } + private static function authMethod(mixed $value): ?TokenEndpointAuthMethod + { + return \is_string($value) ? TokenEndpointAuthMethod::tryFrom($value) : null; + } + public function isExpired(?int $now = null): bool { if (null === $this->clientSecretExpiresAt || 0 === $this->clientSecretExpiresAt) { @@ -106,7 +111,7 @@ 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, + self::authMethod($data['token_endpoint_auth_method'] ?? null) ?? 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, array_values(array_filter((array) ($data['redirect_uris'] ?? []), 'is_string')), diff --git a/tests/Unit/Client/Auth/ClientRegistrationTest.php b/tests/Unit/Client/Auth/ClientRegistrationTest.php index 0940b590a..4c6ca2aa3 100644 --- a/tests/Unit/Client/Auth/ClientRegistrationTest.php +++ b/tests/Unit/Client/Auth/ClientRegistrationTest.php @@ -88,6 +88,13 @@ public function testSecretExpiry(): void $this->assertTrue($registration->isExpired(1001)); } + #[TestDox('an auth method that is not a string counts as not given')] + public function testIgnoresAnAuthMethodThatIsNotAString(): void + { + $this->assertSame(TokenEndpointAuthMethod::ClientSecretPost, ClientRegistration::fromResponse(['client_id' => 'issued', 'client_secret' => 'secret', 'token_endpoint_auth_method' => ['basic']], TokenEndpointAuthMethod::ClientSecretPost)->tokenEndpointAuthMethod); + $this->assertSame(TokenEndpointAuthMethod::None, ClientRegistration::fromArray(['client_id' => 'issued', 'token_endpoint_auth_method' => ['basic']])->tokenEndpointAuthMethod); + } + #[TestDox('an expiry of 0 never expires, however the registration was created')] public function testExpiryOfZeroNeverExpires(): void { From 9f5cd9ab37e7e4bcdbbb74eb0d2be1e9da8c8ac1 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:06:55 +0100 Subject: [PATCH 43/60] [Client] Only request offline_access when it is enabled and supported --- docs/client/authorization.md | 2 +- src/Client/Auth/OAuthAuthenticator.php | 10 ++--- .../Client/Auth/OAuthAuthenticatorTest.php | 42 +++++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index baef86df4..cbae4e67f 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -114,7 +114,7 @@ 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. With any other `403`, the client doesn't authorize 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. +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)` to never request this scope. The server can still issue a refresh token, and the client then uses it. ## Connecting to Older Servers diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index ddcdf058a..d700d8ca4 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -261,21 +261,21 @@ private function selectScopes(AuthorizationChallenge $challenge, ?array $scopesS $scopes = $this->configuration->scopes; if ([] === $scopes) { - $scopes = $challenge->getScopes() ?: ($scopesSupported ?? []); + // What a server names is no reason to ask for a refresh token. + $scopes = array_diff($challenge->getScopes() ?: ($scopesSupported ?? []), ['offline_access']); } elseif ('insufficient_scope' === $challenge->getError()) { - $scopes = [...$scopes, ...$challenge->getScopes()]; + $scopes = [...$scopes, ...array_diff($challenge->getScopes(), ['offline_access'])]; } $held = 'insufficient_scope' === $challenge->getError() ? array_diff($stored->scopes ?? [], ['offline_access']) : []; $scopes = array_values(array_unique([...$this->requestedScopes, ...$held, ...$scopes])); - if ($this->offlineAccessRefused) { + if ($this->offlineAccessRefused || !$this->configuration->offlineAccess) { return array_values(array_diff($scopes, ['offline_access'])); } // An unknown scope is an error, so it is only requested where the server lists it. - if ($this->configuration->offlineAccess - && $metadata->supportsScope('offline_access') + if ($metadata->supportsScope('offline_access') && $metadata->supportsGrant(Grant::RefreshToken->value) && !\in_array('offline_access', $scopes, true) ) { diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index be4244a2e..bf970f410 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -1001,6 +1001,48 @@ public function testStoresARefreshedTokenWhileSynchronized(): void $this->assertSame([true], $storage->savedWhileSynchronized); } + #[TestDox('offline_access named by the resource is not requested when turned off')] + public function testTurningOffOfflineAccessBeatsTheResource(): void + { + $server = new FakeOAuthServer(resourceScopes: ['mcp:read', 'offline_access'], serverScopes: ['mcp:read', 'offline_access']); + + $this->authenticator($server, configuration: OAuth::forApplication('test')->setOfflineAccess(false))->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('mcp:read', $server->authorization['scope'] ?? null); + } + + #[TestDox('offline_access named by a challenge is not requested when turned off')] + public function testTurningOffOfflineAccessBeatsTheChallenge(): void + { + $server = new FakeOAuthServer(serverScopes: ['mcp:read', 'offline_access']); + + $this->authenticator($server, configuration: OAuth::forApplication('test')->setOfflineAccess(false))->handleChallenge($this->request(), $server->challenge(['mcp:read', 'offline_access'])); + + $this->assertSame('mcp:read', $server->authorization['scope'] ?? null); + } + + #[TestDox('offline_access named by the resource is not requested if the authorization server does not list it')] + public function testOfflineAccessNeedsTheAuthorizationServerToListIt(): void + { + $server = new FakeOAuthServer(resourceScopes: ['mcp:read', 'offline_access'], serverScopes: ['mcp:read']); + + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('mcp:read', $server->authorization['scope'] ?? null); + } + + #[TestDox('offline_access asked for explicitly is requested, unless it is turned off')] + public function testExplicitOfflineAccess(): void + { + $server = new FakeOAuthServer(serverScopes: ['mcp:read']); + $this->authenticator($server, configuration: OAuth::forApplication('test')->setScopes('offline_access'))->handleChallenge($this->request(), $server->challenge()); + $this->assertSame('offline_access', $server->authorization['scope'] ?? null); + + $server = new FakeOAuthServer(serverScopes: ['mcp:read']); + $this->authenticator($server, configuration: OAuth::forApplication('test')->setScopes('offline_access', 'mcp:read')->setOfflineAccess(false))->handleChallenge($this->request(), $server->challenge()); + $this->assertSame('mcp:read', $server->authorization['scope'] ?? null); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From ed168c83c463fd5ee32bab00fe8cf1b11a605460 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:07:07 +0100 Subject: [PATCH 44/60] [Client] Not authorize again for scopes it already requested --- docs/client/authorization.md | 2 +- src/Client/Auth/OAuthAuthenticator.php | 11 +++++++- .../Client/Auth/OAuthAuthenticatorTest.php | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index cbae4e67f..59c21abaf 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -112,7 +112,7 @@ By default, the client requests the scopes of the challenge. If there are none, 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. With any other `403`, the client doesn't authorize again. +If the server answers with `insufficient_scope`, the client adds the scopes of this challenge and authorizes again. It doesn't authorize again for scopes that it already requested. With any other `403`, the client doesn't authorize 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)` to never request this scope. The server can still issue a refresh token, and the client then uses it. diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index d700d8ca4..85b40312e 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -100,6 +100,15 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re $this->issuer = $metadata->issuer; $this->resource = $resource; + // Signing in again can't help if this token holds or already asked for all the server wants. + if ('insufficient_scope' === $challenge->getError() + && null !== $this->token + && $request->getHeaderLine('Authorization') === $this->token->getAuthorizationHeader() + && [] === array_diff($challenge->getScopes(), ['offline_access'], $this->token->scopes, $this->requestedScopes) + ) { + return false; + } + $stored = $this->storage->getToken($metadata->issuer, $resource); $scopes = $this->selectScopes($challenge, $scopesSupported, $metadata, $stored); // The resource never requires offline_access, which only gets a refresh token. @@ -139,12 +148,12 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re } $token = $token->withGrantedScopes($scopes); + $this->requestedScopes = $scopes; $this->storage->saveToken($metadata->issuer, $resource, $token); } $this->token = $token; $this->audience = $resource; - $this->requestedScopes = $scopes; return true; } diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index bf970f410..86354012b 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -1043,6 +1043,33 @@ public function testExplicitOfflineAccess(): void $this->assertSame('mcp:read', $server->authorization['scope'] ?? null); } + #[TestDox('an insufficient_scope for scopes the token already holds does not send the user back')] + public function testDoesNotAuthorizeAgainForScopesItAlreadyAskedFor(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server); + $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + $server->authorization = []; + + $retry = $authenticator->handleChallenge($authenticator->authenticate($this->request()), $server->challenge(['mcp:read'], 403, 'insufficient_scope')); + + $this->assertFalse($retry); + $this->assertSame([], $server->authorization); + } + + #[TestDox('an insufficient_scope for a new scope still sends the user back')] + public function testAuthorizesAgainForANewScope(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server); + $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + + $retry = $authenticator->handleChallenge($authenticator->authenticate($this->request()), $server->challenge(['mcp:write'], 403, 'insufficient_scope')); + + $this->assertTrue($retry); + $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From 1f6e50be47859dbc77816e33f74eaaf1e1df425d Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:08:13 +0100 Subject: [PATCH 45/60] [Client] Keep the refresh token through an outage of the token endpoint --- docs/client/authorization.md | 2 +- src/Client/Auth/OAuthAuthenticator.php | 3 +- src/Client/Auth/TokenEndpoint.php | 14 ++++-- .../TokenEndpointUnavailableException.php | 23 ++++++++++ .../Client/Auth/OAuthAuthenticatorTest.php | 44 +++++++++++++++++++ 5 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 src/Exception/TokenEndpointUnavailableException.php diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 59c21abaf..b845376a1 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -176,4 +176,4 @@ try { } ``` -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. +After `connect()`, requests throw the `AuthorizationException` directly. The client doesn't retry a failed authorization. If the token endpoint is unavailable during a refresh, the request fails and the client keeps the refresh token. Pass a PSR-3 logger with `setLogger()` to see each step. diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 85b40312e..eaf7589d0 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -13,6 +13,7 @@ use Mcp\Exception\AuthorizationException; use Mcp\Exception\OAuthErrorException; +use Mcp\Exception\TokenEndpointUnavailableException; use Mcp\Server\Transport\Http\OAuth\SecureUrl; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -360,7 +361,7 @@ private function spendRefreshToken(AccessToken $stored, string $refreshToken, Au 'resource' => $resource, ])); } catch (AuthorizationException $e) { - if (self::isForgottenClient($e, $client)) { + if ($e instanceof TokenEndpointUnavailableException || self::isForgottenClient($e, $client)) { throw $e; } diff --git a/src/Client/Auth/TokenEndpoint.php b/src/Client/Auth/TokenEndpoint.php index 41a97d122..f455aa279 100644 --- a/src/Client/Auth/TokenEndpoint.php +++ b/src/Client/Auth/TokenEndpoint.php @@ -14,6 +14,7 @@ use Mcp\Exception\AuthorizationException; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\OAuthErrorException; +use Mcp\Exception\TokenEndpointUnavailableException; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; @@ -97,7 +98,7 @@ private function post(string $endpoint, array $parameters, ?string $authorizatio 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); + throw new TokenEndpointUnavailableException(\sprintf('The token request to "%s" failed: %s', $endpoint, $e->getMessage()), 0, $e); } $decoded = json_decode(ResponseBody::read($response) ?? '', true); @@ -108,8 +109,15 @@ private function post(string $endpoint, array $parameters, ?string $authorizatio 200 === $response->getStatusCode() => ', and a body this client could not read as JSON', \is_array($decoded) => ': '.self::describeError($decoded), default => '', }); - if (\is_array($decoded) && \is_string($decoded['error'] ?? null)) { - throw new OAuthErrorException($message, $decoded['error']); + $status = $response->getStatusCode(); + $error = \is_array($decoded) ? ($decoded['error'] ?? null) : null; + + if ($status >= 500 || 408 === $status || 429 === $status || \in_array($error, ['temporarily_unavailable', 'server_error'], true)) { + throw new TokenEndpointUnavailableException($message); + } + + if (\is_string($error)) { + throw new OAuthErrorException($message, $error); } throw new AuthorizationException($message); diff --git a/src/Exception/TokenEndpointUnavailableException.php b/src/Exception/TokenEndpointUnavailableException.php new file mode 100644 index 000000000..fbee01bde --- /dev/null +++ b/src/Exception/TokenEndpointUnavailableException.php @@ -0,0 +1,23 @@ + + */ +final class TokenEndpointUnavailableException extends AuthorizationException +{ +} diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 86354012b..72e0972e2 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -1070,6 +1070,44 @@ public function testAuthorizesAgainForANewScope(): void $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); } + #[TestWith([503, ''])] + #[TestWith([500, '{"error":"server_error"}'])] + #[TestWith([429, ''])] + #[TestWith([400, '{"error":"temporarily_unavailable"}'])] + #[TestDox('an outage of the token endpoint keeps the refresh token and does not send the user to the browser')] + public function testAnOutageDoesNotStartANewAuthorization(int $status, string $body): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $server->refreshAnswer = new Response($status, [], $body); + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('expired', 'Bearer', time() - 60, 'still-good')); + + try { + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge()); + $this->fail('The outage should have been reported.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString((string) $status, $e->getMessage()); + } + + $this->assertSame([], $server->authorization); + $this->assertSame('still-good', $storage->getToken('https://auth.example.com', self::ENDPOINT)?->refreshToken); + } + + #[TestWith(['invalid_grant'])] + #[TestWith(['unsupported_grant_type'])] + #[TestDox('a refused refresh token starts a new authorization')] + public function testARefusedRefreshTokenStartsANewAuthorization(string $error): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $server->refreshAnswer = new Response(400, [], (string) json_encode(['error' => $error])); + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('expired', 'Bearer', time() - 60, 'revoked')); + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge()); + + $this->assertNotSame([], $server->authorization); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { @@ -1293,6 +1331,8 @@ final class FakeOAuthServer implements ClientInterface public int $expiresIn = 3600; + public ?ResponseInterface $refreshAnswer = null; + /** @var ?string[] */ public ?array $grantTypes = ['authorization_code', 'refresh_token']; @@ -1427,6 +1467,10 @@ private function issueToken(array $form): ResponseInterface return self::json(['error' => $this->codeError], 400); } + if (null !== $this->refreshAnswer && 'refresh_token' === $form['grant_type']) { + return $this->refreshAnswer; + } + if (!$this->refreshTokens) { return self::json(['access_token' => 'access-token-'.++$this->issued, 'token_type' => 'Bearer', 'expires_in' => $this->expiresIn]); } From be3bd3a99277efbe7dd08a1d8f8e4c7bc94f6f0f Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:08:28 +0100 Subject: [PATCH 46/60] [Client] Not start a sign-in to close a session or open the listening stream --- src/Client/Auth/AuthenticatingHttpClient.php | 8 +++++++ src/Client/Transport/HttpTransport.php | 14 +++++++++++-- .../Transport/HttpTransportListenTest.php | 19 +++++++++++++++++ .../Client/Transport/HttpTransportTest.php | 21 +++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/Client/Auth/AuthenticatingHttpClient.php b/src/Client/Auth/AuthenticatingHttpClient.php index 1a074789a..bbba82386 100644 --- a/src/Client/Auth/AuthenticatingHttpClient.php +++ b/src/Client/Auth/AuthenticatingHttpClient.php @@ -52,6 +52,14 @@ public function __construct( $this->client = NoRedirects::apply($client); } + /** + * Send a request that isn't worth a new authorization, such as closing a session. + */ + public function sendUnchallenged(RequestInterface $request): ResponseInterface + { + return $this->client->sendRequest($this->authenticator->authenticate($request)); + } + public function sendRequest(RequestInterface $request): ResponseInterface { $body = $request->getBody(); diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index e03ec48fd..140cc5640 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -27,6 +27,7 @@ use Psr\Clock\ClockInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\StreamInterface; @@ -162,6 +163,15 @@ public function __construct( $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } + private function sendUnchallenged(RequestInterface $request): ResponseInterface + { + if ($this->httpClient instanceof AuthenticatingHttpClient) { + return $this->httpClient->sendUnchallenged($request); + } + + return $this->httpClient->sendRequest($request); + } + public function connect(): void { $this->resetSseState(); @@ -554,7 +564,7 @@ public function close(): void $request = $request->withHeader($name, $value); } - $response = $this->httpClient->sendRequest($request); + $response = $this->sendUnchallenged($request); if (null !== $redirect = self::describeRedirect($response)) { $response->getBody()->close(); @@ -656,7 +666,7 @@ private function openListenStream(): void } try { - $response = $this->httpClient->sendRequest($request); + $response = $this->sendUnchallenged($request); } catch (\Throwable $e) { $this->logger->warning('Could not open the listening stream', ['exception' => $e]); diff --git a/tests/Unit/Client/Transport/HttpTransportListenTest.php b/tests/Unit/Client/Transport/HttpTransportListenTest.php index 740ea39cc..6fee820e8 100644 --- a/tests/Unit/Client/Transport/HttpTransportListenTest.php +++ b/tests/Unit/Client/Transport/HttpTransportListenTest.php @@ -12,6 +12,7 @@ namespace Mcp\Tests\Unit\Client\Transport; use Mcp\Client; +use Mcp\Client\Auth\AuthenticatorInterface; use Mcp\Client\Handler\Request\ListRootsRequestHandler; use Mcp\Client\Handler\Request\RootsCallbackInterface; use Mcp\Client\Transport\HttpTransport; @@ -70,6 +71,24 @@ public function testNoListenStreamByDefault(): void $client->disconnect(); } + #[TestDox('the optional listening stream never starts a sign-in')] + public function testListenStreamDoesNotHandleAChallenge(): void + { + $server = new FakeListeningServer(getStatus: 401); + $factory = new Psr17Factory(); + $auth = $this->createMock(AuthenticatorInterface::class); + $auth->method('authenticate')->willReturnArgument(0); + $auth->expects($this->never())->method('handleChallenge'); + + $client = Client::builder()->setClientInfo('test-client', '1.0.0')->setProtocolVersion(ProtocolVersion::V2025_11_25)->setInitTimeout(2)->setRequestTimeout(2)->build(); + $client->connect(new HttpTransport('https://example.test/mcp', [], $server, $factory, $factory, auth: $auth, listen: true)); + + $this->assertTrue($client->isConnected()); + $this->assertCount(1, $server->requests('GET')); + + $client->disconnect(); + } + #[TestDox('a server without a listening stream (405) leaves the connection usable')] public function testServerWithoutListenStream(): void { diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 323c20a96..9131352fa 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -14,6 +14,7 @@ use Http\Discovery\Psr18ClientDiscovery; use Http\Discovery\Strategy\DiscoveryStrategy; use Mcp\Client; +use Mcp\Client\Auth\AuthenticatorInterface; use Mcp\Client\Auth\BearerToken; use Mcp\Client\CancellationTokenInterface; use Mcp\Client\Configuration; @@ -1006,6 +1007,26 @@ public function testRedirectOfCloseIsReported(): void $this->assertStringContainsString('https://other.test/mcp', $errors[0]->getMessage()); } + #[TestDox('closing a session never starts a sign-in, and still sends the token')] + public function testCloseDoesNotHandleAChallenge(): void + { + $httpClient = RecordingHttpClient::queued([ + new Response(200, ['Content-Type' => 'application/json', 'Mcp-Session-Id' => 'session'], '{"jsonrpc":"2.0","id":1,"result":{}}'), + new Response(401), + ]); + $auth = $this->createMock(AuthenticatorInterface::class); + $auth->method('authenticate')->willReturnCallback(static fn (RequestInterface $request): RequestInterface => $request->withHeader('Authorization', 'Bearer token')); + $auth->expects($this->never())->method('handleChallenge'); + + $transport = new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory, auth: $auth); + $transport->send('{"jsonrpc":"2.0","id":1,"method":"initialize"}'); + $transport->close(); + + $delete = $httpClient->requests[1]; + $this->assertSame('DELETE', $delete->getMethod()); + $this->assertSame('Bearer token', $delete->getHeaderLine('Authorization')); + } + #[TestDox('a reconnect answered with 404 expires the session like any session-bound request')] public function testReconnectOnExpiredSessionInvalidatesConnection(): void { From 5da0bcefb0c242e53a45f26112e9be8d0f5fb927 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:08:41 +0100 Subject: [PATCH 47/60] [Client] Refuse a redirect URI the loopback handler cannot serve --- docs/client/authorization.md | 2 +- .../Auth/LoopbackAuthorizationHandler.php | 6 ++++- .../Client/Auth/AuthorizationHandlerTest.php | 22 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index b845376a1..300340737 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -69,7 +69,7 @@ final class AcmeAuthorizationHandler implements AuthorizationHandlerInterface } ``` -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. The URI must use `https` or a loopback host (e.g. `127.0.0.1`). Otherwise, `build()` throws an `InvalidArgumentException`. +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 URI must use `https` or a loopback host (e.g. `127.0.0.1`). Otherwise, `build()` throws an `InvalidArgumentException`. The loopback handler listens on the host and port of this URI, so it only works with `http` on a loopback host. For an `https` URI, implement your own handler. ## Storing Credentials diff --git a/src/Client/Auth/LoopbackAuthorizationHandler.php b/src/Client/Auth/LoopbackAuthorizationHandler.php index 39086cc90..e3663e993 100644 --- a/src/Client/Auth/LoopbackAuthorizationHandler.php +++ b/src/Client/Auth/LoopbackAuthorizationHandler.php @@ -40,9 +40,13 @@ public function __construct( public function authorize(string $authorizationUrl, string $redirectUri): array { + if ('http' !== strtolower((string) parse_url($redirectUri, \PHP_URL_SCHEME)) || !AuthorizationServerMetadata::isTransportSecure($redirectUri)) { + throw new AuthorizationException(\sprintf('The loopback handler serves plain HTTP on a loopback host, so it cannot receive "%s". Use such a redirect URI or your own authorization handler.', $redirectUri)); + } + $parts = parse_url($redirectUri); $host = $parts['host'] ?? '127.0.0.1'; - $port = $parts['port'] ?? ('https' === ($parts['scheme'] ?? 'http') ? 443 : 80); + $port = $parts['port'] ?? 80; $callbackPath = $parts['path'] ?? '/'; parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query); diff --git a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php index db404ddf8..1ef6bb5bd 100644 --- a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php +++ b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php @@ -15,6 +15,7 @@ use Mcp\Client\Auth\LoopbackAuthorizationHandler; use Mcp\Exception\AuthorizationException; use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; /** @@ -195,6 +196,27 @@ public function testLoopbackHandlerOpensTheUrlWithoutAShellOnWindows(): void /** * Runs in its own process, as only the environment decides which opener the handler finds. */ + #[TestWith(['https://127.0.0.1:8765/callback'])] + #[TestWith(['https://app.example.com/callback'])] + #[TestWith(['http://0.0.0.0:8765/callback'])] + #[TestDox('the loopback handler refuses a redirect URI it cannot serve, before opening the browser')] + public function testLoopbackHandlerRefusesARedirectUriItCannotServe(string $redirectUri): void + { + $opened = false; + $handler = new LoopbackAuthorizationHandler(static function () use (&$opened): void { + $opened = true; + }, 1); + + try { + $handler->authorize('https://auth.example.com/authorize?state=abc', $redirectUri); + $this->fail('The redirect URI should have been refused.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('loopback handler', $e->getMessage()); + } + + $this->assertFalse($opened); + } + #[TestDox('the headless handler stops reading a header block that never ends')] public function testHeadlessHandlerLimitsTheSizeOfTheHeaders(): void { From 871dc56dfbbe63bc7094dbba9296af61c0e03428 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:08:52 +0100 Subject: [PATCH 48/60] [Client] Remove what only tests use from Pkce --- src/Client/Auth/Pkce.php | 5 ----- tests/Unit/Client/Auth/OAuthAuthenticatorTest.php | 3 +-- tests/Unit/Client/Auth/PkceTest.php | 8 -------- 3 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/Client/Auth/Pkce.php b/src/Client/Auth/Pkce.php index f84169b9b..df7765df2 100644 --- a/src/Client/Auth/Pkce.php +++ b/src/Client/Auth/Pkce.php @@ -35,11 +35,6 @@ public static function generate(): self 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/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 72e0972e2..35374461f 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -21,7 +21,6 @@ use Mcp\Client\Auth\OAuth; use Mcp\Client\Auth\OAuthAuthenticator; use Mcp\Client\Auth\OAuthConfiguration; -use Mcp\Client\Auth\Pkce; use Mcp\Client\Auth\TokenEndpoint; use Mcp\Exception\AuthorizationException; use Nyholm\Psr7\Factory\Psr17Factory; @@ -77,7 +76,7 @@ public function testPkceVerifierMatchesTheChallenge(): void $this->assertSame('S256', $server->authorization['code_challenge_method'] ?? null); $this->assertSame( $server->authorization['code_challenge'] ?? null, - Pkce::fromVerifier($server->token['code_verifier'] ?? '')->challenge, + rtrim(strtr(base64_encode(hash('sha256', $server->token['code_verifier'] ?? '', true)), '+/', '-_'), '='), ); } diff --git a/tests/Unit/Client/Auth/PkceTest.php b/tests/Unit/Client/Auth/PkceTest.php index 9d1b60588..a76a7e844 100644 --- a/tests/Unit/Client/Auth/PkceTest.php +++ b/tests/Unit/Client/Auth/PkceTest.php @@ -42,12 +42,4 @@ 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); - } } From e52b7b66c2f1101c9b97b7853533866495f37376 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:13:28 +0100 Subject: [PATCH 49/60] [Client] Test that the expected issuer allows a loopback authorization server --- docs/client/authorization.md | 2 ++ .../Unit/Client/Auth/OAuthAuthenticatorTest.php | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 300340737..d8893f764 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -152,6 +152,8 @@ The client treats the MCP server and its metadata as untrusted input. It stops w | With `setLegacyDiscovery(true)`, a metadata location answers anything but a `404` and gives no usable metadata | The client only uses the fixed endpoints after a `404` | | The server has no protected resource metadata, without `setLegacyDiscovery(true)` | There is nothing to check the authorization server against | +If you run the client on a server, block requests to private networks in your HTTP client or proxy (e.g. with Symfony's `NoPrivateNetworkHttpClient`) and pass that client to `setHttpClient()`. + 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. A `resource` can name a parent path of the MCP server. All paths below it then belong to the resource, and the client can send the token to them. If several servers share a host, give each one its own host (e.g. `tenant-a.example.com`). diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 35374461f..515dc0358 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -573,6 +573,22 @@ public function testRefusesALoopbackIssuerOfARemoteServer(string $issuer): void $this->assertSame([], array_filter($server->requested, static fn (string $url): bool => str_starts_with($url, $issuer))); } + #[TestDox('an application can allow a loopback authorization server with the expected issuer')] + public function testExpectedIssuerAllowsALoopbackIssuer(): void + { + $server = new FakeOAuthServer(authorizationServer: 'http://127.0.0.1:2375'); + $configuration = OAuth::forApplication('test')->setClientCredentials('known-client')->setExpectedIssuer('http://127.0.0.1:2375'); + + try { + $this->authenticator($server, configuration: $configuration)->handleChallenge($this->request(), $server->challenge()); + $this->fail('The fake server has no metadata for this issuer.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('No usable metadata', $e->getMessage()); + } + + $this->assertNotSame([], array_filter($server->requested, static fn (string $url): bool => str_contains($url, '127.0.0.1'))); + } + #[TestDox('a server on plain http is refused before anything is fetched')] public function testRefusesAnInsecureServer(): void { From 762d39a396b92709133a2d95d96859e02d62c1a5 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:13:43 +0100 Subject: [PATCH 50/60] [Client] Refuse an unidentified authorization endpoint on another origin A hostile MCP server can name itself as the issuer, with the authorization endpoint of a real server and its own token endpoint. The user signs in at the real address, and if that server sends no iss, nothing shows that the response isn't from the issuer the flow was started with. The code and the PKCE verifier then go to the hostile token endpoint. An authorization endpoint on another origin than the issuer now needs the metadata to announce iss, which makes the response prove where it comes from, or an expected issuer. Servers on one origin are not affected. --- docs/client/authorization.md | 1 + src/Client/Auth/OAuthAuthenticator.php | 21 +++++++++ .../Client/Auth/OAuthAuthenticatorTest.php | 47 ++++++++++++++++++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index d8893f764..65bd38677 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -145,6 +145,7 @@ The client treats the MCP server and its metadata as untrusted input. It stops w | 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 | | A server that isn't on loopback names a loopback authorization server or endpoint, unless `setExpectedIssuer()` allows that issuer | It could reach services that only listen on this machine | | The authorization server metadata doesn't list `S256` in `code_challenge_methods_supported` | The client can't use PKCE with this server | +| The authorization endpoint is on another origin than the issuer and the metadata doesn't announce `iss`, without `setExpectedIssuer()` | A response could come from another server, and the code would go to this one | | 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 token response has another `token_type` than `Bearer` | The client can only send bearer tokens | diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index eaf7589d0..59b76f300 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -411,6 +411,7 @@ private function grantWithFallback(AuthorizationServerMetadata $metadata, Client private function grant(AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes, string $resource): AccessToken { self::assertSupportsPkce($metadata); + $this->assertIdentifiesItsResponses($metadata); $pkce = Pkce::generate(); $state = rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '='); @@ -508,6 +509,7 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco } self::assertSupportsPkce($metadata); + $this->assertIdentifiesItsResponses($metadata); $registration = $this->registrar->register( $metadata, @@ -539,6 +541,25 @@ private static function withoutEmptyValues(array $parameters): array return $values; } + /** + * Refuse an authorization endpoint that may belong to another server, unless its responses can prove they don't. + * + * A hostile server can name the endpoint of a real one, and keep the token endpoint. The user signs in at the real + * address, and the code goes to the hostile server unless the response names its issuer (RFC 9700 section 4.4). + */ + private function assertIdentifiesItsResponses(AuthorizationServerMetadata $metadata): void + { + if (null !== $this->configuration->expectedIssuer + || $metadata->issuerParameterSupported + || null === $metadata->authorizationEndpoint + || self::origin($metadata->authorizationEndpoint) === self::origin($metadata->issuer) + ) { + return; + } + + throw new AuthorizationException(\sprintf('The authorization server "%s" has its authorization endpoint "%s" on another origin and does not announce the iss parameter, so a response could come from another server. Call OAuth::setExpectedIssuer() if you trust this authorization server.', $metadata->issuer, $metadata->authorizationEndpoint)); + } + private static function assertSupportsPkce(AuthorizationServerMetadata $metadata): void { if (!$metadata->supportsPkce()) { diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 515dc0358..dd79424a8 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -573,6 +573,47 @@ public function testRefusesALoopbackIssuerOfARemoteServer(string $issuer): void $this->assertSame([], array_filter($server->requested, static fn (string $url): bool => str_starts_with($url, $issuer))); } + #[TestDox('an authorization endpoint on another origin needs iss, so a relayed code can not leave the real server')] + public function testRefusesAnAuthorizationEndpointOnAnotherOriginWithoutIss(): void + { + $server = new FakeOAuthServer(authorizationEndpoint: 'https://login.example.com/authorize', announcesIssuer: false); + + try { + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge()); + $this->fail('The authorization endpoint on another origin was not refused.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('setExpectedIssuer', $e->getMessage()); + } + + $this->assertNotContains('https://auth.example.com/register', $server->requested); + $this->assertSame([], $server->authorization); + } + + #[TestDox('an authorization endpoint on another origin is fine with iss')] + public function testAcceptsAnAuthorizationEndpointOnAnotherOriginWithIss(): void + { + $server = new FakeOAuthServer(authorizationEndpoint: 'https://login.example.com/authorize'); + + $this->assertTrue($this->authenticator($server)->handleChallenge($this->request(), $server->challenge())); + } + + #[TestDox('an authorization endpoint on another origin is fine with an expected issuer')] + public function testAcceptsAnAuthorizationEndpointOnAnotherOriginWithAnExpectedIssuer(): void + { + $server = new FakeOAuthServer(authorizationEndpoint: 'https://login.example.com/authorize', announcesIssuer: false); + $configuration = OAuth::forApplication('test')->setExpectedIssuer('https://auth.example.com'); + + $this->assertTrue($this->authenticator($server, configuration: $configuration)->handleChallenge($this->request(), $server->challenge())); + } + + #[TestDox('an authorization endpoint on the origin of the issuer needs no iss')] + public function testAcceptsAnAuthorizationEndpointOnTheSameOriginWithoutIss(): void + { + $server = new FakeOAuthServer(announcesIssuer: false); + + $this->assertTrue($this->authenticator($server)->handleChallenge($this->request(), $server->challenge())); + } + #[TestDox('an application can allow a loopback authorization server with the expected issuer')] public function testExpectedIssuerAllowsALoopbackIssuer(): void { @@ -1369,6 +1410,8 @@ public function __construct( private readonly ?string $codeError = null, private readonly bool $hasAuthorizationEndpoint = true, private readonly ?array $codeChallengeMethods = ['S256'], + private readonly ?string $authorizationEndpoint = null, + private readonly bool $announcesIssuer = true, ) { } @@ -1413,13 +1456,13 @@ public function sendRequest(RequestInterface $request): ResponseInterface '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, + 'authorization_endpoint' => null !== $this->authorizationEndpoint ? $this->authorizationEndpoint : ($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 ? $this->grantTypes : ['client_credentials'], 'code_challenge_methods_supported' => $this->codeChallengeMethods, - 'authorization_response_iss_parameter_supported' => true, + 'authorization_response_iss_parameter_supported' => $this->announcesIssuer, ]), 'https://auth.example.com/register' => $this->register((string) $request->getBody()), From be9420780947e7b38152350735ef238112fe63d5 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:37:47 +0100 Subject: [PATCH 51/60] [Client] Remove control characters from authorization errors An error description or a token type comes from a server, and the exception message quotes it. A terminal escape sequence or a line break in it could change what an application prints, or forge a line in its log. --- src/Client/Auth/AccessToken.php | 2 +- src/Exception/AuthorizationException.php | 5 +++ .../Exception/AuthorizationExceptionTest.php | 39 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Exception/AuthorizationExceptionTest.php diff --git a/src/Client/Auth/AccessToken.php b/src/Client/Auth/AccessToken.php index 5ec6c6d35..3fa492708 100644 --- a/src/Client/Auth/AccessToken.php +++ b/src/Client/Auth/AccessToken.php @@ -48,7 +48,7 @@ public function __construct( } if (0 !== strcasecmp('Bearer', $tokenType)) { - throw new InvalidArgumentException(\sprintf('The token type "%s" is not supported, only "Bearer" is.', $tokenType)); + throw new InvalidArgumentException('The token type is not supported, only "Bearer" is.'); } } diff --git a/src/Exception/AuthorizationException.php b/src/Exception/AuthorizationException.php index e91702e27..6a101c7cf 100644 --- a/src/Exception/AuthorizationException.php +++ b/src/Exception/AuthorizationException.php @@ -20,4 +20,9 @@ */ class AuthorizationException extends \RuntimeException implements ExceptionInterface, ClientExceptionInterface { + public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null) + { + // A message can quote a server, which must not write to the terminal or the log that shows it. + parent::__construct((string) preg_replace('/(?:[\x00-\x1F\x7F]|\xC2[\x80-\x9F])+/', ' ', $message), $code, $previous); + } } diff --git a/tests/Unit/Exception/AuthorizationExceptionTest.php b/tests/Unit/Exception/AuthorizationExceptionTest.php new file mode 100644 index 000000000..2cdd62f05 --- /dev/null +++ b/tests/Unit/Exception/AuthorizationExceptionTest.php @@ -0,0 +1,39 @@ + + */ + public static function messageProvider(): iterable + { + yield 'a terminal escape sequence' => ["denied: \x1b[2Jsafe", 'denied: [2Jsafe']; + yield 'a forged log line' => ["denied\r\nforged line", 'denied forged line']; + yield 'a C1 control character' => ["denied \u{9B}2J", 'denied 2J']; + yield 'a delete character' => ["de\x7Fnied", 'de nied']; + yield 'text in other scripts' => ['verweigert: Größe 日本語', 'verweigert: Größe 日本語']; + } + + #[DataProvider('messageProvider')] + #[TestDox('a message can not write control characters: $_dataName')] + public function testRemovesControlCharacters(string $message, string $expected): void + { + $this->assertSame($expected, (new AuthorizationException($message))->getMessage()); + } +} From 626d0812699942c3dcf3a7bc39f3ad4c4fb000e1 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 02:40:45 +0100 Subject: [PATCH 52/60] [Client] Document that a passed-in HTTP client must not follow redirects The SDK can't see the redirects of a client that follows them, so an authorization server could send a code or the client credentials anywhere. --- docs/client/authorization.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 65bd38677..645dbc289 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -155,6 +155,9 @@ The client treats the MCP server and its metadata as untrusted input. It stops w If you run the client on a server, block requests to private networks in your HTTP client or proxy (e.g. with Symfony's `NoPrivateNetworkHttpClient`) and pass that client to `setHttpClient()`. +!!! warning + A client that you pass to `setHttpClient()` *must not* follow redirects. The client only turns them off on a `Psr18Client` of `symfony/http-client` 6.2 or later. With redirects on, an authorization server can send the authorization code and the client credentials to any address. + 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. A `resource` can name a parent path of the MCP server. All paths below it then belong to the resource, and the client can send the token to them. If several servers share a host, give each one its own host (e.g. `tenant-a.example.com`). From e72956481f9dacbd91feb20dbef540ffad42561e Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 23:10:29 +0100 Subject: [PATCH 53/60] [Client] Ask for the resource scopes on an insufficient_scope without scopes --- src/Client/Auth/OAuthAuthenticator.php | 12 ++++----- .../Client/Auth/OAuthAuthenticatorTest.php | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 59b76f300..96a25ad4b 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -101,20 +101,20 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re $this->issuer = $metadata->issuer; $this->resource = $resource; + $stored = $this->storage->getToken($metadata->issuer, $resource); + $scopes = $this->selectScopes($challenge, $scopesSupported, $metadata, $stored); + // The resource never requires offline_access, which only gets a refresh token. + $needed = array_values(array_diff($scopes, ['offline_access'])); + // Signing in again can't help if this token holds or already asked for all the server wants. if ('insufficient_scope' === $challenge->getError() && null !== $this->token && $request->getHeaderLine('Authorization') === $this->token->getAuthorizationHeader() - && [] === array_diff($challenge->getScopes(), ['offline_access'], $this->token->scopes, $this->requestedScopes) + && [] === array_diff($needed, $this->token->scopes, $this->requestedScopes) ) { return false; } - $stored = $this->storage->getToken($metadata->issuer, $resource); - $scopes = $this->selectScopes($challenge, $scopesSupported, $metadata, $stored); - // The resource never requires offline_access, which only gets a refresh token. - $needed = array_values(array_diff($scopes, ['offline_access'])); - if (null !== $stored && $this->isUsable($stored, $needed, $request)) { $this->logger->debug('Reusing a stored access token', ['issuer' => $metadata->issuer, 'resource' => $resource]); $this->token = $stored; diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index dd79424a8..bce08c7f4 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -1164,6 +1164,33 @@ public function testARefusedRefreshTokenStartsANewAuthorization(string $error): $this->assertNotSame([], $server->authorization); } + #[TestDox('an insufficient_scope without scopes asks for the scopes of the resource')] + public function testHintlessStepUpUsesTheResourceScopes(): void + { + $server = new FakeOAuthServer(resourceScopes: ['mcp:read', 'mcp:write']); + $authenticator = $this->authenticator($server); + $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + + $retry = $authenticator->handleChallenge($authenticator->authenticate($this->request()), $server->challenge([], 403, 'insufficient_scope')); + + $this->assertTrue($retry); + $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); + } + + #[TestDox('an insufficient_scope without scopes does not send the user back for scopes the token holds')] + public function testHintlessStepUpStopsWhenNothingIsMissing(): void + { + $server = new FakeOAuthServer(resourceScopes: ['mcp:read']); + $authenticator = $this->authenticator($server); + $authenticator->handleChallenge($this->request(), $server->challenge()); + $server->authorization = []; + + $retry = $authenticator->handleChallenge($authenticator->authenticate($this->request()), $server->challenge([], 403, 'insufficient_scope')); + + $this->assertFalse($retry); + $this->assertSame([], $server->authorization); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From 2a5968b351635b056fcbd81251a74e6e777c59a7 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 23:10:40 +0100 Subject: [PATCH 54/60] [Client] Treat an explicit default port as the same origin --- src/Client/Auth/OAuthAuthenticator.php | 10 ++++++++-- .../Client/Auth/OAuthAuthenticatorTest.php | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 96a25ad4b..51c4f3640 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -594,8 +594,14 @@ private static function origin(string $url): string return $url; } - $origin = strtolower(($parts['scheme'] ?? 'https').'://'.($parts['host'] ?? '')); + $scheme = strtolower($parts['scheme'] ?? 'https'); + $origin = $scheme.'://'.strtolower($parts['host'] ?? ''); + $port = $parts['port'] ?? null; - return isset($parts['port']) ? $origin.':'.$parts['port'] : $origin; + if (null === $port || $port === (['https' => 443, 'http' => 80][$scheme] ?? null)) { + return $origin; + } + + return $origin.':'.$port; } } diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index bce08c7f4..826cc05ec 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -1191,6 +1191,25 @@ public function testHintlessStepUpStopsWhenNothingIsMissing(): void $this->assertSame([], $server->authorization); } + #[TestDox('an authorization endpoint with an explicit default port is on the same origin')] + public function testExplicitDefaultPortOfTheAuthorizationEndpoint(): void + { + $server = new FakeOAuthServer(authorizationEndpoint: 'https://auth.example.com:443/authorize', announcesIssuer: false); + + $this->assertTrue($this->authenticator($server)->handleChallenge($this->request(), $server->challenge())); + } + + #[TestDox('a metadata URL with an explicit default port is on the same origin')] + public function testExplicitDefaultPortOfTheMetadataUrl(): void + { + $server = new FakeOAuthServer(); + $challenge = new Response(401, ['WWW-Authenticate' => 'Bearer resource_metadata="https://mcp.example.com:443/custom/location.json"']); + + $this->authenticator($server)->handleChallenge($this->request(), $challenge); + + $this->assertSame('https://mcp.example.com/custom/location.json', $server->requested[0] ?? null); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From 03dd52a7ab18f89910db517ba0f8797d95588bd9 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 23:10:52 +0100 Subject: [PATCH 55/60] [Client] Register the scopes the resource may ask for later --- src/Client/Auth/OAuthAuthenticator.php | 14 +++++++++----- tests/Unit/Client/Auth/OAuthAuthenticatorTest.php | 11 +++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 51c4f3640..eb461f321 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -123,7 +123,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re return true; } - $client = $this->resolveClient($metadata, $scopes); + $client = $this->resolveClient($metadata, $scopes, $scopesSupported); // A refresh can't widen a grant (RFC 6749 section 6), so a step-up needs a new authorization. $refreshable = 'insufficient_scope' === $challenge->getError() ? null : $stored; @@ -133,7 +133,7 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re $this->logger->info('The authorization server no longer knows this client; registering it again.', ['issuer' => $metadata->issuer]); $this->storage->forget($metadata->issuer); $token = null; - $client = $this->resolveClient($metadata, $scopes); + $client = $this->resolveClient($metadata, $scopes, $scopesSupported); } if (null === $token) { @@ -485,9 +485,10 @@ private function verifyCallback(array $callback, string $state, AuthorizationSer /** * Work out which client identity to use with this authorization server. * - * @param string[] $scopes + * @param string[] $scopes + * @param ?string[] $scopesSupported */ - private function resolveClient(AuthorizationServerMetadata $metadata, array $scopes): ClientRegistration + private function resolveClient(AuthorizationServerMetadata $metadata, array $scopes, ?array $scopesSupported): ClientRegistration { $configuration = $this->configuration; @@ -511,11 +512,14 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco self::assertSupportsPkce($metadata); $this->assertIdentifiesItsResponses($metadata); + // A registration limits what the client may ever request (RFC 7591 section 2), so it covers a later step-up. + $registered = array_values(array_unique([...$scopes, ...array_diff($scopesSupported ?? [], ['offline_access'])])); + $registration = $this->registrar->register( $metadata, $configuration->clientMetadata, TokenEndpointAuthMethod::negotiate($metadata->tokenEndpointAuthMethodsSupported, true), - [] === $scopes ? null : implode(' ', $scopes), + [] === $registered ? null : implode(' ', $registered), ); $this->storage->saveClientRegistration($metadata->issuer, $registration); diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 826cc05ec..1bae3c12f 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -1210,6 +1210,17 @@ public function testExplicitDefaultPortOfTheMetadataUrl(): void $this->assertSame('https://mcp.example.com/custom/location.json', $server->requested[0] ?? null); } + #[TestDox('a registration covers the scopes the resource may ask for later')] + public function testRegistersTheScopesOfTheResource(): void + { + $server = new FakeOAuthServer(resourceScopes: ['mcp:read', 'mcp:write', 'offline_access']); + + $this->authenticator($server)->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + + $this->assertSame('mcp:read', $server->authorization['scope'] ?? null); + $this->assertSame('mcp:read mcp:write', $server->registration['scope'] ?? null); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From a0cd0f5404ca7fb8546ba6f2c619e44ec0583640 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 23:14:00 +0100 Subject: [PATCH 56/60] [Client] Send the request of the headless handler with an HTTP client --- docs/client/authorization.md | 4 +- .../Auth/HeadlessAuthorizationHandler.php | 85 +++++------------ .../Client/Auth/AuthorizationHandlerTest.php | 92 ++++++++++--------- 3 files changed, 74 insertions(+), 107 deletions(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 645dbc289..41b2ea765 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -50,7 +50,7 @@ $auth = OAuth::forApplication('My App') 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. It also writes the URL to `STDERR`, so you can open it yourself if no browser starts. 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). +- `HeadlessAuthorizationHandler` requests the authorization URL with an HTTP client 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: @@ -153,7 +153,7 @@ The client treats the MCP server and its metadata as untrusted input. It stops w | With `setLegacyDiscovery(true)`, a metadata location answers anything but a `404` and gives no usable metadata | The client only uses the fixed endpoints after a `404` | | The server has no protected resource metadata, without `setLegacyDiscovery(true)` | There is nothing to check the authorization server against | -If you run the client on a server, block requests to private networks in your HTTP client or proxy (e.g. with Symfony's `NoPrivateNetworkHttpClient`) and pass that client to `setHttpClient()`. +If you run the client on a server, block requests to private networks in your HTTP client or proxy (e.g. with Symfony's `NoPrivateNetworkHttpClient`) and pass that client to `setHttpClient()`. Pass it to `HeadlessAuthorizationHandler` as well, which sends its own request to the authorization endpoint. !!! warning A client that you pass to `setHttpClient()` *must not* follow redirects. The client only turns them off on a `Psr18Client` of `symfony/http-client` 6.2 or later. With redirects on, an authorization server can send the authorization code and the client credentials to any address. diff --git a/src/Client/Auth/HeadlessAuthorizationHandler.php b/src/Client/Auth/HeadlessAuthorizationHandler.php index 7d3f975b2..6a7a68c0b 100644 --- a/src/Client/Auth/HeadlessAuthorizationHandler.php +++ b/src/Client/Auth/HeadlessAuthorizationHandler.php @@ -11,7 +11,12 @@ namespace Mcp\Client\Auth; +use Http\Discovery\Psr17FactoryDiscovery; +use Mcp\Client\Transport\NoRedirects; use Mcp\Exception\AuthorizationException; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -22,12 +27,20 @@ */ final class HeadlessAuthorizationHandler implements AuthorizationHandlerInterface { - private const MAX_HEADER_BYTES = 65536; + private readonly ClientInterface $httpClient; + private readonly RequestFactoryInterface $requestFactory; + /** + * @param ?ClientInterface $httpClient the client for the request, which must not follow redirects, + * so pass the one given to OAuth::setHttpClient() + */ public function __construct( - private readonly float $timeout = 10.0, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, private readonly LoggerInterface $logger = new NullLogger(), ) { + $this->httpClient = null === $httpClient ? NoRedirects::discover() : NoRedirects::apply($httpClient); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); } public function authorize(string $authorizationUrl, string $redirectUri): array @@ -55,71 +68,21 @@ public function authorize(string $authorizationUrl, string $redirectUri): array 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' === strtolower($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 { - $deadline = microtime(true) + $this->timeout; - stream_set_timeout($socket, (int) ceil($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")) { - $remaining = $deadline - microtime(true); - - if ($remaining <= 0) { - throw new AuthorizationException(\sprintf('The authorization endpoint "%s" did not answer within %s seconds.', $url, $this->timeout)); - } - - stream_set_timeout($socket, (int) ceil($remaining)); - $chunk = fread($socket, 4096); - - if (false === $chunk || '' === $chunk) { - break; - } - - $headers .= $chunk; - - if (\strlen($headers) > self::MAX_HEADER_BYTES) { - throw new AuthorizationException(\sprintf('The answer of the authorization endpoint "%s" is too large.', $url)); - } - } - } finally { - fclose($socket); + $response = $this->httpClient->sendRequest($this->requestFactory->createRequest('GET', $url)); + } catch (ClientExceptionInterface $e) { + throw new AuthorizationException(\sprintf('Could not reach the authorization endpoint at "%s": %s', $url, $e->getMessage()), 0, $e); } - if (preg_match('/^Location:\s*(\S+)\s*$/im', $headers, $matches)) { - return $matches[1]; + $response->getBody()->close(); + $location = $response->getHeaderLine('Location'); + + if ($response->getStatusCode() < 300 || $response->getStatusCode() >= 400 || '' === $location) { + 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)); } - 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)); + return $location; } } diff --git a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php index 1ef6bb5bd..22c61ed67 100644 --- a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php +++ b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php @@ -14,9 +14,14 @@ use Mcp\Client\Auth\HeadlessAuthorizationHandler; use Mcp\Client\Auth\LoopbackAuthorizationHandler; use Mcp\Exception\AuthorizationException; +use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; /** * The interactive handlers, exercised over a real socket and a real stream rather than a @@ -139,22 +144,51 @@ public function testLoopbackHandlerTimesOut(): void // 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. - // The listener speaks no TLS and never accepts, so only a plain socket can connect and write. - #[TestDox('the headless handler uses TLS for an uppercase HTTPS scheme')] - public function testHeadlessHandlerUsesTlsForAnUppercaseScheme(): void + #[TestDox('the headless handler sends its request with the given HTTP client')] + public function testHeadlessHandlerUsesTheGivenClient(): void { - $listener = stream_socket_server('tcp://127.0.0.1:0'); - $this->assertIsResource($listener); - $port = (int) explode(':', (string) stream_socket_get_name($listener, false))[1]; + $client = new class implements ClientInterface { + /** @var string[] */ + public array $requested = []; - try { - (new HeadlessAuthorizationHandler(1))->authorize(\sprintf('HTTPS://127.0.0.1:%d/authorize', $port), 'http://127.0.0.1:8765/callback'); - $this->fail('The authorization should have failed.'); - } catch (AuthorizationException $e) { - $this->assertStringContainsString('Could not reach', $e->getMessage()); - } finally { - fclose($listener); - } + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requested[] = $request->getMethod().' '.$request->getUri(); + + return new Response(302, ['Location' => 'http://127.0.0.1:8765/callback?code=abc&state=xyz']); + } + }; + + $parameters = (new HeadlessAuthorizationHandler($client))->authorize('https://auth.example.com/authorize?state=xyz', 'http://127.0.0.1:8765/callback'); + + $this->assertSame(['code' => 'abc', 'state' => 'xyz'], $parameters); + $this->assertSame(['GET https://auth.example.com/authorize?state=xyz'], $client->requested); + } + + #[TestDox('the headless handler reports an endpoint it cannot reach')] + public function testHeadlessHandlerReportsAnUnreachableEndpoint(): void + { + $client = $this->createStub(ClientInterface::class); + $client->method('sendRequest')->willThrowException(new class('Connection refused') extends \RuntimeException implements ClientExceptionInterface {}); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Could not reach'); + + (new HeadlessAuthorizationHandler($client))->authorize('https://auth.example.com/authorize', 'http://127.0.0.1:8765/callback'); + } + + #[TestWith([200, []])] + #[TestWith([302, []])] + #[TestDox('the headless handler needs a redirect')] + public function testHeadlessHandlerNeedsARedirectWithALocation(int $status, array $headers): void + { + $client = $this->createStub(ClientInterface::class); + $client->method('sendRequest')->willReturn(new Response($status, $headers)); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('did not answer with a redirect'); + + (new HeadlessAuthorizationHandler($client))->authorize('https://auth.example.com/authorize', 'http://127.0.0.1:8765/callback'); } #[TestDox('the loopback handler shows the URL when no browser can be opened')] @@ -217,36 +251,6 @@ public function testLoopbackHandlerRefusesARedirectUriItCannotServe(string $redi $this->assertFalse($opened); } - #[TestDox('the headless handler stops reading a header block that never ends')] - public function testHeadlessHandlerLimitsTheSizeOfTheHeaders(): void - { - $this->assertHeadlessHandlerFailsOn('fwrite($c, str_repeat("x", 2097152)); sleep(5);', 'too large'); - } - - #[TestDox('the headless handler stops waiting for a server that answers byte by byte')] - public function testHeadlessHandlerLimitsTheTimeForTheHeaders(): void - { - $this->assertHeadlessHandlerFailsOn('for ($i = 0; $i < 25; ++$i) { fwrite($c, "x"); usleep(200000); }', 'within'); - } - - private function assertHeadlessHandlerFailsOn(string $answer, string $message): void - { - $code = '$s = stream_socket_server("tcp://127.0.0.1:0"); echo substr(strrchr(stream_socket_get_name($s, false), ":"), 1), "\n"; $c = stream_socket_accept($s, 5); fread($c, 4096); fwrite($c, "HTTP/1.1 200 OK\r\n"); '.$answer; - $server = proc_open([\PHP_BINARY, '-r', $code], [1 => ['pipe', 'w']], $pipes); - $this->assertIsResource($server); - - try { - $port = (int) fgets($pipes[1]); - (new HeadlessAuthorizationHandler(1))->authorize(\sprintf('http://127.0.0.1:%d/authorize', $port), 'http://127.0.0.1:1/callback'); - $this->fail('The handler should have given up.'); - } catch (AuthorizationException $e) { - $this->assertStringContainsString($message, $e->getMessage()); - } finally { - proc_terminate($server); - proc_close($server); - } - } - private function runDefaultHandler(string $url, string $path): string { if ('Windows' === \PHP_OS_FAMILY) { From c936c3a9e51dcad81a6290fe33bd2a8e55cfe1af Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 23:36:31 +0100 Subject: [PATCH 57/60] [Client] Register a client under the shared lock --- src/Client/Auth/OAuthAuthenticator.php | 35 ++++++------ .../Client/Auth/OAuthAuthenticatorTest.php | 54 +++++++++++++++++++ 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index eb461f321..a71cade5b 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -503,28 +503,31 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco ); } - $stored = $this->storage->getClientRegistration($metadata->issuer); + // Another process may register at the same time, and its tokens would not work with this client. + return $this->storage->synchronize(function () use ($metadata, $configuration, $scopes, $scopesSupported): ClientRegistration { + $stored = $this->storage->getClientRegistration($metadata->issuer); - if (null !== $stored && !$stored->isExpired() && $this->matchesRedirectUris($stored)) { - return $stored; - } + if (null !== $stored && !$stored->isExpired() && $this->matchesRedirectUris($stored)) { + return $stored; + } - self::assertSupportsPkce($metadata); - $this->assertIdentifiesItsResponses($metadata); + self::assertSupportsPkce($metadata); + $this->assertIdentifiesItsResponses($metadata); - // A registration limits what the client may ever request (RFC 7591 section 2), so it covers a later step-up. - $registered = array_values(array_unique([...$scopes, ...array_diff($scopesSupported ?? [], ['offline_access'])])); + // A registration limits what the client may ever request (RFC 7591 section 2), so it covers a later step-up. + $registered = array_values(array_unique([...$scopes, ...array_diff($scopesSupported ?? [], ['offline_access'])])); - $registration = $this->registrar->register( - $metadata, - $configuration->clientMetadata, - TokenEndpointAuthMethod::negotiate($metadata->tokenEndpointAuthMethodsSupported, true), - [] === $registered ? null : implode(' ', $registered), - ); + $registration = $this->registrar->register( + $metadata, + $configuration->clientMetadata, + TokenEndpointAuthMethod::negotiate($metadata->tokenEndpointAuthMethodsSupported, true), + [] === $registered ? null : implode(' ', $registered), + ); - $this->storage->saveClientRegistration($metadata->issuer, $registration); + $this->storage->saveClientRegistration($metadata->issuer, $registration); - return $registration; + return $registration; + }); } /** diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 1bae3c12f..3a75cbbb8 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -22,6 +22,7 @@ use Mcp\Client\Auth\OAuthAuthenticator; use Mcp\Client\Auth\OAuthConfiguration; use Mcp\Client\Auth\TokenEndpoint; +use Mcp\Client\Auth\TokenEndpointAuthMethod; use Mcp\Exception\AuthorizationException; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; @@ -1221,6 +1222,59 @@ public function testRegistersTheScopesOfTheResource(): void $this->assertSame('mcp:read mcp:write', $server->registration['scope'] ?? null); } + #[TestDox('a client registered by another process in the meantime is used instead of registering again')] + public function testUsesAClientRegisteredByAnotherProcess(): void + { + $server = new FakeOAuthServer(); + $storage = new class implements CredentialStorageInterface { + private readonly InMemoryCredentialStorage $storage; + + public function __construct() + { + $this->storage = new InMemoryCredentialStorage(); + } + + public function getClientRegistration(string $issuer): ?ClientRegistration + { + return $this->storage->getClientRegistration($issuer); + } + + public function saveClientRegistration(string $issuer, ClientRegistration $registration): void + { + $this->storage->saveClientRegistration($issuer, $registration); + } + + public function getToken(string $issuer, string $resource): ?AccessToken + { + return $this->storage->getToken($issuer, $resource); + } + + public function saveToken(string $issuer, string $resource, AccessToken $token): void + { + $this->storage->saveToken($issuer, $resource, $token); + } + + public function forget(?string $issuer = null): void + { + $this->storage->forget($issuer); + } + + public function synchronize(callable $operation): mixed + { + if (null === $this->storage->getClientRegistration('https://auth.example.com')) { + $this->storage->saveClientRegistration('https://auth.example.com', new ClientRegistration('other-process-client', null, TokenEndpointAuthMethod::None, true, null, ['http://127.0.0.1:8765/callback'])); + } + + return $operation(); + } + }; + + $this->authenticator($server, storage: $storage)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame([], $server->registration); + $this->assertSame('other-process-client', $server->authorization['client_id'] ?? null); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From 59d9622e9a5020e6589a7439d571c4dc89cd3693 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 23:37:01 +0100 Subject: [PATCH 58/60] [Client] Register again when a step-up goes beyond the registered scopes --- docs/client/authorization.md | 2 +- src/Client/Auth/ClientRegistrar.php | 2 +- src/Client/Auth/ClientRegistration.php | 8 +++++- src/Client/Auth/OAuthAuthenticator.php | 8 ++++-- .../Client/Auth/ClientRegistrationTest.php | 9 ++++++ .../Client/Auth/OAuthAuthenticatorTest.php | 28 +++++++++++++++++++ 6 files changed, 51 insertions(+), 6 deletions(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index 41b2ea765..e2a6cb218 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -86,7 +86,7 @@ $auth = OAuth::forApplication('My App') 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. Windows ignores these modes, so store the file in a directory that only you can read (e.g. your user profile). The SDK also creates two lock files next to it, so processes can share the file and only one of them refreshes a token at a time. To use a keychain or a secrets manager, implement `CredentialStorageInterface`. Its `synchronize()` method must run one operation at a time across all processes that share the storage. -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. It also registers again if you change the redirect URI, or if the token endpoint answers `invalid_client` for a client that it registered. +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. It also registers again if you change the redirect URI, if it needs scopes that its registration doesn't include, or if the token endpoint answers `invalid_client` for a client that it registered. ## Using a Pre-registered Client diff --git a/src/Client/Auth/ClientRegistrar.php b/src/Client/Auth/ClientRegistrar.php index a87e138fb..8c0cd6c3c 100644 --- a/src/Client/Auth/ClientRegistrar.php +++ b/src/Client/Auth/ClientRegistrar.php @@ -76,7 +76,7 @@ public function register( } try { - return ClientRegistration::fromResponse($decoded, $preferredAuthMethod, $clientMetadata->redirectUris); + return ClientRegistration::fromResponse($decoded, $preferredAuthMethod, $clientMetadata->redirectUris, null === $scope ? null : explode(' ', $scope)); } catch (InvalidArgumentException $e) { throw new AuthorizationException(\sprintf('The authorization server sent an invalid client registration response: %s', $e->getMessage()), 0, $e); } diff --git a/src/Client/Auth/ClientRegistration.php b/src/Client/Auth/ClientRegistration.php index e82dfdf0e..9c1779885 100644 --- a/src/Client/Auth/ClientRegistration.php +++ b/src/Client/Auth/ClientRegistration.php @@ -28,6 +28,7 @@ final class ClientRegistration implements \JsonSerializable * and may therefore be re-created at will * @param ?int $clientSecretExpiresAt unix timestamp the secret expires at, 0/null when it does not * @param string[] $redirectUris the redirect URIs this client was registered with, empty when unknown + * @param ?string[] $scopes the scopes this client was registered for, null when it named none */ public function __construct( public readonly string $clientId, @@ -36,6 +37,7 @@ public function __construct( public readonly bool $dynamic = false, public readonly ?int $clientSecretExpiresAt = null, public readonly array $redirectUris = [], + public readonly ?array $scopes = null, ) { if ('' === trim($clientId)) { throw new InvalidArgumentException('The client id must not be empty.'); @@ -48,8 +50,9 @@ public function __construct( * @param array $response the parsed registration response * @param TokenEndpointAuthMethod $fallback the method to assume when the server does not echo one back * @param string[] $redirectUris the redirect URIs that were registered + * @param ?string[] $scopes the scopes that were registered */ - public static function fromResponse(array $response, TokenEndpointAuthMethod $fallback = TokenEndpointAuthMethod::None, array $redirectUris = []): self + public static function fromResponse(array $response, TokenEndpointAuthMethod $fallback = TokenEndpointAuthMethod::None, array $redirectUris = [], ?array $scopes = null): self { if (!isset($response['client_id']) || !\is_string($response['client_id'])) { throw new InvalidArgumentException('The client registration response is missing a string "client_id".'); @@ -71,6 +74,7 @@ public static function fromResponse(array $response, TokenEndpointAuthMethod $fa true, is_numeric($expiresAt) && 0 !== (int) $expiresAt ? (int) $expiresAt : null, $redirectUris, + $scopes, ); } @@ -100,6 +104,7 @@ public function jsonSerialize(): array 'dynamic' => $this->dynamic, 'client_secret_expires_at' => $this->clientSecretExpiresAt, 'redirect_uris' => $this->redirectUris, + 'scopes' => $this->scopes, ], static fn (mixed $value): bool => null !== $value); } @@ -115,6 +120,7 @@ public static function fromArray(array $data): self (bool) ($data['dynamic'] ?? false), isset($data['client_secret_expires_at']) && is_numeric($data['client_secret_expires_at']) ? (int) $data['client_secret_expires_at'] : null, array_values(array_filter((array) ($data['redirect_uris'] ?? []), 'is_string')), + \is_array($data['scopes'] ?? null) ? array_values(array_filter($data['scopes'], 'is_string')) : null, ); } } diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index a71cade5b..7c6cf9dd3 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -507,15 +507,17 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco return $this->storage->synchronize(function () use ($metadata, $configuration, $scopes, $scopesSupported): ClientRegistration { $stored = $this->storage->getClientRegistration($metadata->issuer); - if (null !== $stored && !$stored->isExpired() && $this->matchesRedirectUris($stored)) { + if (null !== $stored && !$stored->isExpired() && $this->matchesRedirectUris($stored) + && (null === $stored->scopes || [] === array_diff($scopes, $stored->scopes)) + ) { return $stored; } self::assertSupportsPkce($metadata); $this->assertIdentifiesItsResponses($metadata); - // A registration limits what the client may ever request (RFC 7591 section 2), so it covers a later step-up. - $registered = array_values(array_unique([...$scopes, ...array_diff($scopesSupported ?? [], ['offline_access'])])); + // A registration limits what the client may ever request (RFC 7591 section 2), so it covers what it may ask for later. + $registered = array_values(array_unique([...$stored->scopes ?? [], ...$scopes, ...array_diff($scopesSupported ?? [], ['offline_access'])])); $registration = $this->registrar->register( $metadata, diff --git a/tests/Unit/Client/Auth/ClientRegistrationTest.php b/tests/Unit/Client/Auth/ClientRegistrationTest.php index 4c6ca2aa3..06cca8713 100644 --- a/tests/Unit/Client/Auth/ClientRegistrationTest.php +++ b/tests/Unit/Client/Auth/ClientRegistrationTest.php @@ -88,6 +88,15 @@ public function testSecretExpiry(): void $this->assertTrue($registration->isExpired(1001)); } + #[TestDox('the registered scopes survive storage, and a registration without them limits nothing')] + public function testKeepsTheRegisteredScopes(): void + { + $registration = new ClientRegistration('issued', null, TokenEndpointAuthMethod::None, true, null, [], ['mcp:read', 'mcp:write']); + + $this->assertSame(['mcp:read', 'mcp:write'], ClientRegistration::fromArray($registration->jsonSerialize())->scopes); + $this->assertNull(ClientRegistration::fromArray(['client_id' => 'issued'])->scopes); + } + #[TestDox('an auth method that is not a string counts as not given')] public function testIgnoresAnAuthMethodThatIsNotAString(): void { diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 3a75cbbb8..27f6f66b5 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -1275,6 +1275,34 @@ public function synchronize(callable $operation): mixed $this->assertSame('other-process-client', $server->authorization['client_id'] ?? null); } + #[TestDox('a step-up beyond the registered scopes registers the client again')] + public function testRegistersAgainForScopesBeyondTheRegistration(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server); + $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + $this->assertSame('mcp:read', $server->registration['scope'] ?? null); + + $authenticator->handleChallenge($authenticator->authenticate($this->request()), $server->challenge(['mcp:write'], 403, 'insufficient_scope')); + + $this->assertSame('mcp:read mcp:write', $server->registration['scope'] ?? null); + $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); + } + + #[TestDox('a registration that covers the scopes of a step-up is kept')] + public function testKeepsARegistrationThatCoversTheStepUp(): void + { + $server = new FakeOAuthServer(resourceScopes: ['mcp:read', 'mcp:write']); + $authenticator = $this->authenticator($server); + $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + $server->registration = []; + + $authenticator->handleChallenge($authenticator->authenticate($this->request()), $server->challenge(['mcp:write'], 403, 'insufficient_scope')); + + $this->assertSame([], $server->registration); + $this->assertSame('mcp:read mcp:write', $server->authorization['scope'] ?? null); + } + #[TestDox('the expected issuer is chosen wherever the resource lists it')] public function testChoosesTheExpectedIssuerFromTheList(): void { From 45c1d7bfac662dcc631196f129d121e7fabe7b39 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 9 Nov 2026 23:59:31 +0100 Subject: [PATCH 59/60] [Client] Refuse a redirect URI with a fragment or userinfo --- docs/client/authorization.md | 2 +- src/Client/Auth/OAuthConfiguration.php | 7 +++++++ tests/Unit/Client/Auth/OAuthTest.php | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/client/authorization.md b/docs/client/authorization.md index e2a6cb218..83c914334 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -69,7 +69,7 @@ final class AcmeAuthorizationHandler implements AuthorizationHandlerInterface } ``` -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 URI must use `https` or a loopback host (e.g. `127.0.0.1`). Otherwise, `build()` throws an `InvalidArgumentException`. The loopback handler listens on the host and port of this URI, so it only works with `http` on a loopback host. For an `https` URI, implement your own handler. +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 URI must use `https` or a loopback host (e.g. `127.0.0.1`), and must not have a fragment or userinfo. Otherwise, `build()` throws an `InvalidArgumentException`. The loopback handler listens on the host and port of this URI, so it only works with `http` on a loopback host. For an `https` URI, implement your own handler. ## Storing Credentials diff --git a/src/Client/Auth/OAuthConfiguration.php b/src/Client/Auth/OAuthConfiguration.php index a2031fd47..5f47818be 100644 --- a/src/Client/Auth/OAuthConfiguration.php +++ b/src/Client/Auth/OAuthConfiguration.php @@ -49,6 +49,13 @@ public function __construct( throw new InvalidArgumentException(\sprintf('The redirect URI "%s" must use https, or http on a loopback host.', $redirectUri)); } + // RFC 6749 section 3.1.2 rules out a fragment, and userinfo has no meaning in a redirect. + $parts = parse_url($redirectUri); + + if (!\is_array($parts) || isset($parts['fragment']) || isset($parts['user']) || isset($parts['pass'])) { + throw new InvalidArgumentException(\sprintf('The redirect URI "%s" must not have a fragment or userinfo.', $redirectUri)); + } + if (null !== $clientId && null === $expectedIssuer) { throw new InvalidArgumentException('A configured client needs an expected issuer, or a hostile MCP server could receive its authorization codes and secret. Call OAuth::setExpectedIssuer().'); } diff --git a/tests/Unit/Client/Auth/OAuthTest.php b/tests/Unit/Client/Auth/OAuthTest.php index a3e578544..59de70d90 100644 --- a/tests/Unit/Client/Auth/OAuthTest.php +++ b/tests/Unit/Client/Auth/OAuthTest.php @@ -105,6 +105,8 @@ public function testDynamicRegistrationNeedsNoExpectedIssuer(): void #[TestWith(['http://0.0.0.0:8765/callback'])] #[TestWith(['http://192.168.1.5:8765/callback'])] #[TestWith(['com.example.app:/callback'])] + #[TestWith(['http://127.0.0.1:8765/callback#fragment'])] + #[TestWith(['http://user:secret@127.0.0.1:8765/callback'])] #[TestDox('a redirect URI must use https or a loopback host')] public function testRefusesAnInsecureRedirectUri(string $redirectUri): void { From b394ff81f56c44e6e1d82641ce24ca0b016562d2 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 10 Nov 2026 00:00:01 +0100 Subject: [PATCH 60/60] [Client] Fail fast when the token endpoint refuses a configured client --- src/Client/Auth/OAuthAuthenticator.php | 16 +++++++++++--- .../Client/Auth/OAuthAuthenticatorTest.php | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 7c6cf9dd3..3485b83cf 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -129,7 +129,11 @@ public function handleChallenge(RequestInterface $request, ResponseInterface $re try { $token = $this->refresh($refreshable, $metadata, $client, $needed, $resource, $request); - } catch (OAuthErrorException) { + } catch (OAuthErrorException $e) { + if (!$client->dynamic) { + throw new OAuthErrorException(\sprintf('The authorization server "%s" answered invalid_client for the configured client "%s". Check the client ID and secret given to setClientCredentials().', $metadata->issuer, $client->clientId), $e->error); + } + $this->logger->info('The authorization server no longer knows this client; registering it again.', ['issuer' => $metadata->issuer]); $this->storage->forget($metadata->issuer); $token = null; @@ -200,7 +204,12 @@ private function discover(string $endpoint, AuthorizationChallenge $challenge): private static function isForgottenClient(AuthorizationException $e, ClientRegistration $client): bool { - return $client->dynamic && $e instanceof OAuthErrorException && 'invalid_client' === $e->error; + return $client->dynamic && self::isRefusedClient($e); + } + + private static function isRefusedClient(AuthorizationException $e): bool + { + return $e instanceof OAuthErrorException && 'invalid_client' === $e->error; } /** @@ -361,7 +370,8 @@ private function spendRefreshToken(AccessToken $stored, string $refreshToken, Au 'resource' => $resource, ])); } catch (AuthorizationException $e) { - if ($e instanceof TokenEndpointUnavailableException || self::isForgottenClient($e, $client)) { + // A refused client would fail the new authorization as well, after the user signed in. + if ($e instanceof TokenEndpointUnavailableException || self::isRefusedClient($e)) { throw $e; } diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 27f6f66b5..9b0da27a0 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -24,6 +24,7 @@ use Mcp\Client\Auth\TokenEndpoint; use Mcp\Client\Auth\TokenEndpointAuthMethod; use Mcp\Exception\AuthorizationException; +use Mcp\Exception\OAuthErrorException; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; @@ -822,6 +823,27 @@ public function testDoesNotReplaceAConfiguredClient(): void $this->assertNotContains('https://auth.example.com/register', $server->requested); } + #[TestDox('a configured client the token endpoint refuses on a refresh fails before the user signs in')] + public function testConfiguredClientRefusedOnRefreshFailsFast(): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $server->unknownClient = 'known-client'; + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('expired', 'Bearer', time() - 60, 'refresh')); + $configuration = OAuth::forApplication('test')->setClientCredentials('known-client')->setExpectedIssuer('https://auth.example.com'); + + try { + $this->authenticator($server, storage: $storage, configuration: $configuration)->handleChallenge($this->request(), $server->challenge()); + $this->fail('The refused client should have failed.'); + } catch (OAuthErrorException $e) { + $this->assertSame('invalid_client', $e->error); + $this->assertStringContainsString('setClientCredentials()', $e->getMessage()); + } + + $this->assertSame([], $server->authorization); + $this->assertNotNull($storage->getToken('https://auth.example.com', self::ENDPOINT)); + } + #[TestDox('an error that only mentions a refused scope in its text does not send the user back')] public function testErrorDescriptionDoesNotTriggerTheOfflineAccessFallback(): void {