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\Enterprise;
15:
16: use Amp\Cancellation;
17: use Nexus\Assert\Assert;
18: use Nexus\Mcp\Client\Auth\AccessToken;
19: use Nexus\Mcp\Client\Auth\ClientRegistration;
20: use Nexus\Mcp\Client\Auth\GrantContext;
21: use Nexus\Mcp\Client\Auth\GrantStrategyInterface;
22: use Nexus\Mcp\Client\Auth\SecureEndpoint;
23: use Nexus\Mcp\Core\Auth\AuthorizationServerMetadata;
24: use Nexus\Mcp\Core\Exception\RuntimeException;
25: use Nexus\Mcp\Core\SafeDisplay;
26: use Nexus\Mcp\Extension\Auth\GrantTypeAdvertisement;
27: use Psr\Log\LoggerInterface;
28:
29: /**
30: * The enterprise-managed authorization grant (SEP-990), a two-legged token exchange with no user redirect.
31: *
32: * @see https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx
33: */
34: final readonly class IdentityAssertionGrant implements GrantStrategyInterface
35: {
36: private SecureEndpoint $secureEndpoint;
37:
38: /**
39: * @param non-empty-string $idpTokenEndpoint
40: * @param null|non-empty-string $idpClientId
41: * @param bool $allowInsecureLoopback Admits an IdP reached over cleartext HTTP on a loopback host, which the spec does not exempt. For local development and conformance runs, never production
42: */
43: public function __construct(
44: private string $idpTokenEndpoint,
45: private IdentityAssertionProviderInterface $assertions,
46: private ?string $idpClientId = null,
47: bool $allowInsecureLoopback = false,
48: ) {
49: Assert::that($idpTokenEndpoint)->isNonEmptyString('"idpTokenEndpoint" must be a non-empty string.');
50: Assert::that($idpClientId)->nullOr()->isNonEmptyString('"idpClientId" must be a non-empty string or null.');
51:
52: $this->secureEndpoint = new SecureEndpoint($allowInsecureLoopback);
53: $this->secureEndpoint->verifyAuthorizationServerUrl($idpTokenEndpoint, 'IdP token endpoint');
54: }
55:
56: #[\Override]
57: public function grant(GrantContext $context, Cancellation $cancellation): AccessToken
58: {
59: $server = $context->discovered->server;
60: $this->verifyAdvertisedSupport($server, $context->logger);
61: $registration = $this->resolveRegistration($context, $cancellation);
62:
63: $idJag = (new IdentityAssertionExchanger(
64: $this->idpTokenEndpoint,
65: $context->httpClient,
66: $this->idpClientId,
67: $context->options->timeout,
68: $this->secureEndpoint,
69: ))->exchangeForGrant(
70: $this->assertions->provideAssertion($cancellation),
71: $server->issuer,
72: $context->resource,
73: $cancellation,
74: );
75:
76: $parameters = [
77: 'grant_type' => EnterpriseAuthorization::JWT_BEARER_GRANT_TYPE,
78: 'assertion' => $idJag,
79: 'resource' => $context->resource->value,
80: ];
81: $scope = $context->scopes->toParameter();
82:
83: if (null !== $scope) {
84: $parameters['scope'] = $scope;
85: }
86:
87: return $context->requestToken($registration, $parameters, $cancellation);
88: }
89:
90: #[\Override]
91: public function renewsByFreshGrant(): bool
92: {
93: return true;
94: }
95:
96: /**
97: * Holds the authorization server to the grant profile it published, and takes ID-JAG support on trust
98: * where it publishes no profile list at all.
99: */
100: private function verifyAdvertisedSupport(AuthorizationServerMetadata $server, LoggerInterface $logger): void
101: {
102: GrantTypeAdvertisement::verify($server, EnterpriseAuthorization::JWT_BEARER_GRANT_TYPE);
103:
104: $profiles = $server->authorizationGrantProfilesSupported;
105:
106: if (null === $profiles) {
107: $logger->info('The authorization server {issuer} publishes no authorization grant profiles, so ID-JAG support is taken on trust.', [
108: 'issuer' => SafeDisplay::sanitiseCause($server->issuer),
109: ]);
110:
111: return;
112: }
113:
114: if (! \in_array(EnterpriseAuthorization::GRANT_PROFILE, $profiles, true)) {
115: throw new RuntimeException(\sprintf(
116: 'The authorization server "%s" does not advertise the "%s" authorization grant profile.',
117: SafeDisplay::sanitiseCause($server->issuer),
118: EnterpriseAuthorization::GRANT_PROFILE,
119: ));
120: }
121: }
122:
123: /**
124: * SEP-990 authenticates at the resource authorization server with credentials registered out of band or
125: * a Client ID Metadata Document, never Dynamic Client Registration, so the registrar only runs once one
126: * of the two is configured.
127: */
128: private function resolveRegistration(GrantContext $context, Cancellation $cancellation): ClientRegistration
129: {
130: $server = $context->discovered->server;
131: $options = $context->options;
132:
133: if (null === $options->preRegistered && null === $options->clientIdMetadataDocumentUrl) {
134: throw new RuntimeException(
135: 'Enterprise-managed authorization needs pre-registered credentials or a Client ID Metadata Document URL, and the authorization options carry neither.',
136: );
137: }
138:
139: if (null === $options->preRegistered && true !== $server->clientIdMetadataDocumentSupported) {
140: throw new RuntimeException(\sprintf(
141: 'The authorization server "%s" does not support Client ID Metadata Documents.',
142: SafeDisplay::sanitiseCause($server->issuer),
143: ));
144: }
145:
146: return $context->resolveRegistration($cancellation);
147: }
148: }
149: