1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus MCP SDK package.
7: *
8: * (c) 2026 John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Mcp\Extension\Auth\ClientCredentials;
15:
16: use Amp\Cancellation;
17: use Nexus\Clock\Clock;
18: use Nexus\Clock\SystemClock;
19: use Nexus\Mcp\Client\Auth\AccessToken;
20: use Nexus\Mcp\Client\Auth\ClientRegistration;
21: use Nexus\Mcp\Client\Auth\GrantContext;
22: use Nexus\Mcp\Client\Auth\GrantStrategyInterface;
23: use Nexus\Mcp\Core\Auth\AuthorizationServerMetadata;
24: use Nexus\Mcp\Core\Auth\TokenEndpointAuthMethod;
25: use Nexus\Mcp\Core\Exception\RuntimeException;
26: use Nexus\Mcp\Core\SafeDisplay;
27: use Nexus\Mcp\Extension\Auth\ClientAssertionSigner;
28: use Nexus\Mcp\Extension\Auth\GrantTypeAdvertisement;
29:
30: /**
31: * The OAuth 2.1 client credentials grant (SEP-1046), an unattended machine-to-machine flow with no user
32: * and no consent screen.
33: *
34: * @see https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx
35: */
36: final readonly class ClientCredentialsGrant implements GrantStrategyInterface
37: {
38: private ?ClientAssertionSigner $privateKeyJwtSigner;
39: private TokenEndpointAuthMethod $authMethod;
40: private ?string $clientSecret;
41:
42: /**
43: * @var null|non-empty-string
44: */
45: private ?string $signingAlgorithm;
46:
47: public function __construct(
48: private ClientSecretCredential|PrivateKeyJwtCredential $credential,
49: Clock $clock = new SystemClock(),
50: ) {
51: if ($credential instanceof PrivateKeyJwtCredential) {
52: $this->privateKeyJwtSigner = new ClientAssertionSigner($credential, $clock);
53: $this->authMethod = TokenEndpointAuthMethod::PrivateKeyJwt;
54: $this->clientSecret = null;
55: $this->signingAlgorithm = $credential->algorithm;
56: } else {
57: $this->privateKeyJwtSigner = null;
58: $this->authMethod = TokenEndpointAuthMethod::ClientSecretBasic;
59: $this->clientSecret = $credential->clientSecret;
60: $this->signingAlgorithm = null;
61: }
62: }
63:
64: #[\Override]
65: public function grant(GrantContext $context, Cancellation $cancellation): AccessToken
66: {
67: if (null !== $context->options->preRegistered) {
68: throw new RuntimeException(
69: 'The client credentials grant authenticates with the credential it was given, so the authorization options must not carry a pre-registered one as well.',
70: );
71: }
72:
73: $server = $context->discovered->server;
74: $this->verifyAdvertisedSupport($server);
75:
76: $parameters = [
77: 'grant_type' => ClientCredentials::GRANT_TYPE,
78: 'resource' => $context->resource->value,
79: ];
80: $scope = $context->scopes->toParameter();
81:
82: if (null !== $scope) {
83: $parameters['scope'] = $scope;
84: }
85:
86: if (null !== $this->privateKeyJwtSigner) {
87: $parameters['client_assertion_type'] = ClientCredentials::CLIENT_ASSERTION_TYPE;
88: $parameters['client_assertion'] = $this->privateKeyJwtSigner->signAssertion($server->issuer);
89: }
90:
91: $registration = new ClientRegistration(
92: $this->credential->clientId,
93: $server->issuer,
94: $this->clientSecret,
95: $this->authMethod,
96: );
97:
98: return $context->requestToken($registration, $parameters, $cancellation);
99: }
100:
101: #[\Override]
102: public function renewsByFreshGrant(): bool
103: {
104: return true;
105: }
106:
107: /**
108: * SEP-1046 makes the authentication-method list a mandatory discovery signal, so its absence is as
109: * disqualifying as a list that omits the configured method.
110: */
111: private function verifyAdvertisedSupport(AuthorizationServerMetadata $server): void
112: {
113: $methods = $server->tokenEndpointAuthMethodsSupported;
114:
115: if (null === $methods || ! \in_array($this->authMethod->value, $methods, true)) {
116: throw new RuntimeException(\sprintf(
117: 'The authorization server "%s" does not advertise the "%s" token endpoint authentication method.',
118: SafeDisplay::sanitiseCause($server->issuer),
119: $this->authMethod->value,
120: ));
121: }
122:
123: $algorithms = $server->tokenEndpointAuthSigningAlgValuesSupported;
124:
125: if (null !== $this->signingAlgorithm && null !== $algorithms && ! \in_array($this->signingAlgorithm, $algorithms, true)) {
126: throw new RuntimeException(\sprintf(
127: 'The authorization server "%s" does not advertise the "%s" client assertion signing algorithm.',
128: SafeDisplay::sanitiseCause($server->issuer),
129: $this->signingAlgorithm,
130: ));
131: }
132:
133: GrantTypeAdvertisement::verify($server, ClientCredentials::GRANT_TYPE);
134: }
135: }
136: