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: return $this->responseFactory->createResponse($status->value)->withHeader(
104: 'WWW-Authenticate',
105: WwwAuthenticateChallenge::buildForResource($this->resourceMetadataUrl, $error, $this->requiredScopes)->toHeaderValue(),
106: );
107: }
108:
109: /**
110: * Whether any header names the bearer scheme, which is what tells a malformed bearer credential apart
111: * from a request that presented none.
112: *
113: * @param array<array-key, string> $headers
114: *
115: * @phpstan-assert-if-true non-empty-array<array-key, string> $headers
116: */
117: private function presentsBearerScheme(array $headers): bool
118: {
119: foreach ($headers as $header) {
120: if (strcasecmp(explode(' ', $header)[0], WwwAuthenticateChallenge::BEARER_SCHEME) === 0) {
121: return true;
122: }
123: }
124:
125: return false;
126: }
127:
128: /**
129: * @param non-empty-array<array-key, string> $headers
130: */
131: private function readBearerToken(array $headers): ?string
132: {
133: // RFC 7235 permits exactly one, and several joined into one string could smuggle a credential past a lenient validator.
134: if (\count($headers) !== 1) {
135: return null;
136: }
137:
138: $token = trim(substr(reset($headers), \strlen(self::BEARER_PREFIX)));
139:
140: return '' === $token ? null : $token;
141: }
142: }
143: