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\HttpClientBuilder;
20: use Amp\Http\Client\HttpException;
21: use Amp\Http\Client\Interceptor\FollowRedirects;
22: use Amp\Http\Client\Interceptor\TooManyRedirectsException;
23: use Amp\Http\Client\Request;
24: use Amp\Http\Client\Response;
25: use Amp\Sync\LocalSemaphore;
26: use Amp\Sync\Semaphore;
27: use Nexus\Assert\Assert;
28: use Nexus\Clock\Clock;
29: use Nexus\Clock\SystemClock;
30: use Nexus\Mcp\Client\Exception\InsufficientScopeException;
31: use Nexus\Mcp\Client\Exception\RedirectRefusedException;
32: use Nexus\Mcp\Core\Auth\ResourceIdentifier;
33: use Nexus\Mcp\Core\Auth\ScopeSet;
34: use Nexus\Mcp\Core\Auth\WwwAuthenticateChallenge;
35: use Nexus\Mcp\Core\Http\HttpStatus;
36: use Nexus\Mcp\Core\SafeDisplay;
37: use Psr\Log\LoggerInterface;
38: use Psr\Log\NullLogger;
39:
40: /**
41: * HTTP client decorator that presents an OAuth 2.1 bearer token to a protected MCP server.
42: *
43: * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#access-token-usage
44: */
45: final class AuthorizedHttpClient implements DelegateHttpClient
46: {
47: private const int MAX_CHALLENGE_BODY_BYTES = 8_192;
48: private const int MAX_REDIRECTS = 10;
49:
50: private readonly ResourceIdentifier $resource;
51: private readonly AuthorizationCoordinator $coordinator;
52: private readonly DelegateHttpClient $client;
53: private readonly DelegateHttpClient $sealedClient;
54:
55: /**
56: * @param string $resource Absolute URL of the MCP endpoint this client talks to
57: * @param null|UserAuthorizationInterface $userAuthorization Puts the resource owner in front of the authorization server on the authorization-code grant. `null` when a grant strategy runs instead
58: * @param HttpClientBuilder $clientBuilder Builds the inner clients. Credentialed traffic runs on a derived client that never follows a redirect, so a hop can be refused before the credential travels
59: * @param null|TokenStoreInterface $tokens Defaults to a store that lives only as long as the process
60: * @param null|ClientRegistrationStoreInterface $registrations Defaults to a store that lives only as long as the process
61: * @param null|GrantStrategyInterface $grantStrategy An unattended grant run in place of the authorization-code round trip
62: * @param null|Semaphore $lock Serialises grants and renewals, defaulting to one that spans this process only
63: * @param Clock $clock Reads the time expiries are stamped and checked against
64: */
65: public function __construct(
66: string $resource,
67: private readonly AuthorizationOptions $options,
68: ?UserAuthorizationInterface $userAuthorization,
69: HttpClientBuilder $clientBuilder,
70: ?TokenStoreInterface $tokens = null,
71: ?ClientRegistrationStoreInterface $registrations = null,
72: private readonly LoggerInterface $logger = new NullLogger(),
73: ?GrantStrategyInterface $grantStrategy = null,
74: ?Semaphore $lock = null,
75: Clock $clock = new SystemClock(),
76: ) {
77: if (null !== $userAuthorization) {
78: Assert::that($grantStrategy)->isNull('A user authorization and a grant strategy were both given, and the client can run only one.');
79: Assert::that($options->redirectUri)->isNonEmptyString('A user authorization needs a redirect URI, and the authorization options carry none.');
80: $strategy = new AuthorizationCodeGrantStrategy($userAuthorization);
81: } else {
82: Assert::that($grantStrategy)->isInstanceOf(GrantStrategyInterface::class, 'The client needs a user authorization or a grant strategy to obtain tokens, and neither was given.');
83: $strategy = $grantStrategy;
84: }
85:
86: $this->resource = new ResourceIdentifier($resource);
87:
88: $this->client = $clientBuilder->build();
89: $this->sealedClient = $clientBuilder->followRedirects(0)->build();
90:
91: $secureEndpoint = new SecureEndpoint($this->options->allowInsecureLoopback);
92: $this->coordinator = new AuthorizationCoordinator(
93: $this->resource,
94: new MetadataDiscovery($this->sealedClient, $this->options->timeout, $secureEndpoint),
95: new ClientRegistrar(
96: $this->sealedClient,
97: $registrations ?? new InMemoryClientRegistrationStore(),
98: $this->options->timeout,
99: $secureEndpoint,
100: $clock,
101: ),
102: new TokenEndpoint($this->sealedClient, $this->options->timeout, $secureEndpoint, $clock),
103: $this->sealedClient,
104: $strategy,
105: $tokens ?? new InMemoryTokenStore(),
106: $this->options,
107: $this->logger,
108: $lock ?? new LocalSemaphore(1),
109: $clock,
110: );
111: }
112:
113: #[\Override]
114: public function request(Request $request, Cancellation $cancellation): Response
115: {
116: $additionalScopes = new ScopeSet();
117: $scopeUpgrades = 0;
118: $reauthorized = false;
119:
120: $bearsToken = $this->resource->covers((string) $request->getUri());
121:
122: while (true) {
123: $token = $bearsToken ? $this->coordinator->fetchToken($cancellation) : null;
124: $attempt = $this->authorizeRequest($request, $token);
125: $response = $bearsToken
126: ? $this->followWithinResource($attempt, $cancellation)
127: : $this->client->request($attempt, $cancellation);
128:
129: if (! $bearsToken) {
130: return $response;
131: }
132:
133: $status = $response->getStatus();
134:
135: if (HttpStatus::Unauthorized->value !== $status && HttpStatus::Forbidden->value !== $status) {
136: return $response;
137: }
138:
139: $challenge = $this->readChallenge($response);
140:
141: if (HttpStatus::Forbidden->value === $status) {
142: if (null === $challenge || $challenge->readParameter('error') !== 'insufficient_scope') {
143: return $response;
144: }
145:
146: $declaredScope = $challenge->readParameter('scope');
147: $challenged = ScopeSet::parse($declaredScope);
148:
149: if (InsufficientScopePolicy::Fail === $this->options->onInsufficientScope) {
150: $this->reportInsufficientScope($response, $challenged, $cancellation, null !== $declaredScope);
151: }
152:
153: if ($scopeUpgrades >= $this->options->maxScopeUpgrades) {
154: $this->logger->warning('Giving up on {resource} after {attempts} scope upgrades.', [
155: 'resource' => $this->resource->value,
156: 'attempts' => $scopeUpgrades,
157: ]);
158:
159: $this->reportInsufficientScope($response, $additionalScopes->mergeWith($challenged), $cancellation, null !== $declaredScope);
160: }
161:
162: if ((new ScopeSet($token->scopes ?? []))->containsAll($challenged)) {
163: $this->logger->warning('The scope challenge from {resource} names {scopes}.', [
164: 'resource' => $this->resource->value,
165: 'scopes' => SafeDisplay::sanitiseCause(
166: $challenged->toParameter() ?? $this->describeUnusableChallenge($declaredScope),
167: ),
168: ]);
169:
170: $this->reportInsufficientScope($response, $challenged, $cancellation, null !== $declaredScope);
171: }
172:
173: ++$scopeUpgrades;
174: $additionalScopes = $additionalScopes->mergeWith($challenged);
175: $this->drain($response, $cancellation);
176: $this->coordinator->upgradeScopes($token, $additionalScopes, $challenge, $cancellation);
177:
178: continue;
179: }
180:
181: if ($reauthorized) {
182: return $response;
183: }
184:
185: $reauthorized = true;
186: $this->drain($response, $cancellation);
187: $this->coordinator->reauthorize($token, $challenge, $cancellation);
188: }
189: }
190:
191: /**
192: * @throws RedirectRefusedException
193: */
194: private function followWithinResource(Request $request, Cancellation $cancellation): Response
195: {
196: $from = (string) $request->getUri();
197: $previous = null;
198: $response = null;
199:
200: for ($hop = 0; $hop <= self::MAX_REDIRECTS; ++$hop) {
201: $response = $this->sealedClient->request($request, $cancellation);
202: $response->setPreviousResponse($previous);
203: $location = $this->readRedirectTarget($response, $request);
204:
205: if (null === $location) {
206: return $response;
207: }
208:
209: if (! $this->resource->covers($location)) {
210: $this->drain($response, $cancellation);
211:
212: throw new RedirectRefusedException($from, $location);
213: }
214:
215: $this->drain($response, $cancellation);
216: $previous = $response;
217: $request = $this->cloneForRedirect($request, $location);
218: }
219:
220: \assert($response instanceof Response);
221:
222: throw new TooManyRedirectsException($response);
223: }
224:
225: private function readRedirectTarget(Response $response, Request $request): ?string
226: {
227: $status = $response->getStatus();
228:
229: if (! \in_array($status, [301, 302, 303, 307, 308], true)) {
230: return null;
231: }
232:
233: if ($request->getMethod() !== 'GET' && \in_array($status, [307, 308], true)) {
234: return null;
235: }
236:
237: $locations = $response->getHeaderArray('location');
238:
239: if (\count($locations) !== 1) {
240: return null;
241: }
242:
243: $location = $locations[0];
244:
245: try {
246: $target = new Request($location);
247: } catch (\Exception) {
248: return null;
249: }
250:
251: return (string) FollowRedirects::resolve($request->getUri(), $target->getUri());
252: }
253:
254: private function cloneForRedirect(Request $request, string $location): Request
255: {
256: $redirected = clone $request;
257: $redirected->setUri($location);
258: $redirected->setMethod('GET');
259: $redirected->removeHeader('transfer-encoding');
260: $redirected->removeHeader('content-length');
261: $redirected->removeHeader('content-type');
262:
263: return $redirected;
264: }
265:
266: /**
267: * @return non-empty-string
268: */
269: private function describeUnusableChallenge(?string $declaredScope): string
270: {
271: return null === $declaredScope ? 'no scope at all' : 'only scopes that are not RFC 6749 scope-tokens';
272: }
273:
274: private function reportInsufficientScope(Response $response, ScopeSet $challenged, Cancellation $cancellation, bool $named): never
275: {
276: $this->drain($response, $cancellation);
277:
278: throw new InsufficientScopeException($challenged->values, $named);
279: }
280:
281: private function drain(Response $response, Cancellation $cancellation): void
282: {
283: try {
284: $response->getBody()->buffer($cancellation, limit: self::MAX_CHALLENGE_BODY_BYTES);
285: } catch (HttpException|StreamException) {
286: // Losing a challenge body is no reason to abandon the recovery it asked for, where a cancellation propagates instead.
287: }
288: }
289:
290: private function authorizeRequest(Request $request, ?AccessToken $token): Request
291: {
292: // The request is cloned per attempt so a retry never carries the header a spent token set.
293: $attempt = clone $request;
294:
295: if (null !== $token) {
296: $attempt->setHeader('Authorization', \sprintf('%s %s', WwwAuthenticateChallenge::BEARER_SCHEME, $token->value));
297: }
298:
299: return $attempt;
300: }
301:
302: private function readChallenge(Response $response): ?WwwAuthenticateChallenge
303: {
304: $header = $response->getHeader('WWW-Authenticate');
305:
306: return null === $header ? null : WwwAuthenticateChallenge::findBearer($header);
307: }
308: }
309: