From 05c6efa86911e6da50f94d019adefbf334b1143e Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 5 Oct 2026 23:48:11 +0200 Subject: [PATCH] [Server] Fetch JWKS for the issuer a JWT claims JwtTokenValidator always fetched the keys of the first configured issuer. Read the token's iss first, require it to be configured, and verify with that issuer's keys. --- CHANGELOG.md | 1 + docs/run/authorization.md | 6 +- .../Http/OAuth/JwtTokenValidator.php | 49 ++++++--- .../Http/OAuth/JwtTokenValidatorTest.php | 101 +++++++++++++++--- 4 files changed, 127 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed1ec3fc..99e658fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Fix OIDC discovery rejecting issuers with a trailing slash (e.g. Authentik, Auth0). * Fix stateless SSE streams holding back frames until close when PHP output buffering is enabled. * Reject a recognized `Mcp-Param-*` header whose mirrored argument is absent from the body with `-32020`, instead of accepting the request (SEP-2243). +* Fix `JwtTokenValidator` with several issuers always fetching the keys of the first one: keys now come from the issuer the token claims, which must be configured. 0.8.0 ----- diff --git a/docs/run/authorization.md b/docs/run/authorization.md index 0985656a..c42499f3 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -147,15 +147,17 @@ Validates JWT access tokens: ```php $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', // Expected issuer claim + issuer: 'https://auth.example.com', // Expected issuer claim (string or array of aliases) audience: 'mcp-server', // Expected audience (string or array) jwksProvider: $jwksProvider, // JwksProviderInterface - jwksUri: null, // Explicit JWKS URI (auto-discovered) + jwksUri: null, // Explicit JWKS URI for all issuers (auto-discovered) algorithms: ['RS256', 'RS384', 'RS512'], // Allowed algorithms (this is the default) scopeClaim: 'scope', // Claim name for scopes ); ``` +An array of issuers is meant for aliases of one authorization server, e.g. an internal and an external URL of the same Keycloak realm, or the v1 and v2 issuer of a Microsoft Entra ID tenant. The token's `iss` claim must exactly match one of them, and the keys are fetched for that issuer, or from `jwksUri` if given. + **Request Attributes:** After successful validation, these attributes are added to the request: diff --git a/src/Server/Transport/Http/OAuth/JwtTokenValidator.php b/src/Server/Transport/Http/OAuth/JwtTokenValidator.php index a0ffe061..659bc1ad 100644 --- a/src/Server/Transport/Http/OAuth/JwtTokenValidator.php +++ b/src/Server/Transport/Http/OAuth/JwtTokenValidator.php @@ -22,8 +22,9 @@ * Validates JWT access tokens using JWKS from an OAuth 2.0 / OpenID Connect provider. * * This validator: - * - Fetches JWKS from the authorization server (auto-discovered or explicit) - * - Validates signature, audience, issuer, and expiration + * - Checks the token's issuer against the configured issuer(s) + * - Fetches JWKS for that issuer (auto-discovered or explicit) + * - Validates signature, audience, and expiration * - Extracts scopes and claims as authorization attributes * * Requires: firebase/php-jwt @@ -33,10 +34,10 @@ class JwtTokenValidator implements AuthorizationTokenValidatorInterface { /** - * @param string|list $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp") + * @param string|list $issuer Expected token issuer (e.g., "https://auth.example.com/realms/mcp"), a list is meant for aliases of one authorization server * @param string|list $audience Expected audience(s) for the token * @param JwksProviderInterface $jwksProvider JWKS provider - * @param string|null $jwksUri Explicit JWKS URI (auto-discovered from first issuer if null) + * @param string|null $jwksUri Explicit JWKS URI used for all issuers (auto-discovered from the token's issuer if null) * @param list $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512) * @param string $scopeClaim Claim name for scopes (default: "scope") */ @@ -56,14 +57,20 @@ public function __construct( public function validate(string $accessToken): AuthorizationResult { try { - /** @var array $claims */ - $claims = (array) JWT::decode($accessToken, $this->getJwks()); + $payload = $this->decodePayload($accessToken); + if (null === $payload) { + return AuthorizationResult::unauthorized('invalid_token', 'Malformed token.'); + } - // Validate issuer - if (!$this->validateIssuer($claims)) { + // Only fetch keys for a configured issuer, never for an arbitrary one from the token + $issuer = $payload['iss'] ?? null; + if (!\is_string($issuer) || !\in_array($issuer, $this->getIssuers(), true)) { return AuthorizationResult::unauthorized('invalid_token', 'Token issuer mismatch.'); } + /** @var array $claims */ + $claims = (array) JWT::decode($accessToken, $this->getJwks($issuer)); + // Validate audience if (!$this->validateAudience($claims)) { return AuthorizationResult::unauthorized('invalid_token', 'Token audience mismatch.'); @@ -135,9 +142,8 @@ public function requireScopes(AuthorizationResult $result, array $requiredScopes /** * @return array */ - private function getJwks(): array + private function getJwks(string $issuer): array { - $issuer = \is_array($this->issuer) ? $this->issuer[0] : $this->issuer; $jwksData = $this->jwksProvider->getJwks($issuer, $this->jwksUri); /* @var array */ @@ -166,17 +172,28 @@ private function validateAudience(array $claims): bool } /** - * @param array $claims + * Reads the unverified payload, used to pick the issuer before the signature is checked. + * + * @return array|null */ - private function validateIssuer(array $claims): bool + private function decodePayload(string $accessToken): ?array { - if (!isset($claims['iss'])) { - return false; + $segments = explode('.', $accessToken); + if (3 !== \count($segments)) { + return null; } - $expectedIssuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; + $payload = json_decode(JWT::urlsafeB64Decode($segments[1]), true); - return \in_array($claims['iss'], $expectedIssuers, true); + return \is_array($payload) ? $payload : null; + } + + /** + * @return list + */ + private function getIssuers(): array + { + return \is_array($this->issuer) ? $this->issuer : [$this->issuer]; } /** diff --git a/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php b/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php index b6e518ac..82d391bb 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php @@ -14,6 +14,7 @@ use Firebase\JWT\JWT; use Mcp\Exception\RuntimeException; use Mcp\Server\Transport\Http\OAuth\JwksProvider; +use Mcp\Server\Transport\Http\OAuth\JwksProviderInterface; use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; use Mcp\Server\Transport\Http\OAuth\OidcDiscoveryInterface; use Nyholm\Psr7\Factory\Psr17Factory; @@ -79,24 +80,18 @@ public function testValidJwtAllowsAndExposesAttributes(): void $this->assertSame('client-abc', $attributes['oauth.authorized_party']); } - #[TestDox('issuer mismatch yields unauthorized result')] + #[TestDox('issuer mismatch yields unauthorized result without fetching JWKS')] public function testIssuerMismatchIsUnauthorized(): void { - $factory = new Psr17Factory(); - [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + [$privateKeyPem] = $this->generateRsaKeypairAsJwk('test-kid'); - $jwksUri = 'https://auth.example.com/.well-known/jwks.json'; - $httpClient = $this->createHttpClientMock([ - $factory->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), - ]); + $jwksProvider = $this->createMock(JwksProviderInterface::class); + $jwksProvider->expects($this->never())->method('getJwks'); $validator = new JwtTokenValidator( - issuer: 'https://auth.example.com', + issuer: ['https://auth.example.com', 'https://alias.example.com'], audience: 'mcp-api', - jwksUri: $jwksUri, - jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + jwksProvider: $jwksProvider, ); $token = JWT::encode( @@ -121,6 +116,75 @@ public function testIssuerMismatchIsUnauthorized(): void $this->assertSame('Token issuer mismatch.', $result->getErrorDescription()); } + #[TestDox('token from a later configured issuer is verified with that issuer\'s keys')] + public function testTokenFromSecondIssuerIsVerifiedWithItsKeys(): void + { + [, $firstJwk] = $this->generateRsaKeypairAsJwk('first-kid'); + [$secondPrivateKeyPem, $secondJwk] = $this->generateRsaKeypairAsJwk('second-kid'); + + $validator = new JwtTokenValidator( + issuer: ['https://first.example.com', 'https://second.example.com'], + audience: 'mcp-api', + jwksProvider: $this->createJwksProviderStub([ + 'https://first.example.com' => $firstJwk, + 'https://second.example.com' => $secondJwk, + ]), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://second.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $secondPrivateKeyPem, + 'RS256', + keyId: 'second-kid', + ); + + $result = $validator->validate($token); + + $this->assertTrue($result->isAllowed()); + $this->assertSame('user-123', $result->getAttributes()['oauth.subject']); + } + + #[TestDox('token signed by the first issuer but claiming the second issuer is rejected')] + public function testTokenClaimingOtherIssuerIsRejected(): void + { + [$firstPrivateKeyPem, $firstJwk] = $this->generateRsaKeypairAsJwk('first-kid'); + [, $secondJwk] = $this->generateRsaKeypairAsJwk('second-kid'); + + $validator = new JwtTokenValidator( + issuer: ['https://first.example.com', 'https://second.example.com'], + audience: 'mcp-api', + jwksProvider: $this->createJwksProviderStub([ + 'https://first.example.com' => $firstJwk, + 'https://second.example.com' => $secondJwk, + ]), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://second.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $firstPrivateKeyPem, + 'RS256', + keyId: 'first-kid', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + } + #[TestDox('audience mismatch yields unauthorized result')] public function testAudienceMismatchIsUnauthorized(): void { @@ -555,6 +619,19 @@ private function b64urlEncode(string $data): string return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } + /** + * @param array> $jwkByIssuer + */ + private function createJwksProviderStub(array $jwkByIssuer): JwksProviderInterface + { + $jwksProvider = $this->createStub(JwksProviderInterface::class); + $jwksProvider->method('getJwks')->willReturnCallback( + static fn (string $issuer): array => ['keys' => [$jwkByIssuer[$issuer]]], + ); + + return $jwksProvider; + } + private function createDiscoveryStub(): OidcDiscoveryInterface { return $this->createStub(OidcDiscoveryInterface::class);