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