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\Server\Transport\Http\Middleware;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Clock\Clock;
18: use Nexus\Clock\SystemClock;
19: use Nexus\Mcp\Core\Auth\ResourceIdentifier;
20: use Nexus\Mcp\Core\Auth\ScopeSet;
21: use Nexus\Mcp\Core\Auth\VerifiedAccessToken;
22: use Nexus\Mcp\Core\Auth\WwwAuthenticateChallenge;
23: use Nexus\Mcp\Core\Http\HttpStatus;
24: use Nexus\Mcp\Server\Auth\AccessTokenValidatorInterface;
25: use Psr\Http\Message\ResponseFactoryInterface;
26: use Psr\Http\Message\ResponseInterface;
27: use Psr\Http\Message\ServerRequestInterface;
28: use Psr\Http\Server\MiddlewareInterface;
29: use Psr\Http\Server\RequestHandlerInterface;
30:
31: /**
32: * Makes the MCP endpoint an OAuth 2.1 resource server, handing what it validated to handlers on
33: * `ServerContext::$receiveContext->authInfo`.
34: *
35: * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#error-handling
36: */
37: final readonly class BearerAuthenticationMiddleware implements MiddlewareInterface
38: {
39: private const string BEARER_PREFIX = WwwAuthenticateChallenge::BEARER_SCHEME.' ';
40:
41: private ResourceIdentifier $resource;
42: private ScopeSet $requiredScopes;
43:
44: /**
45: * @param string $resource Canonical URI of this MCP server, which a token's audience must name
46: * @param list<non-empty-string> $requiredScopes
47: * @param int<0, max> $expiryLeewaySeconds
48: */
49: public function __construct(
50: private AccessTokenValidatorInterface $validator,
51: string $resource,
52: private string $resourceMetadataUrl,
53: private ResponseFactoryInterface $responseFactory,
54: array $requiredScopes = [],
55: private int $expiryLeewaySeconds = 0,
56: private Clock $clock = new SystemClock(),
57: ) {
58: Assert::that($expiryLeewaySeconds)->isNaturalInt('Expiry leeway must be a non-negative integer, {value} given.');
59:
60: $this->resource = new ResourceIdentifier($resource);
61: $this->requiredScopes = new ScopeSet($requiredScopes);
62: }
63:
64: #[\Override]
65: public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
66: {
67: $headers = $request->getHeader('Authorization');
68:
69: // RFC 6750 answers any request presenting no bearer credential with a bare challenge and no error code.
70: if (! $this->presentsBearerScheme($headers)) {
71: return $this->challenge(HttpStatus::Unauthorized, null);
72: }
73:
74: $presented = $this->readBearerToken($headers);
75:
76: if (null === $presented) {
77: return $this->challenge(HttpStatus::BadRequest, 'invalid_request');
78: }
79:
80: $token = $this->validator->validate($presented);
81:
82: if (null === $token) {
83: return $this->challenge(HttpStatus::Unauthorized, 'invalid_token');
84: }
85:
86: if ($this->clock->now()->getTimestamp() >= $token->expiresAt + $this->expiryLeewaySeconds) {
87: return $this->challenge(HttpStatus::Unauthorized, 'invalid_token');
88: }
89:
90: if (! $this->resource->matchesAudience($token->audience)) {
91: return $this->challenge(HttpStatus::Unauthorized, 'invalid_token');
92: }
93:
94: if (! (new ScopeSet($token->scopes))->containsAll($this->requiredScopes)) {
95: return $this->challenge(HttpStatus::Forbidden, 'insufficient_scope');
96: }
97:
98: return $handler->handle($request->withAttribute(VerifiedAccessToken::REQUEST_ATTRIBUTE, $token));
99: }
100:
101: private function challenge(HttpStatus $status, ?string $error): ResponseInterface
102: {
103: $parameters = ['resource_metadata' => $this->resourceMetadataUrl];
104:
105: if (null !== $error) {
106: $parameters['error'] = $error;
107: }
108:
109: $scope = $this->requiredScopes->toParameter();
110:
111: if (null !== $scope) {
112: $parameters['scope'] = $scope;
113: }
114:
115: return $this->responseFactory->createResponse($status->value)
116: ->withHeader('WWW-Authenticate', (new WwwAuthenticateChallenge(
117: WwwAuthenticateChallenge::BEARER_SCHEME,
118: $parameters,
119: ))->toHeaderValue())
120: ;
121: }
122:
123: /**
124: * Whether any header names the bearer scheme, which is what tells a malformed bearer credential apart
125: * from a request that presented none.
126: *
127: * @param array<array-key, string> $headers
128: *
129: * @phpstan-assert-if-true non-empty-array<array-key, string> $headers
130: */
131: private function presentsBearerScheme(array $headers): bool
132: {
133: foreach ($headers as $header) {
134: if (strcasecmp(explode(' ', $header)[0], WwwAuthenticateChallenge::BEARER_SCHEME) === 0) {
135: return true;
136: }
137: }
138:
139: return false;
140: }
141:
142: /**
143: * @param non-empty-array<array-key, string> $headers
144: */
145: private function readBearerToken(array $headers): ?string
146: {
147: // RFC 7235 permits exactly one, and several joined into one string could smuggle a credential past a lenient validator.
148: if (\count($headers) !== 1) {
149: return null;
150: }
151:
152: $token = trim(substr(reset($headers), \strlen(self::BEARER_PREFIX)));
153:
154: return '' === $token ? null : $token;
155: }
156: }
157: