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..d786517e4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,11 +6,15 @@ 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 +examples/client/..oauth-credentials.json.refresh.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..a05cd0733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,10 @@ 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). +* `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 `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/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..83c914334 --- /dev/null +++ b/docs/client/authorization.md @@ -0,0 +1,185 @@ +# 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. 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 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: + +```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 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 + +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. 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, 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 + +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 + ->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. 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. + +## 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. 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. + +## 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); +``` + +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 + +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 | +| 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 | +| 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 | + +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. + +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. + +## 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. 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/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..3c85145f1 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. @@ -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`: @@ -185,7 +188,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..93cf6c042 --- /dev/null +++ b/examples/client/oauth_client.php @@ -0,0 +1,75 @@ +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 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')) + ->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..3fa492708 --- /dev/null +++ b/src/Client/Auth/AccessToken.php @@ -0,0 +1,168 @@ + + */ +final class AccessToken implements \JsonSerializable +{ + /** + * 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; + + /** + * @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.'); + } + + 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('The token type is not supported, only "Bearer" is.'); + } + } + + /** + * 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".'); + } + + return new self( + $response['access_token'], + \is_string($response['token_type'] ?? null) ? $response['token_type'] : 'Bearer', + 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) { + return false; + } + + return ($now ?? time()) >= $this->expiresAt; + } + + public function isExpiringSoon(?int $now = null): bool + { + return $this->isExpired(($now ?? time()) + self::EXPIRY_LEEWAY_SECONDS); + } + + /** + * Whether every one of the given scopes was granted. + * + * @param string[] $scopes + */ + public function covers(array $scopes): bool + { + if ([] === $this->scopes) { + // RFC 6749 section 5.1: no reported scope means that what was requested was granted. + return true; + } + + return [] === array_diff($scopes, $this->scopes); + } + + /** + * Record the requested scopes when the server reported none (RFC 6749 section 5.1). + * + * @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 'Bearer '.$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/AuthenticatingHttpClient.php b/src/Client/Auth/AuthenticatingHttpClient.php index 40a02930d..bbba82386 100644 --- a/src/Client/Auth/AuthenticatingHttpClient.php +++ b/src/Client/Auth/AuthenticatingHttpClient.php @@ -11,6 +11,8 @@ 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; @@ -36,14 +38,26 @@ 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); + } + + /** + * 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 @@ -63,10 +77,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/src/Client/Auth/AuthorizationHandlerInterface.php b/src/Client/Auth/AuthorizationHandlerInterface.php new file mode 100644 index 000000000..4abf2628d --- /dev/null +++ b/src/Client/Auth/AuthorizationHandlerInterface.php @@ -0,0 +1,29 @@ + + */ +interface AuthorizationHandlerInterface +{ + /** + * Send the user to $authorizationUrl and return the query parameters of the redirect to $redirectUri, unchecked. + * + * @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/AuthorizationServerMetadata.php b/src/Client/Auth/AuthorizationServerMetadata.php index 63ec542af..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. @@ -119,7 +124,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/ClientMetadata.php b/src/Client/Auth/ClientMetadata.php new file mode 100644 index 000000000..c626b1874 --- /dev/null +++ b/src/Client/Auth/ClientMetadata.php @@ -0,0 +1,64 @@ + + */ +final class ClientMetadata +{ + /** + * @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); + } +} diff --git a/src/Client/Auth/ClientRegistrar.php b/src/Client/Auth/ClientRegistrar.php new file mode 100644 index 000000000..8c0cd6c3c --- /dev/null +++ b/src/Client/Auth/ClientRegistrar.php @@ -0,0 +1,84 @@ + + */ +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(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())); + } + + try { + 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 new file mode 100644 index 000000000..9c1779885 --- /dev/null +++ b/src/Client/Auth/ClientRegistration.php @@ -0,0 +1,126 @@ + + */ +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 + * @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, + public readonly ?string $clientSecret = null, + public readonly TokenEndpointAuthMethod $tokenEndpointAuthMethod = TokenEndpointAuthMethod::None, + 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.'); + } + } + + /** + * 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 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 = [], ?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".'); + } + + $secret = \is_string($response['client_secret'] ?? null) ? $response['client_secret'] : null; + + // Without a secret there is nothing to authenticate with, whatever the method says. + $method = null === $secret + ? TokenEndpointAuthMethod::None + : self::authMethod($response['token_endpoint_auth_method'] ?? null) ?? $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, + $redirectUris, + $scopes, + ); + } + + 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) { + return false; + } + + return ($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, + 'redirect_uris' => $this->redirectUris, + 'scopes' => $this->scopes, + ], 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, + 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')), + \is_array($data['scopes'] ?? null) ? array_values(array_filter($data['scopes'], 'is_string')) : null, + ); + } +} diff --git a/src/Client/Auth/CredentialStorageInterface.php b/src/Client/Auth/CredentialStorageInterface.php new file mode 100644 index 000000000..0799541c5 --- /dev/null +++ b/src/Client/Auth/CredentialStorageInterface.php @@ -0,0 +1,51 @@ + + */ +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 may be sent to + */ + 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; + + /** + * 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 new file mode 100644 index 000000000..7567f3560 --- /dev/null +++ b/src/Client/Auth/FileCredentialStorage.php @@ -0,0 +1,192 @@ + + */ +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; + }); + } + + 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>>} + */ + 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)) { + // Not worth failing a connection over: the worst case is one more authorization. + return ['registrations' => [], 'tokens' => []]; + } + + return [ + 'registrations' => \is_array($decoded['registrations'] ?? null) ? $decoded['registrations'] : [], + 'tokens' => \is_array($decoded['tokens'] ?? null) ? array_filter($decoded['tokens'], 'is_array') : [], + ]; + } + + /** + * 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 + { + $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); + + 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).$suffix; + $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)); + } + + return $operation(); + } 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)); + } + + // A private file moved into place is never world-readable or 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..6a7a68c0b --- /dev/null +++ b/src/Client/Auth/HeadlessAuthorizationHandler.php @@ -0,0 +1,88 @@ + + */ +final class HeadlessAuthorizationHandler implements AuthorizationHandlerInterface +{ + 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( + ?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 + { + $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 names, as the query carries the authorization code. + $this->logger->debug('Authorization endpoint redirected', ['parameters' => array_keys($callback)]); + + return $callback; + } + + private function fetchRedirect(string $url): string + { + try { + $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); + } + + $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)); + } + + return $location; + } +} diff --git a/src/Client/Auth/InMemoryCredentialStorage.php b/src/Client/Auth/InMemoryCredentialStorage.php new file mode 100644 index 000000000..d5fe7f3d4 --- /dev/null +++ b/src/Client/Auth/InMemoryCredentialStorage.php @@ -0,0 +1,63 @@ + + */ +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]); + } + + public function synchronize(callable $operation): mixed + { + return $operation(); + } +} diff --git a/src/Client/Auth/LoopbackAuthorizationHandler.php b/src/Client/Auth/LoopbackAuthorizationHandler.php new file mode 100644 index 000000000..e3663e993 --- /dev/null +++ b/src/Client/Auth/LoopbackAuthorizationHandler.php @@ -0,0 +1,195 @@ + + */ +final class LoopbackAuthorizationHandler implements AuthorizationHandlerInterface +{ + private const CALLBACK_PAGE = 'Authorization finished' + .'' + .'

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, + * 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 + { + 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'] ?? 80; + $callbackPath = $parts['path'] ?? '/'; + + 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) { + 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, $callbackPath, $state); + + 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, and return its query parameters only for the callback, as anyone local can connect. + * + * @param resource $connection + * + * @return array|null + */ + private function serve($connection, string $callbackPath, ?string $state): ?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; + } + + $target = parse_url($matches[1]); + parse_str(\is_string($target['query'] ?? null) ? $target['query'] : '', $parameters); + + $callback = []; + + foreach ($parameters as $name => $value) { + if (\is_string($name) && \is_scalar($value)) { + $callback[$name] = (string) $value; + } + } + + $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 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)) { + ($this->openBrowser)($url); + + return; + } + + // 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)); + } + + $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)); + + if (!\function_exists('proc_open')) { + return; + } + + $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/src/Client/Auth/MetadataDiscovery.php b/src/Client/Auth/MetadataDiscovery.php index 5eb009de9..9e47f8ba7 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,12 +201,13 @@ 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; } - $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/OAuth.php b/src/Client/Auth/OAuth.php new file mode 100644 index 000000000..063441103 --- /dev/null +++ b/src/Client/Auth/OAuth.php @@ -0,0 +1,240 @@ + + */ +final class OAuth +{ + /** + * 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'; + + 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 ?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); + } + + /** + * 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 + { + $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 this issuer; required with a configured client, or a hostile MCP server could receive its codes and secret. + */ + public function setExpectedIssuer(string $issuer): self + { + $this->expectedIssuer = $issuer; + + return $this; + } + + /** + * Request these scopes instead of those the server asks for; a step-up challenge still adds its own. + */ + public function setScopes(string ...$scopes): self + { + $this->scopes = array_values($scopes); + + return $this; + } + + /** + * Whether to request a refresh token where the authorization server offers one (default: true). + */ + public function setOfflineAccess(bool $offlineAccess): self + { + $this->offlineAccess = $offlineAccess; + + return $this; + } + + /** + * 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 + { + $this->legacyDiscovery = $legacyDiscovery; + + 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 = null === $this->httpClient ? NoRedirects::discover() : NoRedirects::apply($this->httpClient); + $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->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..3485b83cf --- /dev/null +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -0,0 +1,626 @@ + + */ +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, which stays behind $resource when an authorization fails. + */ + 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; + } + + // 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; + } + + return $request->withHeader('Authorization', $this->token->getAuthorizationHeader()); + } + + 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. + 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, $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) { + // 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 = []; + $this->offlineAccessRefused = false; + } elseif ($resource !== $this->resource) { + $this->requestedScopes = []; + } + + $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($needed, $this->token->scopes, $this->requestedScopes) + ) { + return false; + } + + 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 = $resource; + + return true; + } + + $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; + + try { + $token = $this->refresh($refreshable, $metadata, $client, $needed, $resource, $request); + } 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; + $client = $this->resolveClient($metadata, $scopes, $scopesSupported); + } + + if (null === $token) { + 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); + $this->requestedScopes = $scopes; + $this->storage->saveToken($metadata->issuer, $resource, $token); + } + + $this->token = $token; + $this->audience = $resource; + + return true; + } + + /** + * Find the resource and the server, on every challenge as a resource may move. + * + * @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 [$endpoint, $this->discoverLegacyServer($endpoint), null]; + } + + if (!$metadata->covers($endpoint)) { + // 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 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)); + } + + // 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) { + throw new AuthorizationException(\sprintf('No usable metadata was found for the authorization server "%s".', $issuer)); + } + + return [$metadata->resource, $server, $metadata->scopesSupported]; + } + + private static function isForgottenClient(AuthorizationException $e, ClientRegistration $client): bool + { + return $client->dynamic && self::isRefusedClient($e); + } + + private static function isRefusedClient(AuthorizationException $e): bool + { + return $e instanceof OAuthErrorException && 'invalid_client' === $e->error; + } + + /** + * @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 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 + { + $url = $challenge->getResourceMetadataUrl(); + + if (null === $url) { + return null; + } + + if (self::origin($url) === self::origin($endpoint)) { + return $url; + } + + return null; + } + + /** + * 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 + { + if (!$this->configuration->legacyDiscovery) { + 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); + $this->logger->info('No protected resource metadata; falling back to treating the server as its own authorization server.', ['issuer' => $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)); + } + + /** + * Prefer the scopes of the challenge, then those of the resource, and keep the ones requested before. + * + * @param ?string[] $scopesSupported + * + * @return string[] + */ + private function selectScopes(AuthorizationChallenge $challenge, ?array $scopesSupported, AuthorizationServerMetadata $metadata, ?AccessToken $stored): array + { + $scopes = $this->configuration->scopes; + + if ([] === $scopes) { + // 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, ...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 || !$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 ($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 server just rejected this very token. + if ($request->getHeaderLine('Authorization') === $token->getAuthorizationHeader()) { + return false; + } + + return !$token->isExpiringSoon() && $token->covers($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, string $resource, RequestInterface $request): ?AccessToken + { + if (null === $stored || null === $stored->refreshToken) { + 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; + } + + 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' => $refreshToken, + 'resource' => $resource, + ])); + } catch (AuthorizationException $e) { + // A refused client would fail the new authorization as well, after the user signed in. + if ($e instanceof TokenEndpointUnavailableException || self::isRefusedClient($e)) { + throw $e; + } + + $this->logger->info('Refreshing the access token failed; falling back to a new authorization.', ['exception' => $e]); + + return null; + } + + // 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 ?? $refreshToken, + [] === $token->scopes ? $stored->scopes : $token->scopes, + ); + } + + /** + * Run the grant, and retry without offline_access if it was refused (invalid_scope, or not_allowed from Keycloak). + * + * @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 || !$e instanceof OAuthErrorException || !\in_array($e->error, ['invalid_scope', 'not_allowed'], true)) { + 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 + { + self::assertSupportsPkce($metadata); + $this->assertIdentifiesItsResponses($metadata); + + $pkce = Pkce::generate(); + $state = rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '='); + + $parameters = self::withoutEmptyValues([ + '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, self::withoutEmptyValues([ + '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 + { + // 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.'); + } + + // 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)); + } + } 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['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.'); + } + } + + /** + * Work out which client identity to use with this authorization server. + * + * @param string[] $scopes + * @param ?string[] $scopesSupported + */ + private function resolveClient(AuthorizationServerMetadata $metadata, array $scopes, ?array $scopesSupported): ClientRegistration + { + $configuration = $this->configuration; + + if (null !== $configuration->clientId) { + return new ClientRegistration( + $configuration->clientId, + $configuration->clientSecret, + TokenEndpointAuthMethod::negotiate( + $metadata->tokenEndpointAuthMethodsSupported, + null !== $configuration->clientSecret, + ), + ); + } + + // 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) + && (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 what it may ask for later. + $registered = array_values(array_unique([...$stored->scopes ?? [], ...$scopes, ...array_diff($scopesSupported ?? [], ['offline_access'])])); + + $registration = $this->registrar->register( + $metadata, + $configuration->clientMetadata, + TokenEndpointAuthMethod::negotiate($metadata->tokenEndpointAuthMethodsSupported, true), + [] === $registered ? null : implode(' ', $registered), + ); + + $this->storage->saveClientRegistration($metadata->issuer, $registration); + + 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; + } + + /** + * 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()) { + 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; + } + + /** + * The canonical URI a token is requested for, without a fragment. + */ + 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; + } + + $scheme = strtolower($parts['scheme'] ?? 'https'); + $origin = $scheme.'://'.strtolower($parts['host'] ?? ''); + $port = $parts['port'] ?? null; + + if (null === $port || $port === (['https' => 443, 'http' => 80][$scheme] ?? null)) { + return $origin; + } + + return $origin.':'.$port; + } +} diff --git a/src/Client/Auth/OAuthConfiguration.php b/src/Client/Auth/OAuthConfiguration.php new file mode 100644 index 000000000..5f47818be --- /dev/null +++ b/src/Client/Auth/OAuthConfiguration.php @@ -0,0 +1,63 @@ + + */ +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 $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 + */ + 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 $expectedIssuer = null, + ) { + if (!AuthorizationServerMetadata::isTransportSecure($redirectUri)) { + 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/src/Client/Auth/Pkce.php b/src/Client/Auth/Pkce.php new file mode 100644 index 000000000..df7765df2 --- /dev/null +++ b/src/Client/Auth/Pkce.php @@ -0,0 +1,42 @@ + + */ +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))); + } + + private static function base64Url(string $bytes): string + { + return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + } +} 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 new file mode 100644 index 000000000..f455aa279 --- /dev/null +++ b/src/Client/Auth/TokenEndpoint.php @@ -0,0 +1,139 @@ + + */ +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: form-urlencoded before joining, so a secret may contain a colon. + $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 TokenEndpointUnavailableException(\sprintf('The token request to "%s" failed: %s', $endpoint, $e->getMessage()), 0, $e); + } + + $decoded = json_decode(ResponseBody::read($response) ?? '', true); + + if (200 !== $response->getStatusCode() || !\is_array($decoded)) { + // 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 => '', + }); + + $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); + } + + 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..65d1529a8 --- /dev/null +++ b/src/Client/Auth/TokenEndpointAuthMethod.php @@ -0,0 +1,54 @@ + + */ +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 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 + */ + 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; + } + } + + // Servers under-report this field, so the strongest method is still worth trying. + return $candidates[0]; + } +} diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index ede918160..140cc5640 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; @@ -28,12 +27,11 @@ 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; use Psr\Log\LoggerInterface; -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Component\HttpClient\Psr18Client; /** * HTTP-based client transport using PSR-18 HTTP client. @@ -128,11 +126,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, @@ -158,24 +157,19 @@ 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 + private function sendUnchallenged(RequestInterface $request): ResponseInterface { - $client = Psr18ClientDiscovery::find(); - - if (!$client instanceof Psr18Client) { - return $client; + if ($this->httpClient instanceof AuthenticatingHttpClient) { + return $this->httpClient->sendUnchallenged($request); } - // 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])); + return $this->httpClient->sendRequest($request); } public function connect(): void @@ -570,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(); @@ -672,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/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/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/src/Exception/OAuthErrorException.php b/src/Exception/OAuthErrorException.php new file mode 100644 index 000000000..bec2926ff --- /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/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/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/Conformance/client.php b/tests/Conformance/client.php index d71785df4..a51aea8bd 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,62 @@ 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); + $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 +253,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..0940c2bbf --- /dev/null +++ b/tests/E2E/client.php @@ -0,0 +1,110 @@ +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') + ->setExpectedIssuer($issuer) + ->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..1cb1378c3 --- /dev/null +++ b/tests/E2E/run.sh @@ -0,0 +1,58 @@ +#!/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_ISSUER="http://localhost:$keycloak_port/realms/mcp-e2e" \ +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..aa27b540d --- /dev/null +++ b/tests/Unit/Client/Auth/AccessTokenTest.php @@ -0,0 +1,154 @@ + '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']); + } + + #[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); + } + + #[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 + { + $token = new AccessToken('abc', expiresAt: 1000); + + $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')] + 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/AuthenticatingHttpClientTest.php b/tests/Unit/Client/Auth/AuthenticatingHttpClientTest.php index 9b9e815fc..974081f13 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 { @@ -140,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 { diff --git a/tests/Unit/Client/Auth/AuthorizationHandlerTest.php b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php new file mode 100644 index 000000000..22c61ed67 --- /dev/null +++ b/tests/Unit/Client/Auth/AuthorizationHandlerTest.php @@ -0,0 +1,304 @@ +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 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 + { + $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. + #[TestDox('the headless handler sends its request with the given HTTP client')] + public function testHeadlessHandlerUsesTheGivenClient(): void + { + $client = new class implements ClientInterface { + /** @var string[] */ + public array $requested = []; + + 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')] + public function testLoopbackHandlerShowsTheUrlWithoutABrowser(): void + { + $url = 'https://auth.example.com/authorize?client_id=abc'; + + $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. + */ + #[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); + } + + 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'); + 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/AuthorizationServerMetadataTest.php b/tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php index ecba4de3a..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 { @@ -361,6 +391,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/ClientRegistrationTest.php b/tests/Unit/Client/Auth/ClientRegistrationTest.php new file mode 100644 index 000000000..06cca8713 --- /dev/null +++ b/tests/Unit/Client/Auth/ClientRegistrationTest.php @@ -0,0 +1,230 @@ + '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']); + } + + /** + * @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('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 + { + $registration = ClientRegistration::fromResponse(['client_id' => 'issued', 'client_secret_expires_at' => 1000]); + + $this->assertFalse($registration->isExpired(999)); + $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 + { + $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 + { + $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 + { + $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..b5df96942 --- /dev/null +++ b/tests/Unit/Client/Auth/FileCredentialStorageTest.php @@ -0,0 +1,221 @@ +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); + } + + 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))) { + 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 + { + 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')); + + $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 + { + 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')); + + 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 + { + 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(); + $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('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 + { + $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/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()); diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php new file mode 100644 index 000000000..9b0da27a0 --- /dev/null +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -0,0 +1,1707 @@ +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, + rtrim(strtr(base64_encode(hash('sha256', $server->token['code_verifier'] ?? '', true)), '+/', '-_'), '='), + ); + } + + #[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, 'insufficient_scope')); + + $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()); + } + + /** + * @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 + { + $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('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 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', 'iss' => 'https://auth.example.com', '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 + { + $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.'); + } + } + + #[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); + } + + /** + * @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('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 + { + $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 + { + $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('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('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')->setExpectedIssuer('https://auth.example.com')); + + 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('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 + { + $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', 'iss' => 'https://auth.example.com', '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', 'iss' => 'https://auth.example.com', '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('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); + } + + /** + * @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('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('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 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('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('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('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('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('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); + } + + #[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('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('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('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('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('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 + { + $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 + { + $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()); + } + + /** + * @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); + $this->assertNotContains('https://auth.example.com/register', $server->requested); + } + + 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, + ?CredentialStorageInterface $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 = []; + + /** @var string[] */ + public array $otherAuthorizationServers = []; + + public ?string $unknownClient = null; + + public int $expiresIn = 3600; + + public ?ResponseInterface $refreshAnswer = null; + + /** @var ?string[] */ + public ?array $grantTypes = ['authorization_code', 'refresh_token']; + + private int $issued = 0; + + /** + * @param ?string[] $resourceScopes + * @param string[] $serverScopes + * @param ?string[] $codeChallengeMethods + */ + public function __construct( + public 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, + private readonly ?array $codeChallengeMethods = ['S256'], + private readonly ?string $authorizationEndpoint = null, + private readonly bool $announcesIssuer = 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->otherAuthorizationServers, $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' => 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' => $this->announcesIssuer, + ]), + + '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'] ?? ''; + + 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)) { + 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 (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]); + } + + return self::json(['access_token' => 'access-token-'.++$this->issued, 'token_type' => 'Bearer', 'expires_in' => $this->expiresIn, '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..59de70d90 --- /dev/null +++ b/tests/Unit/Client/Auth/OAuthTest.php @@ -0,0 +1,205 @@ +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 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))); + } + + #[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($clientId, $clientSecret)->build(); + } + + #[TestDox('a client that registers itself needs no expected issuer')] + public function testDynamicRegistrationNeedsNoExpectedIssuer(): void + { + $configuration = $this->configurationOf(OAuth::forApplication('My App')); + + $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'])] + #[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 + { + $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 + { + $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']); + } + + /** + * @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()); + } +} diff --git a/tests/Unit/Client/Auth/PkceTest.php b/tests/Unit/Client/Auth/PkceTest.php new file mode 100644 index 000000000..a76a7e844 --- /dev/null +++ b/tests/Unit/Client/Auth/PkceTest.php @@ -0,0 +1,45 @@ +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); + } +} 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), + ])); + } + }; + } +} 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, + ])); + } +} 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 { 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()); + } +}