Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
6 changes: 4 additions & 2 deletions docs/run/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
49 changes: 33 additions & 16 deletions src/Server/Transport/Http/OAuth/JwtTokenValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,10 +34,10 @@
class JwtTokenValidator implements AuthorizationTokenValidatorInterface
{
/**
* @param string|list<string> $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp")
* @param string|list<string> $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<string> $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<string> $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512)
* @param string $scopeClaim Claim name for scopes (default: "scope")
*/
Expand All @@ -56,14 +57,20 @@ public function __construct(
public function validate(string $accessToken): AuthorizationResult
{
try {
/** @var array<string, mixed> $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<string, mixed> $claims */
$claims = (array) JWT::decode($accessToken, $this->getJwks($issuer));

// Validate audience
if (!$this->validateAudience($claims)) {
return AuthorizationResult::unauthorized('invalid_token', 'Token audience mismatch.');
Expand Down Expand Up @@ -135,9 +142,8 @@ public function requireScopes(AuthorizationResult $result, array $requiredScopes
/**
* @return array<string, \Firebase\JWT\Key>
*/
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<string, \Firebase\JWT\Key> */
Expand Down Expand Up @@ -166,17 +172,28 @@ private function validateAudience(array $claims): bool
}

/**
* @param array<string, mixed> $claims
* Reads the unverified payload, used to pick the issuer before the signature is checked.
*
* @return array<string, mixed>|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<string>
*/
private function getIssuers(): array
{
return \is_array($this->issuer) ? $this->issuer : [$this->issuer];
}

/**
Expand Down
101 changes: 89 additions & 12 deletions tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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
{
Expand Down Expand Up @@ -555,6 +619,19 @@ private function b64urlEncode(string $data): string
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

/**
* @param array<string, array<string, mixed>> $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);
Expand Down
Loading