diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 717ba09f..9e47f14f 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -268,6 +268,13 @@ jobs: --spec-version ${{ matrix.spec-version }} --expected-failures ${{ matrix.baseline }} + # Extension scenarios belong to no spec version, so one leg of the matrix runs them. + - name: Run client conformance tests of the authorization extensions + if: matrix.spec-version == '2025-11-25' + env: + CONFORMANCE: npx --yes ${{ env.CONFORMANCE_PKG }} + run: tests/Conformance/client-extensions.sh + - name: Show logs on failure if: failure() run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a4f9670..fa0b73a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] Fix `HttpTransport` reading a response body that is not an SSE stream without a limit, which could exhaust client memory on a large body. It now reads up to the new `maxResponseBodyBytes` argument (default: 8 MiB): a successful response over that limit fails the request, and an error status keeps its body cut at the limit. Raise the limit if your server sends larger results. * [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 `OAuth::forServiceAccount()` for the client credentials grant, `OAuth::setPrivateKeyJwt()` for `private_key_jwt` client authentication and `OAuth::setCrossAppAccess()` to reach a server through an enterprise identity. * Add `OAuth::setClientMetadataUrl()`, to use a client ID metadata document where the authorization server supports it, and `ConsoleAuthorizationHandler` for a shell without a browser. * Add 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). diff --git a/Makefile b/Makefile index 8dc5f8fb..c0234c7b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.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 +.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-client-extensions 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 @@ -41,7 +41,7 @@ interop-tests: e2e-tests: tests/E2E/run.sh -conformance-tests: conformance-server conformance-client +conformance-tests: conformance-server conformance-client conformance-client-extensions conformance-server: docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d @@ -57,6 +57,9 @@ conformance-client: cd tests/Conformance && $(CONFORMANCE) client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2025-11-25 --expected-failures conformance-baseline-2025-11-25.yml --output-dir results || true php tests/Conformance/score.php client 2025-11-25 +conformance-client-extensions: + CONFORMANCE="$(CONFORMANCE)" tests/Conformance/client-extensions.sh + # --- 2026-07-28 (SEP-2575 stateless lifecycle) ------------------------------ conformance-draft: conformance-draft-server conformance-draft-client diff --git a/composer.json b/composer.json index ed60acb7..819a5388 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "suggest": { - "firebase/php-jwt": "Required for the JwtTokenValidator.", + "firebase/php-jwt": "Required for the JwtTokenValidator, and for private_key_jwt client authentication in the OAuth client.", "psr/cache-implementation": "Required for JwtTokenValidator::fromIssuer() to cache metadata and keys.", "symfony/finder": "Required for file-based discovery.", "symfony/lock": "Required for the SymfonyLockSessionLock." diff --git a/docs/client/authorization.md b/docs/client/authorization.md index f0d0af4c..41d3aaad 100644 --- a/docs/client/authorization.md +++ b/docs/client/authorization.md @@ -117,6 +117,68 @@ The document must be a JSON object whose `client_id` is exactly this URL. It mus The client only uses this URL if the authorization server supports these documents. Otherwise, it registers. A client ID that you pass with `setClientCredentials()` takes precedence. +## Running Without a User + +A background job has no user who can approve a request. Use `forServiceAccount()` with a client ID and a secret that an administrator created for you: + +```php +use Mcp\Client\Auth\OAuth; + +$auth = OAuth::forServiceAccount('Acme Report Job', 'my-client-id', 'my-client-secret') + ->setExpectedIssuer('https://auth.example.com') + ->build(); +``` + +The client then authenticates as itself with the client credentials grant. It doesn't open a browser and doesn't register. If you pass no secret, you must call `setPrivateKeyJwt()`. + +Also call `setScopes()`. Nobody approves what a service account requests, so the client then never asks for more than these scopes, even if a server answers `insufficient_scope` for others. It also doesn't reuse a stored token that holds more, for example one from an earlier configuration. Without `setScopes()`, it requests what the server names. The same applies to cross-app access. + +## Authenticating With a Private Key + +An authorization server can accept a signed assertion in place of a shared secret. This needs the `firebase/php-jwt` package: + +```bash +composer require firebase/php-jwt +``` + +Pass your key with `setPrivateKeyJwt()`: + +```php +// the default algorithm is RS256 +$auth = OAuth::forServiceAccount('Acme Report Job', 'my-client-id') + ->setPrivateKeyJwt(file_get_contents('/etc/acme/private-key.pem'), 'ES256') + ->setExpectedIssuer('https://auth.example.com') + ->build(); +``` + +The algorithm can be `RS256`, `RS384`, `RS512`, `ES256`, `ES256K` or `ES384`. Use one that the authorization server lists in `token_endpoint_auth_signing_alg_values_supported`. You can also use a key for a user-facing client, together with `setClientCredentials()`. + +## Using an Enterprise Identity + +!!! warning "Experimental" + Cross-app access is based on a draft specification. It has only been tested against the MCP conformance suite, not against a real identity provider yet, so its API may still change. + +If your users already signed in at a company identity provider, they don't need to approve each MCP server. With cross-app access, the client exchanges their identity token for a grant for one MCP server. It then gets the access token with this grant: + +```php +use Mcp\Client\Auth\CrossAppAccess; +use Mcp\Client\Auth\OAuth; + +$auth = OAuth::forApplication('My App') + ->setClientCredentials('my-client-id', 'my-client-secret') + ->setExpectedIssuer('https://auth.example.com') + ->setCrossAppAccess(new CrossAppAccess('https://idp.example.com/token', $identityToken)) + ->build(); +``` + +Your application gets `$identityToken` from its own sign-in. Identity tokens expire quickly, so a client that runs for a long time can pass a closure instead. The client calls it each time it needs a new grant: + +```php +new CrossAppAccess('https://idp.example.com/token', fn (): string => $session->getIdToken()); +``` + +The token endpoint of the identity provider must use `https`, unless it runs on a loopback address. By default, the client sends the same client ID to the identity provider and no secret. If the identity provider expects other credentials, pass the `clientId` and `clientSecret` arguments of `CrossAppAccess`. + ## Choosing Scopes By default, the client requests the scopes of the challenge. If there are none, it requests all scopes in `scopes_supported` of the resource. If there are none either, it sends no `scope` parameter. Use `setScopes()` to request your own: diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index c1c04949..1cb684b2 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -27,6 +27,7 @@ Which extras you need depends on what you build: | emit the response from a standalone HTTP entry point | `laminas/laminas-httphandlerrunner` | | [connect a client over HTTP](../client/transports.md) | any PSR-18 client and PSR-17 implementation, e.g. `symfony/http-client` and `nyholm/psr7` | | [validate JWT access tokens](../run/authorization.md) | `firebase/php-jwt` | +| [authenticate a client with a private key](../client/authorization.md#authenticating-with-a-private-key) | `firebase/php-jwt` | | store sessions, cache discovery or share notifications through a PSR-6 or PSR-16 cache | `psr/cache` or `psr/simple-cache` implementation, e.g. `symfony/cache` | | [fetch JWT keys from an issuer](../run/authorization.md) with `JwtTokenValidator::fromIssuer()` | `psr/cache` (PSR-6) implementation, PSR-18 client and PSR-17 implementation, e.g. `symfony/cache`, `symfony/http-client` and `nyholm/psr7` | | [lock sessions across machines](../run/sessions.md) with `SymfonyLockSessionLock` | `symfony/lock` | diff --git a/src/Client/Auth/CrossAppAccess.php b/src/Client/Auth/CrossAppAccess.php new file mode 100644 index 00000000..4eaa0d33 --- /dev/null +++ b/src/Client/Auth/CrossAppAccess.php @@ -0,0 +1,72 @@ + + */ +final class CrossAppAccess +{ + public const AUTHORIZATION_GRANT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id-jag'; + + public const ID_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token'; + + /** + * @param string $tokenEndpoint the token endpoint of the identity provider + * @param string|\Closure(): string $identityToken the token identifying the user, usually an OIDC ID token + * @param string $identityTokenType the RFC 8693 type of that token + * @param ?string $clientId the client id at the identity provider, when it differs + * from the client id at the authorization server + * @param ?string $clientSecret the client secret at the identity provider, when it requires one + * + * @throws InvalidArgumentException when the token endpoint is neither https nor loopback + */ + public function __construct( + public readonly string $tokenEndpoint, + private readonly string|\Closure $identityToken, + public readonly string $identityTokenType = self::ID_TOKEN_TYPE, + public readonly ?string $clientId = null, + public readonly ?string $clientSecret = null, + ) { + if (!AuthorizationServerMetadata::isTransportSecure($tokenEndpoint)) { + throw new InvalidArgumentException(\sprintf('The identity provider token endpoint "%s" is neither an HTTPS URL nor a loopback address.', $tokenEndpoint)); + } + } + + /** + * @throws AuthorizationException when the closure returns no token + */ + public function getIdentityToken(): string + { + if (\is_string($this->identityToken)) { + return $this->identityToken; + } + + $token = ($this->identityToken)(); + + if (!\is_string($token) || '' === $token) { + throw new AuthorizationException('The identity token closure must return a non-empty string.'); + } + + return $token; + } +} diff --git a/src/Client/Auth/Grant.php b/src/Client/Auth/Grant.php index aa4536a0..d140b529 100644 --- a/src/Client/Auth/Grant.php +++ b/src/Client/Auth/Grant.php @@ -23,6 +23,15 @@ enum Grant: string /** A user approves the request in a browser; the default for anything with a human behind it. */ case AuthorizationCode = 'authorization_code'; + /** The client acts as itself, with no user involved -- daemons, cron jobs, service accounts. */ + case ClientCredentials = 'client_credentials'; + /** Trades a still-valid refresh token for a fresh access token. */ case RefreshToken = 'refresh_token'; + + /** Exchanges a token the client already holds for another token, such as an authorization grant (RFC 8693). */ + case TokenExchange = 'urn:ietf:params:oauth:grant-type:token-exchange'; + + /** Presents an assertion signed by a party the authorization server trusts (RFC 7523). */ + case JwtBearer = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; } diff --git a/src/Client/Auth/OAuth.php b/src/Client/Auth/OAuth.php index 3e3016f4..01dd51d8 100644 --- a/src/Client/Auth/OAuth.php +++ b/src/Client/Auth/OAuth.php @@ -37,7 +37,11 @@ final class OAuth private ?string $clientId = null; private ?string $clientSecret = null; private ?string $expectedIssuer = null; + private Grant $grant = Grant::AuthorizationCode; private ?string $clientMetadataUrl = null; + private ?string $privateKeyPem = null; + private string $signingAlgorithm = 'RS256'; + private ?CrossAppAccess $crossAppAccess = null; private string $applicationType = 'native'; private ?string $clientUri = null; private ?string $softwareId = null; @@ -74,6 +78,23 @@ public static function forApplication(string $clientName): self return new self($clientName); } + /** + * A client acting as itself, with no user and no browser (RFC 6749 section 4.4). + * + * The credentials have to be issued out of band: there is nobody to consent to a + * dynamic registration. + */ + public static function forServiceAccount(string $clientName, string $clientId, ?string $clientSecret = null): self + { + $oauth = new self($clientName); + $oauth->grant = Grant::ClientCredentials; + $oauth->clientId = $clientId; + $oauth->clientSecret = $clientSecret; + $oauth->offlineAccess = false; + + return $oauth; + } + /** * Must match what the authorization handler listens on and, for a pre-registered client, what the server has on file. */ @@ -95,6 +116,22 @@ public function setClientCredentials(string $clientId, ?string $clientSecret = n return $this; } + /** + * Authenticate at the token endpoint with a signed assertion instead of a secret. + * + * Needs the firebase/php-jwt package. + * + * @param string $privateKeyPem a PEM-encoded private key + * @param string $algorithm one of RS256, RS384, RS512, ES256, ES256K or ES384 + */ + public function setPrivateKeyJwt(string $privateKeyPem, string $algorithm = 'RS256'): self + { + $this->privateKeyPem = $privateKeyPem; + $this->signingAlgorithm = $algorithm; + + return $this; + } + /** * Only authorize with this issuer; required with a configured client, or a hostile MCP server could receive its codes and secret. */ @@ -161,6 +198,22 @@ public function setLegacyDiscovery(bool $legacyDiscovery): self return $this; } + /** + * Reach the server through an identity the user already holds at their identity + * provider, rather than sending them to a consent screen. + * + * Requires the client id the authorization server knows this client by, set with + * {@see self::setClientCredentials()}. + */ + public function setCrossAppAccess(CrossAppAccess $crossAppAccess): self + { + $this->crossAppAccess = $crossAppAccess; + $this->grant = Grant::JwtBearer; + $this->offlineAccess = false; + + return $this; + } + /** * Whether the client runs on the user's machine ("native") or on a server ("web"). */ @@ -232,9 +285,11 @@ public function build(): OAuthAuthenticator $requestFactory = $this->requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); $streamFactory = $this->streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); - $grantTypes = $this->offlineAccess - ? [Grant::AuthorizationCode->value, Grant::RefreshToken->value] - : [Grant::AuthorizationCode->value]; + $grantTypes = match (true) { + Grant::AuthorizationCode !== $this->grant => [$this->grant->value], + $this->offlineAccess => [Grant::AuthorizationCode->value, Grant::RefreshToken->value], + default => [Grant::AuthorizationCode->value], + }; $configuration = new OAuthConfiguration( new ClientMetadata( @@ -247,12 +302,16 @@ public function build(): OAuthAuthenticator $this->softwareVersion, ), $this->redirectUri, + $this->grant, $this->clientId, $this->clientSecret, $this->clientMetadataUrl, + $this->privateKeyPem, + $this->signingAlgorithm, $this->scopes, $this->offlineAccess, $this->legacyDiscovery, + $this->crossAppAccess, $this->expectedIssuer, ); diff --git a/src/Client/Auth/OAuthAuthenticator.php b/src/Client/Auth/OAuthAuthenticator.php index 3ea78b57..ac44f7ae 100644 --- a/src/Client/Auth/OAuthAuthenticator.php +++ b/src/Client/Auth/OAuthAuthenticator.php @@ -293,6 +293,17 @@ private function selectScopes(AuthorizationChallenge $challenge, ?array $scopesS $held = 'insufficient_scope' === $challenge->getError() ? array_diff($stored->scopes ?? [], ['offline_access']) : []; $scopes = array_values(array_unique([...$this->requestedScopes, ...$held, ...$scopes])); + $limit = $this->scopeLimit(); + + if (null !== $limit) { + $beyond = array_values(array_diff($scopes, $limit)); + + if ([] !== $beyond) { + $this->logger->warning('Not requesting scopes beyond the configured ones for a client without a user', ['dropped' => $beyond, 'configured' => $limit]); + $scopes = array_values(array_intersect($scopes, $limit)); + } + } + if ($this->offlineAccessRefused || !$this->configuration->offlineAccess) { return array_values(array_diff($scopes, ['offline_access'])); } @@ -320,7 +331,31 @@ private function isUsable(AccessToken $token, array $scopes, RequestInterface $r return false; } - return !$token->isExpiringSoon() && $token->covers($scopes); + return !$token->isExpiringSoon() && $token->covers($scopes) && $this->isWithinScopeLimit($token); + } + + /** + * Tokens are stored per server and resource, so a stored one may come from a broader configuration. + */ + private function isWithinScopeLimit(AccessToken $token): bool + { + $limit = $this->scopeLimit(); + + return null === $limit || ([] !== $token->scopes && [] === array_diff($token->scopes, $limit)); + } + + /** + * Without a user to consent, the configured scopes are all a client may hold, whatever a server or a stored token names. + * + * @return ?string[] + */ + private function scopeLimit(): ?array + { + if (Grant::AuthorizationCode === $this->configuration->grant || [] === $this->configuration->scopes) { + return null; + } + + return $this->configuration->scopes; } /** @@ -351,7 +386,8 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta return $current; } - if (null === $current->refreshToken || !$current->covers($scopes)) { + // A refresh keeps the scopes of the grant, so a broader stored one is replaced by a new grant instead. + if (null === $current->refreshToken || !$current->covers($scopes) || !$this->isWithinScopeLimit($current)) { return null; } @@ -368,7 +404,7 @@ private function refresh(?AccessToken $stored, AuthorizationServerMetadata $meta private function spendRefreshToken(AccessToken $stored, string $refreshToken, AuthorizationServerMetadata $metadata, ClientRegistration $client, string $resource): ?AccessToken { try { - $token = $this->tokenEndpoint->request($metadata, $client, self::withoutEmptyValues([ + $token = $this->requestToken($metadata, $client, self::withoutEmptyValues([ 'grant_type' => Grant::RefreshToken->value, 'refresh_token' => $refreshToken, 'resource' => $resource, @@ -424,6 +460,23 @@ private function grantWithFallback(AuthorizationServerMetadata $metadata, Client */ private function grant(AuthorizationServerMetadata $metadata, ClientRegistration $client, array $scopes, string $resource): AccessToken { + if (Grant::JwtBearer === $this->configuration->grant) { + return $this->requestToken($metadata, $client, self::withoutEmptyValues([ + 'grant_type' => Grant::JwtBearer->value, + 'assertion' => $this->requestAuthorizationGrant($metadata, $scopes, $resource), + 'scope' => implode(' ', $scopes), + 'resource' => $resource, + ])); + } + + if (Grant::ClientCredentials === $this->configuration->grant) { + return $this->requestToken($metadata, $client, self::withoutEmptyValues([ + 'grant_type' => Grant::ClientCredentials->value, + 'scope' => implode(' ', $scopes), + 'resource' => $resource, + ])); + } + self::assertSupportsPkce($metadata); $this->assertIdentifiesItsResponses($metadata); @@ -455,7 +508,7 @@ private function grant(AuthorizationServerMetadata $metadata, ClientRegistration $this->verifyCallback($callback, $state, $metadata); - return $this->tokenEndpoint->request($metadata, $client, self::withoutEmptyValues([ + return $this->requestToken($metadata, $client, self::withoutEmptyValues([ 'grant_type' => Grant::AuthorizationCode->value, 'code' => $callback['code'], 'redirect_uri' => $this->configuration->redirectUri, @@ -464,6 +517,49 @@ private function grant(AuthorizationServerMetadata $metadata, ClientRegistration ])); } + /** + * @param array $parameters + */ + private function requestToken(AuthorizationServerMetadata $metadata, ClientRegistration $client, array $parameters): AccessToken + { + return $this->tokenEndpoint->request($metadata, $client, $parameters, $this->configuration->privateKeyPem, $this->configuration->signingAlgorithm); + } + + /** + * Trades the identity token for a grant for this resource and authorization server. + * + * @param string[] $scopes + */ + private function requestAuthorizationGrant(AuthorizationServerMetadata $metadata, array $scopes, string $resource): string + { + $crossAppAccess = $this->configuration->crossAppAccess; + \assert(null !== $crossAppAccess); + + $this->logger->info('Exchanging the enterprise identity for an authorization grant', [ + 'identity_provider' => $crossAppAccess->tokenEndpoint, + 'audience' => $metadata->issuer, + 'resource' => $resource, + ]); + + $response = $this->tokenEndpoint->post($crossAppAccess->tokenEndpoint, self::withoutEmptyValues([ + 'grant_type' => Grant::TokenExchange->value, + 'subject_token' => $crossAppAccess->getIdentityToken(), + 'subject_token_type' => $crossAppAccess->identityTokenType, + 'requested_token_type' => CrossAppAccess::AUTHORIZATION_GRANT_TOKEN_TYPE, + 'audience' => $metadata->issuer, + 'resource' => $resource, + 'scope' => implode(' ', $scopes), + 'client_id' => $crossAppAccess->clientId ?? $this->configuration->clientId, + 'client_secret' => $crossAppAccess->clientSecret, + ])); + + if (!\is_string($response['access_token'] ?? null) || CrossAppAccess::AUTHORIZATION_GRANT_TOKEN_TYPE !== ($response['issued_token_type'] ?? null)) { + throw new AuthorizationException(\sprintf('The identity provider at "%s" returned no authorization grant.', $crossAppAccess->tokenEndpoint)); + } + + return $response['access_token']; + } + /** * Check the authorization response before spending the code it carries. * @@ -513,6 +609,8 @@ private function resolveClient(AuthorizationServerMetadata $metadata, array $sco TokenEndpointAuthMethod::negotiate( $metadata->tokenEndpointAuthMethodsSupported, null !== $configuration->clientSecret, + null !== $configuration->privateKeyPem, + Grant::ClientCredentials !== $configuration->grant, ), ); } diff --git a/src/Client/Auth/OAuthConfiguration.php b/src/Client/Auth/OAuthConfiguration.php index 10b3fa16..b041146f 100644 --- a/src/Client/Auth/OAuthConfiguration.php +++ b/src/Client/Auth/OAuthConfiguration.php @@ -23,29 +23,39 @@ final class OAuthConfiguration { /** - * @param ClientMetadata $clientMetadata what the client says about itself when registering - * @param string $redirectUri where the authorization server sends the user back - * @param ?string $clientId a client id issued out of band, skipping registration - * @param ?string $clientSecret the matching secret, when there is one - * @param ?string $clientMetadataUrl a URL serving the client metadata document, usable as a - * client id against servers that accept one - * @param string[] $scopes scopes to request regardless of what is advertised - * @param bool $offlineAccess whether to ask for a refresh token where the server offers one - * @param bool $legacyDiscovery whether to fall back to pre-2025-06-18 discovery when a - * server publishes no protected resource metadata - * @param ?string $expectedIssuer the only authorization server to authorize with + * @param ClientMetadata $clientMetadata what the client says about itself when registering + * @param string $redirectUri where the authorization server sends the user back + * @param Grant $grant the grant to drive + * @param ?string $clientId a client id issued out of band, skipping registration + * @param ?string $clientSecret the matching secret, when there is one + * @param ?string $clientMetadataUrl a URL serving the client metadata document, usable as a + * client id against servers that accept one + * @param ?string $privateKeyPem a PEM private key for `private_key_jwt` client authentication + * @param string $signingAlgorithm the algorithm that key is used with + * @param string[] $scopes scopes to request regardless of what is advertised + * @param bool $offlineAccess whether to ask for a refresh token where the server offers one + * @param bool $legacyDiscovery whether to fall back to pre-2025-06-18 discovery when a + * server publishes no protected resource metadata + * @param ?CrossAppAccess $crossAppAccess the enterprise identity to reach the server through, + * instead of asking the user to approve anything + * @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 + * @throws InvalidArgumentException when the redirect URI is insecure, a client id, secret or private key is set without an + * expected issuer, or the grant, cross-app access and signing algorithm don't fit together */ public function __construct( public readonly ClientMetadata $clientMetadata, public readonly string $redirectUri = OAuth::DEFAULT_REDIRECT_URI, + public readonly Grant $grant = Grant::AuthorizationCode, public readonly ?string $clientId = null, public readonly ?string $clientSecret = null, public readonly ?string $clientMetadataUrl = null, + public readonly ?string $privateKeyPem = null, + public readonly string $signingAlgorithm = 'RS256', public readonly array $scopes = [], public readonly bool $offlineAccess = true, public readonly bool $legacyDiscovery = false, + public readonly ?CrossAppAccess $crossAppAccess = null, public readonly ?string $expectedIssuer = null, ) { if (!AuthorizationServerMetadata::isTransportSecure($redirectUri)) { @@ -59,8 +69,32 @@ public function __construct( throw new InvalidArgumentException(\sprintf('The redirect URI "%s" must not have a fragment or userinfo.', $redirectUri)); } - if (null !== $clientId && null === $expectedIssuer) { + if (!\in_array($grant, [Grant::AuthorizationCode, Grant::ClientCredentials, Grant::JwtBearer], true)) { + throw new InvalidArgumentException(\sprintf('The "%s" grant cannot be used to authorize a client.', $grant->value)); + } + + if ((Grant::JwtBearer === $grant) !== (null !== $crossAppAccess)) { + throw new InvalidArgumentException('The JWT bearer grant and cross-app access only work together. Call OAuth::setCrossAppAccess().'); + } + + if (!\in_array($signingAlgorithm, TokenEndpoint::ASSERTION_ALGORITHMS, true)) { + throw new InvalidArgumentException(\sprintf('Unsupported client assertion algorithm "%s". Use one of %s.', $signingAlgorithm, implode(', ', TokenEndpoint::ASSERTION_ALGORITHMS))); + } + + if (Grant::ClientCredentials === $grant && (null === $clientId || (null === $clientSecret && null === $privateKeyPem))) { + throw new InvalidArgumentException('The client credentials grant needs a client id and either a client secret or a private key.'); + } + + if (null !== $crossAppAccess && null === $clientId) { + throw new InvalidArgumentException('Cross-app access needs the client id the authorization server knows this client by; it is what the authorization grant is bound to.'); + } + + if ((null !== $clientId || null !== $clientSecret || null !== $privateKeyPem) && 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().'); } + + if (null !== $privateKeyPem && null === $clientId) { + throw new InvalidArgumentException('Private key JWT client authentication needs a client id to name in the assertion.'); + } } } diff --git a/src/Client/Auth/TokenEndpoint.php b/src/Client/Auth/TokenEndpoint.php index f455aa27..7c1f51e5 100644 --- a/src/Client/Auth/TokenEndpoint.php +++ b/src/Client/Auth/TokenEndpoint.php @@ -11,6 +11,7 @@ namespace Mcp\Client\Auth; +use Firebase\JWT\JWT; use Mcp\Exception\AuthorizationException; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\OAuthErrorException; @@ -31,6 +32,12 @@ */ final class TokenEndpoint { + /** Asymmetric, since the authorization server holds the public key, and signable from a PEM key with every supported firebase/php-jwt. */ + public const ASSERTION_ALGORITHMS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES256K', 'ES384']; + + /** RFC 10070 section 4: an explicit type keeps the assertion from passing for another kind of JWT. */ + private const ASSERTION_TYPE = 'client-authentication+jwt'; + public function __construct( private readonly ClientInterface $httpClient, private readonly RequestFactoryInterface $requestFactory, @@ -46,6 +53,8 @@ public function request( AuthorizationServerMetadata $metadata, ClientRegistration $client, array $parameters, + ?string $privateKeyPem = null, + string $signingAlgorithm = 'RS256', ): AccessToken { $parameters['client_id'] = $client->clientId; $authorization = null; @@ -60,6 +69,15 @@ public function request( $parameters['client_secret'] = (string) $client->clientSecret; break; + case TokenEndpointAuthMethod::PrivateKeyJwt: + if (null === $privateKeyPem) { + throw new AuthorizationException('The client registration uses private_key_jwt client authentication, but no private key was configured. Call OAuth::setPrivateKeyJwt().'); + } + + $parameters['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; + $parameters['client_assertion'] = $this->createClientAssertion($client->clientId, $metadata->issuer, $privateKeyPem, $signingAlgorithm); + break; + case TokenEndpointAuthMethod::None: break; } @@ -80,11 +98,13 @@ public function request( } /** + * Post a form-encoded grant request, for an exchange that doesn't return an access token. + * * @param array $parameters * * @return array */ - private function post(string $endpoint, array $parameters, ?string $authorization = null): array + public function post(string $endpoint, array $parameters, ?string $authorization = null): array { $request = $this->requestFactory->createRequest('POST', $endpoint) ->withHeader('Content-Type', 'application/x-www-form-urlencoded') @@ -105,7 +125,7 @@ private function post(string $endpoint, array $parameters, ?string $authorizatio 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) { + $message = \sprintf('The token endpoint "%s" answered with %d%s.', $endpoint, $response->getStatusCode(), match (true) { 200 === $response->getStatusCode() => ', and a body this client could not read as JSON', \is_array($decoded) => ': '.self::describeError($decoded), default => '', }); @@ -126,6 +146,35 @@ private function post(string $endpoint, array $parameters, ?string $authorizatio return $decoded; } + /** + * Builds the JWT a client signs to authenticate itself (RFC 7523 section 2.2, as updated by RFC 10070). + */ + private function createClientAssertion(string $clientId, string $audience, string $privateKeyPem, string $algorithm): string + { + if (!class_exists(JWT::class)) { + throw new AuthorizationException('For using private_key_jwt client authentication, the firebase/php-jwt package is required. Try running "composer require firebase/php-jwt".'); + } + + if (!\in_array($algorithm, self::ASSERTION_ALGORITHMS, true)) { + throw new AuthorizationException(\sprintf('Unsupported client assertion algorithm "%s". Use one of %s.', $algorithm, implode(', ', self::ASSERTION_ALGORITHMS))); + } + + $now = time(); + + try { + return JWT::encode([ + 'iss' => $clientId, + 'sub' => $clientId, + 'aud' => $audience, + 'jti' => bin2hex(random_bytes(16)), + 'iat' => $now, + 'exp' => $now + 60, + ], $privateKeyPem, $algorithm, head: ['typ' => self::ASSERTION_TYPE]); + } catch (\DomainException|\InvalidArgumentException|\UnexpectedValueException $e) { + throw new AuthorizationException(\sprintf('Signing the client assertion with %s failed: %s', $algorithm, $e->getMessage()), 0, $e); + } + } + /** * @param array $response */ diff --git a/src/Client/Auth/TokenEndpointAuthMethod.php b/src/Client/Auth/TokenEndpointAuthMethod.php index 65d1529a..6f4d6405 100644 --- a/src/Client/Auth/TokenEndpointAuthMethod.php +++ b/src/Client/Auth/TokenEndpointAuthMethod.php @@ -27,19 +27,28 @@ enum TokenEndpointAuthMethod: string /** Client id and secret in the request body. */ case ClientSecretPost = 'client_secret_post'; + /** A JWT assertion signed with the client's private key (RFC 7523). */ + case PrivateKeyJwt = 'private_key_jwt'; + /** - * Pick the method for the advertised list: no list means Basic (RFC 8414), and a client without a secret is public. + * Pick the method for the advertised list: no list means Basic (RFC 8414), and a client without a secret or key is public. * - * @param string[] $supported the server's token_endpoint_auth_methods_supported + * @param string[] $supported the server's token_endpoint_auth_methods_supported + * @param bool $mayBePublic false for a grant that requires client authentication, like client credentials */ - public static function negotiate(array $supported, bool $hasSecret): self + public static function negotiate(array $supported, bool $hasSecret, bool $hasPrivateKey = false, bool $mayBePublic = true): self { - $candidates = $hasSecret - ? [self::ClientSecretBasic, self::ClientSecretPost, self::None] - : [self::None]; + $candidates = [ + ...($hasPrivateKey ? [self::PrivateKeyJwt] : []), + ...($hasSecret ? [self::ClientSecretBasic, self::ClientSecretPost] : []), + ]; + + if ($mayBePublic || [] === $candidates) { + $candidates[] = self::None; + } if ([] === $supported) { - return $candidates[0]; + return $hasSecret ? self::ClientSecretBasic : $candidates[0]; } foreach ($candidates as $candidate) { diff --git a/tests/Conformance/client-extensions.sh b/tests/Conformance/client-extensions.sh new file mode 100755 index 00000000..812d9fe9 --- /dev/null +++ b/tests/Conformance/client-extensions.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env sh +# +# Runs the client against the conformance scenarios of the authorization extensions. +# +# `--suite all` leaves extension scenarios out, and `--spec-version` skips them with exit +# code 0, so they run one by one, without a version, and a skipped one fails the run. +# +# CONFORMANCE="npx --yes @modelcontextprotocol/conformance@" tests/Conformance/client-extensions.sh + +set -eu + +directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +conformance=${CONFORMANCE:?Set CONFORMANCE to the command that starts the conformance runner.} + +for scenario in auth/client-credentials-basic auth/client-credentials-jwt auth/enterprise-managed-authorization; do + echo "=== $scenario" + + if ! output=$(cd "$directory" && $conformance client --command "php $directory/client.php" --scenario "$scenario" 2>&1); then + echo "$output" + exit 1 + fi + + echo "$output" + + case $output in + *SKIPPED*) + echo "The scenario $scenario was skipped instead of run." >&2 + exit 1 + ;; + esac +done diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php index 1a3c9c17..c9ae646a 100644 --- a/tests/Conformance/client.php +++ b/tests/Conformance/client.php @@ -11,12 +11,17 @@ 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\CrossAppAccess; 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\Exception\RuntimeException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ElicitAction; use Mcp\Schema\Enum\ProtocolVersion; @@ -43,9 +48,14 @@ // on the scenario: // // toolCalls http-custom-headers: the exact calls to make -// client_id auth/pre-registration: the credentials and the issuer an -// client_secret administrator would have configured out of band -// issuer +// client_id auth/pre-registration, auth/client-credentials-*, +// client_secret auth/enterprise-managed-authorization: the credentials and +// issuer the issuer an administrator would have configured out of band +// private_key_pem auth/client-credentials-jwt: the key to sign the client +// signing_algorithm assertion with, and the algorithm to sign it with +// idp_token_endpoint auth/enterprise-managed-authorization: the identity provider +// idp_id_token the user is already signed in to, the token proving it, and +// idp_client_id this client's identifier there $context = json_decode(getenv('MCP_CONFORMANCE_CONTEXT') ?: '[]', true); $context = is_array($context) ? $context : []; @@ -95,6 +105,23 @@ 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. * @@ -102,7 +129,7 @@ public function handle(Request $request): Response * headless handler stands in for the browser. Everything else -- discovery, PKCE, * registration, scope selection -- is the SDK doing what it would do in production. */ -function buildAuthenticator(string $scenario, array $context, Psr\Log\LoggerInterface $logger): ?AuthenticatorInterface +function buildAuthenticator(string $url, string $scenario, array $context, Psr\Log\LoggerInterface $logger): ?AuthenticatorInterface { if (!str_starts_with($scenario, 'auth/')) { return null; @@ -110,6 +137,37 @@ function buildAuthenticator(string $scenario, array $context, Psr\Log\LoggerInte $handler = new HeadlessAuthorizationHandler(logger: $logger); + // A client authenticating as itself: the harness hands over the credentials the + // authorization server already knows about, since nobody can consent to a registration. + if (str_starts_with($scenario, 'auth/client-credentials')) { + $oauth = OAuth::forServiceAccount( + 'mcp-conformance-test-client', + $context['client_id'] ?? 'conformance-test-client', + $context['client_secret'] ?? null, + ); + + if (isset($context['private_key_pem'])) { + $oauth->setPrivateKeyJwt($context['private_key_pem'], $context['signing_algorithm'] ?? 'ES256'); + } + + return $oauth->setExpectedIssuer(discoverIssuer($url))->setLogger($logger)->build(); + } + + // Enterprise-managed authorization: the user already signed in at the company + // identity provider, so the runner hands over that token instead of a browser. + if ('auth/enterprise-managed-authorization' === $scenario) { + return OAuth::forApplication('mcp-conformance-test-client') + ->setClientCredentials($context['client_id'], $context['client_secret']) + ->setCrossAppAccess(new CrossAppAccess( + $context['idp_token_endpoint'], + $context['idp_id_token'], + clientId: $context['idp_client_id'] ?? null, + )) + ->setExpectedIssuer(discoverIssuer($url)) + ->setLogger($logger) + ->build(); + } + $oauth = OAuth::forApplication('mcp-conformance-test-client') ->setRedirectUri('http://localhost:3000/callback') ->setAuthorizationHandler($handler) @@ -138,7 +196,7 @@ function buildAuthenticator(string $scenario, array $context, Psr\Log\LoggerInte } $client = $builder->build(); -$transport = new HttpTransport($url, logger: $logger, auth: buildAuthenticator($scenario, $context, $logger)); +$transport = new HttpTransport($url, logger: $logger, auth: buildAuthenticator($url, $scenario, $context, $logger)); try { $client->connect($transport); diff --git a/tests/Conformance/conformance-baseline-2025-11-25.yml b/tests/Conformance/conformance-baseline-2025-11-25.yml index c53cc62c..ac08dab2 100644 --- a/tests/Conformance/conformance-baseline-2025-11-25.yml +++ b/tests/Conformance/conformance-baseline-2025-11-25.yml @@ -1,6 +1,3 @@ client: # Elicitation defaults (SEP-1034) are not filled in when accepting. - elicitation-sep1034-client-defaults - - auth/client-credentials-jwt - - auth/client-credentials-basic - - auth/cross-app-access-complete-flow diff --git a/tests/E2E/KeycloakAdmin.php b/tests/E2E/KeycloakAdmin.php new file mode 100644 index 00000000..b3d2c17e --- /dev/null +++ b/tests/E2E/KeycloakAdmin.php @@ -0,0 +1,86 @@ + + */ +final class KeycloakAdmin +{ + private readonly HttpClientInterface $httpClient; + + public function __construct( + private readonly string $baseUrl, + private readonly string $realm, + private readonly string $username = 'admin', + private readonly string $password = 'admin', + ) { + $this->httpClient = HttpClient::create(['timeout' => 15]); + } + + /** + * @return string the PEM private key whose certificate the client now accepts + */ + public function issueSigningKey(string $clientId): string + { + $key = openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + $csr = false !== $key ? openssl_csr_new(['commonName' => $clientId], $key, ['digest_alg' => 'sha256']) : false; + $certificate = $csr instanceof \OpenSSLCertificateSigningRequest ? openssl_csr_sign($csr, null, $key, 1, ['digest_alg' => 'sha256']) : false; + + if (false === $key || false === $certificate || !openssl_pkey_export($key, $privateKeyPem) || !openssl_x509_export($certificate, $certificatePem)) { + throw new RuntimeException('Generating the signing key failed: '.openssl_error_string()); + } + + // Keycloak merges the attributes of a partial update; sending the whole client back fails. + $this->httpClient->request('PUT', $this->adminUrl('/clients/'.$this->findClientId($clientId)), [ + 'auth_bearer' => $this->adminToken(), + 'json' => ['attributes' => ['jwt.credential.certificate' => preg_replace('/-----[A-Z ]+-----|\s/', '', $certificatePem)]], + ])->getContent(); + + return $privateKeyPem; + } + + private function findClientId(string $clientId): string + { + $clients = $this->httpClient->request('GET', $this->adminUrl('/clients'), [ + 'auth_bearer' => $this->adminToken(), + 'query' => ['clientId' => $clientId], + ])->toArray(); + + if (!isset($clients[0]['id'])) { + throw new RuntimeException(\sprintf('The realm "%s" has no client "%s".', $this->realm, $clientId)); + } + + return $clients[0]['id']; + } + + private function adminToken(): string + { + return $this->httpClient->request('POST', $this->baseUrl.'/realms/master/protocol/openid-connect/token', [ + 'body' => ['grant_type' => 'password', 'client_id' => 'admin-cli', 'username' => $this->username, 'password' => $this->password], + ])->toArray()['access_token']; + } + + private function adminUrl(string $path): string + { + return $this->baseUrl.'/admin/realms/'.$this->realm.$path; + } +} diff --git a/tests/E2E/README.md b/tests/E2E/README.md index 8a61a514..e7fa3a6f 100644 --- a/tests/E2E/README.md +++ b/tests/E2E/README.md @@ -24,6 +24,10 @@ takes a minute; afterwards the whole thing is about twenty seconds. ok the protected tool answers ok the credentials were written to disk ok the stored token still opens the server + ok a service account with a secret gets in + ok a wrong secret is refused + ok a service account with a private key gets in + ok a key Keycloak does not know is refused ``` Between the first and second line the client does all of this against a real identity @@ -40,6 +44,12 @@ provider, unattended: keys, and its audience against the mapper in the realm. 7. Writes the token to disk, so the second connection needs no browser. +Then two service accounts get in with the client credentials grant, with no user and +no browser: one authenticates with a secret, the other with an assertion signed by an +ES256 key. `KeycloakAdmin` generates that key for each run and registers its +certificate at Keycloak, as an administrator would, so no private key is committed. +Keycloak verifies the signature and the `aud` claim, and refuses a key it doesn't know. + ## Layout | File | What it is | @@ -48,15 +58,17 @@ provider, unattended: | `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 | +| `KeycloakAdmin.php` | Registers the service account's freshly generated key through Keycloak's admin API | | `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 | +| `keycloak/realm.json` | The realm Keycloak imports: one public client, two service accounts, 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. +without breaking this run. On top of that, it has two confidential service accounts that +get the same client scope. ## In continuous integration diff --git a/tests/E2E/client.php b/tests/E2E/client.php index 0940c2bb..127e439f 100644 --- a/tests/E2E/client.php +++ b/tests/E2E/client.php @@ -19,6 +19,9 @@ * credentials -- is the SDK's, not this script's. The script only supplies the browser * (see BrowserAuthorizationHandler) and then checks the outcome. * + * The service accounts then get in with no user at all: one with a secret, one with a + * key that KeycloakAdmin registers first, as an administrator would. + * * Run it through run.sh, which brings the environment up first. */ @@ -30,13 +33,16 @@ use Mcp\Client\Transport\HttpTransport; use Mcp\Exception\AuthorizationException; use Mcp\Exception\ConnectionException; +use Mcp\Exception\RuntimeException; use Mcp\Tests\E2E\BrowserAuthorizationHandler; +use Mcp\Tests\E2E\KeycloakAdmin; $endpoint = getenv('MCP_E2E_ENDPOINT') ?: 'http://localhost:8001/mcp'; $issuer = getenv('MCP_E2E_ISSUER') ?: 'http://localhost:8181/realms/mcp-e2e'; $username = getenv('MCP_E2E_USERNAME') ?: 'demo'; $password = getenv('MCP_E2E_PASSWORD') ?: 'demo123'; $credentialsFile = getenv('MCP_E2E_CREDENTIALS') ?: sys_get_temp_dir().'/mcp-e2e-credentials.json'; +[$keycloakUrl, $realm] = explode('/realms/', $issuer, 2); $failures = 0; $check = static function (string $what, bool $passed, string $detail = '') use (&$failures): void { @@ -104,6 +110,49 @@ $check('the stored token still opens the server', str_contains((string) json_encode($second->callTool('whoami', [])->content), 'authorized')); $second->disconnect(); +$callAs = static function (OAuth $oauth) use ($endpoint, $issuer): string { + $client = Client::builder()->setClientInfo('mcp-client-auth-e2e', '1.0.0')->setInitTimeout(30)->setRequestTimeout(30)->build(); + $client->connect(new HttpTransport($endpoint, auth: $oauth->setExpectedIssuer($issuer)->build())); + + try { + return (string) json_encode($client->callTool('whoami', [])->content); + } finally { + $client->disconnect(); + } +}; + +fwrite(\STDOUT, "\nService account with a secret\n"); + +$result = $callAs(OAuth::forServiceAccount('MCP PHP SDK end-to-end job', 'mcp-service-account', 'mcp-service-secret')); +$check('a service account with a secret gets in', str_contains($result, 'authorized'), $result); + +try { + $callAs(OAuth::forServiceAccount('MCP PHP SDK end-to-end job', 'mcp-service-account', 'not-the-secret')); + $check('a wrong secret is refused', false, 'the server let it in'); +} catch (ConnectionException $e) { + $check('a wrong secret is refused', $e->getPrevious() instanceof AuthorizationException, $e->getMessage()); +} + +fwrite(\STDOUT, "\nService account with a private key\n"); + +$privateKey = (new KeycloakAdmin($keycloakUrl, $realm))->issueSigningKey('mcp-service-account-jwt'); +$result = $callAs(OAuth::forServiceAccount('MCP PHP SDK end-to-end job', 'mcp-service-account-jwt')->setPrivateKeyJwt($privateKey, 'ES256')); +$check('a service account with a private key gets in', str_contains($result, 'authorized'), $result); + +// Keycloak checks the signature, so a key it doesn't know proves the assertion is what it verifies. +$unknown = openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + +if (false === $unknown || !openssl_pkey_export($unknown, $unknownKey)) { + throw new RuntimeException('Generating the unknown key failed: '.openssl_error_string()); +} + +try { + $callAs(OAuth::forServiceAccount('MCP PHP SDK end-to-end job', 'mcp-service-account-jwt')->setPrivateKeyJwt($unknownKey, 'ES256')); + $check('a key Keycloak does not know is refused', false, 'the server let it in'); +} catch (ConnectionException $e) { + $check('a key Keycloak does not know is refused', str_contains($e->getMessage(), 'invalid_client'), $e->getMessage()); +} + fwrite(\STDOUT, "\n"); fwrite(\STDOUT, 0 === $failures ? "End-to-end authorization succeeded.\n" : sprintf("%d end-to-end check(s) failed.\n", $failures)); diff --git a/tests/E2E/keycloak/realm.json b/tests/E2E/keycloak/realm.json index e29df05c..e9377d88 100644 --- a/tests/E2E/keycloak/realm.json +++ b/tests/E2E/keycloak/realm.json @@ -41,6 +41,39 @@ "pkce.code.challenge.method": "S256" } }, + { + "clientId": "mcp-service-account", + "name": "MCP Service Account", + "description": "Confidential client for a job that runs without a user, authenticating with a secret", + "enabled": true, + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "mcp-service-secret", + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "fullScopeAllowed": true, + "defaultClientScopes": [ + "mcp" + ], + "optionalClientScopes": [] + }, + { + "clientId": "mcp-service-account-jwt", + "name": "MCP Service Account (private key)", + "description": "Confidential client authenticating with a signed assertion; client.php registers its key", + "enabled": true, + "publicClient": false, + "clientAuthenticatorType": "client-jwt", + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "fullScopeAllowed": true, + "defaultClientScopes": [ + "mcp" + ], + "optionalClientScopes": [] + }, { "clientId": "mcp-server", "name": "MCP Server Resource", diff --git a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php index 971d0622..6ba3d41a 100644 --- a/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php +++ b/tests/Unit/Client/Auth/OAuthAuthenticatorTest.php @@ -16,6 +16,7 @@ use Mcp\Client\Auth\ClientRegistrar; use Mcp\Client\Auth\ClientRegistration; use Mcp\Client\Auth\CredentialStorageInterface; +use Mcp\Client\Auth\CrossAppAccess; use Mcp\Client\Auth\InMemoryCredentialStorage; use Mcp\Client\Auth\MetadataDiscovery; use Mcp\Client\Auth\OAuth; @@ -28,6 +29,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; @@ -575,6 +577,25 @@ public function testRefusesALoopbackIssuerOfARemoteServer(string $issuer): void $this->assertSame([], array_filter($server->requested, static fn (string $url): bool => str_starts_with($url, $issuer))); } + #[TestDox('another type of token from the identity provider is not presented as a grant')] + public function testCrossAppAccessRefusesAnotherTokenType(): void + { + $server = new FakeOAuthServer(issuedTokenType: 'urn:ietf:params:oauth:token-type:access_token'); + $oauth = OAuth::forApplication('test') + ->setClientCredentials('known-client', 'known-secret') + ->setExpectedIssuer('https://auth.example.com') + ->setCrossAppAccess(new CrossAppAccess('https://idp.example.com/token', 'an-id-token')); + + try { + $this->authenticator($server, configuration: $oauth)->handleChallenge($this->request(), $server->challenge()); + $this->fail('The other type of token was presented as a grant.'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('returned no authorization grant', $e->getMessage()); + } + + $this->assertNotContains('https://auth.example.com/token', $server->requested); + } + #[TestDox('an authorization endpoint on another origin needs iss, so a relayed code can not leave the real server')] public function testRefusesAnAuthorizationEndpointOnAnotherOriginWithoutIss(): void { @@ -1431,6 +1452,286 @@ public function testConfiguredClientIdWinsOverTheClientMetadataUrl(): void $this->assertSame('known-client', $server->authorization['client_id'] ?? null); } + #[TestDox('a service account gets a token without registering or a browser')] + public function testClientCredentialsGrant(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: OAuth::forServiceAccount('job', 'job-client', 'job-secret')->setExpectedIssuer('https://auth.example.com')); + + $this->assertTrue($authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read']))); + + $this->assertSame('client_credentials', $server->token['grant_type'] ?? null); + $this->assertSame('mcp:read', $server->token['scope'] ?? null); + $this->assertSame(self::ENDPOINT, $server->token['resource'] ?? null); + $this->assertSame([], $server->authorization); + $this->assertNotContains('https://auth.example.com/register', $server->requested); + } + + #[TestDox('a service account never asks for scopes beyond the configured ones, whatever the server demands')] + public function testServiceAccountKeepsToTheConfiguredScopes(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: OAuth::forServiceAccount('job', 'job-client', 'job-secret') + ->setExpectedIssuer('https://auth.example.com') + ->setScopes('mcp:read')); + $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + $server->token = []; + + $retry = $authenticator->handleChallenge($authenticator->authenticate($this->request()), $server->challenge(['admin'], 403, 'insufficient_scope')); + + $this->assertFalse($retry); + $this->assertSame([], $server->token); + } + + /** + * @return iterable + */ + public static function broaderStoredScopesProvider(): iterable + { + yield 'a scope beyond the configured ones' => [['mcp:read', 'admin']]; + yield 'no reported scopes, which cover anything' => [[]]; + } + + /** + * @param string[] $storedScopes + */ + #[DataProvider('broaderStoredScopesProvider')] + #[TestDox('a service account does not take over a stored token that may exceed its configured scopes: $_dataName')] + public function testServiceAccountIgnoresABroaderStoredToken(array $storedScopes): void + { + $server = new FakeOAuthServer(); + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('broader', 'Bearer', time() + 3600, null, $storedScopes)); + $authenticator = $this->authenticator($server, storage: $storage, configuration: OAuth::forServiceAccount('job', 'job-client', 'job-secret') + ->setExpectedIssuer('https://auth.example.com') + ->setScopes('mcp:read')); + + $this->assertTrue($authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read']))); + + $this->assertNotSame('Bearer broader', $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); + $this->assertSame('mcp:read', $server->token['scope'] ?? null); + } + + #[TestDox('a service account does not refresh a stored token that exceeds its configured scopes')] + public function testServiceAccountDoesNotRefreshABroaderStoredToken(): void + { + $server = new FakeOAuthServer(refreshTokens: true); + $storage = new InMemoryCredentialStorage(); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('broader', 'Bearer', time() - 60, 'broader-refresh', ['mcp:read', 'admin'])); + $authenticator = $this->authenticator($server, storage: $storage, configuration: OAuth::forServiceAccount('job', 'job-client', 'job-secret') + ->setExpectedIssuer('https://auth.example.com') + ->setScopes('mcp:read')); + + $this->assertTrue($authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read']))); + + $this->assertSame('client_credentials', $server->token['grant_type'] ?? null); + $this->assertSame('mcp:read', $server->token['scope'] ?? null); + } + + #[TestDox('a service account still reuses the token it stored itself')] + public function testServiceAccountReusesItsOwnStoredToken(): void + { + $server = new FakeOAuthServer(); + $storage = new InMemoryCredentialStorage(); + $configuration = OAuth::forServiceAccount('job', 'job-client', 'job-secret') + ->setExpectedIssuer('https://auth.example.com') + ->setScopes('mcp:read'); + $this->authenticator($server, storage: $storage, configuration: $configuration)->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + $server->token = []; + + $this->assertTrue($this->authenticator($server, storage: $storage, configuration: $configuration)->handleChallenge($this->request(), $server->challenge(['mcp:read']))); + $this->assertSame([], $server->token); + } + + /** + * @return iterable + */ + public static function broaderStoredTokenLifetimeProvider(): iterable + { + yield 'still valid, so it would be taken over' => [3600]; + yield 'expired, so its scopes would be requested' => [-60]; + } + + #[DataProvider('broaderStoredTokenLifetimeProvider')] + #[TestDox('a step-up does not use the scopes of a broader token another process stored: $_dataName')] + public function testServiceAccountStepUpIgnoresStoredScopes(int $expiresIn): void + { + $server = new FakeOAuthServer(); + $storage = new InMemoryCredentialStorage(); + $authenticator = $this->authenticator($server, storage: $storage, configuration: OAuth::forServiceAccount('job', 'job-client', 'job-secret') + ->setExpectedIssuer('https://auth.example.com') + ->setScopes('mcp:read')); + $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + $current = $authenticator->authenticate($this->request()); + $storage->saveToken('https://auth.example.com', self::ENDPOINT, new AccessToken('broader', 'Bearer', time() + $expiresIn, null, ['mcp:read', 'admin'])); + $server->token = []; + + $retry = $authenticator->handleChallenge($current, $server->challenge(['admin'], 403, 'insufficient_scope')); + + $this->assertFalse($retry); + $this->assertSame([], $server->token); + $this->assertSame($current->getHeaderLine('Authorization'), $authenticator->authenticate($this->request())->getHeaderLine('Authorization')); + } + + #[TestDox('cross-app access never asks the identity provider for scopes beyond the configured ones')] + public function testCrossAppAccessKeepsToTheConfiguredScopes(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: $this->crossAppAccess()->setScopes('mcp:read')); + $authenticator->handleChallenge($this->request(), $server->challenge(['mcp:read'])); + $server->exchange = []; + $server->token = []; + + $retry = $authenticator->handleChallenge($authenticator->authenticate($this->request()), $server->challenge(['admin'], 403, 'insufficient_scope')); + + $this->assertFalse($retry); + $this->assertSame([], $server->exchange); + $this->assertSame([], $server->token); + } + + #[TestDox('a service account secret never reaches an authorization server other than the expected one')] + public function testServiceAccountRefusesAnUnexpectedIssuer(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: OAuth::forServiceAccount('job', 'job-client', 'job-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->token); + } + + #[TestDox('cross-app access exchanges the identity token and presents the grant as an assertion')] + public function testCrossAppAccessGrant(): void + { + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: $this->crossAppAccess()); + + $this->assertTrue($authenticator->handleChallenge($this->request(), $server->challenge())); + + $this->assertSame('urn:ietf:params:oauth:grant-type:token-exchange', $server->exchange['grant_type'] ?? null); + $this->assertSame('an-id-token', $server->exchange['subject_token'] ?? null); + $this->assertSame(CrossAppAccess::AUTHORIZATION_GRANT_TOKEN_TYPE, $server->exchange['requested_token_type'] ?? null); + $this->assertSame('https://auth.example.com', $server->exchange['audience'] ?? null); + $this->assertSame(self::ENDPOINT, $server->exchange['resource'] ?? null); + $this->assertSame('urn:ietf:params:oauth:grant-type:jwt-bearer', $server->token['grant_type'] ?? null); + $this->assertSame('an-authorization-grant', $server->token['assertion'] ?? null); + $this->assertSame([], $server->authorization); + } + + #[TestDox('the identity provider is asked for a grant covering the requested scopes')] + public function testCrossAppAccessRequestsTheScopes(): void + { + $server = new FakeOAuthServer(); + $configuration = $this->crossAppAccess()->setScopes('mcp:read', 'mcp:write'); + + $this->authenticator($server, configuration: $configuration)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame($server->token['scope'] ?? null, $server->exchange['scope'] ?? null); + $this->assertContains('mcp:write', explode(' ', $server->exchange['scope'] ?? '')); + } + + #[TestDox('a closure hands over a fresh identity token for each grant')] + public function testCrossAppAccessWithAnIdentityTokenClosure(): void + { + $server = new FakeOAuthServer(); + $configuration = OAuth::forApplication('test') + ->setClientCredentials('known-client', 'known-secret') + ->setExpectedIssuer('https://auth.example.com') + ->setCrossAppAccess(new CrossAppAccess('https://idp.example.com/token', static fn (): string => 'a-fresh-id-token')); + + $this->authenticator($server, configuration: $configuration)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('a-fresh-id-token', $server->exchange['subject_token'] ?? null); + } + + #[TestDox('the identity provider gets the client id of the authorization server by default')] + public function testCrossAppAccessFallsBackToTheClientId(): void + { + $server = new FakeOAuthServer(); + + $this->authenticator($server, configuration: $this->crossAppAccess())->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('known-client', $server->exchange['client_id'] ?? null); + $this->assertArrayNotHasKey('client_secret', $server->exchange); + } + + #[TestDox('the identity provider gets its own client id and secret, when configured')] + public function testCrossAppAccessWithIdentityProviderCredentials(): void + { + $server = new FakeOAuthServer(); + $configuration = OAuth::forApplication('test') + ->setClientCredentials('known-client', 'known-secret') + ->setExpectedIssuer('https://auth.example.com') + ->setCrossAppAccess(new CrossAppAccess('https://idp.example.com/token', 'an-id-token', clientId: 'idp-client', clientSecret: 'idp-secret')); + + $this->authenticator($server, configuration: $configuration)->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('idp-client', $server->exchange['client_id'] ?? null); + $this->assertSame('idp-secret', $server->exchange['client_secret'] ?? null); + } + + #[TestDox('an identity provider that returns no grant ends the flow')] + public function testCrossAppAccessWithoutAGrant(): void + { + $server = new FakeOAuthServer(identityProviderGrants: false); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('returned no authorization grant'); + + $this->authenticator($server, configuration: $this->crossAppAccess())->handleChallenge($this->request(), $server->challenge()); + } + + #[TestDox('a configured private key signs the code exchange')] + #[RequiresPhpExtension('openssl')] + public function testPrivateKeySignsTheCodeExchange(): void + { + $key = openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + $this->assertNotFalse($key); + openssl_pkey_export($key, $pem); + + $server = new FakeOAuthServer(); + $authenticator = $this->authenticator($server, configuration: OAuth::forApplication('test') + ->setClientCredentials('known-client') + ->setPrivateKeyJwt($pem, 'ES256') + ->setExpectedIssuer('https://auth.example.com')); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('urn:ietf:params:oauth:client-assertion-type:jwt-bearer', $server->token['client_assertion_type'] ?? null); + $this->assertNotEmpty($server->token['client_assertion'] ?? null); + } + + #[TestDox('a service account with only a key signs an assertion even where the server also takes public clients')] + public function testServiceAccountNeverAuthenticatesAsAPublicClient(): void + { + $key = openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + $this->assertNotFalse($key); + openssl_pkey_export($key, $pem); + + $server = new FakeOAuthServer(tokenEndpointAuthMethods: ['client_secret_basic', 'none']); + $authenticator = $this->authenticator($server, configuration: OAuth::forServiceAccount('job', 'job-client') + ->setPrivateKeyJwt($pem, 'ES256') + ->setExpectedIssuer('https://auth.example.com')); + + $authenticator->handleChallenge($this->request(), $server->challenge()); + + $this->assertSame('client_credentials', $server->token['grant_type'] ?? null); + $this->assertNotEmpty($server->token['client_assertion'] ?? null); + } + + private function crossAppAccess(): OAuth + { + return OAuth::forApplication('test') + ->setClientCredentials('known-client', 'known-secret') + ->setExpectedIssuer('https://auth.example.com') + ->setCrossAppAccess(new CrossAppAccess('https://idp.example.com/token', 'an-id-token')); + } + /** * @return iterable */ @@ -1612,6 +1913,8 @@ final class FakeOAuthServer implements ClientInterface /** @var ?string[] */ public ?array $grantTypes = ['authorization_code', 'refresh_token']; + /** @var array */ + public array $exchange = []; private int $issued = 0; @@ -1633,7 +1936,10 @@ public function __construct( private readonly ?array $codeChallengeMethods = ['S256'], private readonly ?string $authorizationEndpoint = null, private readonly bool $announcesIssuer = true, + private readonly string $issuedTokenType = CrossAppAccess::AUTHORIZATION_GRANT_TOKEN_TYPE, private readonly bool $clientMetadataDocuments = false, + private readonly bool $identityProviderGrants = true, + private readonly ?array $tokenEndpointAuthMethods = null, ) { } @@ -1686,10 +1992,12 @@ public function sendRequest(RequestInterface $request): ResponseInterface 'grant_types_supported' => $this->hasAuthorizationEndpoint ? $this->grantTypes : ['client_credentials'], 'code_challenge_methods_supported' => $this->codeChallengeMethods, 'authorization_response_iss_parameter_supported' => $this->announcesIssuer, + ...(null !== $this->tokenEndpointAuthMethods ? ['token_endpoint_auth_methods_supported' => $this->tokenEndpointAuthMethods] : []), ]), 'https://auth.example.com/register' => $this->register((string) $request->getBody()), 'https://auth.example.com/token' => $this->issueToken($form), + 'https://idp.example.com/token' => $this->exchangeToken($form), default => new Response(404), }; @@ -1722,6 +2030,20 @@ private function register(string $body): ResponseInterface /** * @param array $form */ + private function exchangeToken(array $form): ResponseInterface + { + $this->exchange = $form; + + return self::json($this->identityProviderGrants ? [ + 'access_token' => 'an-authorization-grant', + 'issued_token_type' => $this->issuedTokenType, + 'token_type' => 'N_A', + ] : []); + } + + /** + * @param array $form + */ private function issueToken(array $form): ResponseInterface { $flat = []; diff --git a/tests/Unit/Client/Auth/OAuthTest.php b/tests/Unit/Client/Auth/OAuthTest.php index 6c8dee75..72c9327b 100644 --- a/tests/Unit/Client/Auth/OAuthTest.php +++ b/tests/Unit/Client/Auth/OAuthTest.php @@ -13,6 +13,9 @@ use Http\Discovery\Psr18ClientDiscovery; use Http\Discovery\Strategy\DiscoveryStrategy; +use Mcp\Client\Auth\ClientMetadata; +use Mcp\Client\Auth\CrossAppAccess; +use Mcp\Client\Auth\Grant; use Mcp\Client\Auth\OAuth; use Mcp\Client\Auth\OAuthAuthenticator; use Mcp\Client\Auth\OAuthConfiguration; @@ -37,6 +40,7 @@ public function testApplicationDefaults(): void { $configuration = $this->configurationOf(OAuth::forApplication('My App')); $this->assertSame(OAuth::DEFAULT_REDIRECT_URI, $configuration->redirectUri); + $this->assertSame(Grant::AuthorizationCode, $configuration->grant); $this->assertSame('My App', $configuration->clientMetadata->clientName); $this->assertSame(['http://127.0.0.1:8765/callback'], $configuration->clientMetadata->redirectUris); $this->assertSame('native', $configuration->clientMetadata->applicationType); @@ -211,6 +215,111 @@ public function testSoftwareInfo(): void $this->assertSame('2.1.0', $body['software_version']); } + #[TestDox('a service account authenticates as itself, with no user and no refresh token')] + public function testServiceAccountDefaults(): void + { + $configuration = $this->configurationOf(OAuth::forServiceAccount('My Job', 'job-client', 'job-secret')->setExpectedIssuer('https://auth.example.com')); + + $this->assertSame(Grant::ClientCredentials, $configuration->grant); + $this->assertSame('job-client', $configuration->clientId); + $this->assertSame('job-secret', $configuration->clientSecret); + $this->assertFalse($configuration->offlineAccess); + $this->assertSame(['client_credentials'], $configuration->clientMetadata->grantTypes); + } + + #[TestDox('cross-app access switches to the assertion grant')] + public function testCrossAppAccessSwitchesGrant(): void + { + $configuration = $this->configurationOf(OAuth::forApplication('My App') + ->setClientCredentials('client', 'secret') + ->setExpectedIssuer('https://auth.example.com') + ->setCrossAppAccess(new CrossAppAccess('https://idp.example.com/token', 'an-id-token'))); + + $crossAppAccess = $configuration->crossAppAccess; + + $this->assertSame(Grant::JwtBearer, $configuration->grant); + $this->assertNotNull($crossAppAccess); + $this->assertSame('https://idp.example.com/token', $crossAppAccess->tokenEndpoint); + $this->assertSame(CrossAppAccess::ID_TOKEN_TYPE, $crossAppAccess->identityTokenType); + } + + #[TestDox('cross-app access without a client id is refused: the grant is bound to one')] + public function testCrossAppAccessNeedsAClientId(): void + { + $this->expectException(InvalidArgumentException::class); + + OAuth::forApplication('My App') + ->setCrossAppAccess(new CrossAppAccess('https://idp.example.com/token', 'an-id-token')) + ->build(); + } + + #[TestDox('a service account needs a client secret or a private key')] + public function testServiceAccountNeedsACredential(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('client secret or a private key'); + + OAuth::forServiceAccount('My Job', 'job-client')->build(); + } + + #[TestDox('an unsupported signing algorithm is refused when building')] + public function testPrivateKeyRefusesAnUnsupportedAlgorithm(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported client assertion algorithm "HS256"'); + + OAuth::forServiceAccount('My Job', 'job-client') + ->setPrivateKeyJwt('a-pem', 'HS256') + ->setExpectedIssuer('https://auth.example.com') + ->build(); + } + + #[TestWith([Grant::RefreshToken])] + #[TestWith([Grant::TokenExchange])] + #[TestDox('a grant that cannot authorize a client on its own is refused: $grant')] + public function testRefusesAGrantThatCannotAuthorize(Grant $grant): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot be used to authorize a client'); + + new OAuthConfiguration(new ClientMetadata('My App'), grant: $grant); + } + + #[TestDox('the JWT bearer grant needs cross-app access')] + public function testJwtBearerGrantNeedsCrossAppAccess(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('only work together'); + + new OAuthConfiguration(new ClientMetadata('My App'), grant: Grant::JwtBearer, clientId: 'client', expectedIssuer: 'https://auth.example.com'); + } + + #[TestDox('a private key needs an expected issuer')] + public function testPrivateKeyNeedsAnExpectedIssuer(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('expected issuer'); + + OAuth::forServiceAccount('My Job', 'job-client')->setPrivateKeyJwt('a-pem', 'ES256')->build(); + } + + #[TestDox('a service account can authenticate with only a private key')] + public function testServiceAccountWithAPrivateKey(): void + { + $configuration = $this->configurationOf(OAuth::forServiceAccount('My Job', 'job-client')->setPrivateKeyJwt('a-pem', 'ES256')->setExpectedIssuer('https://auth.example.com')); + + $this->assertSame('a-pem', $configuration->privateKeyPem); + $this->assertSame('ES256', $configuration->signingAlgorithm); + } + + #[TestDox('the identity token is not sent to an insecure identity provider')] + public function testCrossAppAccessRefusesAnInsecureIdentityProvider(): void + { + $this->expectException(InvalidArgumentException::class); + + new CrossAppAccess('http://idp.example.com/token', 'an-id-token'); + } + /** * @param int[] $seen */ diff --git a/tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php b/tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php index 2d99e81e..26828175 100644 --- a/tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php +++ b/tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php @@ -19,41 +19,64 @@ final class TokenEndpointAuthMethodTest extends TestCase { /** - * @return iterable + * @return iterable */ public static function negotiationProvider(): iterable { yield 'a public client can only be public' => [ - ['client_secret_basic', 'none'], false, TokenEndpointAuthMethod::None, + ['client_secret_basic', 'none'], false, false, TokenEndpointAuthMethod::None, ]; yield 'a client with a secret prefers the header over the body' => [ - ['client_secret_post', 'client_secret_basic'], true, TokenEndpointAuthMethod::ClientSecretBasic, + ['client_secret_post', 'client_secret_basic'], true, false, TokenEndpointAuthMethod::ClientSecretBasic, ]; yield 'a server offering only the body form gets it' => [ - ['client_secret_post'], true, TokenEndpointAuthMethod::ClientSecretPost, + ['client_secret_post'], true, false, TokenEndpointAuthMethod::ClientSecretPost, ]; yield 'a server that only takes public clients gets one' => [ - ['none'], true, TokenEndpointAuthMethod::None, + ['none'], true, false, TokenEndpointAuthMethod::None, + ]; + + yield 'a private key beats a shared secret' => [ + ['client_secret_basic', 'private_key_jwt'], true, true, TokenEndpointAuthMethod::PrivateKeyJwt, + ]; + + yield 'a private key without a secret never claims Basic' => [ + ['client_secret_basic', 'none'], false, true, TokenEndpointAuthMethod::None, + ]; + + yield 'a silent server gets Basic even when a private key is configured' => [ + [], true, true, TokenEndpointAuthMethod::ClientSecretBasic, + ]; + + yield 'a private key is used against a silent server' => [ + [], false, true, TokenEndpointAuthMethod::PrivateKeyJwt, ]; // RFC 6749 makes Basic the one method every server must accept, so it is the // right guess for a server that documents nothing. yield 'a silent server is assumed to take Basic' => [ - [], true, TokenEndpointAuthMethod::ClientSecretBasic, + [], true, false, TokenEndpointAuthMethod::ClientSecretBasic, ]; yield 'a silent server with a public client still gets none' => [ - [], false, TokenEndpointAuthMethod::None, + [], false, false, TokenEndpointAuthMethod::None, ]; } #[DataProvider('negotiationProvider')] #[TestDox('the token endpoint method is negotiated for: $_dataName')] - public function testNegotiate(array $supported, bool $hasSecret, TokenEndpointAuthMethod $expected): void + public function testNegotiate(array $supported, bool $hasSecret, bool $hasPrivateKey, TokenEndpointAuthMethod $expected): void + { + $this->assertSame($expected, TokenEndpointAuthMethod::negotiate($supported, $hasSecret, $hasPrivateKey)); + } + + #[TestDox('a grant that needs client authentication never negotiates a public client')] + public function testNegotiateWithoutPublicClient(): void { - $this->assertSame($expected, TokenEndpointAuthMethod::negotiate($supported, $hasSecret)); + $this->assertSame(TokenEndpointAuthMethod::PrivateKeyJwt, TokenEndpointAuthMethod::negotiate(['client_secret_basic', 'none'], false, true, false)); + $this->assertSame(TokenEndpointAuthMethod::ClientSecretPost, TokenEndpointAuthMethod::negotiate(['none', 'client_secret_post'], true, false, false)); } } diff --git a/tests/Unit/Client/Auth/TokenEndpointTest.php b/tests/Unit/Client/Auth/TokenEndpointTest.php index f74d6e32..87bb68dc 100644 --- a/tests/Unit/Client/Auth/TokenEndpointTest.php +++ b/tests/Unit/Client/Auth/TokenEndpointTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Client\Auth; +use Firebase\JWT\JWT; use Firebase\JWT\Key; use Mcp\Client\Auth\AuthorizationServerMetadata; use Mcp\Client\Auth\ClientRegistration; @@ -20,6 +21,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientInterface; @@ -132,6 +134,134 @@ public function testDoesNotLeakAnUnreadableSuccessBody(): void } } + #[RequiresPhpExtension('openssl')] + #[TestDox('a private key assertion verifies against the public key, and names the client')] + public function testPrivateKeyJwtAssertion(): void + { + $key = openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + $this->assertNotFalse($key); + openssl_pkey_export($key, $privateKey); + + $server = new CapturingTokenServer(); + $this->endpoint($server)->request( + $this->metadata(), + new ClientRegistration('a-client', null, TokenEndpointAuthMethod::PrivateKeyJwt), + ['grant_type' => 'client_credentials'], + $privateKey, + 'ES256', + ); + + $this->assertSame('urn:ietf:params:oauth:client-assertion-type:jwt-bearer', $server->form['client_assertion_type'] ?? null); + + $details = openssl_pkey_get_details($key); + $this->assertIsArray($details); + + $header = new \stdClass(); + $claims = JWT::decode($server->form['client_assertion'], new Key($details['key'], 'ES256'), $header); + + $this->assertSame('client-authentication+jwt', $header?->typ); + $this->assertSame('a-client', $claims->iss); + $this->assertSame('a-client', $claims->sub); + $this->assertSame('https://auth.example.com', $claims->aud); + $this->assertSame(60, $claims->exp - $claims->iat); + } + + #[RequiresPhpExtension('openssl')] + #[TestDox('an RSA assertion verifies against its public key too')] + public function testPrivateKeyJwtWithRsa(): void + { + $key = openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]); + $this->assertNotFalse($key); + openssl_pkey_export($key, $privateKey); + + $server = new CapturingTokenServer(); + $this->endpoint($server)->request( + $this->metadata(), + new ClientRegistration('a-client', null, TokenEndpointAuthMethod::PrivateKeyJwt), + ['grant_type' => 'client_credentials'], + $privateKey, + 'RS256', + ); + + $details = openssl_pkey_get_details($key); + $this->assertIsArray($details); + + $claims = JWT::decode($server->form['client_assertion'], new Key($details['key'], 'RS256')); + + $this->assertSame('a-client', $claims->iss); + $this->assertSame('https://auth.example.com', $claims->aud); + } + + // Signing with the HMAC family would use the private key as a shared secret, which + // is `client_secret_jwt` wearing the wrong name -- a downgrade, not a signature. + #[TestDox('a symmetric algorithm is refused rather than used on the private key')] + public function testRefusesASymmetricAlgorithm(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Unsupported client assertion algorithm "HS256"'); + + $this->endpoint(new CapturingTokenServer())->request( + $this->metadata(), + new ClientRegistration('a-client', null, TokenEndpointAuthMethod::PrivateKeyJwt), + ['grant_type' => 'client_credentials'], + '-----BEGIN PRIVATE KEY-----not-a-real-key-----END PRIVATE KEY-----', + 'HS256', + ); + } + + #[TestDox('an unreadable key is reported rather than producing a broken assertion')] + public function testReportsAnUnusableKey(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Signing the client assertion with ES256 failed'); + + $this->endpoint(new CapturingTokenServer())->request( + $this->metadata(), + new ClientRegistration('a-client', null, TokenEndpointAuthMethod::PrivateKeyJwt), + ['grant_type' => 'client_credentials'], + 'not a key at all', + 'ES256', + ); + } + + #[TestDox('an assertion is refused rather than skipped when no key was configured')] + public function testPrivateKeyJwtNeedsAKey(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('no private key was configured'); + + $this->endpoint(new CapturingTokenServer())->request( + $this->metadata(), + new ClientRegistration('a-client', null, TokenEndpointAuthMethod::PrivateKeyJwt), + ['grant_type' => 'client_credentials'], + ); + } + + /** + * @return iterable + */ + public static function unsupportedAssertionAlgorithmProvider(): iterable + { + yield 'missing from the lowest supported firebase/php-jwt' => ['PS256']; + yield 'not signable with a PEM key' => ['EdDSA']; + } + + #[DataProvider('unsupportedAssertionAlgorithmProvider')] + #[TestDox('an algorithm the key format or the library cannot sign with is refused: $_dataName')] + public function testRefusesAnUnsupportedAlgorithm(string $algorithm): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage(\sprintf('Unsupported client assertion algorithm "%s"', $algorithm)); + + $this->endpoint(new CapturingTokenServer())->request( + $this->metadata(), + new ClientRegistration('a-client', null, TokenEndpointAuthMethod::PrivateKeyJwt), + ['grant_type' => 'client_credentials'], + 'a-pem', + $algorithm, + ); + } + private function endpoint(ClientInterface $client): TokenEndpoint { $factory = new Psr17Factory();