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\Auth;
15:
16: use Firebase\JWT\JWT;
17: use Firebase\JWT\Key;
18: use Nexus\Assert\Assert;
19: use Nexus\Mcp\Core\Auth\ResourceIdentifier;
20: use Nexus\Mcp\Core\Auth\VerifiedAccessToken;
21: use Nexus\Mcp\Core\Validation\SuggestedDependencyGuard;
22:
23: /**
24: * JWT bearer token validator over a key set, needing the suggested `firebase/php-jwt` package.
25: */
26: final readonly class JwksAccessTokenValidator implements AccessTokenValidatorInterface
27: {
28: private ResourceIdentifier $resource;
29:
30: /**
31: * @param array<string, Key>|\ArrayAccess<string, Key> $keys Keys by `kid`, typically a `Firebase\JWT\CachedKeySet`
32: * @param non-empty-string $expectedIssuer The `iss` every accepted token must carry
33: * @param string $resource Canonical URI of this MCP server, which a token's audience must name
34: */
35: public function __construct(
36: private array|\ArrayAccess $keys,
37: private string $expectedIssuer,
38: string $resource,
39: ) {
40: SuggestedDependencyGuard::verify(self::class, JWT::class, 'firebase/php-jwt', '^7.0');
41: Assert::that($expectedIssuer)->isNonEmptyString('JWKS validator expected issuer must be a non-empty string, {type} given.');
42:
43: $this->resource = new ResourceIdentifier($resource);
44: }
45:
46: #[\Override]
47: public function validate(string $token): ?VerifiedAccessToken
48: {
49: $claims = $this->decode($token);
50:
51: if (null === $claims) {
52: return null;
53: }
54:
55: $audience = $this->readAudience($claims);
56:
57: if (($claims['iss'] ?? null) !== $this->expectedIssuer) {
58: return null;
59: }
60:
61: if (! $this->resource->matchesAudience($audience)) {
62: return null;
63: }
64:
65: $expiresAt = $claims['exp'] ?? null;
66:
67: // `JWT::decode` checks expiry only when the claim is present, so absence is what must be refused.
68: if (! is_numeric($expiresAt)) {
69: return null;
70: }
71:
72: $expiresAt = (int) $expiresAt;
73:
74: if ($expiresAt < 1) {
75: return null;
76: }
77:
78: $subject = $claims['sub'] ?? null;
79:
80: return new VerifiedAccessToken(
81: audience: $audience,
82: expiresAt: $expiresAt,
83: scopes: $this->readScopes($claims),
84: subject: \is_string($subject) && '' !== $subject ? $subject : null,
85: clientId: $this->readClientId($claims),
86: );
87: }
88:
89: /**
90: * @return null|array<array-key, mixed>
91: */
92: private function decode(string $token): ?array
93: {
94: try {
95: return (array) JWT::decode($token, $this->keys);
96: } catch (\Exception) {
97: return null;
98: }
99: }
100:
101: /**
102: * @param array<array-key, mixed> $claims
103: *
104: * @return list<string>
105: */
106: private function readAudience(array $claims): array
107: {
108: $aud = $claims['aud'] ?? [];
109:
110: if (\is_string($aud)) {
111: return [$aud];
112: }
113:
114: $audience = [];
115:
116: if (\is_array($aud)) {
117: foreach ($aud as $entry) {
118: if (\is_string($entry)) {
119: $audience[] = $entry;
120: }
121: }
122: }
123:
124: return $audience;
125: }
126:
127: /**
128: * The client the token names, from `azp`, `client_id` or `cid`, skipping any that names nobody.
129: *
130: * @param array<array-key, mixed> $claims
131: *
132: * @return null|non-empty-string
133: */
134: private function readClientId(array $claims): ?string
135: {
136: foreach (['azp', 'client_id', 'cid'] as $claim) {
137: $candidate = $claims[$claim] ?? null;
138:
139: if (\is_string($candidate) && '' !== $candidate) {
140: return $candidate;
141: }
142: }
143:
144: return null;
145: }
146:
147: /**
148: * The granted scopes, from `scope` (a space-joined string, RFC 8693) or `scp` (a string or a
149: * list, the Entra and Okta spellings).
150: *
151: * @param array<array-key, mixed> $claims
152: *
153: * @return list<non-empty-string>
154: */
155: private function readScopes(array $claims): array
156: {
157: $scope = $claims['scope'] ?? $claims['scp'] ?? null;
158:
159: if (\is_string($scope)) {
160: $scope = explode(' ', $scope);
161: }
162:
163: if (! \is_array($scope)) {
164: return [];
165: }
166:
167: $scopes = [];
168:
169: foreach ($scope as $entry) {
170: if (\is_string($entry) && '' !== $entry) {
171: $scopes[] = $entry;
172: }
173: }
174:
175: return $scopes;
176: }
177: }
178: