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\Client\Auth;
15:
16: use Amp\ByteStream\StreamException;
17: use Amp\Cancellation;
18: use Amp\Http\Client\DelegateHttpClient;
19: use Amp\Http\Client\HttpException;
20: use Amp\Http\Client\Request;
21: use Amp\Http\Client\Response;
22: use Nexus\Mcp\Client\Exception\InsufficientScopeException;
23: use Nexus\Mcp\Client\Exception\RedirectRefusedException;
24: use Nexus\Mcp\Core\Auth\ResourceIdentifier;
25: use Nexus\Mcp\Core\Auth\ScopeSet;
26: use Nexus\Mcp\Core\Auth\WwwAuthenticateChallenge;
27: use Nexus\Mcp\Core\Http\HttpStatus;
28: use Psr\Log\LoggerInterface;
29: use Psr\Log\NullLogger;
30:
31: /**
32: * HTTP client decorator that presents an OAuth 2.1 bearer token to a protected MCP server, obtains one when
33: * challenged, and steps its scopes up when the server says they are insufficient.
34: *
35: * Hand it to `StreamableHttpClientTransport` in place of the default client.
36: *
37: * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#access-token-usage
38: */
39: final class AuthorizedHttpClient implements DelegateHttpClient
40: {
41: private const string INSUFFICIENT_SCOPE = 'insufficient_scope';
42:
43: /**
44: * Bytes of a challenge body drained before the connection carrying it is given up on instead.
45: */
46: private const int MAX_CHALLENGE_BODY_BYTES = 8192;
47:
48: private readonly ResourceIdentifier $resource;
49: private readonly DelegateHttpClient $client;
50: private readonly AuthorizationCoordinator $coordinator;
51:
52: /**
53: * @param string $resource Absolute URL of the MCP endpoint this client talks to
54: * @param DelegateHttpClient $client Inner client, which carries the authorization traffic as well as the MCP traffic
55: * @param null|TokenStoreInterface $tokens Defaults to a store that lives only as long as the process
56: * @param null|ClientRegistrationStoreInterface $registrations Defaults to a store that lives only as long as the process
57: */
58: public function __construct(
59: string $resource,
60: private readonly AuthorizationOptions $options,
61: UserAuthorizationInterface $userAuthorization,
62: DelegateHttpClient $client,
63: ?TokenStoreInterface $tokens = null,
64: ?ClientRegistrationStoreInterface $registrations = null,
65: private readonly LoggerInterface $logger = new NullLogger(),
66: ) {
67: $this->resource = new ResourceIdentifier($resource);
68: $this->client = $client;
69: $this->coordinator = new AuthorizationCoordinator(
70: $this->resource,
71: new MetadataDiscovery($this->client, $this->options->timeout, $this->options->allowInsecureLoopback),
72: new ClientRegistrar($this->client, $registrations ?? new InMemoryClientRegistrationStore(), $this->options->timeout, $this->options->allowInsecureLoopback),
73: new TokenEndpoint($this->client, $this->options->timeout, $this->options->allowInsecureLoopback),
74: $userAuthorization,
75: $tokens ?? new InMemoryTokenStore(),
76: $this->options,
77: $this->logger,
78: );
79: }
80:
81: #[\Override]
82: public function request(Request $request, Cancellation $cancellation): Response
83: {
84: $additionalScopes = new ScopeSet();
85: $scopeUpgrades = 0;
86: $reauthorized = false;
87:
88: // A token is minted for one MCP server, and a caller may hand this decorator a request aimed
89: // anywhere, so the header goes on only where the token belongs.
90: $bearsToken = $this->resource->sharesOriginWith((string) $request->getUri());
91:
92: while (true) {
93: $token = $bearsToken ? $this->coordinator->fetchToken($cancellation) : null;
94: $attempt = self::authorizeRequest($request, $token);
95: $response = $this->client->request($attempt, $cancellation);
96: $strayed = null === $token ? null : $this->findHopOffOrigin($response);
97:
98: if (null !== $strayed) {
99: self::drain($response);
100:
101: throw new RedirectRefusedException((string) $attempt->getUri(), $strayed);
102: }
103:
104: if (! $bearsToken) {
105: // A challenge from anywhere but this MCP server steers nothing here. Its scopes would reach
106: // the consent screen at the real authorization server, and the token that consent granted
107: // would replace the one held for this one.
108: return $response;
109: }
110:
111: $status = $response->getStatus();
112:
113: if (HttpStatus::Unauthorized->value !== $status && HttpStatus::Forbidden->value !== $status) {
114: return $response;
115: }
116:
117: $challenge = self::readChallenge($response);
118:
119: if (HttpStatus::Forbidden->value === $status) {
120: // Only an insufficient-scope challenge is recoverable. Every other 403 is the server's answer.
121: if (null === $challenge || self::INSUFFICIENT_SCOPE !== $challenge->readParameter('error')) {
122: return $response;
123: }
124:
125: $challenged = ScopeSet::parse($challenge->readParameter('scope'));
126:
127: // The caller asked to be told rather than asked, so neither the retry budget nor whether
128: // another round would help has any bearing on what happens next.
129: if (InsufficientScopePolicy::Fail === $this->options->onInsufficientScope) {
130: self::drain($response);
131:
132: throw new InsufficientScopeException($challenged->values);
133: }
134:
135: if ($scopeUpgrades >= $this->options->maxScopeUpgrades) {
136: $this->logger->warning('Giving up on {resource} after {attempts} scope upgrades.', [
137: 'resource' => $this->resource->value,
138: 'attempts' => $scopeUpgrades,
139: ]);
140:
141: return $response;
142: }
143:
144: // Granting again would produce the same token and the same answer, so the only thing another
145: // round buys is a second consent screen. What settles that is the token this attempt
146: // presented: what the client was granted at some point says nothing about what it holds now.
147: if (new ScopeSet($token->scopes ?? [])->containsAll($challenged)) {
148: $this->logger->warning('The scope challenge from {resource} names {scopes}.', [
149: 'resource' => $this->resource->value,
150: 'scopes' => $challenged->toParameter() ?? 'no scope at all',
151: ]);
152:
153: return $response;
154: }
155:
156: ++$scopeUpgrades;
157: $additionalScopes = $additionalScopes->mergeWith($challenged);
158: self::drain($response);
159: $this->coordinator->upgradeScopes($token, $additionalScopes, $challenge, $cancellation);
160:
161: continue;
162: }
163:
164: // A second challenge to a token just obtained is the server's answer, not a stale token.
165: if ($reauthorized) {
166: return $response;
167: }
168:
169: $reauthorized = true;
170: self::drain($response);
171: $this->coordinator->reauthorize($token, $challenge, $cancellation);
172: }
173: }
174:
175: /**
176: * The URL of the hop that left this MCP server's origin, walking a redirect chain back from the answer,
177: * or `null` when every hop stayed on it.
178: *
179: * An HTTP client that follows redirects strips credentials only when the authority changes, and an
180: * authority carries no scheme, so a hop from HTTPS to cleartext on the same host takes the bearer token
181: * with it. Checking only where the chain ended would miss exactly that hop.
182: */
183: private function findHopOffOrigin(Response $response): ?string
184: {
185: for ($hop = $response; null !== $hop; $hop = $hop->getPreviousResponse()) {
186: $url = (string) $hop->getRequest()->getUri();
187:
188: if (! $this->resource->sharesOriginWith($url)) {
189: return $url;
190: }
191: }
192:
193: return null;
194: }
195:
196: /**
197: * Reads a challenge body to its end so its connection returns to the pool. An undrained body cancels
198: * instead, which tears the connection down for the whole of the authorization flow, user included.
199: */
200: private static function drain(Response $response): void
201: {
202: try {
203: $response->getBody()->buffer(limit: self::MAX_CHALLENGE_BODY_BYTES);
204: } catch (HttpException|StreamException) {
205: // Losing the body of a challenge is never a reason to abandon the recovery it asked for. This
206: // covers the oversized case, a connection that dies partway through it, and a body the server
207: // framed so badly that the parser gives up on it.
208: }
209: }
210:
211: private static function authorizeRequest(Request $request, ?AccessToken $token): Request
212: {
213: // The request is cloned per attempt so a retry never carries the header a spent token set.
214: $attempt = clone $request;
215:
216: if (null !== $token) {
217: $attempt->setHeader('Authorization', \sprintf('%s %s', WwwAuthenticateChallenge::BEARER_SCHEME, $token->value));
218: }
219:
220: return $attempt;
221: }
222:
223: private static function readChallenge(Response $response): ?WwwAuthenticateChallenge
224: {
225: $header = $response->getHeader('WWW-Authenticate');
226:
227: return null === $header ? null : WwwAuthenticateChallenge::findBearer($header);
228: }
229: }
230: