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\Mcp\Core\Auth\ResourceIdentifier;
17: use Nexus\Mcp\Core\Auth\ScopeSet;
18: use Nexus\Mcp\Core\Auth\VerifiedAccessToken;
19: use Nexus\Mcp\Core\Auth\WwwAuthenticateChallenge;
20: use Nexus\Mcp\Core\Http\HttpStatus;
21: use Nexus\Mcp\Server\Auth\AccessTokenValidatorInterface;
22: use Psr\Http\Message\ResponseFactoryInterface;
23: use Psr\Http\Message\ResponseInterface;
24: use Psr\Http\Message\ServerRequestInterface;
25: use Psr\Http\Server\MiddlewareInterface;
26: use Psr\Http\Server\RequestHandlerInterface;
27:
28: /**
29: * Makes the MCP endpoint an OAuth 2.1 resource server: it requires a bearer token, binds that token's audience
30: * to this server, and enforces the scopes the endpoint calls for.
31: *
32: * A request presenting no bearer credential is answered `401` with a `WWW-Authenticate` challenge naming the
33: * Protected Resource Metadata document, one presenting a bearer credential that cannot be read is answered
34: * `400 invalid_request`, and a token that is valid but too narrow is answered `403 insufficient_scope`. The
35: * validated token reaches request handlers on `ServerContext::$receiveContext->authInfo`.
36: *
37: * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#error-handling
38: */
39: final readonly class BearerAuthenticationMiddleware implements MiddlewareInterface
40: {
41: private const string BEARER_PREFIX = WwwAuthenticateChallenge::BEARER_SCHEME.' ';
42:
43: private ResourceIdentifier $resource;
44: private ScopeSet $requiredScopes;
45:
46: /**
47: * @param string $resource Canonical URI of this MCP server, which a token's audience must name
48: * @param string $resourceMetadataUrl URL of this server's Protected Resource Metadata document
49: * @param list<non-empty-string> $requiredScopes Scopes every request to the endpoint must carry
50: */
51: public function __construct(
52: private AccessTokenValidatorInterface $validator,
53: string $resource,
54: private string $resourceMetadataUrl,
55: private ResponseFactoryInterface $responseFactory,
56: array $requiredScopes = [],
57: ) {
58: $this->resource = new ResourceIdentifier($resource);
59: $this->requiredScopes = new ScopeSet($requiredScopes);
60: }
61:
62: #[\Override]
63: public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
64: {
65: $headers = $request->getHeader('Authorization');
66:
67: // RFC 6750 puts a request that presented no bearer credential, whether it carried nothing at all or
68: // tried an authentication method this server does not support, in one bucket, answered with a bare
69: // challenge and no error code. Only a bearer credential that cannot be read gets one.
70: if (! self::presentsBearerScheme($headers)) {
71: return $this->challenge(HttpStatus::Unauthorized, null);
72: }
73:
74: $presented = self::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: // A token minted for a different resource must never be accepted here, nor passed further on.
87: if (! $this->resource->matchesAudience($token->audience)) {
88: return $this->challenge(HttpStatus::Unauthorized, 'invalid_token');
89: }
90:
91: if (! new ScopeSet($token->scopes)->containsAll($this->requiredScopes)) {
92: return $this->challenge(HttpStatus::Forbidden, 'insufficient_scope');
93: }
94:
95: return $handler->handle($request->withAttribute(VerifiedAccessToken::REQUEST_ATTRIBUTE, $token));
96: }
97:
98: private function challenge(HttpStatus $status, ?string $error): ResponseInterface
99: {
100: $parameters = ['resource_metadata' => $this->resourceMetadataUrl];
101:
102: if (null !== $error) {
103: $parameters['error'] = $error;
104: }
105:
106: $scope = $this->requiredScopes->toParameter();
107:
108: if (null !== $scope) {
109: $parameters['scope'] = $scope;
110: }
111:
112: return $this->responseFactory->createResponse($status->value)
113: ->withHeader('WWW-Authenticate', new WwwAuthenticateChallenge(
114: WwwAuthenticateChallenge::BEARER_SCHEME,
115: $parameters,
116: )->toHeaderValue())
117: ;
118: }
119:
120: /**
121: * Whether any header names the bearer scheme, which is what tells a malformed bearer credential apart
122: * from a request that presented none.
123: *
124: * @param array<array-key, string> $headers
125: *
126: * @phpstan-assert-if-true non-empty-array<array-key, string> $headers
127: */
128: private static function presentsBearerScheme(array $headers): bool
129: {
130: foreach ($headers as $header) {
131: // RFC 7235 makes the scheme case-insensitive and separates it from the credential by a space.
132: if (strcasecmp(explode(' ', $header)[0], WwwAuthenticateChallenge::BEARER_SCHEME) === 0) {
133: return true;
134: }
135: }
136:
137: return false;
138: }
139:
140: /**
141: * @param non-empty-array<array-key, string> $headers
142: */
143: private static function readBearerToken(array $headers): ?string
144: {
145: // RFC 7235 permits exactly one. Several would be joined into one string, smuggling a second
146: // credential past a lenient validator.
147: if (\count($headers) !== 1) {
148: return null;
149: }
150:
151: // The scheme was matched case-insensitively before this, so only what follows it is read, and that
152: // is compared as sent.
153: $token = trim(substr(reset($headers), \strlen(self::BEARER_PREFIX)));
154:
155: return '' === $token ? null : $token;
156: }
157: }
158: